@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.
@@ -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 {
@@ -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; // give 30s grace period
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
- console.log(`[forge-agent] Nudged ${targetEntry.config.label} to reply to ${senderLabel}`);
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) || 'Check upstream outputs for updates.'}`;
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 that depend on this one
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 + profile env vars
1884
- const forgeVars = `export FORGE_WORKSPACE_ID="${this.workspaceId}" FORGE_AGENT_ID="${config.id}" FORGE_PORT="${Number(process.env.PORT) || 8403}"`;
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}" '${forgeVars} && ${envExports.replace(/ && $/, '')}' Enter`, { timeout: 5000 });
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 (claude -p).`, timestamp: new Date().toISOString() } } as any);
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 = this.bus.getPendingMessagesFor(agentId).filter(m => m.from !== agentId && m.type !== 'ack');
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: '⚡ Executed via claude -p', timestamp: new Date().toISOString() } } as any);
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
+ }
@@ -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.8",
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": "./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",
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "forge": {
4
+ "type": "sse",
5
+ "url": "http://localhost:8406/sse?workspaceId=656c9e65-9d73-4cb6-a065-60d966e1fc78&agentId=qa-1774920510930"
6
+ }
7
+ }
8
+ }