@aion0/forge 0.5.7 → 0.5.8
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/.forge/mcp.json +8 -0
- package/RELEASE_NOTES.md +128 -5
- package/app/api/monitor/route.ts +12 -0
- package/app/api/project-sessions/route.ts +61 -0
- package/app/api/workspace/route.ts +1 -1
- package/check-forge-status.sh +9 -0
- package/components/MonitorPanel.tsx +15 -0
- package/components/ProjectDetail.tsx +99 -5
- package/components/SessionView.tsx +67 -19
- package/components/WebTerminal.tsx +40 -25
- package/components/WorkspaceView.tsx +545 -103
- package/lib/claude-sessions.ts +26 -28
- package/lib/forge-mcp-server.ts +389 -0
- package/lib/forge-skills/forge-inbox.md +13 -12
- package/lib/forge-skills/forge-send.md +13 -6
- package/lib/forge-skills/forge-status.md +12 -12
- package/lib/project-sessions.ts +48 -0
- package/lib/session-utils.ts +49 -0
- package/lib/workspace/__tests__/state-machine.test.ts +2 -2
- package/lib/workspace/agent-worker.ts +2 -5
- package/lib/workspace/backends/cli-backend.ts +3 -0
- package/lib/workspace/orchestrator.ts +740 -88
- package/lib/workspace/persistence.ts +0 -1
- package/lib/workspace/types.ts +10 -6
- package/lib/workspace/watch-manager.ts +17 -7
- package/lib/workspace-standalone.ts +83 -27
- package/package.json +4 -2
|
@@ -11,14 +11,15 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { EventEmitter } from 'node:events';
|
|
14
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
15
|
-
import {
|
|
14
|
+
import { readFileSync, existsSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
15
|
+
import { execSync } from 'node:child_process';
|
|
16
|
+
import { resolve, join } from 'node:path';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
16
18
|
import type {
|
|
17
19
|
WorkspaceAgentConfig,
|
|
18
20
|
AgentState,
|
|
19
21
|
SmithStatus,
|
|
20
22
|
TaskStatus,
|
|
21
|
-
AgentMode,
|
|
22
23
|
WorkerEvent,
|
|
23
24
|
BusMessage,
|
|
24
25
|
Artifact,
|
|
@@ -200,6 +201,14 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
200
201
|
return check(agentB);
|
|
201
202
|
}
|
|
202
203
|
|
|
204
|
+
/** Get the primary agent for this workspace (if any) */
|
|
205
|
+
getPrimaryAgent(): { config: WorkspaceAgentConfig; state: AgentState } | null {
|
|
206
|
+
for (const [, entry] of this.agents) {
|
|
207
|
+
if (entry.config.primary) return entry;
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
203
212
|
addAgent(config: WorkspaceAgentConfig): void {
|
|
204
213
|
const conflict = this.validateOutputs(config);
|
|
205
214
|
if (conflict) throw new Error(conflict);
|
|
@@ -208,20 +217,37 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
208
217
|
const cycleErr = this.detectCycle(config.id, config.dependsOn);
|
|
209
218
|
if (cycleErr) throw new Error(cycleErr);
|
|
210
219
|
|
|
220
|
+
// Primary agent validation
|
|
221
|
+
this.validatePrimaryRules(config);
|
|
222
|
+
|
|
211
223
|
const state: AgentState = {
|
|
212
224
|
smithStatus: 'down',
|
|
213
|
-
mode: 'auto',
|
|
214
225
|
taskStatus: 'idle',
|
|
215
226
|
history: [],
|
|
216
227
|
artifacts: [],
|
|
217
228
|
};
|
|
229
|
+
// Primary agent: force terminal-only, root dir
|
|
230
|
+
if (config.primary) {
|
|
231
|
+
config.persistentSession = true;
|
|
232
|
+
config.workDir = './';
|
|
233
|
+
}
|
|
218
234
|
this.agents.set(config.id, { config, worker: null, state });
|
|
235
|
+
// If daemon active, start persistent session + worker
|
|
236
|
+
if (this.daemonActive && config.type !== 'input' && config.persistentSession) {
|
|
237
|
+
this.enterDaemonListening(config.id);
|
|
238
|
+
const entry = this.agents.get(config.id)!;
|
|
239
|
+
entry.state.smithStatus = 'active';
|
|
240
|
+
this.ensurePersistentSession(config.id, config).then(() => {
|
|
241
|
+
this.startMessageLoop(config.id);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
219
244
|
this.saveNow();
|
|
220
245
|
this.emitAgentsChanged();
|
|
221
246
|
}
|
|
222
247
|
|
|
223
248
|
removeAgent(id: string): void {
|
|
224
249
|
const entry = this.agents.get(id);
|
|
250
|
+
if (entry?.config.primary) throw new Error('Cannot remove the primary agent');
|
|
225
251
|
if (entry?.worker) {
|
|
226
252
|
entry.worker.stop();
|
|
227
253
|
}
|
|
@@ -240,6 +266,28 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
240
266
|
this.emitAgentsChanged();
|
|
241
267
|
}
|
|
242
268
|
|
|
269
|
+
/** Validate primary agent rules */
|
|
270
|
+
private validatePrimaryRules(config: WorkspaceAgentConfig, excludeId?: string): void {
|
|
271
|
+
if (config.primary) {
|
|
272
|
+
// Only one primary allowed
|
|
273
|
+
for (const [id, entry] of this.agents) {
|
|
274
|
+
if (id !== excludeId && entry.config.primary) {
|
|
275
|
+
throw new Error(`Only one primary agent allowed. "${entry.config.label}" is already primary.`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// Non-primary agents cannot use root directory if a primary exists
|
|
280
|
+
if (!config.primary && config.type !== 'input') {
|
|
281
|
+
const workDir = config.workDir?.replace(/\/+$/, '') || '';
|
|
282
|
+
if (!workDir || workDir === '.' || workDir === './') {
|
|
283
|
+
const primary = this.getPrimaryAgent();
|
|
284
|
+
if (primary && primary.config.id !== excludeId) {
|
|
285
|
+
throw new Error(`Root directory is reserved for primary agent "${primary.config.label}". Choose a subdirectory.`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
243
291
|
updateAgentConfig(id: string, config: WorkspaceAgentConfig): void {
|
|
244
292
|
const entry = this.agents.get(id);
|
|
245
293
|
if (!entry) return;
|
|
@@ -247,22 +295,57 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
247
295
|
if (conflict) throw new Error(conflict);
|
|
248
296
|
const cycleErr = this.detectCycle(id, config.dependsOn);
|
|
249
297
|
if (cycleErr) throw new Error(cycleErr);
|
|
298
|
+
this.validatePrimaryRules(config, id);
|
|
299
|
+
// Primary agent: force terminal-only, root dir
|
|
300
|
+
if (config.primary) {
|
|
301
|
+
config.persistentSession = true;
|
|
302
|
+
config.workDir = './';
|
|
303
|
+
}
|
|
250
304
|
if (entry.worker && entry.state.taskStatus === 'running') {
|
|
251
305
|
entry.worker.stop();
|
|
252
306
|
}
|
|
307
|
+
|
|
308
|
+
// If agent CLI changed (claude→codex, etc.), kill old terminal and clear bound session
|
|
309
|
+
const agentChanged = entry.config.agentId !== config.agentId;
|
|
310
|
+
if (agentChanged) {
|
|
311
|
+
console.log(`[workspace] ${config.label}: agent changed ${entry.config.agentId} → ${config.agentId}`);
|
|
312
|
+
if (entry.state.tmuxSession) {
|
|
313
|
+
try { execSync(`tmux kill-session -t "${entry.state.tmuxSession}" 2>/dev/null`, { timeout: 3000 }); } catch {}
|
|
314
|
+
console.log(`[workspace] ${config.label}: killed tmux session ${entry.state.tmuxSession}`);
|
|
315
|
+
}
|
|
316
|
+
entry.state.tmuxSession = undefined;
|
|
317
|
+
config.boundSessionId = undefined;
|
|
318
|
+
}
|
|
319
|
+
|
|
253
320
|
entry.config = config;
|
|
254
321
|
// Reset status but keep history/artifacts (don't wipe logs)
|
|
255
322
|
entry.state.taskStatus = 'idle';
|
|
256
323
|
entry.state.error = undefined;
|
|
324
|
+
if (entry.worker) {
|
|
325
|
+
entry.worker.removeAllListeners();
|
|
326
|
+
entry.worker.stop();
|
|
327
|
+
}
|
|
257
328
|
entry.worker = null;
|
|
258
|
-
|
|
329
|
+
|
|
259
330
|
if (this.daemonActive) {
|
|
331
|
+
// Rebuild worker + message loop
|
|
332
|
+
this.enterDaemonListening(id);
|
|
333
|
+
entry.state.smithStatus = 'active';
|
|
334
|
+
// Restart watch if config changed
|
|
260
335
|
this.watchManager.startWatch(id, config);
|
|
336
|
+
// Create persistent session if configured (before message loop so inject works)
|
|
337
|
+
if (config.persistentSession) {
|
|
338
|
+
this.ensurePersistentSession(id, config).then(() => {
|
|
339
|
+
this.startMessageLoop(id);
|
|
340
|
+
});
|
|
341
|
+
} else {
|
|
342
|
+
this.startMessageLoop(id);
|
|
343
|
+
}
|
|
261
344
|
}
|
|
262
345
|
this.saveNow();
|
|
263
346
|
this.emitAgentsChanged();
|
|
264
|
-
// Push status update so frontend reflects the reset
|
|
265
347
|
this.emit('event', { type: 'task_status', agentId: id, taskStatus: 'idle' } satisfies WorkerEvent);
|
|
348
|
+
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: entry.state.smithStatus } as any);
|
|
266
349
|
}
|
|
267
350
|
|
|
268
351
|
getAgentState(id: string): Readonly<AgentState> | undefined {
|
|
@@ -275,7 +358,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
275
358
|
const workerState = entry.worker?.getState();
|
|
276
359
|
// Merge: worker state for task/smith, entry.state for mode (orchestrator controls mode)
|
|
277
360
|
result[id] = workerState
|
|
278
|
-
? { ...workerState,
|
|
361
|
+
? { ...workerState, tmuxSession: entry.state.tmuxSession, currentMessageId: entry.state.currentMessageId }
|
|
279
362
|
: entry.state;
|
|
280
363
|
}
|
|
281
364
|
return result;
|
|
@@ -341,7 +424,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
341
424
|
console.log(`[workspace] Killed tmux session ${entry.state.tmuxSession}`);
|
|
342
425
|
} catch {} // session might already be dead
|
|
343
426
|
}
|
|
344
|
-
entry.state = { smithStatus: 'down',
|
|
427
|
+
entry.state = { smithStatus: 'down', taskStatus: 'idle', history: entry.state.history, artifacts: [] };
|
|
345
428
|
this.emit('event', { type: 'task_status', agentId, taskStatus: 'idle' } satisfies WorkerEvent);
|
|
346
429
|
this.emitAgentsChanged();
|
|
347
430
|
this.saveNow();
|
|
@@ -359,7 +442,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
359
442
|
console.log(`[workspace] Resetting ${entry.config.label} (${id}) to idle (upstream ${agentId} changed)`);
|
|
360
443
|
if (entry.worker) entry.worker.stop();
|
|
361
444
|
entry.worker = null;
|
|
362
|
-
entry.state = { smithStatus: entry.state.smithStatus,
|
|
445
|
+
entry.state = { smithStatus: entry.state.smithStatus, taskStatus: 'idle', history: entry.state.history, artifacts: [], cliSessionId: entry.state.cliSessionId };
|
|
363
446
|
this.emit('event', { type: 'task_status', agentId: id, taskStatus: 'idle' } satisfies WorkerEvent);
|
|
364
447
|
this.resetDownstream(id, visited);
|
|
365
448
|
}
|
|
@@ -418,11 +501,10 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
418
501
|
if (entry.worker) entry.worker.stop();
|
|
419
502
|
entry.worker = null;
|
|
420
503
|
if (!resumeFromCheckpoint) {
|
|
421
|
-
entry.state = { smithStatus: entry.state.smithStatus,
|
|
504
|
+
entry.state = { smithStatus: entry.state.smithStatus, taskStatus: 'idle', history: entry.state.history, artifacts: [], cliSessionId: entry.state.cliSessionId };
|
|
422
505
|
} else {
|
|
423
506
|
entry.state.taskStatus = 'idle';
|
|
424
507
|
entry.state.error = undefined;
|
|
425
|
-
entry.state.mode = 'auto';
|
|
426
508
|
}
|
|
427
509
|
}
|
|
428
510
|
|
|
@@ -603,7 +685,6 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
603
685
|
entry.worker = null;
|
|
604
686
|
entry.state = {
|
|
605
687
|
smithStatus: entry.state.smithStatus,
|
|
606
|
-
mode: entry.state.mode,
|
|
607
688
|
taskStatus: 'idle',
|
|
608
689
|
history: [],
|
|
609
690
|
artifacts: [],
|
|
@@ -614,7 +695,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
614
695
|
// Ensure smith is active when daemon starts this agent
|
|
615
696
|
if (this.daemonActive && entry.state.smithStatus !== 'active') {
|
|
616
697
|
entry.state.smithStatus = 'active';
|
|
617
|
-
this.emit('event', { type: 'smith_status', agentId, smithStatus: 'active'
|
|
698
|
+
this.emit('event', { type: 'smith_status', agentId, smithStatus: 'active' } satisfies WorkerEvent);
|
|
618
699
|
}
|
|
619
700
|
|
|
620
701
|
const { config } = entry;
|
|
@@ -706,7 +787,6 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
706
787
|
}
|
|
707
788
|
if (event.type === 'smith_status') {
|
|
708
789
|
entry.state.smithStatus = event.smithStatus;
|
|
709
|
-
entry.state.mode = event.mode;
|
|
710
790
|
}
|
|
711
791
|
if (event.type === 'log') {
|
|
712
792
|
appendAgentLog(this.workspaceId, agentId, event.entry).catch(() => {});
|
|
@@ -793,29 +873,42 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
793
873
|
|
|
794
874
|
// 3. Set smith status to active
|
|
795
875
|
entry.state.smithStatus = 'active';
|
|
796
|
-
entry.state.mode = 'auto';
|
|
797
876
|
entry.state.error = undefined;
|
|
798
877
|
|
|
799
|
-
// 4. Start message
|
|
800
|
-
|
|
878
|
+
// 4. Start message loop (delayed for persistent session agents — session must exist first)
|
|
879
|
+
if (!entry.config.persistentSession) {
|
|
880
|
+
this.startMessageLoop(id);
|
|
881
|
+
}
|
|
801
882
|
|
|
802
883
|
// 5. Update liveness for bus routing
|
|
803
884
|
this.updateAgentLiveness(id);
|
|
804
885
|
|
|
805
886
|
// 6. Notify frontend
|
|
806
|
-
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'active'
|
|
887
|
+
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'active' } satisfies WorkerEvent);
|
|
807
888
|
|
|
808
889
|
started++;
|
|
809
890
|
console.log(`[daemon] ✓ ${entry.config.label}: active (task=${entry.state.taskStatus})`);
|
|
810
891
|
} catch (err: any) {
|
|
811
892
|
entry.state.smithStatus = 'down';
|
|
812
893
|
entry.state.error = `Failed to start: ${err.message}`;
|
|
813
|
-
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'down'
|
|
894
|
+
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'down' } satisfies WorkerEvent);
|
|
814
895
|
failed++;
|
|
815
896
|
console.error(`[daemon] ✗ ${entry.config.label}: failed — ${err.message}`);
|
|
816
897
|
}
|
|
817
898
|
}
|
|
818
899
|
|
|
900
|
+
// Create persistent terminal sessions, then start their message loops
|
|
901
|
+
for (const [id, entry] of this.agents) {
|
|
902
|
+
if (entry.config.type === 'input' || !entry.config.persistentSession) continue;
|
|
903
|
+
await this.ensurePersistentSession(id, entry.config);
|
|
904
|
+
// Only start message loop if session was created successfully
|
|
905
|
+
if (entry.state.smithStatus === 'active') {
|
|
906
|
+
this.startMessageLoop(id);
|
|
907
|
+
} else {
|
|
908
|
+
console.log(`[daemon] ${entry.config.label}: skipped message loop (smith=${entry.state.smithStatus})`);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
819
912
|
// Start watch loops for agents with watch config
|
|
820
913
|
this.watchManager.start();
|
|
821
914
|
|
|
@@ -912,7 +1005,6 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
912
1005
|
}
|
|
913
1006
|
if (event.type === 'smith_status') {
|
|
914
1007
|
entry.state.smithStatus = event.smithStatus;
|
|
915
|
-
entry.state.mode = event.mode;
|
|
916
1008
|
}
|
|
917
1009
|
if (event.type === 'log') {
|
|
918
1010
|
appendAgentLog(this.workspaceId, agentId, event.entry).catch(() => {});
|
|
@@ -957,11 +1049,17 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
957
1049
|
entry.worker = null;
|
|
958
1050
|
}
|
|
959
1051
|
|
|
960
|
-
// 3.
|
|
1052
|
+
// 3. Kill tmux session
|
|
1053
|
+
if (entry.state.tmuxSession) {
|
|
1054
|
+
try { execSync(`tmux kill-session -t "${entry.state.tmuxSession}" 2>/dev/null`, { timeout: 3000 }); } catch {}
|
|
1055
|
+
entry.state.tmuxSession = undefined;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// 4. Set smith down
|
|
961
1059
|
entry.state.smithStatus = 'down';
|
|
962
1060
|
entry.state.error = undefined;
|
|
963
1061
|
this.updateAgentLiveness(id);
|
|
964
|
-
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'down'
|
|
1062
|
+
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'down' } satisfies WorkerEvent);
|
|
965
1063
|
|
|
966
1064
|
console.log(`[daemon] ■ ${entry.config.label}: stopped`);
|
|
967
1065
|
}
|
|
@@ -970,7 +1068,9 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
970
1068
|
this.bus.markAllRunningAsFailed();
|
|
971
1069
|
this.emitAgentsChanged();
|
|
972
1070
|
this.watchManager.stop();
|
|
1071
|
+
this.stopAllTerminalMonitors();
|
|
973
1072
|
this.stopHealthCheck();
|
|
1073
|
+
this.forgeActedMessages.clear();
|
|
974
1074
|
console.log('[workspace] Daemon stopped');
|
|
975
1075
|
}
|
|
976
1076
|
|
|
@@ -994,14 +1094,13 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
994
1094
|
|
|
995
1095
|
for (const [id, entry] of this.agents) {
|
|
996
1096
|
if (entry.config.type === 'input') continue;
|
|
997
|
-
if (entry.state.mode === 'manual') continue;
|
|
998
1097
|
|
|
999
1098
|
// Check 1: Worker should exist for all active agents
|
|
1000
1099
|
if (!entry.worker) {
|
|
1001
1100
|
console.log(`[health] ${entry.config.label}: no worker — recreating`);
|
|
1002
1101
|
this.enterDaemonListening(id);
|
|
1003
1102
|
entry.state.smithStatus = 'active';
|
|
1004
|
-
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'active'
|
|
1103
|
+
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'active' } as any);
|
|
1005
1104
|
continue;
|
|
1006
1105
|
}
|
|
1007
1106
|
|
|
@@ -1009,7 +1108,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1009
1108
|
if (entry.state.smithStatus !== 'active') {
|
|
1010
1109
|
console.log(`[health] ${entry.config.label}: smith=${entry.state.smithStatus} — setting active`);
|
|
1011
1110
|
entry.state.smithStatus = 'active';
|
|
1012
|
-
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'active'
|
|
1111
|
+
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'active' } as any);
|
|
1013
1112
|
}
|
|
1014
1113
|
|
|
1015
1114
|
// Check 3: Message loop should be running
|
|
@@ -1032,7 +1131,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1032
1131
|
}
|
|
1033
1132
|
|
|
1034
1133
|
// Check 5: Pending messages but agent idle — try wake
|
|
1035
|
-
if (entry.state.taskStatus !== 'running'
|
|
1134
|
+
if (entry.state.taskStatus !== 'running') {
|
|
1036
1135
|
const pending = this.bus.getPendingMessagesFor(id).filter(m => m.from !== id && m.type !== 'ack');
|
|
1037
1136
|
if (pending.length > 0 && entry.worker.isListening()) {
|
|
1038
1137
|
// Message loop should handle this, but if it didn't, log it
|
|
@@ -1042,6 +1141,115 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1042
1141
|
}
|
|
1043
1142
|
}
|
|
1044
1143
|
}
|
|
1144
|
+
|
|
1145
|
+
// Check 6: persistentSession agent without tmux → auto-restart terminal
|
|
1146
|
+
if (entry.config.persistentSession && !entry.state.tmuxSession) {
|
|
1147
|
+
console.log(`[health] ${entry.config.label}: persistentSession but no tmux — restarting terminal`);
|
|
1148
|
+
this.ensurePersistentSession(id, entry.config).catch(err => {
|
|
1149
|
+
console.error(`[health] ${entry.config.label}: failed to restart terminal: ${err.message}`);
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// ── Forge Agent: autonomous bus monitor ──
|
|
1155
|
+
this.runForgeAgentCheck();
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// Track which messages Forge agent already acted on (avoid duplicate nudges)
|
|
1159
|
+
private forgeActedMessages = new Set<string>();
|
|
1160
|
+
private forgeAgentStartTime = 0;
|
|
1161
|
+
|
|
1162
|
+
/** Forge agent scans bus for actionable states (only recent messages) */
|
|
1163
|
+
private runForgeAgentCheck(): void {
|
|
1164
|
+
if (!this.forgeAgentStartTime) this.forgeAgentStartTime = Date.now();
|
|
1165
|
+
const log = this.bus.getLog();
|
|
1166
|
+
const now = Date.now();
|
|
1167
|
+
|
|
1168
|
+
// Only scan messages from after daemon start (skip all history)
|
|
1169
|
+
for (const msg of log) {
|
|
1170
|
+
if (msg.timestamp < this.forgeAgentStartTime) continue;
|
|
1171
|
+
if (msg.type === 'ack' || msg.from === '_forge') continue;
|
|
1172
|
+
if (this.forgeActedMessages.has(msg.id)) continue;
|
|
1173
|
+
|
|
1174
|
+
// Case 1: Message done but no reply from target → ask target to send summary
|
|
1175
|
+
if (msg.status === 'done') {
|
|
1176
|
+
const age = now - msg.timestamp;
|
|
1177
|
+
if (age < 30_000) continue; // give 30s grace period
|
|
1178
|
+
|
|
1179
|
+
const hasReply = log.some(r =>
|
|
1180
|
+
r.from === msg.to && r.to === msg.from &&
|
|
1181
|
+
r.timestamp > msg.timestamp && r.type !== 'ack'
|
|
1182
|
+
);
|
|
1183
|
+
if (!hasReply) {
|
|
1184
|
+
const senderLabel = this.agents.get(msg.from)?.config.label || msg.from;
|
|
1185
|
+
const targetEntry = this.agents.get(msg.to);
|
|
1186
|
+
if (targetEntry && targetEntry.state.smithStatus === 'active') {
|
|
1187
|
+
this.bus.send('_forge', msg.to, 'notify', {
|
|
1188
|
+
action: 'info_request',
|
|
1189
|
+
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
|
+
});
|
|
1191
|
+
this.forgeActedMessages.add(msg.id);
|
|
1192
|
+
console.log(`[forge-agent] Nudged ${targetEntry.config.label} to reply to ${senderLabel}`);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// Case 2: Message running too long (>5min) → log warning
|
|
1198
|
+
if (msg.status === 'running') {
|
|
1199
|
+
const age = now - msg.timestamp;
|
|
1200
|
+
if (age > 300_000 && !this.forgeActedMessages.has(`running-${msg.id}`)) {
|
|
1201
|
+
const targetLabel = this.agents.get(msg.to)?.config.label || msg.to;
|
|
1202
|
+
console.log(`[forge-agent] Warning: ${targetLabel} has been running message ${msg.id.slice(0, 8)} for ${Math.round(age / 60000)}min`);
|
|
1203
|
+
this.emit('event', { type: 'log', agentId: msg.to, entry: { type: 'system', subtype: 'warning', content: `Message running for ${Math.round(age / 60000)}min — may be stuck`, timestamp: new Date().toISOString() } } as any);
|
|
1204
|
+
this.forgeActedMessages.add(`running-${msg.id}`);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// Case 3: Pending too long (>2min) → try to restart message loop
|
|
1209
|
+
if (msg.status === 'pending') {
|
|
1210
|
+
const age = now - msg.timestamp;
|
|
1211
|
+
if (age > 120_000 && !this.forgeActedMessages.has(`pending-${msg.id}`)) {
|
|
1212
|
+
const targetEntry = this.agents.get(msg.to);
|
|
1213
|
+
const targetLabel = targetEntry?.config.label || msg.to;
|
|
1214
|
+
|
|
1215
|
+
// If agent is active but not running a task, restart message loop
|
|
1216
|
+
if (targetEntry && targetEntry.state.smithStatus === 'active' && targetEntry.state.taskStatus !== 'running') {
|
|
1217
|
+
if (!this.messageLoopTimers.has(msg.to)) {
|
|
1218
|
+
this.startMessageLoop(msg.to);
|
|
1219
|
+
console.log(`[forge-agent] Restarted message loop for ${targetLabel} (pending ${Math.round(age / 60000)}min)`);
|
|
1220
|
+
} else {
|
|
1221
|
+
console.log(`[forge-agent] ${targetLabel} has pending message ${msg.id.slice(0, 8)} for ${Math.round(age / 60000)}min — loop running but not consuming`);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
this.emit('event', { type: 'log', agentId: msg.to, entry: { type: 'system', subtype: 'warning', content: `Pending message from ${this.agents.get(msg.from)?.config.label || msg.from} waiting for ${Math.round(age / 60000)}min`, timestamp: new Date().toISOString() } } as any);
|
|
1226
|
+
this.forgeActedMessages.add(`pending-${msg.id}`);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// Case 4: Failed → notify sender so they know
|
|
1231
|
+
if (msg.status === 'failed' && !this.forgeActedMessages.has(`failed-${msg.id}`)) {
|
|
1232
|
+
const senderEntry = this.agents.get(msg.from);
|
|
1233
|
+
const targetLabel = this.agents.get(msg.to)?.config.label || msg.to;
|
|
1234
|
+
if (senderEntry && msg.from !== '_forge' && msg.from !== '_system') {
|
|
1235
|
+
this.bus.send('_forge', msg.from, 'notify', {
|
|
1236
|
+
action: 'update_notify',
|
|
1237
|
+
content: `Your message to ${targetLabel} has failed. You may want to retry or take a different approach.`,
|
|
1238
|
+
});
|
|
1239
|
+
console.log(`[forge-agent] Notified ${senderEntry.config.label} that message to ${targetLabel} failed`);
|
|
1240
|
+
}
|
|
1241
|
+
this.forgeActedMessages.add(`failed-${msg.id}`);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// Case 5: Pending approval too long (>5min) → log reminder
|
|
1245
|
+
if (msg.status === 'pending_approval') {
|
|
1246
|
+
const age = now - msg.timestamp;
|
|
1247
|
+
if (age > 300_000 && !this.forgeActedMessages.has(`approval-${msg.id}`)) {
|
|
1248
|
+
const targetLabel = this.agents.get(msg.to)?.config.label || msg.to;
|
|
1249
|
+
this.emit('event', { type: 'log', agentId: msg.to, entry: { type: 'system', subtype: 'warning', content: `Message awaiting approval for ${Math.round(age / 60000)}min — requires manual action`, timestamp: new Date().toISOString() } } as any);
|
|
1250
|
+
this.forgeActedMessages.add(`approval-${msg.id}`);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1045
1253
|
}
|
|
1046
1254
|
}
|
|
1047
1255
|
|
|
@@ -1058,8 +1266,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1058
1266
|
|
|
1059
1267
|
if (action === 'analyze') {
|
|
1060
1268
|
// Auto-wake agent to analyze changes (skip if busy/manual)
|
|
1061
|
-
if (entry.state.
|
|
1062
|
-
console.log(`[watch] ${entry.config.label}: skipped analyze (
|
|
1269
|
+
if (entry.state.taskStatus === 'running') {
|
|
1270
|
+
console.log(`[watch] ${entry.config.label}: skipped analyze (task=${entry.state.taskStatus})`);
|
|
1063
1271
|
return;
|
|
1064
1272
|
}
|
|
1065
1273
|
if (!entry.worker?.isListening()) {
|
|
@@ -1103,23 +1311,46 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1103
1311
|
return;
|
|
1104
1312
|
}
|
|
1105
1313
|
|
|
1106
|
-
|
|
1314
|
+
const prompt = entry.config.watch?.prompt;
|
|
1315
|
+
// For terminal injection: send the configured prompt directly (pattern is the trigger, not the payload)
|
|
1316
|
+
// If no prompt configured, send the summary
|
|
1317
|
+
const message = prompt || summary;
|
|
1318
|
+
|
|
1319
|
+
// Try to inject directly into an open terminal session
|
|
1320
|
+
// Verify stored session is alive, clear if dead
|
|
1321
|
+
if (targetEntry.state.tmuxSession) {
|
|
1322
|
+
try { execSync(`tmux has-session -t "${targetEntry.state.tmuxSession}" 2>/dev/null`, { timeout: 3000 }); }
|
|
1323
|
+
catch { targetEntry.state.tmuxSession = undefined; }
|
|
1324
|
+
}
|
|
1325
|
+
const tmuxSession = targetEntry.state.tmuxSession || this.findTmuxSession(targetEntry.config.label);
|
|
1326
|
+
if (tmuxSession) {
|
|
1327
|
+
try {
|
|
1328
|
+
const tmpFile = `/tmp/forge-watch-${Date.now()}.txt`;
|
|
1329
|
+
writeFileSync(tmpFile, message);
|
|
1330
|
+
execSync(`tmux load-buffer ${tmpFile}`, { timeout: 5000 });
|
|
1331
|
+
execSync(`tmux paste-buffer -t "${tmuxSession}" && sleep 0.2 && tmux send-keys -t "${tmuxSession}" Enter`, { timeout: 5000 });
|
|
1332
|
+
try { unlinkSync(tmpFile); } catch {}
|
|
1333
|
+
console.log(`[watch] ${entry.config.label} → ${targetEntry.config.label}: injected into terminal (${tmuxSession})`);
|
|
1334
|
+
} catch (err: any) {
|
|
1335
|
+
console.error(`[watch] Terminal inject failed: ${err.message}, falling back to bus`);
|
|
1336
|
+
this.bus.send(agentId, targetId, 'notify', { action: 'watch_alert', content: message });
|
|
1337
|
+
}
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// No terminal open — send via bus (will start new session)
|
|
1107
1342
|
const hasPendingFromWatch = this.bus.getLog().some(m =>
|
|
1108
1343
|
m.from === agentId && m.to === targetId &&
|
|
1109
1344
|
(m.status === 'pending' || m.status === 'running' || m.status === 'pending_approval') &&
|
|
1110
1345
|
m.type !== 'ack'
|
|
1111
1346
|
);
|
|
1112
1347
|
if (hasPendingFromWatch) {
|
|
1113
|
-
console.log(`[watch] ${entry.config.label}: skipping send — target
|
|
1348
|
+
console.log(`[watch] ${entry.config.label}: skipping bus send — target still processing`);
|
|
1114
1349
|
return;
|
|
1115
1350
|
}
|
|
1116
1351
|
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
action: 'watch_alert',
|
|
1120
|
-
content: `${prompt}\n\n${summary}`,
|
|
1121
|
-
});
|
|
1122
|
-
console.log(`[watch] ${entry.config.label} → ${targetEntry.config.label}: sent watch alert`);
|
|
1352
|
+
this.bus.send(agentId, targetId, 'notify', { action: 'watch_alert', content: message });
|
|
1353
|
+
console.log(`[watch] ${entry.config.label} → ${targetEntry.config.label}: sent via bus`);
|
|
1123
1354
|
}
|
|
1124
1355
|
}
|
|
1125
1356
|
|
|
@@ -1189,44 +1420,51 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1189
1420
|
this.emitAgentsChanged();
|
|
1190
1421
|
}
|
|
1191
1422
|
|
|
1192
|
-
|
|
1423
|
+
clearTmuxSession(agentId: string): void {
|
|
1424
|
+
const entry = this.agents.get(agentId);
|
|
1425
|
+
if (!entry) return;
|
|
1426
|
+
entry.state.tmuxSession = undefined;
|
|
1427
|
+
this.saveNow();
|
|
1428
|
+
this.emitAgentsChanged();
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
/** Record that an agent has an open terminal (tmux session tracking) */
|
|
1193
1432
|
setManualMode(agentId: string): void {
|
|
1194
1433
|
const entry = this.agents.get(agentId);
|
|
1195
1434
|
if (!entry) return;
|
|
1196
|
-
|
|
1197
|
-
this.emit('event', { type: 'smith_status', agentId, smithStatus: entry.state.smithStatus, mode: 'manual' } satisfies WorkerEvent);
|
|
1435
|
+
// tmuxSession is set separately when terminal opens
|
|
1198
1436
|
this.emitAgentsChanged();
|
|
1199
1437
|
this.saveNow();
|
|
1200
|
-
console.log(`[workspace] Agent "${entry.config.label}"
|
|
1438
|
+
console.log(`[workspace] Agent "${entry.config.label}" terminal opened`);
|
|
1201
1439
|
}
|
|
1202
1440
|
|
|
1203
|
-
/**
|
|
1441
|
+
/** Called when agent's terminal is closed */
|
|
1204
1442
|
restartAgentDaemon(agentId: string): void {
|
|
1205
1443
|
if (!this.daemonActive) return;
|
|
1206
1444
|
const entry = this.agents.get(agentId);
|
|
1207
1445
|
if (!entry || entry.config.type === 'input') return;
|
|
1208
1446
|
|
|
1209
|
-
entry.state.mode = 'auto';
|
|
1210
1447
|
entry.state.error = undefined;
|
|
1448
|
+
// Don't clear tmuxSession here — it may still be alive (persistent session)
|
|
1449
|
+
// Terminal close just means the UI panel is closed, not necessarily tmux killed
|
|
1211
1450
|
|
|
1212
|
-
// Recreate worker if needed
|
|
1451
|
+
// Recreate worker if needed
|
|
1213
1452
|
if (!entry.worker) {
|
|
1214
1453
|
this.enterDaemonListening(agentId);
|
|
1215
1454
|
this.startMessageLoop(agentId);
|
|
1216
1455
|
}
|
|
1217
1456
|
|
|
1218
1457
|
entry.state.smithStatus = 'active';
|
|
1219
|
-
this.emit('event', { type: 'smith_status', agentId, smithStatus: 'active'
|
|
1458
|
+
this.emit('event', { type: 'smith_status', agentId, smithStatus: 'active' } satisfies WorkerEvent);
|
|
1220
1459
|
this.emitAgentsChanged();
|
|
1221
1460
|
}
|
|
1222
1461
|
|
|
1223
|
-
/** Complete
|
|
1462
|
+
/** Complete an agent from terminal — called by forge-done skill */
|
|
1224
1463
|
completeManualAgent(agentId: string, changedFiles: string[]): void {
|
|
1225
1464
|
const entry = this.agents.get(agentId);
|
|
1226
1465
|
if (!entry) return;
|
|
1227
1466
|
|
|
1228
1467
|
entry.state.taskStatus = 'done';
|
|
1229
|
-
entry.state.mode = 'auto'; // clear manual mode
|
|
1230
1468
|
entry.state.completedAt = Date.now();
|
|
1231
1469
|
entry.state.artifacts = changedFiles.map(f => ({ type: 'file' as const, path: f }));
|
|
1232
1470
|
|
|
@@ -1322,13 +1560,12 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1322
1560
|
this.agents.clear();
|
|
1323
1561
|
this.daemonActive = false; // Reset daemon — user must click Start Daemon again after restart
|
|
1324
1562
|
for (const config of data.agents) {
|
|
1325
|
-
const state = data.agentStates[config.id] || { smithStatus: 'down' as const,
|
|
1563
|
+
const state = data.agentStates[config.id] || { smithStatus: 'down' as const, taskStatus: 'idle' as const, history: [], artifacts: [] };
|
|
1326
1564
|
|
|
1327
1565
|
// Migrate old format if loading from pre-two-layer state
|
|
1328
1566
|
if ('status' in state && !('smithStatus' in state)) {
|
|
1329
1567
|
const oldStatus = (state as any).status;
|
|
1330
1568
|
(state as any).smithStatus = 'down';
|
|
1331
|
-
(state as any).mode = (state as any).runMode || 'auto';
|
|
1332
1569
|
(state as any).taskStatus = (oldStatus === 'running' || oldStatus === 'listening') ? 'idle' :
|
|
1333
1570
|
(oldStatus === 'interrupted') ? 'idle' :
|
|
1334
1571
|
(oldStatus === 'waiting_approval') ? 'idle' :
|
|
@@ -1496,20 +1733,9 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1496
1733
|
const causedBy = this.buildCausedBy(agentId, entry);
|
|
1497
1734
|
const processedMsg = causedBy ? this.bus.getLog().find(m => m.id === causedBy.messageId) : null;
|
|
1498
1735
|
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
// Sender can check their outbox message status. Only broadcast to downstream.
|
|
1503
|
-
const senderLabel = this.agents.get(processedMsg.from)?.config.label || processedMsg.from;
|
|
1504
|
-
console.log(`[bus] ${entry.config.label}: processed request from ${senderLabel} — marked done, no reply`);
|
|
1505
|
-
// Still broadcast to own downstream (e.g., QA processed Engineer's msg → notify Reviewer)
|
|
1506
|
-
this.broadcastCompletion(agentId, causedBy);
|
|
1507
|
-
} else {
|
|
1508
|
-
// Normal upstream completion or initial execution → broadcast to all downstream
|
|
1509
|
-
this.broadcastCompletion(agentId, causedBy);
|
|
1510
|
-
// notifyDownstreamForRevalidation removed — causes duplicate messages and re-execution loops
|
|
1511
|
-
// Downstream agents that already completed will be handled in future iteration mode
|
|
1512
|
-
}
|
|
1736
|
+
this.broadcastCompletion(agentId, causedBy);
|
|
1737
|
+
// Note: Forge agent (runForgeAgentCheck) monitors for missing replies
|
|
1738
|
+
// and nudges agents to send summaries. No action needed here.
|
|
1513
1739
|
|
|
1514
1740
|
this.emitWorkspaceStatus();
|
|
1515
1741
|
this.checkWorkspaceComplete?.();
|
|
@@ -1552,6 +1778,305 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1552
1778
|
|
|
1553
1779
|
// ─── Agent liveness ─────────────────────────────────────
|
|
1554
1780
|
|
|
1781
|
+
/** Find an active tmux session for an agent by checking naming conventions */
|
|
1782
|
+
// ─── Persistent Terminal Sessions ────────────────────────
|
|
1783
|
+
|
|
1784
|
+
/** Resolve the CLI session directory for a given project path */
|
|
1785
|
+
private getCliSessionDir(workDir?: string): string {
|
|
1786
|
+
const projectPath = workDir && workDir !== './' && workDir !== '.'
|
|
1787
|
+
? join(this.projectPath, workDir) : this.projectPath;
|
|
1788
|
+
const encoded = resolve(projectPath).replace(/\//g, '-');
|
|
1789
|
+
return join(homedir(), '.claude', 'projects', encoded);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
/** Create a persistent tmux session with the CLI agent */
|
|
1793
|
+
private async ensurePersistentSession(agentId: string, config: WorkspaceAgentConfig): Promise<void> {
|
|
1794
|
+
const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
|
|
1795
|
+
const sessionName = `mw-forge-${safeName(this.projectName)}-${safeName(config.label)}`;
|
|
1796
|
+
|
|
1797
|
+
// Pre-flight: check project's .claude/settings.json is valid
|
|
1798
|
+
const workDir = config.workDir && config.workDir !== './' && config.workDir !== '.'
|
|
1799
|
+
? `${this.projectPath}/${config.workDir}` : this.projectPath;
|
|
1800
|
+
const projectSettingsFile = join(workDir, '.claude', 'settings.json');
|
|
1801
|
+
if (existsSync(projectSettingsFile)) {
|
|
1802
|
+
try {
|
|
1803
|
+
const raw = readFileSync(projectSettingsFile, 'utf-8');
|
|
1804
|
+
JSON.parse(raw);
|
|
1805
|
+
} catch (err: any) {
|
|
1806
|
+
const errorMsg = `Invalid .claude/settings.json: ${err.message}`;
|
|
1807
|
+
console.error(`[daemon] ${config.label}: ${errorMsg}`);
|
|
1808
|
+
const entry = this.agents.get(agentId);
|
|
1809
|
+
if (entry) {
|
|
1810
|
+
entry.state.error = errorMsg;
|
|
1811
|
+
entry.state.smithStatus = 'down';
|
|
1812
|
+
this.emit('event', { type: 'smith_status', agentId, smithStatus: 'down' } as any);
|
|
1813
|
+
this.emit('event', { type: 'log', agentId, entry: { type: 'system', subtype: 'error', content: `⚠️ ${errorMsg}`, timestamp: new Date().toISOString() } } as any);
|
|
1814
|
+
this.emitAgentsChanged();
|
|
1815
|
+
}
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
// Check if tmux session already exists
|
|
1821
|
+
try {
|
|
1822
|
+
execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`, { timeout: 3000 });
|
|
1823
|
+
console.log(`[daemon] ${config.label}: persistent session already exists (${sessionName})`);
|
|
1824
|
+
} catch {
|
|
1825
|
+
// Create new tmux session and start the CLI agent
|
|
1826
|
+
try {
|
|
1827
|
+
// Resolve agent launch info
|
|
1828
|
+
let cliCmd = 'claude';
|
|
1829
|
+
let cliType = 'claude-code';
|
|
1830
|
+
let supportsSession = true;
|
|
1831
|
+
let skipPermissionsFlag = '--dangerously-skip-permissions';
|
|
1832
|
+
let envExports = '';
|
|
1833
|
+
let modelFlag = '';
|
|
1834
|
+
try {
|
|
1835
|
+
const { resolveTerminalLaunch, listAgents } = await import('../agents/index') as any;
|
|
1836
|
+
const info = resolveTerminalLaunch(config.agentId);
|
|
1837
|
+
cliCmd = info.cliCmd || 'claude';
|
|
1838
|
+
cliType = info.cliType || 'claude-code';
|
|
1839
|
+
supportsSession = info.supportsSession ?? true;
|
|
1840
|
+
const agents = listAgents();
|
|
1841
|
+
const agentDef = agents.find((a: any) => a.id === config.agentId);
|
|
1842
|
+
if (agentDef?.skipPermissionsFlag) skipPermissionsFlag = agentDef.skipPermissionsFlag;
|
|
1843
|
+
if (info.env) {
|
|
1844
|
+
envExports = Object.entries(info.env)
|
|
1845
|
+
.filter(([k]) => k !== 'CLAUDE_MODEL')
|
|
1846
|
+
.map(([k, v]) => `export ${k}="${v}"`)
|
|
1847
|
+
.join(' && ');
|
|
1848
|
+
if (envExports) envExports += ' && ';
|
|
1849
|
+
}
|
|
1850
|
+
if (info.model) modelFlag = ` --model ${info.model}`;
|
|
1851
|
+
} catch {}
|
|
1852
|
+
|
|
1853
|
+
// Generate MCP config for Claude Code agents
|
|
1854
|
+
let mcpConfigFlag = '';
|
|
1855
|
+
if (cliType === 'claude-code') {
|
|
1856
|
+
try {
|
|
1857
|
+
const mcpPort = Number(process.env.MCP_PORT) || 8406;
|
|
1858
|
+
const mcpConfigPath = join(workDir, '.forge', 'mcp.json');
|
|
1859
|
+
const mcpConfig = {
|
|
1860
|
+
mcpServers: {
|
|
1861
|
+
forge: {
|
|
1862
|
+
type: 'sse',
|
|
1863
|
+
url: `http://localhost:${mcpPort}/sse?workspaceId=${this.workspaceId}&agentId=${config.id}`,
|
|
1864
|
+
},
|
|
1865
|
+
},
|
|
1866
|
+
};
|
|
1867
|
+
const { mkdirSync: mkdirS } = await import('node:fs');
|
|
1868
|
+
mkdirS(join(workDir, '.forge'), { recursive: true });
|
|
1869
|
+
writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2));
|
|
1870
|
+
mcpConfigFlag = ` --mcp-config "${mcpConfigPath}"`;
|
|
1871
|
+
} catch (err: any) {
|
|
1872
|
+
console.log(`[daemon] ${config.label}: MCP config generation failed: ${err.message}`);
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
execSync(`tmux new-session -d -s "${sessionName}" -c "${workDir}"`, { timeout: 5000 });
|
|
1877
|
+
|
|
1878
|
+
// Reset profile env vars (unset any leftover from previous agent) then set new ones
|
|
1879
|
+
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'];
|
|
1880
|
+
const unsetCmd = profileVarsToReset.map(v => `unset ${v}`).join(' && ');
|
|
1881
|
+
execSync(`tmux send-keys -t "${sessionName}" '${unsetCmd}' Enter`, { timeout: 5000 });
|
|
1882
|
+
|
|
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}"`;
|
|
1885
|
+
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 });
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
// Build CLI start command
|
|
1892
|
+
const parts: string[] = [];
|
|
1893
|
+
let cmd = cliCmd;
|
|
1894
|
+
|
|
1895
|
+
// Session resume: use bound session ID (primary from project-sessions, others from config)
|
|
1896
|
+
if (supportsSession) {
|
|
1897
|
+
let sessionId: string | undefined;
|
|
1898
|
+
|
|
1899
|
+
if (config.primary) {
|
|
1900
|
+
try {
|
|
1901
|
+
const { getFixedSession } = await import('../project-sessions') as any;
|
|
1902
|
+
sessionId = getFixedSession(this.projectPath);
|
|
1903
|
+
} catch {}
|
|
1904
|
+
} else {
|
|
1905
|
+
sessionId = config.boundSessionId;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
if (sessionId) {
|
|
1909
|
+
const sessionFile = join(this.getCliSessionDir(config.workDir), `${sessionId}.jsonl`);
|
|
1910
|
+
if (existsSync(sessionFile)) {
|
|
1911
|
+
cmd += ` --resume ${sessionId}`;
|
|
1912
|
+
} else {
|
|
1913
|
+
console.log(`[daemon] ${config.label}: bound session ${sessionId} missing, starting fresh`);
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
// No bound session → start fresh (no -c, avoids "No conversation found")
|
|
1917
|
+
}
|
|
1918
|
+
if (modelFlag) cmd += modelFlag;
|
|
1919
|
+
if (config.skipPermissions !== false && skipPermissionsFlag) cmd += ` ${skipPermissionsFlag}`;
|
|
1920
|
+
if (mcpConfigFlag) cmd += mcpConfigFlag;
|
|
1921
|
+
parts.push(cmd);
|
|
1922
|
+
|
|
1923
|
+
const startCmd = parts.join(' && ');
|
|
1924
|
+
execSync(`tmux send-keys -t "${sessionName}" '${startCmd}' Enter`, { timeout: 5000 });
|
|
1925
|
+
|
|
1926
|
+
console.log(`[daemon] ${config.label}: persistent session created (${sessionName}) [${cliType}: ${cliCmd}]`);
|
|
1927
|
+
|
|
1928
|
+
// Verify CLI started successfully (check after 3s if process is still alive)
|
|
1929
|
+
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
1930
|
+
try {
|
|
1931
|
+
const paneContent = execSync(`tmux capture-pane -t "${sessionName}" -p -S -20`, { timeout: 3000, encoding: 'utf-8' });
|
|
1932
|
+
// Check for common startup errors
|
|
1933
|
+
const errorPatterns = [
|
|
1934
|
+
/error.*settings\.json/i,
|
|
1935
|
+
/invalid.*json/i,
|
|
1936
|
+
/SyntaxError/i,
|
|
1937
|
+
/ENOENT.*settings/i,
|
|
1938
|
+
/failed to parse/i,
|
|
1939
|
+
/could not read/i,
|
|
1940
|
+
/fatal/i,
|
|
1941
|
+
/No conversation found/i,
|
|
1942
|
+
/could not connect/i,
|
|
1943
|
+
/ECONNREFUSED/i,
|
|
1944
|
+
];
|
|
1945
|
+
const hasError = errorPatterns.some(p => p.test(paneContent));
|
|
1946
|
+
if (hasError) {
|
|
1947
|
+
const errorLines = paneContent.split('\n').filter(l => /error|invalid|syntax|fatal|failed|No conversation|ECONNREFUSED/i.test(l)).slice(0, 3);
|
|
1948
|
+
const errorMsg = errorLines.join(' ').slice(0, 200) || 'CLI failed to start (check project settings)';
|
|
1949
|
+
console.error(`[daemon] ${config.label}: CLI startup error detected: ${errorMsg}`);
|
|
1950
|
+
|
|
1951
|
+
const entry = this.agents.get(agentId);
|
|
1952
|
+
if (entry) {
|
|
1953
|
+
entry.state.error = `Terminal failed: ${errorMsg}. Falling back to headless mode.`;
|
|
1954
|
+
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);
|
|
1956
|
+
this.emitAgentsChanged();
|
|
1957
|
+
}
|
|
1958
|
+
// Kill the failed tmux session
|
|
1959
|
+
try { execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null`, { timeout: 3000 }); } catch {}
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
} catch {}
|
|
1963
|
+
// Auto-bind session: if no boundSessionId, detect new session file after 5s
|
|
1964
|
+
if (!config.primary && !config.boundSessionId && supportsSession) {
|
|
1965
|
+
setTimeout(() => {
|
|
1966
|
+
try {
|
|
1967
|
+
const sessionDir = this.getCliSessionDir(config.workDir);
|
|
1968
|
+
if (existsSync(sessionDir)) {
|
|
1969
|
+
const { readdirSync, statSync: statS } = require('node:fs');
|
|
1970
|
+
const files = readdirSync(sessionDir).filter((f: string) => f.endsWith('.jsonl'));
|
|
1971
|
+
if (files.length > 0) {
|
|
1972
|
+
const latest = files
|
|
1973
|
+
.map((f: string) => ({ name: f, mtime: statS(join(sessionDir, f)).mtimeMs }))
|
|
1974
|
+
.sort((a: any, b: any) => b.mtime - a.mtime)[0];
|
|
1975
|
+
config.boundSessionId = latest.name.replace('.jsonl', '');
|
|
1976
|
+
this.saveNow();
|
|
1977
|
+
console.log(`[daemon] ${config.label}: auto-bound to session ${config.boundSessionId}`);
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
} catch {}
|
|
1981
|
+
}, 5000);
|
|
1982
|
+
}
|
|
1983
|
+
} catch (err: any) {
|
|
1984
|
+
console.error(`[daemon] ${config.label}: failed to create persistent session: ${err.message}`);
|
|
1985
|
+
const entry = this.agents.get(agentId);
|
|
1986
|
+
if (entry) {
|
|
1987
|
+
entry.state.error = `Failed to create terminal: ${err.message}`;
|
|
1988
|
+
entry.state.smithStatus = 'down';
|
|
1989
|
+
this.emit('event', { type: 'smith_status', agentId, smithStatus: 'down' } as any);
|
|
1990
|
+
this.emitAgentsChanged();
|
|
1991
|
+
}
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
// Store tmux session name in agent state
|
|
1997
|
+
const entry = this.agents.get(agentId);
|
|
1998
|
+
if (entry) {
|
|
1999
|
+
entry.state.tmuxSession = sessionName;
|
|
2000
|
+
this.saveNow();
|
|
2001
|
+
this.emitAgentsChanged();
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
/** Inject text into an agent's persistent terminal session */
|
|
2006
|
+
injectIntoSession(agentId: string, text: string): boolean {
|
|
2007
|
+
const entry = this.agents.get(agentId);
|
|
2008
|
+
// Verify stored session is alive
|
|
2009
|
+
if (entry?.state.tmuxSession) {
|
|
2010
|
+
try { execSync(`tmux has-session -t "${entry.state.tmuxSession}" 2>/dev/null`, { timeout: 3000 }); }
|
|
2011
|
+
catch { entry.state.tmuxSession = undefined; }
|
|
2012
|
+
}
|
|
2013
|
+
const tmuxSession = entry?.state.tmuxSession || this.findTmuxSession(entry?.config.label || '');
|
|
2014
|
+
if (!tmuxSession) return false;
|
|
2015
|
+
// Cache found session for future use
|
|
2016
|
+
if (entry && !entry.state.tmuxSession) entry.state.tmuxSession = tmuxSession;
|
|
2017
|
+
|
|
2018
|
+
try {
|
|
2019
|
+
const tmpFile = `/tmp/forge-inject-${Date.now()}.txt`;
|
|
2020
|
+
writeFileSync(tmpFile, text);
|
|
2021
|
+
execSync(`tmux load-buffer ${tmpFile}`, { timeout: 5000 });
|
|
2022
|
+
execSync(`tmux paste-buffer -t "${tmuxSession}"`, { timeout: 5000 });
|
|
2023
|
+
execSync(`tmux send-keys -t "${tmuxSession}" Enter`, { timeout: 5000 });
|
|
2024
|
+
try { unlinkSync(tmpFile); } catch {}
|
|
2025
|
+
return true;
|
|
2026
|
+
} catch (err: any) {
|
|
2027
|
+
console.error(`[inject] Failed for ${tmuxSession}: ${err.message}`);
|
|
2028
|
+
return false;
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
/** Check if agent has a persistent session available */
|
|
2033
|
+
hasPersistentSession(agentId: string): boolean {
|
|
2034
|
+
const entry = this.agents.get(agentId);
|
|
2035
|
+
if (!entry) return false;
|
|
2036
|
+
if (entry.state.tmuxSession) return true;
|
|
2037
|
+
return !!this.findTmuxSession(entry.config.label);
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
private findTmuxSession(agentLabel: string): string | null {
|
|
2041
|
+
const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
|
|
2042
|
+
const projectSafe = safeName(this.projectName);
|
|
2043
|
+
const agentSafe = safeName(agentLabel);
|
|
2044
|
+
|
|
2045
|
+
// Try workspace naming: mw-forge-{project}-{agent}
|
|
2046
|
+
const wsName = `mw-forge-${projectSafe}-${agentSafe}`;
|
|
2047
|
+
try { execSync(`tmux has-session -t "${wsName}" 2>/dev/null`, { timeout: 3000 }); return wsName; } catch {}
|
|
2048
|
+
|
|
2049
|
+
// Try VibeCoding naming: mw-{project}
|
|
2050
|
+
const vcName = `mw-${projectSafe}`;
|
|
2051
|
+
try { execSync(`tmux has-session -t "${vcName}" 2>/dev/null`, { timeout: 3000 }); return vcName; } catch {}
|
|
2052
|
+
|
|
2053
|
+
// Search terminal-state.json for project matching tmux session
|
|
2054
|
+
try {
|
|
2055
|
+
const statePath = join(homedir(), '.forge', 'data', 'terminal-state.json');
|
|
2056
|
+
if (existsSync(statePath)) {
|
|
2057
|
+
const termState = JSON.parse(readFileSync(statePath, 'utf-8'));
|
|
2058
|
+
for (const tab of termState.tabs || []) {
|
|
2059
|
+
if (tab.projectPath === this.projectPath) {
|
|
2060
|
+
const findSession = (tree: any): string | null => {
|
|
2061
|
+
if (tree?.type === 'terminal' && tree.sessionName) return tree.sessionName;
|
|
2062
|
+
for (const child of tree?.children || []) {
|
|
2063
|
+
const found = findSession(child);
|
|
2064
|
+
if (found) return found;
|
|
2065
|
+
}
|
|
2066
|
+
return null;
|
|
2067
|
+
};
|
|
2068
|
+
const sess = findSession(tab.tree);
|
|
2069
|
+
if (sess) {
|
|
2070
|
+
try { execSync(`tmux has-session -t "${sess}" 2>/dev/null`, { timeout: 3000 }); return sess; } catch {}
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
} catch {}
|
|
2076
|
+
|
|
2077
|
+
return null;
|
|
2078
|
+
}
|
|
2079
|
+
|
|
1555
2080
|
private updateAgentLiveness(agentId: string): void {
|
|
1556
2081
|
const entry = this.agents.get(agentId);
|
|
1557
2082
|
if (!entry) {
|
|
@@ -1616,13 +2141,6 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1616
2141
|
// ── Store message in agent history ──
|
|
1617
2142
|
target.state.history.push(logEntry);
|
|
1618
2143
|
|
|
1619
|
-
// ── Manual mode → store in inbox (user handles in terminal) ──
|
|
1620
|
-
if (target.state.mode === 'manual') {
|
|
1621
|
-
ackAndDeliver();
|
|
1622
|
-
console.log(`[bus] ${target.config.label}: received ${action} in manual mode — stored in inbox`);
|
|
1623
|
-
return;
|
|
1624
|
-
}
|
|
1625
|
-
|
|
1626
2144
|
// ── requiresApproval → set pending_approval on arrival ──
|
|
1627
2145
|
if (target.config.requiresApproval) {
|
|
1628
2146
|
msg.status = 'pending_approval';
|
|
@@ -1654,29 +2172,40 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1654
2172
|
// (loop stays alive so it works when smith comes back)
|
|
1655
2173
|
if (entry.state.smithStatus !== 'active') return;
|
|
1656
2174
|
|
|
1657
|
-
// Skip if
|
|
1658
|
-
if (entry.state.mode === 'manual') return;
|
|
2175
|
+
// Skip if already busy
|
|
1659
2176
|
if (entry.state.taskStatus === 'running') return;
|
|
1660
2177
|
|
|
1661
|
-
// Skip if no worker ready — recreate if needed
|
|
1662
|
-
if (!entry.worker) {
|
|
1663
|
-
if (this.daemonActive) {
|
|
1664
|
-
console.log(`[inbox] ${entry.config.label}: no worker, recreating...`);
|
|
1665
|
-
this.enterDaemonListening(agentId);
|
|
1666
|
-
}
|
|
1667
|
-
return;
|
|
1668
|
-
}
|
|
1669
|
-
if (!entry.worker.isListening()) {
|
|
1670
|
-
if (++debugTick % 15 === 0) {
|
|
1671
|
-
console.log(`[inbox] ${entry.config.label}: not listening (smith=${entry.state.smithStatus} task=${entry.state.taskStatus})`);
|
|
1672
|
-
}
|
|
1673
|
-
return;
|
|
1674
|
-
}
|
|
1675
|
-
|
|
1676
2178
|
// Skip if any message is already running for this agent
|
|
1677
2179
|
const hasRunning = this.bus.getLog().some(m => m.to === agentId && m.status === 'running' && m.type !== 'ack');
|
|
1678
2180
|
if (hasRunning) return;
|
|
1679
2181
|
|
|
2182
|
+
// Execution path determined by config, not runtime tmux state
|
|
2183
|
+
const isTerminalMode = entry.config.persistentSession;
|
|
2184
|
+
if (isTerminalMode) {
|
|
2185
|
+
// Terminal mode: need tmux session. If missing, skip this tick (health check will restart it)
|
|
2186
|
+
if (!entry.state.tmuxSession) {
|
|
2187
|
+
if (++debugTick % 15 === 0) {
|
|
2188
|
+
console.log(`[inbox] ${entry.config.label}: terminal mode but no tmux session — waiting for auto-restart`);
|
|
2189
|
+
}
|
|
2190
|
+
return;
|
|
2191
|
+
}
|
|
2192
|
+
} else {
|
|
2193
|
+
// Headless mode: need worker ready
|
|
2194
|
+
if (!entry.worker) {
|
|
2195
|
+
if (this.daemonActive) {
|
|
2196
|
+
console.log(`[inbox] ${entry.config.label}: no worker, recreating...`);
|
|
2197
|
+
this.enterDaemonListening(agentId);
|
|
2198
|
+
}
|
|
2199
|
+
return;
|
|
2200
|
+
}
|
|
2201
|
+
if (!entry.worker.isListening()) {
|
|
2202
|
+
if (++debugTick % 15 === 0) {
|
|
2203
|
+
console.log(`[inbox] ${entry.config.label}: not listening (smith=${entry.state.smithStatus} task=${entry.state.taskStatus})`);
|
|
2204
|
+
}
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
|
|
1680
2209
|
// requiresApproval is handled at message arrival time (routeMessageToAgent),
|
|
1681
2210
|
// not in the message loop. Approved messages come through as normal 'pending'.
|
|
1682
2211
|
|
|
@@ -1733,8 +2262,29 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1733
2262
|
timestamp: new Date(nextMsg.timestamp).toISOString(),
|
|
1734
2263
|
};
|
|
1735
2264
|
|
|
1736
|
-
|
|
1737
|
-
|
|
2265
|
+
// Terminal mode → inject; headless → worker (claude -p)
|
|
2266
|
+
if (isTerminalMode) {
|
|
2267
|
+
const injected = this.injectIntoSession(agentId, nextMsg.payload.content || nextMsg.payload.action);
|
|
2268
|
+
if (injected) {
|
|
2269
|
+
this.emit('event', { type: 'log', agentId, entry: { type: 'system', subtype: 'execution_method', content: '📺 Injected into terminal, monitoring for completion...', timestamp: new Date().toISOString() } } as any);
|
|
2270
|
+
console.log(`[inbox] ${entry.config.label}: injected into terminal, starting completion monitor`);
|
|
2271
|
+
entry.state.currentMessageId = nextMsg.id;
|
|
2272
|
+
this.monitorTerminalCompletion(agentId, nextMsg.id, entry.state.tmuxSession!);
|
|
2273
|
+
} else {
|
|
2274
|
+
// Terminal inject failed — clear dead session, message stays pending
|
|
2275
|
+
// Health check will auto-restart the terminal session
|
|
2276
|
+
entry.state.tmuxSession = undefined;
|
|
2277
|
+
nextMsg.status = 'pending' as any; // revert to pending for retry
|
|
2278
|
+
this.emit('event', { type: 'bus_message_status', messageId: nextMsg.id, status: 'pending' } as any);
|
|
2279
|
+
this.emit('event', { type: 'log', agentId, entry: { type: 'system', subtype: 'warning', content: '⚠️ Terminal session down — waiting for auto-restart, message will retry', timestamp: new Date().toISOString() } } as any);
|
|
2280
|
+
console.log(`[inbox] ${entry.config.label}: terminal inject failed, cleared session — waiting for health check restart`);
|
|
2281
|
+
this.emitAgentsChanged();
|
|
2282
|
+
}
|
|
2283
|
+
} else {
|
|
2284
|
+
entry.worker!.setProcessingMessage(nextMsg.id);
|
|
2285
|
+
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);
|
|
2287
|
+
}
|
|
1738
2288
|
};
|
|
1739
2289
|
|
|
1740
2290
|
// Check every 2 seconds
|
|
@@ -1761,6 +2311,108 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1761
2311
|
}
|
|
1762
2312
|
}
|
|
1763
2313
|
|
|
2314
|
+
// ─── Terminal completion monitor ──────────────────────
|
|
2315
|
+
private terminalMonitors = new Map<string, NodeJS.Timeout>();
|
|
2316
|
+
|
|
2317
|
+
/**
|
|
2318
|
+
* Monitor a tmux session for completion after injecting a message.
|
|
2319
|
+
* Detects CLI prompt patterns (❯, $, >) indicating the agent is idle.
|
|
2320
|
+
* Requires 2 consecutive prompt detections (10s) to confirm completion.
|
|
2321
|
+
*/
|
|
2322
|
+
private monitorTerminalCompletion(agentId: string, messageId: string, tmuxSession: string): void {
|
|
2323
|
+
// Stop any existing monitor for this agent
|
|
2324
|
+
const existing = this.terminalMonitors.get(agentId);
|
|
2325
|
+
if (existing) clearInterval(existing);
|
|
2326
|
+
|
|
2327
|
+
// Prompt patterns that indicate the CLI is idle and waiting for input
|
|
2328
|
+
// Claude Code: ❯ Codex: > Aider: > Generic shell: $ #
|
|
2329
|
+
const PROMPT_PATTERNS = [
|
|
2330
|
+
/^❯\s*$/, // Claude Code idle prompt
|
|
2331
|
+
/^>\s*$/, // Codex / generic prompt
|
|
2332
|
+
/^\$\s*$/, // Shell prompt
|
|
2333
|
+
/^#\s*$/, // Root shell prompt
|
|
2334
|
+
/^aider>\s*$/, // Aider prompt
|
|
2335
|
+
];
|
|
2336
|
+
|
|
2337
|
+
let promptCount = 0;
|
|
2338
|
+
let started = false;
|
|
2339
|
+
const CONFIRM_CHECKS = 2; // 2 consecutive prompt detections = done
|
|
2340
|
+
const CHECK_INTERVAL = 5000; // 5s between checks
|
|
2341
|
+
|
|
2342
|
+
const timer = setInterval(() => {
|
|
2343
|
+
try {
|
|
2344
|
+
const output = execSync(`tmux capture-pane -t "${tmuxSession}" -p -S -30`, { timeout: 3000, encoding: 'utf-8' });
|
|
2345
|
+
|
|
2346
|
+
// Strip ANSI escape sequences for clean matching
|
|
2347
|
+
const clean = output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
|
|
2348
|
+
// Get last few non-empty lines
|
|
2349
|
+
const lines = clean.split('\n').map(l => l.trim()).filter(Boolean);
|
|
2350
|
+
const tail = lines.slice(-5);
|
|
2351
|
+
|
|
2352
|
+
// First check: detect that agent started working (output changed from inject)
|
|
2353
|
+
if (!started && lines.length > 3) {
|
|
2354
|
+
started = true;
|
|
2355
|
+
}
|
|
2356
|
+
if (!started) return;
|
|
2357
|
+
|
|
2358
|
+
// Check if any of the last lines match a prompt pattern
|
|
2359
|
+
const hasPrompt = tail.some(line => PROMPT_PATTERNS.some(p => p.test(line)));
|
|
2360
|
+
|
|
2361
|
+
if (hasPrompt) {
|
|
2362
|
+
promptCount++;
|
|
2363
|
+
if (promptCount >= CONFIRM_CHECKS) {
|
|
2364
|
+
clearInterval(timer);
|
|
2365
|
+
this.terminalMonitors.delete(agentId);
|
|
2366
|
+
|
|
2367
|
+
// Extract output summary (skip prompt lines)
|
|
2368
|
+
const contentLines = lines.filter(l => !PROMPT_PATTERNS.some(p => p.test(l)));
|
|
2369
|
+
const summary = contentLines.slice(-15).join('\n');
|
|
2370
|
+
|
|
2371
|
+
// Mark message done
|
|
2372
|
+
const msg = this.bus.getLog().find(m => m.id === messageId);
|
|
2373
|
+
if (msg && msg.status !== 'done') {
|
|
2374
|
+
msg.status = 'done' as any;
|
|
2375
|
+
this.emit('event', { type: 'bus_message_status', messageId, status: 'done' } as any);
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
// Emit output to log panel
|
|
2379
|
+
this.emit('event', { type: 'log', agentId, entry: { type: 'assistant', subtype: 'terminal_output', content: `📺 Terminal completed:\n${summary.slice(0, 500)}`, timestamp: new Date().toISOString() } } as any);
|
|
2380
|
+
|
|
2381
|
+
// Trigger downstream notifications
|
|
2382
|
+
const entry = this.agents.get(agentId);
|
|
2383
|
+
if (entry) {
|
|
2384
|
+
entry.state.currentMessageId = undefined;
|
|
2385
|
+
this.handleAgentDone(agentId, entry, summary.slice(0, 300));
|
|
2386
|
+
}
|
|
2387
|
+
console.log(`[terminal-monitor] ${agentId}: prompt detected, completed`);
|
|
2388
|
+
}
|
|
2389
|
+
} else {
|
|
2390
|
+
promptCount = 0; // reset — still working
|
|
2391
|
+
}
|
|
2392
|
+
} catch {
|
|
2393
|
+
// Session died
|
|
2394
|
+
clearInterval(timer);
|
|
2395
|
+
this.terminalMonitors.delete(agentId);
|
|
2396
|
+
const msg = this.bus.getLog().find(m => m.id === messageId);
|
|
2397
|
+
if (msg && msg.status !== 'done' && msg.status !== 'failed') {
|
|
2398
|
+
msg.status = 'failed' as any;
|
|
2399
|
+
this.emit('event', { type: 'bus_message_status', messageId, status: 'failed' } as any);
|
|
2400
|
+
}
|
|
2401
|
+
const entry = this.agents.get(agentId);
|
|
2402
|
+
if (entry) entry.state.currentMessageId = undefined;
|
|
2403
|
+
console.error(`[terminal-monitor] ${agentId}: session died, marked message failed`);
|
|
2404
|
+
}
|
|
2405
|
+
}, CHECK_INTERVAL);
|
|
2406
|
+
timer.unref();
|
|
2407
|
+
this.terminalMonitors.set(agentId, timer);
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
/** Stop all terminal monitors (on daemon stop) */
|
|
2411
|
+
private stopAllTerminalMonitors(): void {
|
|
2412
|
+
for (const [, timer] of this.terminalMonitors) clearInterval(timer);
|
|
2413
|
+
this.terminalMonitors.clear();
|
|
2414
|
+
}
|
|
2415
|
+
|
|
1764
2416
|
/** Check if all agents are done and no pending work remains */
|
|
1765
2417
|
private checkWorkspaceComplete(): void {
|
|
1766
2418
|
let allDone = true;
|