@aion0/forge 0.5.9 → 0.5.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -77,3 +77,10 @@ Example: find /Users/zliu/MyDocuments/obsidian-project -name "*.md" | head -20
77
77
  When I ask about my notes, use bash to search and read files from the vault directory.
78
78
  Example: find <vault_path> -name "*.md" | head -20
79
79
  <!-- /forge:template:obsidian-vault -->
80
+
81
+
82
+ <!-- FORGE:BEGIN -->
83
+ ## Forge Workspace Integration
84
+ When you finish processing a task or message from Forge, end your final response with the marker: [FORGE_DONE]
85
+ This helps Forge detect task completion. Do not include this marker if you are still working.
86
+ <!-- FORGE:END -->
package/RELEASE_NOTES.md CHANGED
@@ -1,15 +1,14 @@
1
- # Forge v0.5.9
1
+ # Forge v0.5.14
2
2
 
3
- Released: 2026-03-30
3
+ Released: 2026-03-31
4
4
 
5
- ## Changes since v0.5.8
6
-
7
- ### Features
8
- - feat: terminal floats below agent node, follows on drag/zoom
5
+ ## Changes since v0.5.13
9
6
 
10
7
  ### Bug Fixes
11
- - fix: revise dedup to not block legitimate messages
12
- - fix: reduce duplicate messages in workspace agent communication
8
+ - fix: dedup forge failed notifications by sender→target pair
9
+ - fix: TerminalLaunchDialog also uses daemon to create session
10
+ - fix: FloatingTerminal only attaches, daemon creates all sessions
11
+ - fix: write launch script to file to avoid tmux send-keys truncation
13
12
 
14
13
 
15
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.8...v0.5.9
14
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.13...v0.5.14
@@ -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 {}
@@ -1914,14 +1967,26 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1914
1967
  const envWithoutModel = profileEnv ? Object.fromEntries(
1915
1968
  Object.entries(profileEnv).filter(([k]) => k !== 'CLAUDE_MODEL')
1916
1969
  ) : {};
1917
- // 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
1918
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'];
1919
- const unsetPrefix = profileVarsToReset.map(v => `unset ${v}`).join(' && ') + ' && ';
1920
- const envExportsClean = unsetPrefix + (Object.keys(envWithoutModel).length > 0
1921
- ? Object.entries(envWithoutModel).map(([k, v]) => `export ${k}="${v}"`).join(' && ') + ' && '
1922
- : '');
1923
- // Primary: use fixed session. Non-primary: use explicit sessionId or -c
1924
- // 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
1925
1990
  let resumeId = resumeSessionId || boundSessionId;
1926
1991
  if (isClaude && !resumeId && isPrimary) {
1927
1992
  try {
@@ -1932,11 +1997,15 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1932
1997
  const resumeFlag = isClaude && resumeId ? ` --resume ${resumeId}` : '';
1933
1998
  let mcpFlag = '';
1934
1999
  if (isClaude) { try { const { getMcpFlag } = await import('@/lib/session-utils'); mcpFlag = await getMcpFlag(projectPath); } catch {} }
1935
- const sf = skipPermissions ? ' --dangerously-skip-permissions' : '';
1936
- const cmd = `${envExportsClean}${cdCmd} && ${cli}${resumeFlag}${modelFlag}${sf}${mcpFlag}\n`;
1937
- setTimeout(() => {
1938
- if (!disposed && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data: cmd }));
1939
- }, 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
+ });
1940
2009
  }
1941
2010
  } catch {}
1942
2011
  };
@@ -2190,8 +2259,13 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
2190
2259
  {(() => {
2191
2260
  // Execution mode is determined by config, not tmux state
2192
2261
  const isTerminalMode = config.persistentSession;
2193
- const color = isTerminalMode ? (hasTmux ? '#3fb950' : '#f0883e') : '#484f58';
2194
- 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)');
2195
2269
  return (<>
2196
2270
  <div className="w-1.5 h-1.5 rounded-full" style={{ background: color }} />
2197
2271
  <span className="text-[7px] font-medium" style={{ color }}>{label}</span>
@@ -2410,10 +2484,9 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2410
2484
  inboxFailed: busLog.filter(m => m.to === agent.id && m.status === 'failed' && m.type !== 'ack').length,
2411
2485
  onOpenTerminal: async () => {
2412
2486
  if (!workspaceId) return;
2413
- if (!daemonActiveFromStream) {
2414
- alert('Start daemon first before opening terminal.');
2415
- return;
2416
- }
2487
+ // Sync stale daemonActiveFromStream from agent states
2488
+ const anyActive = Object.values(states).some(s => s?.smithStatus === 'active');
2489
+ if (anyActive && !daemonActiveFromStream) setDaemonActiveFromStream(true);
2417
2490
  // Close existing terminal (config may have changed)
2418
2491
  setFloatingTerminals(prev => prev.filter(t => t.agentId !== agent.id));
2419
2492
 
@@ -2444,43 +2517,20 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2444
2517
  },
2445
2518
  };
2446
2519
 
2447
- // If tmux session exists attach (primary or non-primary)
2448
- if (existingTmux) {
2449
- wsApi(workspaceId, 'open_terminal', { agentId: agent.id });
2450
- setFloatingTerminals(prev => [...prev, {
2451
- agentId: agent.id, label: agent.label, icon: agent.icon,
2452
- cliId: agent.agentId || 'claude', ...launchInfo, workDir,
2453
- tmuxSession: existingTmux, sessionName: sessName,
2454
- isPrimary: agent.primary, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId, initialPos,
2455
- }]);
2456
- return;
2457
- }
2458
-
2459
- // Primary without session → open directly (no dialog)
2460
- if (agent.primary) {
2461
- const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id }).catch(() => ({})) as any;
2462
- setFloatingTerminals(prev => [...prev, {
2463
- agentId: agent.id, label: agent.label, icon: agent.icon,
2464
- cliId: agent.agentId || 'claude', ...launchInfo, workDir,
2465
- tmuxSession: res?.tmuxSession || sessName, sessionName: sessName,
2466
- isPrimary: true, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId, initialPos,
2467
- }]);
2468
- return;
2469
- }
2470
-
2471
- // Non-primary: has boundSessionId → use it directly; no bound → show dialog
2472
- if (agent.boundSessionId) {
2520
+ // All paths: let daemon create/ensure session, then attach
2521
+ if (existingTmux || agent.primary || agent.persistentSession || agent.boundSessionId) {
2522
+ // Daemon creates session via ensurePersistentSession (launch script, no truncation)
2473
2523
  const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id }).catch(() => ({})) as any;
2524
+ const tmux = existingTmux || res?.tmuxSession || sessName;
2474
2525
  setFloatingTerminals(prev => [...prev, {
2475
2526
  agentId: agent.id, label: agent.label, icon: agent.icon,
2476
- cliId: agent.agentId || 'claude', ...launchInfo, workDir,
2477
- tmuxSession: res?.tmuxSession || sessName, sessionName: sessName,
2478
- resumeSessionId: agent.boundSessionId,
2479
- isPrimary: false, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId, initialPos,
2527
+ cliId: agent.agentId || 'claude', workDir,
2528
+ tmuxSession: tmux, sessionName: sessName,
2529
+ isPrimary: agent.primary, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId, initialPos,
2480
2530
  }]);
2481
2531
  return;
2482
2532
  }
2483
- // No bound session → show launch dialog (New / Resume / Select)
2533
+ // No persistent session, no bound session → show launch dialog
2484
2534
  setTermLaunchDialog({ agent, sessName, workDir, sessions: [], supportsSession: resolveRes?.supportsSession ?? true, initialPos });
2485
2535
  },
2486
2536
  onSwitchSession: async () => {
@@ -2861,28 +2911,19 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2861
2911
  onLaunch={async (resumeMode, sessionId) => {
2862
2912
  const { agent, sessName, workDir } = termLaunchDialog;
2863
2913
  setTermLaunchDialog(null);
2864
- const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id });
2865
- if (res.ok) {
2866
- // Save selected session as boundSessionId if user chose a specific one
2867
- if (sessionId) {
2868
- wsApi(workspaceId, 'update', { agentId: agent.id, config: { ...agent, boundSessionId: sessionId } }).catch(() => {});
2869
- }
2870
- setFloatingTerminals(prev => [...prev, {
2871
- agentId: agent.id, label: agent.label, icon: agent.icon,
2872
- cliId: agent.agentId || 'claude',
2873
- cliCmd: res.cliCmd || 'claude',
2874
- cliType: res.cliType || 'claude-code',
2875
- workDir,
2876
- sessionName: sessName, resumeMode, resumeSessionId: sessionId, isPrimary: false, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: sessionId || agent.boundSessionId, initialPos: termLaunchDialog.initialPos,
2877
- profileEnv: {
2878
- ...(res.env || {}),
2879
- ...(res.model ? { CLAUDE_MODEL: res.model } : {}),
2880
- FORGE_AGENT_ID: agent.id,
2881
- FORGE_WORKSPACE_ID: workspaceId,
2882
- FORGE_PORT: String(window.location.port || 8403),
2883
- },
2884
- }]);
2914
+ // Save selected session as boundSessionId
2915
+ if (sessionId) {
2916
+ await wsApi(workspaceId, 'update', { agentId: agent.id, config: { ...agent, boundSessionId: sessionId } }).catch(() => {});
2885
2917
  }
2918
+ // Daemon creates session (launch script), then attach
2919
+ const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id }).catch(() => ({})) as any;
2920
+ const tmux = res?.tmuxSession || sessName;
2921
+ setFloatingTerminals(prev => [...prev, {
2922
+ agentId: agent.id, label: agent.label, icon: agent.icon,
2923
+ cliId: agent.agentId || 'claude', workDir,
2924
+ tmuxSession: tmux, sessionName: sessName,
2925
+ isPrimary: false, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: sessionId || agent.boundSessionId, initialPos: termLaunchDialog.initialPos,
2926
+ }]);
2886
2927
  }}
2887
2928
  onCancel={() => setTermLaunchDialog(null)}
2888
2929
  />
@@ -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);
@@ -61,11 +61,17 @@ export class WorkspaceOrchestrator extends EventEmitter {
61
61
  private agents = new Map<string, { config: WorkspaceAgentConfig; worker: AgentWorker | null; state: AgentState }>();
62
62
  private bus: AgentBus;
63
63
  private watchManager: WatchManager;
64
+ private sessionMonitor: import('./session-monitor').SessionFileMonitor | null = null;
64
65
  private approvalQueue = new Set<string>();
65
66
  private daemonActive = false;
66
67
  private createdAt = Date.now();
67
68
  private healthCheckTimer: NodeJS.Timeout | null = null;
68
69
 
70
+ /** Emit a log event (auto-persisted via constructor listener) */
71
+ emitLog(agentId: string, entry: any): void {
72
+ this.emit('event', { type: 'log', agentId, entry } as any);
73
+ }
74
+
69
75
  constructor(workspaceId: string, projectPath: string, projectName: string) {
70
76
  super();
71
77
  this.workspaceId = workspaceId;
@@ -73,6 +79,13 @@ export class WorkspaceOrchestrator extends EventEmitter {
73
79
  this.projectName = projectName;
74
80
  this.bus = new AgentBus();
75
81
  this.watchManager = new WatchManager(workspaceId, projectPath, () => this.agents as any);
82
+
83
+ // Auto-persist all log events to disk (so LogPanel can read them)
84
+ this.on('event', (event: any) => {
85
+ if (event.type === 'log' && event.agentId && event.entry) {
86
+ appendAgentLog(this.workspaceId, event.agentId, event.entry).catch(() => {});
87
+ }
88
+ });
76
89
  // Handle watch events
77
90
  this.watchManager.on('watch_alert', (event) => {
78
91
  this.emit('event', event);
@@ -358,7 +371,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
358
371
  const workerState = entry.worker?.getState();
359
372
  // Merge: worker state for task/smith, entry.state for mode (orchestrator controls mode)
360
373
  result[id] = workerState
361
- ? { ...workerState, tmuxSession: entry.state.tmuxSession, currentMessageId: entry.state.currentMessageId }
374
+ ? { ...workerState, taskStatus: entry.state.taskStatus, tmuxSession: entry.state.tmuxSession, currentMessageId: entry.state.currentMessageId }
362
375
  : entry.state;
363
376
  }
364
377
  return result;
@@ -912,6 +925,9 @@ export class WorkspaceOrchestrator extends EventEmitter {
912
925
  // Start watch loops for agents with watch config
913
926
  this.watchManager.start();
914
927
 
928
+ // Start session file monitors for agents with known session IDs
929
+ this.startSessionMonitors().catch(err => console.error('[session-monitor] Failed to start:', err.message));
930
+
915
931
  // Start health check — monitor all agents every 10s, auto-heal
916
932
  this.startHealthCheck();
917
933
 
@@ -1049,14 +1065,26 @@ export class WorkspaceOrchestrator extends EventEmitter {
1049
1065
  entry.worker = null;
1050
1066
  }
1051
1067
 
1052
- // 3. Kill tmux session
1068
+ // 3. Kill tmux session (skip if user is attached to it)
1053
1069
  if (entry.state.tmuxSession) {
1054
- try { execSync(`tmux kill-session -t "${entry.state.tmuxSession}" 2>/dev/null`, { timeout: 3000 }); } catch {}
1070
+ let isAttached = false;
1071
+ try {
1072
+ const info = execSync(`tmux display-message -t "${entry.state.tmuxSession}" -p "#{session_attached}" 2>/dev/null`, { timeout: 3000, encoding: 'utf-8' }).trim();
1073
+ isAttached = info !== '0';
1074
+ } catch {}
1075
+ if (isAttached) {
1076
+ console.log(`[daemon] ${entry.config.label}: tmux session attached by user, not killing`);
1077
+ } else {
1078
+ try { execSync(`tmux kill-session -t "${entry.state.tmuxSession}" 2>/dev/null`, { timeout: 3000 }); } catch {}
1079
+ }
1055
1080
  entry.state.tmuxSession = undefined;
1056
1081
  }
1057
1082
 
1058
- // 4. Set smith down
1083
+ // 4. Set smith down, reset running tasks
1059
1084
  entry.state.smithStatus = 'down';
1085
+ if (entry.state.taskStatus === 'running') {
1086
+ entry.state.taskStatus = 'idle';
1087
+ }
1060
1088
  entry.state.error = undefined;
1061
1089
  this.updateAgentLiveness(id);
1062
1090
  this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'down' } satisfies WorkerEvent);
@@ -1069,11 +1097,100 @@ export class WorkspaceOrchestrator extends EventEmitter {
1069
1097
  this.emitAgentsChanged();
1070
1098
  this.watchManager.stop();
1071
1099
  this.stopAllTerminalMonitors();
1100
+ if (this.sessionMonitor) { this.sessionMonitor.stopAll(); this.sessionMonitor = null; }
1072
1101
  this.stopHealthCheck();
1073
1102
  this.forgeActedMessages.clear();
1074
1103
  console.log('[workspace] Daemon stopped');
1075
1104
  }
1076
1105
 
1106
+ // ─── Session File Monitor ──────────────────────────────
1107
+
1108
+ private async startSessionMonitors(): Promise<void> {
1109
+ console.log('[session-monitor] Initializing...');
1110
+ const { SessionFileMonitor } = await import('./session-monitor');
1111
+ this.sessionMonitor = new SessionFileMonitor();
1112
+
1113
+ // Listen for state changes from session file monitor
1114
+ this.sessionMonitor.on('stateChange', (event: any) => {
1115
+ const entry = this.agents.get(event.agentId);
1116
+ if (!entry) {
1117
+ console.log(`[session-monitor] stateChange: agent ${event.agentId} not found in map`);
1118
+ return;
1119
+ }
1120
+ console.log(`[session-monitor] stateChange: ${entry.config.label} ${event.state} (current taskStatus=${entry.state.taskStatus})`);
1121
+
1122
+ if (event.state === 'running' && entry.state.taskStatus !== 'running') {
1123
+ entry.state.taskStatus = 'running';
1124
+ console.log(`[session-monitor] → emitting task_status=running for ${entry.config.label}`);
1125
+ this.emit('event', { type: 'task_status', agentId: event.agentId, taskStatus: 'running' } as any);
1126
+ this.emitAgentsChanged();
1127
+ }
1128
+
1129
+ if (event.state === 'done' && entry.state.taskStatus === 'running') {
1130
+ entry.state.taskStatus = 'done';
1131
+ this.emit('event', { type: 'task_status', agentId: event.agentId, taskStatus: 'done' } as any);
1132
+ console.log(`[session-monitor] ${event.agentId}: done — ${event.detail || 'turn completed'}`);
1133
+ this.handleAgentDone(event.agentId, entry, event.detail);
1134
+ this.emitAgentsChanged();
1135
+ }
1136
+ });
1137
+
1138
+ // Start monitors for all agents with known session IDs
1139
+ for (const [id, entry] of this.agents) {
1140
+ if (entry.config.type === 'input') continue;
1141
+ await this.startAgentSessionMonitor(id, entry.config);
1142
+ }
1143
+ }
1144
+
1145
+ private async startAgentSessionMonitor(agentId: string, config: WorkspaceAgentConfig): Promise<void> {
1146
+ if (!this.sessionMonitor) return;
1147
+
1148
+ // Determine session file path
1149
+ let sessionId: string | undefined;
1150
+
1151
+ if (config.primary) {
1152
+ try {
1153
+ const mod = await import('../project-sessions');
1154
+ sessionId = (mod as any).getFixedSession(this.projectPath);
1155
+ console.log(`[session-monitor] ${config.label}: primary fixedSession=${sessionId || 'NONE'}`);
1156
+ } catch (err: any) {
1157
+ console.log(`[session-monitor] ${config.label}: failed to get fixedSession: ${err.message}`);
1158
+ }
1159
+ } else {
1160
+ sessionId = config.boundSessionId;
1161
+ console.log(`[session-monitor] ${config.label}: boundSession=${sessionId || 'NONE'}`);
1162
+ }
1163
+
1164
+ if (!sessionId) {
1165
+ // Try to auto-bind from session files on disk
1166
+ try {
1167
+ const sessionDir = this.getCliSessionDir(config.workDir);
1168
+ if (existsSync(sessionDir)) {
1169
+ const files = require('node:fs').readdirSync(sessionDir).filter((f: string) => f.endsWith('.jsonl'));
1170
+ if (files.length > 0) {
1171
+ const sorted = files
1172
+ .map((f: string) => ({ name: f, mtime: require('node:fs').statSync(join(sessionDir, f)).mtimeMs }))
1173
+ .sort((a: any, b: any) => b.mtime - a.mtime);
1174
+ sessionId = sorted[0].name.replace('.jsonl', '');
1175
+ if (!config.primary) {
1176
+ config.boundSessionId = sessionId;
1177
+ this.saveNow();
1178
+ console.log(`[session-monitor] ${config.label}: auto-bound to ${sessionId}`);
1179
+ }
1180
+ }
1181
+ }
1182
+ } catch {}
1183
+ if (!sessionId) {
1184
+ console.log(`[session-monitor] ${config.label}: no sessionId, skipping`);
1185
+ return;
1186
+ }
1187
+ }
1188
+
1189
+ const { SessionFileMonitor } = await import('./session-monitor');
1190
+ const filePath = SessionFileMonitor.resolveSessionPath(this.projectPath, config.workDir, sessionId);
1191
+ this.sessionMonitor.startMonitoring(agentId, filePath);
1192
+ }
1193
+
1077
1194
  // ─── Health Check — auto-heal agents ─────────────────
1078
1195
 
1079
1196
  private startHealthCheck(): void {
@@ -1172,7 +1289,11 @@ export class WorkspaceOrchestrator extends EventEmitter {
1172
1289
  if (this.forgeActedMessages.has(msg.id)) continue;
1173
1290
 
1174
1291
  // Case 1: Message done but no reply from target → ask target to send summary (once per pair)
1292
+ // Skip notification-only messages that don't need replies
1175
1293
  if (msg.status === 'done') {
1294
+ const action = msg.payload?.action;
1295
+ if (action === 'upstream_complete' || action === 'task_complete' || action === 'ack') { this.forgeActedMessages.add(msg.id); continue; }
1296
+ if (msg.from === '_system' || msg.from === '_watch') { this.forgeActedMessages.add(msg.id); continue; }
1176
1297
  const age = now - msg.timestamp;
1177
1298
  if (age < 30_000) continue;
1178
1299
 
@@ -1232,16 +1353,20 @@ export class WorkspaceOrchestrator extends EventEmitter {
1232
1353
  }
1233
1354
  }
1234
1355
 
1235
- // Case 4: Failed → notify sender so they know
1236
- if (msg.status === 'failed' && !this.forgeActedMessages.has(`failed-${msg.id}`)) {
1237
- const senderEntry = this.agents.get(msg.from);
1238
- const targetLabel = this.agents.get(msg.to)?.config.label || msg.to;
1239
- if (senderEntry && msg.from !== '_forge' && msg.from !== '_system') {
1240
- this.bus.send('_forge', msg.from, 'notify', {
1241
- action: 'update_notify',
1242
- content: `Your message to ${targetLabel} has failed. You may want to retry or take a different approach.`,
1243
- });
1244
- console.log(`[forge-agent] Notified ${senderEntry.config.label} that message to ${targetLabel} failed`);
1356
+ // Case 4: Failed → notify sender (once per sender→target pair)
1357
+ if (msg.status === 'failed') {
1358
+ const failKey = `failed-${msg.from}->${msg.to}`;
1359
+ if (!this.forgeActedMessages.has(failKey)) {
1360
+ const senderEntry = this.agents.get(msg.from);
1361
+ const targetLabel = this.agents.get(msg.to)?.config.label || msg.to;
1362
+ if (senderEntry && msg.from !== '_forge' && msg.from !== '_system') {
1363
+ this.bus.send('_forge', msg.from, 'notify', {
1364
+ action: 'update_notify',
1365
+ content: `Your message to ${targetLabel} has failed. You may want to retry or take a different approach.`,
1366
+ });
1367
+ console.log(`[forge-agent] Notified ${senderEntry.config.label} that message to ${targetLabel} failed (once)`);
1368
+ }
1369
+ this.forgeActedMessages.add(failKey);
1245
1370
  }
1246
1371
  this.forgeActedMessages.add(`failed-${msg.id}`);
1247
1372
  }
@@ -1757,8 +1882,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
1757
1882
  .slice(-1)[0]?.content || '';
1758
1883
 
1759
1884
  const content = files.length > 0
1760
- ? `${completedLabel} completed: ${files.length} files changed. ${summary.slice(0, 200)}`
1761
- : `${completedLabel} completed. ${summary.slice(0, 300) || 'Check upstream outputs for updates.'}`;
1885
+ ? `${completedLabel} completed: ${files.length} files changed. ${summary.slice(0, 200)}. If you are currently processing a task or have seen this before, ignore this notification.`
1886
+ : `${completedLabel} completed. ${summary.slice(0, 300) || 'If you are currently processing a task or have seen this before, ignore this notification.'}`;
1762
1887
 
1763
1888
  // Find all downstream agents — skip if already sent upstream_complete recently (60s)
1764
1889
  const now = Date.now();
@@ -1779,6 +1904,14 @@ export class WorkspaceOrchestrator extends EventEmitter {
1779
1904
  continue;
1780
1905
  }
1781
1906
 
1907
+ // Merge: auto-complete older pending upstream_complete from same sender
1908
+ for (const m of this.bus.getLog()) {
1909
+ if (m.from === completedAgentId && m.to === id && m.status === 'pending' && m.payload?.action === 'upstream_complete') {
1910
+ m.status = 'done' as any;
1911
+ this.emit('event', { type: 'bus_message_status', messageId: m.id, status: 'done' } as any);
1912
+ }
1913
+ }
1914
+
1782
1915
  this.bus.send(completedAgentId, id, 'notify', {
1783
1916
  action: 'upstream_complete',
1784
1917
  content,
@@ -1835,8 +1968,10 @@ export class WorkspaceOrchestrator extends EventEmitter {
1835
1968
  }
1836
1969
 
1837
1970
  // Check if tmux session already exists
1971
+ let sessionAlreadyExists = false;
1838
1972
  try {
1839
1973
  execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`, { timeout: 3000 });
1974
+ sessionAlreadyExists = true;
1840
1975
  console.log(`[daemon] ${config.label}: persistent session already exists (${sessionName})`);
1841
1976
  } catch {
1842
1977
  // Create new tmux session and start the CLI agent
@@ -1892,27 +2027,24 @@ export class WorkspaceOrchestrator extends EventEmitter {
1892
2027
 
1893
2028
  execSync(`tmux new-session -d -s "${sessionName}" -c "${workDir}"`, { timeout: 5000 });
1894
2029
 
1895
- // Reset profile env vars (unset any leftover from previous agent) then set new ones
1896
- 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'];
1897
- const unsetCmd = profileVarsToReset.map(v => `unset ${v}`).join(' && ');
1898
- execSync(`tmux send-keys -t "${sessionName}" '${unsetCmd}' Enter`, { timeout: 5000 });
2030
+ // Build launch script to avoid tmux send-keys truncation
2031
+ const scriptLines: string[] = ['#!/bin/bash', `cd "${workDir}"`];
2032
+
2033
+ // Unset old profile vars
2034
+ scriptLines.push('unset 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');
1899
2035
 
1900
- // Set FORGE env vars + profile env vars
1901
- const forgeVars = `export FORGE_WORKSPACE_ID="${this.workspaceId}" FORGE_AGENT_ID="${config.id}" FORGE_PORT="${Number(process.env.PORT) || 8403}"`;
2036
+ // Set FORGE env vars
2037
+ scriptLines.push(`export FORGE_WORKSPACE_ID="${this.workspaceId}" FORGE_AGENT_ID="${config.id}" FORGE_PORT="${Number(process.env.PORT) || 8403}"`);
2038
+
2039
+ // Set profile env vars
1902
2040
  if (envExports) {
1903
- execSync(`tmux send-keys -t "${sessionName}" '${forgeVars} && ${envExports.replace(/ && $/, '')}' Enter`, { timeout: 5000 });
1904
- } else {
1905
- execSync(`tmux send-keys -t "${sessionName}" '${forgeVars}' Enter`, { timeout: 5000 });
2041
+ scriptLines.push(envExports.replace(/ && /g, '\n').replace(/\n$/, ''));
1906
2042
  }
1907
2043
 
1908
- // Build CLI start command
1909
- const parts: string[] = [];
2044
+ // Build CLI command
1910
2045
  let cmd = cliCmd;
1911
-
1912
- // Session resume: use bound session ID (primary from project-sessions, others from config)
1913
2046
  if (supportsSession) {
1914
2047
  let sessionId: string | undefined;
1915
-
1916
2048
  if (config.primary) {
1917
2049
  try {
1918
2050
  const { getFixedSession } = await import('../project-sessions') as any;
@@ -1921,7 +2053,6 @@ export class WorkspaceOrchestrator extends EventEmitter {
1921
2053
  } else {
1922
2054
  sessionId = config.boundSessionId;
1923
2055
  }
1924
-
1925
2056
  if (sessionId) {
1926
2057
  const sessionFile = join(this.getCliSessionDir(config.workDir), `${sessionId}.jsonl`);
1927
2058
  if (existsSync(sessionFile)) {
@@ -1930,15 +2061,16 @@ export class WorkspaceOrchestrator extends EventEmitter {
1930
2061
  console.log(`[daemon] ${config.label}: bound session ${sessionId} missing, starting fresh`);
1931
2062
  }
1932
2063
  }
1933
- // No bound session → start fresh (no -c, avoids "No conversation found")
1934
2064
  }
1935
2065
  if (modelFlag) cmd += modelFlag;
1936
2066
  if (config.skipPermissions !== false && skipPermissionsFlag) cmd += ` ${skipPermissionsFlag}`;
1937
2067
  if (mcpConfigFlag) cmd += mcpConfigFlag;
1938
- parts.push(cmd);
2068
+ scriptLines.push(`exec ${cmd}`);
1939
2069
 
1940
- const startCmd = parts.join(' && ');
1941
- execSync(`tmux send-keys -t "${sessionName}" '${startCmd}' Enter`, { timeout: 5000 });
2070
+ // Write script and execute in tmux
2071
+ const scriptPath = `/tmp/forge-launch-${config.id.replace(/[^a-z0-9-]/g, '')}.sh`;
2072
+ writeFileSync(scriptPath, scriptLines.join('\n'), { mode: 0o755 });
2073
+ execSync(`tmux send-keys -t "${sessionName}" 'bash ${scriptPath}' Enter`, { timeout: 5000 });
1942
2074
 
1943
2075
  console.log(`[daemon] ${config.label}: persistent session created (${sessionName}) [${cliType}: ${cliCmd}]`);
1944
2076
 
@@ -1969,7 +2101,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
1969
2101
  if (entry) {
1970
2102
  entry.state.error = `Terminal failed: ${errorMsg}. Falling back to headless mode.`;
1971
2103
  entry.state.tmuxSession = undefined; // clear so message loop uses headless (claude -p)
1972
- this.emit('event', { type: 'log', agentId, entry: { type: 'system', subtype: 'error', content: `Terminal startup failed: ${errorMsg}. Auto-fallback to headless (claude -p).`, timestamp: new Date().toISOString() } } as any);
2104
+ this.emit('event', { type: 'log', agentId, entry: { type: 'system', subtype: 'error', content: `Terminal startup failed: ${errorMsg}. Auto-fallback to headless.`, timestamp: new Date().toISOString() } } as any);
1973
2105
  this.emitAgentsChanged();
1974
2106
  }
1975
2107
  // Kill the failed tmux session
@@ -2017,6 +2149,31 @@ export class WorkspaceOrchestrator extends EventEmitter {
2017
2149
  this.saveNow();
2018
2150
  this.emitAgentsChanged();
2019
2151
  }
2152
+
2153
+ // Ensure boundSessionId is set (required for session monitor + --resume)
2154
+ if (!config.primary && !config.boundSessionId) {
2155
+ const bindDelay = sessionAlreadyExists ? 500 : 5000;
2156
+ setTimeout(() => {
2157
+ try {
2158
+ const sessionDir = this.getCliSessionDir(config.workDir);
2159
+ if (existsSync(sessionDir)) {
2160
+ const { readdirSync, statSync: statS } = require('node:fs');
2161
+ const files = readdirSync(sessionDir).filter((f: string) => f.endsWith('.jsonl'));
2162
+ if (files.length > 0) {
2163
+ const latest = files
2164
+ .map((f: string) => ({ name: f, mtime: statS(join(sessionDir, f)).mtimeMs }))
2165
+ .sort((a: any, b: any) => b.mtime - a.mtime)[0];
2166
+ config.boundSessionId = latest.name.replace('.jsonl', '');
2167
+ this.saveNow();
2168
+ console.log(`[daemon] ${config.label}: bound to session ${config.boundSessionId}`);
2169
+ this.startAgentSessionMonitor(agentId, config);
2170
+ } else {
2171
+ console.log(`[daemon] ${config.label}: no session files yet, will bind on next check`);
2172
+ }
2173
+ }
2174
+ } catch {}
2175
+ }, bindDelay);
2176
+ }
2020
2177
  }
2021
2178
 
2022
2179
  /** Inject text into an agent's persistent terminal session */
@@ -2315,7 +2472,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
2315
2472
  } else {
2316
2473
  entry.worker!.setProcessingMessage(nextMsg.id);
2317
2474
  entry.worker!.wake({ type: 'bus_message', messages: [logEntry] });
2318
- this.emit('event', { type: 'log', agentId, entry: { type: 'system', subtype: 'execution_method', content: '⚡ Executed via claude -p', timestamp: new Date().toISOString() } } as any);
2475
+ this.emit('event', { type: 'log', agentId, entry: { type: 'system', subtype: 'execution_method', content: `⚡ Executed via headless (agent: ${entry.config.agentId || 'claude'})`, timestamp: new Date().toISOString() } } as any);
2319
2476
  }
2320
2477
  };
2321
2478
 
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Session File Monitor — detects agent running/idle state by watching
3
+ * Claude Code's .jsonl session files.
4
+ *
5
+ * How it works:
6
+ * - Each agent has a known session file path (boundSessionId/fixedSessionId/--session-id)
7
+ * - Monitor checks file mtime every 3s
8
+ * - mtime changing → agent is running (LLM streaming, tool use, etc.)
9
+ * - mtime stable for IDLE_THRESHOLD → check last lines for 'result' entry → done
10
+ * - No session file → idle (not started)
11
+ *
12
+ * Works for both terminal and headless modes — both write the same .jsonl format.
13
+ */
14
+
15
+ import { statSync, readFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { homedir } from 'node:os';
18
+ import { resolve } from 'node:path';
19
+ import { EventEmitter } from 'node:events';
20
+
21
+ export type SessionMonitorState = 'idle' | 'running' | 'done';
22
+
23
+ export interface SessionMonitorEvent {
24
+ agentId: string;
25
+ state: SessionMonitorState;
26
+ sessionFile: string;
27
+ detail?: string; // e.g., result summary
28
+ }
29
+
30
+ const POLL_INTERVAL = 1000; // check every 1s (need to catch short executions)
31
+ const IDLE_THRESHOLD = 10000; // 10s of no file change → check for done
32
+ const STABLE_THRESHOLD = 20000; // 20s of no change → force done
33
+
34
+ export class SessionFileMonitor extends EventEmitter {
35
+ private timers = new Map<string, NodeJS.Timeout>();
36
+ private lastMtime = new Map<string, number>();
37
+ private lastSize = new Map<string, number>();
38
+ private lastStableTime = new Map<string, number>();
39
+ private currentState = new Map<string, SessionMonitorState>();
40
+
41
+ /**
42
+ * Start monitoring a session file for an agent.
43
+ * @param agentId - Agent identifier
44
+ * @param sessionFilePath - Full path to the .jsonl session file
45
+ */
46
+ startMonitoring(agentId: string, sessionFilePath: string): void {
47
+ this.stopMonitoring(agentId);
48
+ this.currentState.set(agentId, 'idle');
49
+ this.lastStableTime.set(agentId, Date.now());
50
+
51
+ const timer = setInterval(() => {
52
+ this.checkFile(agentId, sessionFilePath);
53
+ }, POLL_INTERVAL);
54
+ timer.unref();
55
+ this.timers.set(agentId, timer);
56
+
57
+ console.log(`[session-monitor] Started monitoring ${agentId}: ${sessionFilePath}`);
58
+ }
59
+
60
+ /**
61
+ * Stop monitoring an agent's session file.
62
+ */
63
+ stopMonitoring(agentId: string): void {
64
+ const timer = this.timers.get(agentId);
65
+ if (timer) clearInterval(timer);
66
+ this.timers.delete(agentId);
67
+ this.lastMtime.delete(agentId);
68
+ this.lastSize.delete(agentId);
69
+ this.lastStableTime.delete(agentId);
70
+ this.currentState.delete(agentId);
71
+ }
72
+
73
+ /**
74
+ * Stop all monitors.
75
+ */
76
+ stopAll(): void {
77
+ for (const [id] of this.timers) {
78
+ this.stopMonitoring(id);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Get current state for an agent.
84
+ */
85
+ getState(agentId: string): SessionMonitorState {
86
+ return this.currentState.get(agentId) || 'idle';
87
+ }
88
+
89
+ /**
90
+ * Resolve session file path for a project + session ID.
91
+ */
92
+ static resolveSessionPath(projectPath: string, workDir: string | undefined, sessionId: string): string {
93
+ const fullPath = workDir && workDir !== './' && workDir !== '.'
94
+ ? join(projectPath, workDir) : projectPath;
95
+ const encoded = resolve(fullPath).replace(/\//g, '-');
96
+ return join(homedir(), '.claude', 'projects', encoded, `${sessionId}.jsonl`);
97
+ }
98
+
99
+ private initialized = new Set<string>();
100
+ private checkFile(agentId: string, filePath: string): void {
101
+ try {
102
+ const stat = statSync(filePath);
103
+ const mtime = stat.mtimeMs;
104
+ const size = stat.size;
105
+
106
+ // First poll: just record baseline, don't trigger state change
107
+ if (!this.initialized.has(agentId)) {
108
+ this.initialized.add(agentId);
109
+ this.lastMtime.set(agentId, mtime);
110
+ this.lastSize.set(agentId, size);
111
+ this.lastStableTime.set(agentId, Date.now());
112
+ console.log(`[session-monitor] ${agentId}: baseline mtime=${mtime} size=${size}`);
113
+ return;
114
+ }
115
+
116
+ const prevMtime = this.lastMtime.get(agentId) || 0;
117
+ const prevSize = this.lastSize.get(agentId) || 0;
118
+ const prevState = this.currentState.get(agentId) || 'idle';
119
+ const now = Date.now();
120
+
121
+ this.lastMtime.set(agentId, mtime);
122
+ this.lastSize.set(agentId, size);
123
+
124
+ // File changed (mtime or size different) → running
125
+ if (mtime !== prevMtime || size !== prevSize) {
126
+ this.lastStableTime.set(agentId, now);
127
+ if (prevState !== 'running') {
128
+ this.setState(agentId, 'running', filePath);
129
+ }
130
+ return;
131
+ }
132
+
133
+ // File unchanged — how long has it been stable?
134
+ const stableFor = now - (this.lastStableTime.get(agentId) || now);
135
+
136
+ if (prevState === 'running') {
137
+ if (stableFor >= IDLE_THRESHOLD) {
138
+ // Check if session file has a 'result' entry at the end
139
+ const resultInfo = this.checkForResult(filePath);
140
+ if (resultInfo) {
141
+ this.setState(agentId, 'done', filePath, resultInfo);
142
+ return;
143
+ }
144
+ }
145
+ if (stableFor >= STABLE_THRESHOLD) {
146
+ // Force done after 30s even without result entry
147
+ this.setState(agentId, 'done', filePath, 'stable timeout');
148
+ return;
149
+ }
150
+ }
151
+ } catch (err: any) {
152
+ if (!this.initialized.has(`err-${agentId}`)) {
153
+ this.initialized.add(`err-${agentId}`);
154
+ console.log(`[session-monitor] ${agentId}: checkFile error — ${err.message}`);
155
+ }
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Check the last few lines of the session file for a 'result' type entry.
161
+ * Claude Code writes this when a turn completes.
162
+ */
163
+ private checkForResult(filePath: string): string | null {
164
+ try {
165
+ // Read last 4KB of the file
166
+ const stat = statSync(filePath);
167
+ const readSize = Math.min(4096, stat.size);
168
+ const fd = require('node:fs').openSync(filePath, 'r');
169
+ const buf = Buffer.alloc(readSize);
170
+ require('node:fs').readSync(fd, buf, 0, readSize, Math.max(0, stat.size - readSize));
171
+ require('node:fs').closeSync(fd);
172
+
173
+ const tail = buf.toString('utf-8');
174
+ const lines = tail.split('\n').filter(l => l.trim());
175
+
176
+ // Scan last lines for result entry
177
+ for (let i = lines.length - 1; i >= Math.max(0, lines.length - 10); i--) {
178
+ try {
179
+ const entry = JSON.parse(lines[i]);
180
+ // Claude Code writes result entries with these fields
181
+ if (entry.type === 'result' || entry.result || entry.duration_ms !== undefined) {
182
+ const summary = entry.result?.slice?.(0, 200) || entry.summary?.slice?.(0, 200) || '';
183
+ return summary || 'completed';
184
+ }
185
+ // Also check for assistant message without tool_use (model stopped)
186
+ if (entry.type === 'assistant' && entry.message?.content) {
187
+ const content = entry.message.content;
188
+ const hasToolUse = Array.isArray(content)
189
+ ? content.some((b: any) => b.type === 'tool_use')
190
+ : false;
191
+ if (!hasToolUse) {
192
+ return 'model stopped (no tool_use)';
193
+ }
194
+ }
195
+ } catch {} // skip non-JSON lines
196
+ }
197
+ } catch {}
198
+ return null;
199
+ }
200
+
201
+ private setState(agentId: string, state: SessionMonitorState, filePath: string, detail?: string): void {
202
+ const prev = this.currentState.get(agentId);
203
+ if (prev === state) return;
204
+
205
+ this.currentState.set(agentId, state);
206
+ const event: SessionMonitorEvent = { agentId, state, sessionFile: filePath, detail };
207
+ this.emit('stateChange', event);
208
+ console.log(`[session-monitor] ${agentId}: ${prev} → ${state}${detail ? ` (${detail})` : ''}`);
209
+ }
210
+ }
@@ -47,7 +47,7 @@ export interface WorkspaceAgentConfig {
47
47
  // ─── Watch Config ─────────────────────────────────────────
48
48
 
49
49
  export interface WatchTarget {
50
- type: 'directory' | 'git' | 'agent_output' | 'agent_log' | 'session' | 'command';
50
+ type: 'directory' | 'git' | 'agent_output' | 'agent_log' | 'session' | 'command' | 'agent_status';
51
51
  path?: string; // directory: relative path; agent_output/agent_log: agent ID
52
52
  pattern?: string; // glob for directory, regex/keyword for agent_log, stdout pattern for command
53
53
  cmd?: string; // shell command (type='command' only)
@@ -21,6 +21,7 @@ interface WatchSnapshot {
21
21
  gitHash?: string;
22
22
  commandOutput?: string;
23
23
  logLineCount?: number; // last known line count in agent's logs.jsonl
24
+ agentStatus?: string; // last known taskStatus of monitored agent
24
25
  sessionFileSize?: number; // last known file size of session JSONL (bytes)
25
26
  }
26
27
 
@@ -454,6 +455,28 @@ export class WatchManager extends EventEmitter {
454
455
  if (changes) allChanges.push(changes);
455
456
  break;
456
457
  }
458
+ case 'agent_status': {
459
+ // Monitor another agent's task status (running → done/failed)
460
+ const targetAgentId = target.path; // path = agent ID to monitor
461
+ if (targetAgentId) {
462
+ const agents = this.getAgents();
463
+ const targetEntry = agents.get(targetAgentId);
464
+ if (targetEntry) {
465
+ const currentStatus = targetEntry.state.taskStatus;
466
+ const prevStatus = prev.agentStatus;
467
+ newSnapshot.agentStatus = currentStatus;
468
+ if (prevStatus && prevStatus !== currentStatus) {
469
+ const label = targetEntry.config.label;
470
+ // Match pattern if specified (e.g., "done" or "failed")
471
+ const pattern = target.pattern;
472
+ if (!pattern || currentStatus.match(new RegExp(pattern, 'i'))) {
473
+ allChanges.push({ targetType: 'agent_status', description: `Agent ${label} status: ${prevStatus} → ${currentStatus}`, files: [] });
474
+ }
475
+ }
476
+ }
477
+ }
478
+ break;
479
+ }
457
480
  }
458
481
  }
459
482
 
@@ -259,7 +259,6 @@ async function handleAgentsPost(id: string, body: any, res: ServerResponse): Pro
259
259
  }
260
260
  case 'open_terminal': {
261
261
  if (!agentId) return jsonError(res, 'agentId required');
262
- if (!orch.isDaemonActive()) return jsonError(res, 'Start daemon first before opening terminal');
263
262
  const agentState = orch.getAgentState(agentId);
264
263
  const agentConfig = orch.getSnapshot().agents.find(a => a.id === agentId);
265
264
  if (!agentState || !agentConfig) return jsonError(res, 'Agent not found', 404);
@@ -387,6 +386,15 @@ async function handleAgentsPost(id: string, body: any, res: ServerResponse): Pro
387
386
  orch.emit('event', { type: 'bus_log_updated', log: orch.getBus().getLog() } as any);
388
387
  return json(res, { ok: true, deleted: ids.length });
389
388
  }
389
+ case 'message_done': {
390
+ const { messageId } = body;
391
+ if (!messageId) return jsonError(res, 'messageId required');
392
+ const msg = orch.getBus().getLog().find(m => m.id === messageId);
393
+ if (!msg) return jsonError(res, 'Message not found');
394
+ msg.status = 'done';
395
+ orch.emit('event', { type: 'bus_message_status', messageId, status: 'done' } as any);
396
+ return json(res, { ok: true });
397
+ }
390
398
  case 'start_daemon': {
391
399
  // Check active daemon count before starting
392
400
  const activeCount = Array.from(orchestrators.values()).filter(o => o.isDaemonActive()).length;
package/next-env.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /// <reference types="next" />
2
2
  /// <reference types="next/image-types/global" />
3
- import "./.next/types/routes.d.ts";
3
+ import "./.next/dev/types/routes.d.ts";
4
4
 
5
5
  // NOTE: This file should not be edited
6
6
  // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.5.9",
3
+ "version": "0.5.14",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -10,9 +10,9 @@
10
10
  "forge": "npx tsx cli/mw.ts"
11
11
  },
12
12
  "bin": {
13
- "forge": "./cli/mw.ts",
14
- "forge-server": "./bin/forge-server.mjs",
15
- "mw": "./cli/mw.ts"
13
+ "forge": "cli/mw.ts",
14
+ "forge-server": "bin/forge-server.mjs",
15
+ "mw": "cli/mw.ts"
16
16
  },
17
17
  "keywords": [
18
18
  "ai",