@aion0/forge 0.5.8 → 0.5.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +7 -0
- package/RELEASE_NOTES.md +7 -128
- package/components/WorkspaceView.tsx +191 -70
- 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 +206 -18
- 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/package.json +4 -4
- package/qa/.forge/mcp.json +8 -0
|
@@ -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 {
|
|
@@ -1171,10 +1288,18 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1171
1288
|
if (msg.type === 'ack' || msg.from === '_forge') continue;
|
|
1172
1289
|
if (this.forgeActedMessages.has(msg.id)) continue;
|
|
1173
1290
|
|
|
1174
|
-
// Case 1: Message done but no reply from target → ask target to send summary
|
|
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
|
-
if (age < 30_000) continue;
|
|
1298
|
+
if (age < 30_000) continue;
|
|
1299
|
+
|
|
1300
|
+
// Dedup by target→sender pair (only nudge once per relationship)
|
|
1301
|
+
const nudgeKey = `nudge-${msg.to}->${msg.from}`;
|
|
1302
|
+
if (this.forgeActedMessages.has(nudgeKey)) { this.forgeActedMessages.add(msg.id); continue; }
|
|
1178
1303
|
|
|
1179
1304
|
const hasReply = log.some(r =>
|
|
1180
1305
|
r.from === msg.to && r.to === msg.from &&
|
|
@@ -1189,7 +1314,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1189
1314
|
content: `[IMPORTANT] You finished a task requested by ${senderLabel} but did not send them the results. You MUST call the MCP tool "send_message" (NOT the forge-send skill) with to="${senderLabel}" and include a summary of what you did and the outcome. Do not do any other work until you have sent this reply.`,
|
|
1190
1315
|
});
|
|
1191
1316
|
this.forgeActedMessages.add(msg.id);
|
|
1192
|
-
|
|
1317
|
+
this.forgeActedMessages.add(nudgeKey);
|
|
1318
|
+
console.log(`[forge-agent] Nudged ${targetEntry.config.label} to reply to ${senderLabel} (once)`);
|
|
1193
1319
|
}
|
|
1194
1320
|
}
|
|
1195
1321
|
}
|
|
@@ -1752,16 +1878,36 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1752
1878
|
.slice(-1)[0]?.content || '';
|
|
1753
1879
|
|
|
1754
1880
|
const content = files.length > 0
|
|
1755
|
-
? `${completedLabel} completed: ${files.length} files changed. ${summary.slice(0, 200)}
|
|
1756
|
-
: `${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.'}`;
|
|
1757
1883
|
|
|
1758
|
-
// Find all downstream agents
|
|
1884
|
+
// Find all downstream agents — skip if already sent upstream_complete recently (60s)
|
|
1885
|
+
const now = Date.now();
|
|
1759
1886
|
let sent = 0;
|
|
1760
1887
|
for (const [id, entry] of this.agents) {
|
|
1761
1888
|
if (id === completedAgentId) continue;
|
|
1762
1889
|
if (entry.config.type === 'input') continue;
|
|
1763
1890
|
if (!entry.config.dependsOn.includes(completedAgentId)) continue;
|
|
1764
1891
|
|
|
1892
|
+
// Dedup: skip if upstream_complete was sent to this target within last 60s
|
|
1893
|
+
const recentDup = this.bus.getLog().some(m =>
|
|
1894
|
+
m.from === completedAgentId && m.to === id &&
|
|
1895
|
+
m.payload?.action === 'upstream_complete' &&
|
|
1896
|
+
now - m.timestamp < 60_000
|
|
1897
|
+
);
|
|
1898
|
+
if (recentDup) {
|
|
1899
|
+
console.log(`[bus] ${completedLabel} → ${entry.config.label}: upstream_complete skipped (sent <60s ago)`);
|
|
1900
|
+
continue;
|
|
1901
|
+
}
|
|
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
|
+
|
|
1765
1911
|
this.bus.send(completedAgentId, id, 'notify', {
|
|
1766
1912
|
action: 'upstream_complete',
|
|
1767
1913
|
content,
|
|
@@ -1818,8 +1964,10 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1818
1964
|
}
|
|
1819
1965
|
|
|
1820
1966
|
// Check if tmux session already exists
|
|
1967
|
+
let sessionAlreadyExists = false;
|
|
1821
1968
|
try {
|
|
1822
1969
|
execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`, { timeout: 3000 });
|
|
1970
|
+
sessionAlreadyExists = true;
|
|
1823
1971
|
console.log(`[daemon] ${config.label}: persistent session already exists (${sessionName})`);
|
|
1824
1972
|
} catch {
|
|
1825
1973
|
// Create new tmux session and start the CLI agent
|
|
@@ -1880,12 +2028,12 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1880
2028
|
const unsetCmd = profileVarsToReset.map(v => `unset ${v}`).join(' && ');
|
|
1881
2029
|
execSync(`tmux send-keys -t "${sessionName}" '${unsetCmd}' Enter`, { timeout: 5000 });
|
|
1882
2030
|
|
|
1883
|
-
// Set FORGE env vars
|
|
1884
|
-
|
|
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)
|
|
1885
2035
|
if (envExports) {
|
|
1886
|
-
execSync(`tmux send-keys -t "${sessionName}" '${
|
|
1887
|
-
} else {
|
|
1888
|
-
execSync(`tmux send-keys -t "${sessionName}" '${forgeVars}' Enter`, { timeout: 5000 });
|
|
2036
|
+
execSync(`tmux send-keys -t "${sessionName}" '${envExports.replace(/ && $/, '')}' Enter`, { timeout: 5000 });
|
|
1889
2037
|
}
|
|
1890
2038
|
|
|
1891
2039
|
// Build CLI start command
|
|
@@ -1952,7 +2100,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1952
2100
|
if (entry) {
|
|
1953
2101
|
entry.state.error = `Terminal failed: ${errorMsg}. Falling back to headless mode.`;
|
|
1954
2102
|
entry.state.tmuxSession = undefined; // clear so message loop uses headless (claude -p)
|
|
1955
|
-
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);
|
|
1956
2104
|
this.emitAgentsChanged();
|
|
1957
2105
|
}
|
|
1958
2106
|
// Kill the failed tmux session
|
|
@@ -2000,6 +2148,31 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2000
2148
|
this.saveNow();
|
|
2001
2149
|
this.emitAgentsChanged();
|
|
2002
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
|
+
}
|
|
2003
2176
|
}
|
|
2004
2177
|
|
|
2005
2178
|
/** Inject text into an agent's persistent terminal session */
|
|
@@ -2209,8 +2382,23 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2209
2382
|
// requiresApproval is handled at message arrival time (routeMessageToAgent),
|
|
2210
2383
|
// not in the message loop. Approved messages come through as normal 'pending'.
|
|
2211
2384
|
|
|
2385
|
+
// Dedup: if multiple upstream_complete from same sender are pending, keep only latest
|
|
2386
|
+
const allRaw = this.bus.getPendingMessagesFor(agentId).filter(m => m.from !== agentId && m.type !== 'ack');
|
|
2387
|
+
const upstreamSeen = new Set<string>();
|
|
2388
|
+
for (let i = allRaw.length - 1; i >= 0; i--) {
|
|
2389
|
+
const m = allRaw[i];
|
|
2390
|
+
if (m.payload?.action === 'upstream_complete') {
|
|
2391
|
+
const key = `upstream-${m.from}`;
|
|
2392
|
+
if (upstreamSeen.has(key)) {
|
|
2393
|
+
m.status = 'done' as any;
|
|
2394
|
+
this.emit('event', { type: 'bus_message_status', messageId: m.id, status: 'done' } as any);
|
|
2395
|
+
}
|
|
2396
|
+
upstreamSeen.add(key);
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2212
2400
|
// Find next pending message, applying causedBy rules
|
|
2213
|
-
const allPending =
|
|
2401
|
+
const allPending = allRaw.filter(m => m.status === 'pending');
|
|
2214
2402
|
const pending = allPending.filter(m => {
|
|
2215
2403
|
// Tickets: accepted but check retry limit
|
|
2216
2404
|
if (m.category === 'ticket') {
|
|
@@ -2283,7 +2471,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2283
2471
|
} else {
|
|
2284
2472
|
entry.worker!.setProcessingMessage(nextMsg.id);
|
|
2285
2473
|
entry.worker!.wake({ type: 'bus_message', messages: [logEntry] });
|
|
2286
|
-
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);
|
|
2287
2475
|
}
|
|
2288
2476
|
};
|
|
2289
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/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",
|