@aion0/forge 0.5.9 → 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.
- package/CLAUDE.md +7 -0
- package/RELEASE_NOTES.md +7 -9
- package/components/WorkspaceView.tsx +125 -52
- package/lib/agents/index.ts +2 -2
- package/lib/workspace/agent-worker.ts +2 -11
- package/lib/workspace/backends/cli-backend.ts +11 -2
- package/lib/workspace/orchestrator.ts +169 -13
- package/lib/workspace/session-monitor.ts +210 -0
- package/lib/workspace/types.ts +1 -1
- package/lib/workspace/watch-manager.ts +23 -0
- package/lib/workspace-standalone.ts +9 -1
- package/next-env.d.ts +1 -1
- package/package.json +4 -4
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,13 @@
|
|
|
1
|
-
# Forge v0.5.
|
|
1
|
+
# Forge v0.5.12
|
|
2
2
|
|
|
3
|
-
Released: 2026-03-
|
|
3
|
+
Released: 2026-03-31
|
|
4
4
|
|
|
5
|
-
## Changes since v0.5.
|
|
6
|
-
|
|
7
|
-
### Features
|
|
8
|
-
- feat: terminal floats below agent node, follows on drag/zoom
|
|
5
|
+
## Changes since v0.5.11
|
|
9
6
|
|
|
10
7
|
### Bug Fixes
|
|
11
|
-
- fix:
|
|
12
|
-
- fix:
|
|
8
|
+
- fix: correct bin paths in package.json
|
|
9
|
+
- fix: merge pending upstream_complete — auto-complete older ones before sending new
|
|
10
|
+
- fix: upstream_complete notification tells agent to ignore if busy or duplicate
|
|
13
11
|
|
|
14
12
|
|
|
15
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.
|
|
13
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.11...v0.5.12
|
|
@@ -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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
<
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1741
|
-
const
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
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
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
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
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
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
|
|
2194
|
-
const
|
|
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
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
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
|
|
package/lib/agents/index.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
|
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
|
-
||
|
|
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
|
-
|
|
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
|
|
|
@@ -1757,8 +1878,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1757
1878
|
.slice(-1)[0]?.content || '';
|
|
1758
1879
|
|
|
1759
1880
|
const content = files.length > 0
|
|
1760
|
-
? `${completedLabel} completed: ${files.length} files changed. ${summary.slice(0, 200)}
|
|
1761
|
-
: `${completedLabel} completed. ${summary.slice(0, 300) || '
|
|
1881
|
+
? `${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.`
|
|
1882
|
+
: `${completedLabel} completed. ${summary.slice(0, 300) || 'If you are currently processing a task or have seen this before, ignore this notification.'}`;
|
|
1762
1883
|
|
|
1763
1884
|
// Find all downstream agents — skip if already sent upstream_complete recently (60s)
|
|
1764
1885
|
const now = Date.now();
|
|
@@ -1779,6 +1900,14 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1779
1900
|
continue;
|
|
1780
1901
|
}
|
|
1781
1902
|
|
|
1903
|
+
// Merge: auto-complete older pending upstream_complete from same sender
|
|
1904
|
+
for (const m of this.bus.getLog()) {
|
|
1905
|
+
if (m.from === completedAgentId && m.to === id && m.status === 'pending' && m.payload?.action === 'upstream_complete') {
|
|
1906
|
+
m.status = 'done' as any;
|
|
1907
|
+
this.emit('event', { type: 'bus_message_status', messageId: m.id, status: 'done' } as any);
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1782
1911
|
this.bus.send(completedAgentId, id, 'notify', {
|
|
1783
1912
|
action: 'upstream_complete',
|
|
1784
1913
|
content,
|
|
@@ -1835,8 +1964,10 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1835
1964
|
}
|
|
1836
1965
|
|
|
1837
1966
|
// Check if tmux session already exists
|
|
1967
|
+
let sessionAlreadyExists = false;
|
|
1838
1968
|
try {
|
|
1839
1969
|
execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`, { timeout: 3000 });
|
|
1970
|
+
sessionAlreadyExists = true;
|
|
1840
1971
|
console.log(`[daemon] ${config.label}: persistent session already exists (${sessionName})`);
|
|
1841
1972
|
} catch {
|
|
1842
1973
|
// Create new tmux session and start the CLI agent
|
|
@@ -1897,12 +2028,12 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1897
2028
|
const unsetCmd = profileVarsToReset.map(v => `unset ${v}`).join(' && ');
|
|
1898
2029
|
execSync(`tmux send-keys -t "${sessionName}" '${unsetCmd}' Enter`, { timeout: 5000 });
|
|
1899
2030
|
|
|
1900
|
-
// Set FORGE env vars
|
|
1901
|
-
|
|
2031
|
+
// Set FORGE env vars (short, separate command)
|
|
2032
|
+
execSync(`tmux send-keys -t "${sessionName}" 'export FORGE_WORKSPACE_ID="${this.workspaceId}" FORGE_AGENT_ID="${config.id}" FORGE_PORT="${Number(process.env.PORT) || 8403}"' Enter`, { timeout: 5000 });
|
|
2033
|
+
|
|
2034
|
+
// Set profile env vars if any (separate command to avoid truncation)
|
|
1902
2035
|
if (envExports) {
|
|
1903
|
-
execSync(`tmux send-keys -t "${sessionName}" '${
|
|
1904
|
-
} else {
|
|
1905
|
-
execSync(`tmux send-keys -t "${sessionName}" '${forgeVars}' Enter`, { timeout: 5000 });
|
|
2036
|
+
execSync(`tmux send-keys -t "${sessionName}" '${envExports.replace(/ && $/, '')}' Enter`, { timeout: 5000 });
|
|
1906
2037
|
}
|
|
1907
2038
|
|
|
1908
2039
|
// Build CLI start command
|
|
@@ -1969,7 +2100,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1969
2100
|
if (entry) {
|
|
1970
2101
|
entry.state.error = `Terminal failed: ${errorMsg}. Falling back to headless mode.`;
|
|
1971
2102
|
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
|
|
2103
|
+
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
2104
|
this.emitAgentsChanged();
|
|
1974
2105
|
}
|
|
1975
2106
|
// Kill the failed tmux session
|
|
@@ -2017,6 +2148,31 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2017
2148
|
this.saveNow();
|
|
2018
2149
|
this.emitAgentsChanged();
|
|
2019
2150
|
}
|
|
2151
|
+
|
|
2152
|
+
// Ensure boundSessionId is set (required for session monitor + --resume)
|
|
2153
|
+
if (!config.primary && !config.boundSessionId) {
|
|
2154
|
+
const bindDelay = sessionAlreadyExists ? 500 : 5000;
|
|
2155
|
+
setTimeout(() => {
|
|
2156
|
+
try {
|
|
2157
|
+
const sessionDir = this.getCliSessionDir(config.workDir);
|
|
2158
|
+
if (existsSync(sessionDir)) {
|
|
2159
|
+
const { readdirSync, statSync: statS } = require('node:fs');
|
|
2160
|
+
const files = readdirSync(sessionDir).filter((f: string) => f.endsWith('.jsonl'));
|
|
2161
|
+
if (files.length > 0) {
|
|
2162
|
+
const latest = files
|
|
2163
|
+
.map((f: string) => ({ name: f, mtime: statS(join(sessionDir, f)).mtimeMs }))
|
|
2164
|
+
.sort((a: any, b: any) => b.mtime - a.mtime)[0];
|
|
2165
|
+
config.boundSessionId = latest.name.replace('.jsonl', '');
|
|
2166
|
+
this.saveNow();
|
|
2167
|
+
console.log(`[daemon] ${config.label}: bound to session ${config.boundSessionId}`);
|
|
2168
|
+
this.startAgentSessionMonitor(agentId, config);
|
|
2169
|
+
} else {
|
|
2170
|
+
console.log(`[daemon] ${config.label}: no session files yet, will bind on next check`);
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
} catch {}
|
|
2174
|
+
}, bindDelay);
|
|
2175
|
+
}
|
|
2020
2176
|
}
|
|
2021
2177
|
|
|
2022
2178
|
/** Inject text into an agent's persistent terminal session */
|
|
@@ -2315,7 +2471,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2315
2471
|
} else {
|
|
2316
2472
|
entry.worker!.setProcessingMessage(nextMsg.id);
|
|
2317
2473
|
entry.worker!.wake({ type: 'bus_message', messages: [logEntry] });
|
|
2318
|
-
this.emit('event', { type: 'log', agentId, entry: { type: 'system', subtype: 'execution_method', content:
|
|
2474
|
+
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
2475
|
}
|
|
2320
2476
|
};
|
|
2321
2477
|
|
|
@@ -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
|
+
}
|
package/lib/workspace/types.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.5.12",
|
|
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": "
|
|
14
|
-
"forge-server": "
|
|
15
|
-
"mw": "
|
|
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",
|