@aion0/forge 0.5.7 → 0.5.9
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 +10 -6
- 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 +599 -109
- 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 +774 -90
- 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/next-env.d.ts +1 -1
- package/package.json +4 -2
- package/qa/.forge/mcp.json +8 -0
|
@@ -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,120 @@ 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 (once per pair)
|
|
1175
|
+
if (msg.status === 'done') {
|
|
1176
|
+
const age = now - msg.timestamp;
|
|
1177
|
+
if (age < 30_000) continue;
|
|
1178
|
+
|
|
1179
|
+
// Dedup by target→sender pair (only nudge once per relationship)
|
|
1180
|
+
const nudgeKey = `nudge-${msg.to}->${msg.from}`;
|
|
1181
|
+
if (this.forgeActedMessages.has(nudgeKey)) { this.forgeActedMessages.add(msg.id); continue; }
|
|
1182
|
+
|
|
1183
|
+
const hasReply = log.some(r =>
|
|
1184
|
+
r.from === msg.to && r.to === msg.from &&
|
|
1185
|
+
r.timestamp > msg.timestamp && r.type !== 'ack'
|
|
1186
|
+
);
|
|
1187
|
+
if (!hasReply) {
|
|
1188
|
+
const senderLabel = this.agents.get(msg.from)?.config.label || msg.from;
|
|
1189
|
+
const targetEntry = this.agents.get(msg.to);
|
|
1190
|
+
if (targetEntry && targetEntry.state.smithStatus === 'active') {
|
|
1191
|
+
this.bus.send('_forge', msg.to, 'notify', {
|
|
1192
|
+
action: 'info_request',
|
|
1193
|
+
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.`,
|
|
1194
|
+
});
|
|
1195
|
+
this.forgeActedMessages.add(msg.id);
|
|
1196
|
+
this.forgeActedMessages.add(nudgeKey);
|
|
1197
|
+
console.log(`[forge-agent] Nudged ${targetEntry.config.label} to reply to ${senderLabel} (once)`);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// Case 2: Message running too long (>5min) → log warning
|
|
1203
|
+
if (msg.status === 'running') {
|
|
1204
|
+
const age = now - msg.timestamp;
|
|
1205
|
+
if (age > 300_000 && !this.forgeActedMessages.has(`running-${msg.id}`)) {
|
|
1206
|
+
const targetLabel = this.agents.get(msg.to)?.config.label || msg.to;
|
|
1207
|
+
console.log(`[forge-agent] Warning: ${targetLabel} has been running message ${msg.id.slice(0, 8)} for ${Math.round(age / 60000)}min`);
|
|
1208
|
+
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);
|
|
1209
|
+
this.forgeActedMessages.add(`running-${msg.id}`);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// Case 3: Pending too long (>2min) → try to restart message loop
|
|
1214
|
+
if (msg.status === 'pending') {
|
|
1215
|
+
const age = now - msg.timestamp;
|
|
1216
|
+
if (age > 120_000 && !this.forgeActedMessages.has(`pending-${msg.id}`)) {
|
|
1217
|
+
const targetEntry = this.agents.get(msg.to);
|
|
1218
|
+
const targetLabel = targetEntry?.config.label || msg.to;
|
|
1219
|
+
|
|
1220
|
+
// If agent is active but not running a task, restart message loop
|
|
1221
|
+
if (targetEntry && targetEntry.state.smithStatus === 'active' && targetEntry.state.taskStatus !== 'running') {
|
|
1222
|
+
if (!this.messageLoopTimers.has(msg.to)) {
|
|
1223
|
+
this.startMessageLoop(msg.to);
|
|
1224
|
+
console.log(`[forge-agent] Restarted message loop for ${targetLabel} (pending ${Math.round(age / 60000)}min)`);
|
|
1225
|
+
} else {
|
|
1226
|
+
console.log(`[forge-agent] ${targetLabel} has pending message ${msg.id.slice(0, 8)} for ${Math.round(age / 60000)}min — loop running but not consuming`);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
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);
|
|
1231
|
+
this.forgeActedMessages.add(`pending-${msg.id}`);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// Case 4: Failed → notify sender so they know
|
|
1236
|
+
if (msg.status === 'failed' && !this.forgeActedMessages.has(`failed-${msg.id}`)) {
|
|
1237
|
+
const senderEntry = this.agents.get(msg.from);
|
|
1238
|
+
const targetLabel = this.agents.get(msg.to)?.config.label || msg.to;
|
|
1239
|
+
if (senderEntry && msg.from !== '_forge' && msg.from !== '_system') {
|
|
1240
|
+
this.bus.send('_forge', msg.from, 'notify', {
|
|
1241
|
+
action: 'update_notify',
|
|
1242
|
+
content: `Your message to ${targetLabel} has failed. You may want to retry or take a different approach.`,
|
|
1243
|
+
});
|
|
1244
|
+
console.log(`[forge-agent] Notified ${senderEntry.config.label} that message to ${targetLabel} failed`);
|
|
1245
|
+
}
|
|
1246
|
+
this.forgeActedMessages.add(`failed-${msg.id}`);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// Case 5: Pending approval too long (>5min) → log reminder
|
|
1250
|
+
if (msg.status === 'pending_approval') {
|
|
1251
|
+
const age = now - msg.timestamp;
|
|
1252
|
+
if (age > 300_000 && !this.forgeActedMessages.has(`approval-${msg.id}`)) {
|
|
1253
|
+
const targetLabel = this.agents.get(msg.to)?.config.label || msg.to;
|
|
1254
|
+
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);
|
|
1255
|
+
this.forgeActedMessages.add(`approval-${msg.id}`);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1045
1258
|
}
|
|
1046
1259
|
}
|
|
1047
1260
|
|
|
@@ -1058,8 +1271,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1058
1271
|
|
|
1059
1272
|
if (action === 'analyze') {
|
|
1060
1273
|
// Auto-wake agent to analyze changes (skip if busy/manual)
|
|
1061
|
-
if (entry.state.
|
|
1062
|
-
console.log(`[watch] ${entry.config.label}: skipped analyze (
|
|
1274
|
+
if (entry.state.taskStatus === 'running') {
|
|
1275
|
+
console.log(`[watch] ${entry.config.label}: skipped analyze (task=${entry.state.taskStatus})`);
|
|
1063
1276
|
return;
|
|
1064
1277
|
}
|
|
1065
1278
|
if (!entry.worker?.isListening()) {
|
|
@@ -1103,23 +1316,46 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1103
1316
|
return;
|
|
1104
1317
|
}
|
|
1105
1318
|
|
|
1106
|
-
|
|
1319
|
+
const prompt = entry.config.watch?.prompt;
|
|
1320
|
+
// For terminal injection: send the configured prompt directly (pattern is the trigger, not the payload)
|
|
1321
|
+
// If no prompt configured, send the summary
|
|
1322
|
+
const message = prompt || summary;
|
|
1323
|
+
|
|
1324
|
+
// Try to inject directly into an open terminal session
|
|
1325
|
+
// Verify stored session is alive, clear if dead
|
|
1326
|
+
if (targetEntry.state.tmuxSession) {
|
|
1327
|
+
try { execSync(`tmux has-session -t "${targetEntry.state.tmuxSession}" 2>/dev/null`, { timeout: 3000 }); }
|
|
1328
|
+
catch { targetEntry.state.tmuxSession = undefined; }
|
|
1329
|
+
}
|
|
1330
|
+
const tmuxSession = targetEntry.state.tmuxSession || this.findTmuxSession(targetEntry.config.label);
|
|
1331
|
+
if (tmuxSession) {
|
|
1332
|
+
try {
|
|
1333
|
+
const tmpFile = `/tmp/forge-watch-${Date.now()}.txt`;
|
|
1334
|
+
writeFileSync(tmpFile, message);
|
|
1335
|
+
execSync(`tmux load-buffer ${tmpFile}`, { timeout: 5000 });
|
|
1336
|
+
execSync(`tmux paste-buffer -t "${tmuxSession}" && sleep 0.2 && tmux send-keys -t "${tmuxSession}" Enter`, { timeout: 5000 });
|
|
1337
|
+
try { unlinkSync(tmpFile); } catch {}
|
|
1338
|
+
console.log(`[watch] ${entry.config.label} → ${targetEntry.config.label}: injected into terminal (${tmuxSession})`);
|
|
1339
|
+
} catch (err: any) {
|
|
1340
|
+
console.error(`[watch] Terminal inject failed: ${err.message}, falling back to bus`);
|
|
1341
|
+
this.bus.send(agentId, targetId, 'notify', { action: 'watch_alert', content: message });
|
|
1342
|
+
}
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// No terminal open — send via bus (will start new session)
|
|
1107
1347
|
const hasPendingFromWatch = this.bus.getLog().some(m =>
|
|
1108
1348
|
m.from === agentId && m.to === targetId &&
|
|
1109
1349
|
(m.status === 'pending' || m.status === 'running' || m.status === 'pending_approval') &&
|
|
1110
1350
|
m.type !== 'ack'
|
|
1111
1351
|
);
|
|
1112
1352
|
if (hasPendingFromWatch) {
|
|
1113
|
-
console.log(`[watch] ${entry.config.label}: skipping send — target
|
|
1353
|
+
console.log(`[watch] ${entry.config.label}: skipping bus send — target still processing`);
|
|
1114
1354
|
return;
|
|
1115
1355
|
}
|
|
1116
1356
|
|
|
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`);
|
|
1357
|
+
this.bus.send(agentId, targetId, 'notify', { action: 'watch_alert', content: message });
|
|
1358
|
+
console.log(`[watch] ${entry.config.label} → ${targetEntry.config.label}: sent via bus`);
|
|
1123
1359
|
}
|
|
1124
1360
|
}
|
|
1125
1361
|
|
|
@@ -1189,44 +1425,51 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1189
1425
|
this.emitAgentsChanged();
|
|
1190
1426
|
}
|
|
1191
1427
|
|
|
1192
|
-
|
|
1428
|
+
clearTmuxSession(agentId: string): void {
|
|
1429
|
+
const entry = this.agents.get(agentId);
|
|
1430
|
+
if (!entry) return;
|
|
1431
|
+
entry.state.tmuxSession = undefined;
|
|
1432
|
+
this.saveNow();
|
|
1433
|
+
this.emitAgentsChanged();
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/** Record that an agent has an open terminal (tmux session tracking) */
|
|
1193
1437
|
setManualMode(agentId: string): void {
|
|
1194
1438
|
const entry = this.agents.get(agentId);
|
|
1195
1439
|
if (!entry) return;
|
|
1196
|
-
|
|
1197
|
-
this.emit('event', { type: 'smith_status', agentId, smithStatus: entry.state.smithStatus, mode: 'manual' } satisfies WorkerEvent);
|
|
1440
|
+
// tmuxSession is set separately when terminal opens
|
|
1198
1441
|
this.emitAgentsChanged();
|
|
1199
1442
|
this.saveNow();
|
|
1200
|
-
console.log(`[workspace] Agent "${entry.config.label}"
|
|
1443
|
+
console.log(`[workspace] Agent "${entry.config.label}" terminal opened`);
|
|
1201
1444
|
}
|
|
1202
1445
|
|
|
1203
|
-
/**
|
|
1446
|
+
/** Called when agent's terminal is closed */
|
|
1204
1447
|
restartAgentDaemon(agentId: string): void {
|
|
1205
1448
|
if (!this.daemonActive) return;
|
|
1206
1449
|
const entry = this.agents.get(agentId);
|
|
1207
1450
|
if (!entry || entry.config.type === 'input') return;
|
|
1208
1451
|
|
|
1209
|
-
entry.state.mode = 'auto';
|
|
1210
1452
|
entry.state.error = undefined;
|
|
1453
|
+
// Don't clear tmuxSession here — it may still be alive (persistent session)
|
|
1454
|
+
// Terminal close just means the UI panel is closed, not necessarily tmux killed
|
|
1211
1455
|
|
|
1212
|
-
// Recreate worker if needed
|
|
1456
|
+
// Recreate worker if needed
|
|
1213
1457
|
if (!entry.worker) {
|
|
1214
1458
|
this.enterDaemonListening(agentId);
|
|
1215
1459
|
this.startMessageLoop(agentId);
|
|
1216
1460
|
}
|
|
1217
1461
|
|
|
1218
1462
|
entry.state.smithStatus = 'active';
|
|
1219
|
-
this.emit('event', { type: 'smith_status', agentId, smithStatus: 'active'
|
|
1463
|
+
this.emit('event', { type: 'smith_status', agentId, smithStatus: 'active' } satisfies WorkerEvent);
|
|
1220
1464
|
this.emitAgentsChanged();
|
|
1221
1465
|
}
|
|
1222
1466
|
|
|
1223
|
-
/** Complete
|
|
1467
|
+
/** Complete an agent from terminal — called by forge-done skill */
|
|
1224
1468
|
completeManualAgent(agentId: string, changedFiles: string[]): void {
|
|
1225
1469
|
const entry = this.agents.get(agentId);
|
|
1226
1470
|
if (!entry) return;
|
|
1227
1471
|
|
|
1228
1472
|
entry.state.taskStatus = 'done';
|
|
1229
|
-
entry.state.mode = 'auto'; // clear manual mode
|
|
1230
1473
|
entry.state.completedAt = Date.now();
|
|
1231
1474
|
entry.state.artifacts = changedFiles.map(f => ({ type: 'file' as const, path: f }));
|
|
1232
1475
|
|
|
@@ -1322,13 +1565,12 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1322
1565
|
this.agents.clear();
|
|
1323
1566
|
this.daemonActive = false; // Reset daemon — user must click Start Daemon again after restart
|
|
1324
1567
|
for (const config of data.agents) {
|
|
1325
|
-
const state = data.agentStates[config.id] || { smithStatus: 'down' as const,
|
|
1568
|
+
const state = data.agentStates[config.id] || { smithStatus: 'down' as const, taskStatus: 'idle' as const, history: [], artifacts: [] };
|
|
1326
1569
|
|
|
1327
1570
|
// Migrate old format if loading from pre-two-layer state
|
|
1328
1571
|
if ('status' in state && !('smithStatus' in state)) {
|
|
1329
1572
|
const oldStatus = (state as any).status;
|
|
1330
1573
|
(state as any).smithStatus = 'down';
|
|
1331
|
-
(state as any).mode = (state as any).runMode || 'auto';
|
|
1332
1574
|
(state as any).taskStatus = (oldStatus === 'running' || oldStatus === 'listening') ? 'idle' :
|
|
1333
1575
|
(oldStatus === 'interrupted') ? 'idle' :
|
|
1334
1576
|
(oldStatus === 'waiting_approval') ? 'idle' :
|
|
@@ -1496,20 +1738,9 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1496
1738
|
const causedBy = this.buildCausedBy(agentId, entry);
|
|
1497
1739
|
const processedMsg = causedBy ? this.bus.getLog().find(m => m.id === causedBy.messageId) : null;
|
|
1498
1740
|
|
|
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
|
-
}
|
|
1741
|
+
this.broadcastCompletion(agentId, causedBy);
|
|
1742
|
+
// Note: Forge agent (runForgeAgentCheck) monitors for missing replies
|
|
1743
|
+
// and nudges agents to send summaries. No action needed here.
|
|
1513
1744
|
|
|
1514
1745
|
this.emitWorkspaceStatus();
|
|
1515
1746
|
this.checkWorkspaceComplete?.();
|
|
@@ -1529,13 +1760,25 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1529
1760
|
? `${completedLabel} completed: ${files.length} files changed. ${summary.slice(0, 200)}`
|
|
1530
1761
|
: `${completedLabel} completed. ${summary.slice(0, 300) || 'Check upstream outputs for updates.'}`;
|
|
1531
1762
|
|
|
1532
|
-
// Find all downstream agents
|
|
1763
|
+
// Find all downstream agents — skip if already sent upstream_complete recently (60s)
|
|
1764
|
+
const now = Date.now();
|
|
1533
1765
|
let sent = 0;
|
|
1534
1766
|
for (const [id, entry] of this.agents) {
|
|
1535
1767
|
if (id === completedAgentId) continue;
|
|
1536
1768
|
if (entry.config.type === 'input') continue;
|
|
1537
1769
|
if (!entry.config.dependsOn.includes(completedAgentId)) continue;
|
|
1538
1770
|
|
|
1771
|
+
// Dedup: skip if upstream_complete was sent to this target within last 60s
|
|
1772
|
+
const recentDup = this.bus.getLog().some(m =>
|
|
1773
|
+
m.from === completedAgentId && m.to === id &&
|
|
1774
|
+
m.payload?.action === 'upstream_complete' &&
|
|
1775
|
+
now - m.timestamp < 60_000
|
|
1776
|
+
);
|
|
1777
|
+
if (recentDup) {
|
|
1778
|
+
console.log(`[bus] ${completedLabel} → ${entry.config.label}: upstream_complete skipped (sent <60s ago)`);
|
|
1779
|
+
continue;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1539
1782
|
this.bus.send(completedAgentId, id, 'notify', {
|
|
1540
1783
|
action: 'upstream_complete',
|
|
1541
1784
|
content,
|
|
@@ -1552,6 +1795,305 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1552
1795
|
|
|
1553
1796
|
// ─── Agent liveness ─────────────────────────────────────
|
|
1554
1797
|
|
|
1798
|
+
/** Find an active tmux session for an agent by checking naming conventions */
|
|
1799
|
+
// ─── Persistent Terminal Sessions ────────────────────────
|
|
1800
|
+
|
|
1801
|
+
/** Resolve the CLI session directory for a given project path */
|
|
1802
|
+
private getCliSessionDir(workDir?: string): string {
|
|
1803
|
+
const projectPath = workDir && workDir !== './' && workDir !== '.'
|
|
1804
|
+
? join(this.projectPath, workDir) : this.projectPath;
|
|
1805
|
+
const encoded = resolve(projectPath).replace(/\//g, '-');
|
|
1806
|
+
return join(homedir(), '.claude', 'projects', encoded);
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
/** Create a persistent tmux session with the CLI agent */
|
|
1810
|
+
private async ensurePersistentSession(agentId: string, config: WorkspaceAgentConfig): Promise<void> {
|
|
1811
|
+
const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
|
|
1812
|
+
const sessionName = `mw-forge-${safeName(this.projectName)}-${safeName(config.label)}`;
|
|
1813
|
+
|
|
1814
|
+
// Pre-flight: check project's .claude/settings.json is valid
|
|
1815
|
+
const workDir = config.workDir && config.workDir !== './' && config.workDir !== '.'
|
|
1816
|
+
? `${this.projectPath}/${config.workDir}` : this.projectPath;
|
|
1817
|
+
const projectSettingsFile = join(workDir, '.claude', 'settings.json');
|
|
1818
|
+
if (existsSync(projectSettingsFile)) {
|
|
1819
|
+
try {
|
|
1820
|
+
const raw = readFileSync(projectSettingsFile, 'utf-8');
|
|
1821
|
+
JSON.parse(raw);
|
|
1822
|
+
} catch (err: any) {
|
|
1823
|
+
const errorMsg = `Invalid .claude/settings.json: ${err.message}`;
|
|
1824
|
+
console.error(`[daemon] ${config.label}: ${errorMsg}`);
|
|
1825
|
+
const entry = this.agents.get(agentId);
|
|
1826
|
+
if (entry) {
|
|
1827
|
+
entry.state.error = errorMsg;
|
|
1828
|
+
entry.state.smithStatus = 'down';
|
|
1829
|
+
this.emit('event', { type: 'smith_status', agentId, smithStatus: 'down' } as any);
|
|
1830
|
+
this.emit('event', { type: 'log', agentId, entry: { type: 'system', subtype: 'error', content: `⚠️ ${errorMsg}`, timestamp: new Date().toISOString() } } as any);
|
|
1831
|
+
this.emitAgentsChanged();
|
|
1832
|
+
}
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
// Check if tmux session already exists
|
|
1838
|
+
try {
|
|
1839
|
+
execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`, { timeout: 3000 });
|
|
1840
|
+
console.log(`[daemon] ${config.label}: persistent session already exists (${sessionName})`);
|
|
1841
|
+
} catch {
|
|
1842
|
+
// Create new tmux session and start the CLI agent
|
|
1843
|
+
try {
|
|
1844
|
+
// Resolve agent launch info
|
|
1845
|
+
let cliCmd = 'claude';
|
|
1846
|
+
let cliType = 'claude-code';
|
|
1847
|
+
let supportsSession = true;
|
|
1848
|
+
let skipPermissionsFlag = '--dangerously-skip-permissions';
|
|
1849
|
+
let envExports = '';
|
|
1850
|
+
let modelFlag = '';
|
|
1851
|
+
try {
|
|
1852
|
+
const { resolveTerminalLaunch, listAgents } = await import('../agents/index') as any;
|
|
1853
|
+
const info = resolveTerminalLaunch(config.agentId);
|
|
1854
|
+
cliCmd = info.cliCmd || 'claude';
|
|
1855
|
+
cliType = info.cliType || 'claude-code';
|
|
1856
|
+
supportsSession = info.supportsSession ?? true;
|
|
1857
|
+
const agents = listAgents();
|
|
1858
|
+
const agentDef = agents.find((a: any) => a.id === config.agentId);
|
|
1859
|
+
if (agentDef?.skipPermissionsFlag) skipPermissionsFlag = agentDef.skipPermissionsFlag;
|
|
1860
|
+
if (info.env) {
|
|
1861
|
+
envExports = Object.entries(info.env)
|
|
1862
|
+
.filter(([k]) => k !== 'CLAUDE_MODEL')
|
|
1863
|
+
.map(([k, v]) => `export ${k}="${v}"`)
|
|
1864
|
+
.join(' && ');
|
|
1865
|
+
if (envExports) envExports += ' && ';
|
|
1866
|
+
}
|
|
1867
|
+
if (info.model) modelFlag = ` --model ${info.model}`;
|
|
1868
|
+
} catch {}
|
|
1869
|
+
|
|
1870
|
+
// Generate MCP config for Claude Code agents
|
|
1871
|
+
let mcpConfigFlag = '';
|
|
1872
|
+
if (cliType === 'claude-code') {
|
|
1873
|
+
try {
|
|
1874
|
+
const mcpPort = Number(process.env.MCP_PORT) || 8406;
|
|
1875
|
+
const mcpConfigPath = join(workDir, '.forge', 'mcp.json');
|
|
1876
|
+
const mcpConfig = {
|
|
1877
|
+
mcpServers: {
|
|
1878
|
+
forge: {
|
|
1879
|
+
type: 'sse',
|
|
1880
|
+
url: `http://localhost:${mcpPort}/sse?workspaceId=${this.workspaceId}&agentId=${config.id}`,
|
|
1881
|
+
},
|
|
1882
|
+
},
|
|
1883
|
+
};
|
|
1884
|
+
const { mkdirSync: mkdirS } = await import('node:fs');
|
|
1885
|
+
mkdirS(join(workDir, '.forge'), { recursive: true });
|
|
1886
|
+
writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2));
|
|
1887
|
+
mcpConfigFlag = ` --mcp-config "${mcpConfigPath}"`;
|
|
1888
|
+
} catch (err: any) {
|
|
1889
|
+
console.log(`[daemon] ${config.label}: MCP config generation failed: ${err.message}`);
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
execSync(`tmux new-session -d -s "${sessionName}" -c "${workDir}"`, { timeout: 5000 });
|
|
1894
|
+
|
|
1895
|
+
// Reset profile env vars (unset any leftover from previous agent) then set new ones
|
|
1896
|
+
const profileVarsToReset = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_SMALL_FAST_MODEL', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', 'DISABLE_TELEMETRY', 'DISABLE_ERROR_REPORTING', 'DISABLE_AUTOUPDATER', 'DISABLE_NON_ESSENTIAL_MODEL_CALLS', 'CLAUDE_MODEL'];
|
|
1897
|
+
const unsetCmd = profileVarsToReset.map(v => `unset ${v}`).join(' && ');
|
|
1898
|
+
execSync(`tmux send-keys -t "${sessionName}" '${unsetCmd}' Enter`, { timeout: 5000 });
|
|
1899
|
+
|
|
1900
|
+
// Set FORGE env vars + profile env vars
|
|
1901
|
+
const forgeVars = `export FORGE_WORKSPACE_ID="${this.workspaceId}" FORGE_AGENT_ID="${config.id}" FORGE_PORT="${Number(process.env.PORT) || 8403}"`;
|
|
1902
|
+
if (envExports) {
|
|
1903
|
+
execSync(`tmux send-keys -t "${sessionName}" '${forgeVars} && ${envExports.replace(/ && $/, '')}' Enter`, { timeout: 5000 });
|
|
1904
|
+
} else {
|
|
1905
|
+
execSync(`tmux send-keys -t "${sessionName}" '${forgeVars}' Enter`, { timeout: 5000 });
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
// Build CLI start command
|
|
1909
|
+
const parts: string[] = [];
|
|
1910
|
+
let cmd = cliCmd;
|
|
1911
|
+
|
|
1912
|
+
// Session resume: use bound session ID (primary from project-sessions, others from config)
|
|
1913
|
+
if (supportsSession) {
|
|
1914
|
+
let sessionId: string | undefined;
|
|
1915
|
+
|
|
1916
|
+
if (config.primary) {
|
|
1917
|
+
try {
|
|
1918
|
+
const { getFixedSession } = await import('../project-sessions') as any;
|
|
1919
|
+
sessionId = getFixedSession(this.projectPath);
|
|
1920
|
+
} catch {}
|
|
1921
|
+
} else {
|
|
1922
|
+
sessionId = config.boundSessionId;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
if (sessionId) {
|
|
1926
|
+
const sessionFile = join(this.getCliSessionDir(config.workDir), `${sessionId}.jsonl`);
|
|
1927
|
+
if (existsSync(sessionFile)) {
|
|
1928
|
+
cmd += ` --resume ${sessionId}`;
|
|
1929
|
+
} else {
|
|
1930
|
+
console.log(`[daemon] ${config.label}: bound session ${sessionId} missing, starting fresh`);
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
// No bound session → start fresh (no -c, avoids "No conversation found")
|
|
1934
|
+
}
|
|
1935
|
+
if (modelFlag) cmd += modelFlag;
|
|
1936
|
+
if (config.skipPermissions !== false && skipPermissionsFlag) cmd += ` ${skipPermissionsFlag}`;
|
|
1937
|
+
if (mcpConfigFlag) cmd += mcpConfigFlag;
|
|
1938
|
+
parts.push(cmd);
|
|
1939
|
+
|
|
1940
|
+
const startCmd = parts.join(' && ');
|
|
1941
|
+
execSync(`tmux send-keys -t "${sessionName}" '${startCmd}' Enter`, { timeout: 5000 });
|
|
1942
|
+
|
|
1943
|
+
console.log(`[daemon] ${config.label}: persistent session created (${sessionName}) [${cliType}: ${cliCmd}]`);
|
|
1944
|
+
|
|
1945
|
+
// Verify CLI started successfully (check after 3s if process is still alive)
|
|
1946
|
+
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
1947
|
+
try {
|
|
1948
|
+
const paneContent = execSync(`tmux capture-pane -t "${sessionName}" -p -S -20`, { timeout: 3000, encoding: 'utf-8' });
|
|
1949
|
+
// Check for common startup errors
|
|
1950
|
+
const errorPatterns = [
|
|
1951
|
+
/error.*settings\.json/i,
|
|
1952
|
+
/invalid.*json/i,
|
|
1953
|
+
/SyntaxError/i,
|
|
1954
|
+
/ENOENT.*settings/i,
|
|
1955
|
+
/failed to parse/i,
|
|
1956
|
+
/could not read/i,
|
|
1957
|
+
/fatal/i,
|
|
1958
|
+
/No conversation found/i,
|
|
1959
|
+
/could not connect/i,
|
|
1960
|
+
/ECONNREFUSED/i,
|
|
1961
|
+
];
|
|
1962
|
+
const hasError = errorPatterns.some(p => p.test(paneContent));
|
|
1963
|
+
if (hasError) {
|
|
1964
|
+
const errorLines = paneContent.split('\n').filter(l => /error|invalid|syntax|fatal|failed|No conversation|ECONNREFUSED/i.test(l)).slice(0, 3);
|
|
1965
|
+
const errorMsg = errorLines.join(' ').slice(0, 200) || 'CLI failed to start (check project settings)';
|
|
1966
|
+
console.error(`[daemon] ${config.label}: CLI startup error detected: ${errorMsg}`);
|
|
1967
|
+
|
|
1968
|
+
const entry = this.agents.get(agentId);
|
|
1969
|
+
if (entry) {
|
|
1970
|
+
entry.state.error = `Terminal failed: ${errorMsg}. Falling back to headless mode.`;
|
|
1971
|
+
entry.state.tmuxSession = undefined; // clear so message loop uses headless (claude -p)
|
|
1972
|
+
this.emit('event', { type: 'log', agentId, entry: { type: 'system', subtype: 'error', content: `Terminal startup failed: ${errorMsg}. Auto-fallback to headless (claude -p).`, timestamp: new Date().toISOString() } } as any);
|
|
1973
|
+
this.emitAgentsChanged();
|
|
1974
|
+
}
|
|
1975
|
+
// Kill the failed tmux session
|
|
1976
|
+
try { execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null`, { timeout: 3000 }); } catch {}
|
|
1977
|
+
return;
|
|
1978
|
+
}
|
|
1979
|
+
} catch {}
|
|
1980
|
+
// Auto-bind session: if no boundSessionId, detect new session file after 5s
|
|
1981
|
+
if (!config.primary && !config.boundSessionId && supportsSession) {
|
|
1982
|
+
setTimeout(() => {
|
|
1983
|
+
try {
|
|
1984
|
+
const sessionDir = this.getCliSessionDir(config.workDir);
|
|
1985
|
+
if (existsSync(sessionDir)) {
|
|
1986
|
+
const { readdirSync, statSync: statS } = require('node:fs');
|
|
1987
|
+
const files = readdirSync(sessionDir).filter((f: string) => f.endsWith('.jsonl'));
|
|
1988
|
+
if (files.length > 0) {
|
|
1989
|
+
const latest = files
|
|
1990
|
+
.map((f: string) => ({ name: f, mtime: statS(join(sessionDir, f)).mtimeMs }))
|
|
1991
|
+
.sort((a: any, b: any) => b.mtime - a.mtime)[0];
|
|
1992
|
+
config.boundSessionId = latest.name.replace('.jsonl', '');
|
|
1993
|
+
this.saveNow();
|
|
1994
|
+
console.log(`[daemon] ${config.label}: auto-bound to session ${config.boundSessionId}`);
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
} catch {}
|
|
1998
|
+
}, 5000);
|
|
1999
|
+
}
|
|
2000
|
+
} catch (err: any) {
|
|
2001
|
+
console.error(`[daemon] ${config.label}: failed to create persistent session: ${err.message}`);
|
|
2002
|
+
const entry = this.agents.get(agentId);
|
|
2003
|
+
if (entry) {
|
|
2004
|
+
entry.state.error = `Failed to create terminal: ${err.message}`;
|
|
2005
|
+
entry.state.smithStatus = 'down';
|
|
2006
|
+
this.emit('event', { type: 'smith_status', agentId, smithStatus: 'down' } as any);
|
|
2007
|
+
this.emitAgentsChanged();
|
|
2008
|
+
}
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
// Store tmux session name in agent state
|
|
2014
|
+
const entry = this.agents.get(agentId);
|
|
2015
|
+
if (entry) {
|
|
2016
|
+
entry.state.tmuxSession = sessionName;
|
|
2017
|
+
this.saveNow();
|
|
2018
|
+
this.emitAgentsChanged();
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
/** Inject text into an agent's persistent terminal session */
|
|
2023
|
+
injectIntoSession(agentId: string, text: string): boolean {
|
|
2024
|
+
const entry = this.agents.get(agentId);
|
|
2025
|
+
// Verify stored session is alive
|
|
2026
|
+
if (entry?.state.tmuxSession) {
|
|
2027
|
+
try { execSync(`tmux has-session -t "${entry.state.tmuxSession}" 2>/dev/null`, { timeout: 3000 }); }
|
|
2028
|
+
catch { entry.state.tmuxSession = undefined; }
|
|
2029
|
+
}
|
|
2030
|
+
const tmuxSession = entry?.state.tmuxSession || this.findTmuxSession(entry?.config.label || '');
|
|
2031
|
+
if (!tmuxSession) return false;
|
|
2032
|
+
// Cache found session for future use
|
|
2033
|
+
if (entry && !entry.state.tmuxSession) entry.state.tmuxSession = tmuxSession;
|
|
2034
|
+
|
|
2035
|
+
try {
|
|
2036
|
+
const tmpFile = `/tmp/forge-inject-${Date.now()}.txt`;
|
|
2037
|
+
writeFileSync(tmpFile, text);
|
|
2038
|
+
execSync(`tmux load-buffer ${tmpFile}`, { timeout: 5000 });
|
|
2039
|
+
execSync(`tmux paste-buffer -t "${tmuxSession}"`, { timeout: 5000 });
|
|
2040
|
+
execSync(`tmux send-keys -t "${tmuxSession}" Enter`, { timeout: 5000 });
|
|
2041
|
+
try { unlinkSync(tmpFile); } catch {}
|
|
2042
|
+
return true;
|
|
2043
|
+
} catch (err: any) {
|
|
2044
|
+
console.error(`[inject] Failed for ${tmuxSession}: ${err.message}`);
|
|
2045
|
+
return false;
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
/** Check if agent has a persistent session available */
|
|
2050
|
+
hasPersistentSession(agentId: string): boolean {
|
|
2051
|
+
const entry = this.agents.get(agentId);
|
|
2052
|
+
if (!entry) return false;
|
|
2053
|
+
if (entry.state.tmuxSession) return true;
|
|
2054
|
+
return !!this.findTmuxSession(entry.config.label);
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
private findTmuxSession(agentLabel: string): string | null {
|
|
2058
|
+
const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
|
|
2059
|
+
const projectSafe = safeName(this.projectName);
|
|
2060
|
+
const agentSafe = safeName(agentLabel);
|
|
2061
|
+
|
|
2062
|
+
// Try workspace naming: mw-forge-{project}-{agent}
|
|
2063
|
+
const wsName = `mw-forge-${projectSafe}-${agentSafe}`;
|
|
2064
|
+
try { execSync(`tmux has-session -t "${wsName}" 2>/dev/null`, { timeout: 3000 }); return wsName; } catch {}
|
|
2065
|
+
|
|
2066
|
+
// Try VibeCoding naming: mw-{project}
|
|
2067
|
+
const vcName = `mw-${projectSafe}`;
|
|
2068
|
+
try { execSync(`tmux has-session -t "${vcName}" 2>/dev/null`, { timeout: 3000 }); return vcName; } catch {}
|
|
2069
|
+
|
|
2070
|
+
// Search terminal-state.json for project matching tmux session
|
|
2071
|
+
try {
|
|
2072
|
+
const statePath = join(homedir(), '.forge', 'data', 'terminal-state.json');
|
|
2073
|
+
if (existsSync(statePath)) {
|
|
2074
|
+
const termState = JSON.parse(readFileSync(statePath, 'utf-8'));
|
|
2075
|
+
for (const tab of termState.tabs || []) {
|
|
2076
|
+
if (tab.projectPath === this.projectPath) {
|
|
2077
|
+
const findSession = (tree: any): string | null => {
|
|
2078
|
+
if (tree?.type === 'terminal' && tree.sessionName) return tree.sessionName;
|
|
2079
|
+
for (const child of tree?.children || []) {
|
|
2080
|
+
const found = findSession(child);
|
|
2081
|
+
if (found) return found;
|
|
2082
|
+
}
|
|
2083
|
+
return null;
|
|
2084
|
+
};
|
|
2085
|
+
const sess = findSession(tab.tree);
|
|
2086
|
+
if (sess) {
|
|
2087
|
+
try { execSync(`tmux has-session -t "${sess}" 2>/dev/null`, { timeout: 3000 }); return sess; } catch {}
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
} catch {}
|
|
2093
|
+
|
|
2094
|
+
return null;
|
|
2095
|
+
}
|
|
2096
|
+
|
|
1555
2097
|
private updateAgentLiveness(agentId: string): void {
|
|
1556
2098
|
const entry = this.agents.get(agentId);
|
|
1557
2099
|
if (!entry) {
|
|
@@ -1616,13 +2158,6 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1616
2158
|
// ── Store message in agent history ──
|
|
1617
2159
|
target.state.history.push(logEntry);
|
|
1618
2160
|
|
|
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
2161
|
// ── requiresApproval → set pending_approval on arrival ──
|
|
1627
2162
|
if (target.config.requiresApproval) {
|
|
1628
2163
|
msg.status = 'pending_approval';
|
|
@@ -1654,34 +2189,60 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1654
2189
|
// (loop stays alive so it works when smith comes back)
|
|
1655
2190
|
if (entry.state.smithStatus !== 'active') return;
|
|
1656
2191
|
|
|
1657
|
-
// Skip if
|
|
1658
|
-
if (entry.state.mode === 'manual') return;
|
|
2192
|
+
// Skip if already busy
|
|
1659
2193
|
if (entry.state.taskStatus === 'running') return;
|
|
1660
2194
|
|
|
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
2195
|
// Skip if any message is already running for this agent
|
|
1677
2196
|
const hasRunning = this.bus.getLog().some(m => m.to === agentId && m.status === 'running' && m.type !== 'ack');
|
|
1678
2197
|
if (hasRunning) return;
|
|
1679
2198
|
|
|
2199
|
+
// Execution path determined by config, not runtime tmux state
|
|
2200
|
+
const isTerminalMode = entry.config.persistentSession;
|
|
2201
|
+
if (isTerminalMode) {
|
|
2202
|
+
// Terminal mode: need tmux session. If missing, skip this tick (health check will restart it)
|
|
2203
|
+
if (!entry.state.tmuxSession) {
|
|
2204
|
+
if (++debugTick % 15 === 0) {
|
|
2205
|
+
console.log(`[inbox] ${entry.config.label}: terminal mode but no tmux session — waiting for auto-restart`);
|
|
2206
|
+
}
|
|
2207
|
+
return;
|
|
2208
|
+
}
|
|
2209
|
+
} else {
|
|
2210
|
+
// Headless mode: need worker ready
|
|
2211
|
+
if (!entry.worker) {
|
|
2212
|
+
if (this.daemonActive) {
|
|
2213
|
+
console.log(`[inbox] ${entry.config.label}: no worker, recreating...`);
|
|
2214
|
+
this.enterDaemonListening(agentId);
|
|
2215
|
+
}
|
|
2216
|
+
return;
|
|
2217
|
+
}
|
|
2218
|
+
if (!entry.worker.isListening()) {
|
|
2219
|
+
if (++debugTick % 15 === 0) {
|
|
2220
|
+
console.log(`[inbox] ${entry.config.label}: not listening (smith=${entry.state.smithStatus} task=${entry.state.taskStatus})`);
|
|
2221
|
+
}
|
|
2222
|
+
return;
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
|
|
1680
2226
|
// requiresApproval is handled at message arrival time (routeMessageToAgent),
|
|
1681
2227
|
// not in the message loop. Approved messages come through as normal 'pending'.
|
|
1682
2228
|
|
|
2229
|
+
// Dedup: if multiple upstream_complete from same sender are pending, keep only latest
|
|
2230
|
+
const allRaw = this.bus.getPendingMessagesFor(agentId).filter(m => m.from !== agentId && m.type !== 'ack');
|
|
2231
|
+
const upstreamSeen = new Set<string>();
|
|
2232
|
+
for (let i = allRaw.length - 1; i >= 0; i--) {
|
|
2233
|
+
const m = allRaw[i];
|
|
2234
|
+
if (m.payload?.action === 'upstream_complete') {
|
|
2235
|
+
const key = `upstream-${m.from}`;
|
|
2236
|
+
if (upstreamSeen.has(key)) {
|
|
2237
|
+
m.status = 'done' as any;
|
|
2238
|
+
this.emit('event', { type: 'bus_message_status', messageId: m.id, status: 'done' } as any);
|
|
2239
|
+
}
|
|
2240
|
+
upstreamSeen.add(key);
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
|
|
1683
2244
|
// Find next pending message, applying causedBy rules
|
|
1684
|
-
const allPending =
|
|
2245
|
+
const allPending = allRaw.filter(m => m.status === 'pending');
|
|
1685
2246
|
const pending = allPending.filter(m => {
|
|
1686
2247
|
// Tickets: accepted but check retry limit
|
|
1687
2248
|
if (m.category === 'ticket') {
|
|
@@ -1733,8 +2294,29 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1733
2294
|
timestamp: new Date(nextMsg.timestamp).toISOString(),
|
|
1734
2295
|
};
|
|
1735
2296
|
|
|
1736
|
-
|
|
1737
|
-
|
|
2297
|
+
// Terminal mode → inject; headless → worker (claude -p)
|
|
2298
|
+
if (isTerminalMode) {
|
|
2299
|
+
const injected = this.injectIntoSession(agentId, nextMsg.payload.content || nextMsg.payload.action);
|
|
2300
|
+
if (injected) {
|
|
2301
|
+
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);
|
|
2302
|
+
console.log(`[inbox] ${entry.config.label}: injected into terminal, starting completion monitor`);
|
|
2303
|
+
entry.state.currentMessageId = nextMsg.id;
|
|
2304
|
+
this.monitorTerminalCompletion(agentId, nextMsg.id, entry.state.tmuxSession!);
|
|
2305
|
+
} else {
|
|
2306
|
+
// Terminal inject failed — clear dead session, message stays pending
|
|
2307
|
+
// Health check will auto-restart the terminal session
|
|
2308
|
+
entry.state.tmuxSession = undefined;
|
|
2309
|
+
nextMsg.status = 'pending' as any; // revert to pending for retry
|
|
2310
|
+
this.emit('event', { type: 'bus_message_status', messageId: nextMsg.id, status: 'pending' } as any);
|
|
2311
|
+
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);
|
|
2312
|
+
console.log(`[inbox] ${entry.config.label}: terminal inject failed, cleared session — waiting for health check restart`);
|
|
2313
|
+
this.emitAgentsChanged();
|
|
2314
|
+
}
|
|
2315
|
+
} else {
|
|
2316
|
+
entry.worker!.setProcessingMessage(nextMsg.id);
|
|
2317
|
+
entry.worker!.wake({ type: 'bus_message', messages: [logEntry] });
|
|
2318
|
+
this.emit('event', { type: 'log', agentId, entry: { type: 'system', subtype: 'execution_method', content: '⚡ Executed via claude -p', timestamp: new Date().toISOString() } } as any);
|
|
2319
|
+
}
|
|
1738
2320
|
};
|
|
1739
2321
|
|
|
1740
2322
|
// Check every 2 seconds
|
|
@@ -1761,6 +2343,108 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1761
2343
|
}
|
|
1762
2344
|
}
|
|
1763
2345
|
|
|
2346
|
+
// ─── Terminal completion monitor ──────────────────────
|
|
2347
|
+
private terminalMonitors = new Map<string, NodeJS.Timeout>();
|
|
2348
|
+
|
|
2349
|
+
/**
|
|
2350
|
+
* Monitor a tmux session for completion after injecting a message.
|
|
2351
|
+
* Detects CLI prompt patterns (❯, $, >) indicating the agent is idle.
|
|
2352
|
+
* Requires 2 consecutive prompt detections (10s) to confirm completion.
|
|
2353
|
+
*/
|
|
2354
|
+
private monitorTerminalCompletion(agentId: string, messageId: string, tmuxSession: string): void {
|
|
2355
|
+
// Stop any existing monitor for this agent
|
|
2356
|
+
const existing = this.terminalMonitors.get(agentId);
|
|
2357
|
+
if (existing) clearInterval(existing);
|
|
2358
|
+
|
|
2359
|
+
// Prompt patterns that indicate the CLI is idle and waiting for input
|
|
2360
|
+
// Claude Code: ❯ Codex: > Aider: > Generic shell: $ #
|
|
2361
|
+
const PROMPT_PATTERNS = [
|
|
2362
|
+
/^❯\s*$/, // Claude Code idle prompt
|
|
2363
|
+
/^>\s*$/, // Codex / generic prompt
|
|
2364
|
+
/^\$\s*$/, // Shell prompt
|
|
2365
|
+
/^#\s*$/, // Root shell prompt
|
|
2366
|
+
/^aider>\s*$/, // Aider prompt
|
|
2367
|
+
];
|
|
2368
|
+
|
|
2369
|
+
let promptCount = 0;
|
|
2370
|
+
let started = false;
|
|
2371
|
+
const CONFIRM_CHECKS = 2; // 2 consecutive prompt detections = done
|
|
2372
|
+
const CHECK_INTERVAL = 5000; // 5s between checks
|
|
2373
|
+
|
|
2374
|
+
const timer = setInterval(() => {
|
|
2375
|
+
try {
|
|
2376
|
+
const output = execSync(`tmux capture-pane -t "${tmuxSession}" -p -S -30`, { timeout: 3000, encoding: 'utf-8' });
|
|
2377
|
+
|
|
2378
|
+
// Strip ANSI escape sequences for clean matching
|
|
2379
|
+
const clean = output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
|
|
2380
|
+
// Get last few non-empty lines
|
|
2381
|
+
const lines = clean.split('\n').map(l => l.trim()).filter(Boolean);
|
|
2382
|
+
const tail = lines.slice(-5);
|
|
2383
|
+
|
|
2384
|
+
// First check: detect that agent started working (output changed from inject)
|
|
2385
|
+
if (!started && lines.length > 3) {
|
|
2386
|
+
started = true;
|
|
2387
|
+
}
|
|
2388
|
+
if (!started) return;
|
|
2389
|
+
|
|
2390
|
+
// Check if any of the last lines match a prompt pattern
|
|
2391
|
+
const hasPrompt = tail.some(line => PROMPT_PATTERNS.some(p => p.test(line)));
|
|
2392
|
+
|
|
2393
|
+
if (hasPrompt) {
|
|
2394
|
+
promptCount++;
|
|
2395
|
+
if (promptCount >= CONFIRM_CHECKS) {
|
|
2396
|
+
clearInterval(timer);
|
|
2397
|
+
this.terminalMonitors.delete(agentId);
|
|
2398
|
+
|
|
2399
|
+
// Extract output summary (skip prompt lines)
|
|
2400
|
+
const contentLines = lines.filter(l => !PROMPT_PATTERNS.some(p => p.test(l)));
|
|
2401
|
+
const summary = contentLines.slice(-15).join('\n');
|
|
2402
|
+
|
|
2403
|
+
// Mark message done
|
|
2404
|
+
const msg = this.bus.getLog().find(m => m.id === messageId);
|
|
2405
|
+
if (msg && msg.status !== 'done') {
|
|
2406
|
+
msg.status = 'done' as any;
|
|
2407
|
+
this.emit('event', { type: 'bus_message_status', messageId, status: 'done' } as any);
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
// Emit output to log panel
|
|
2411
|
+
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);
|
|
2412
|
+
|
|
2413
|
+
// Trigger downstream notifications
|
|
2414
|
+
const entry = this.agents.get(agentId);
|
|
2415
|
+
if (entry) {
|
|
2416
|
+
entry.state.currentMessageId = undefined;
|
|
2417
|
+
this.handleAgentDone(agentId, entry, summary.slice(0, 300));
|
|
2418
|
+
}
|
|
2419
|
+
console.log(`[terminal-monitor] ${agentId}: prompt detected, completed`);
|
|
2420
|
+
}
|
|
2421
|
+
} else {
|
|
2422
|
+
promptCount = 0; // reset — still working
|
|
2423
|
+
}
|
|
2424
|
+
} catch {
|
|
2425
|
+
// Session died
|
|
2426
|
+
clearInterval(timer);
|
|
2427
|
+
this.terminalMonitors.delete(agentId);
|
|
2428
|
+
const msg = this.bus.getLog().find(m => m.id === messageId);
|
|
2429
|
+
if (msg && msg.status !== 'done' && msg.status !== 'failed') {
|
|
2430
|
+
msg.status = 'failed' as any;
|
|
2431
|
+
this.emit('event', { type: 'bus_message_status', messageId, status: 'failed' } as any);
|
|
2432
|
+
}
|
|
2433
|
+
const entry = this.agents.get(agentId);
|
|
2434
|
+
if (entry) entry.state.currentMessageId = undefined;
|
|
2435
|
+
console.error(`[terminal-monitor] ${agentId}: session died, marked message failed`);
|
|
2436
|
+
}
|
|
2437
|
+
}, CHECK_INTERVAL);
|
|
2438
|
+
timer.unref();
|
|
2439
|
+
this.terminalMonitors.set(agentId, timer);
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
/** Stop all terminal monitors (on daemon stop) */
|
|
2443
|
+
private stopAllTerminalMonitors(): void {
|
|
2444
|
+
for (const [, timer] of this.terminalMonitors) clearInterval(timer);
|
|
2445
|
+
this.terminalMonitors.clear();
|
|
2446
|
+
}
|
|
2447
|
+
|
|
1764
2448
|
/** Check if all agents are done and no pending work remains */
|
|
1765
2449
|
private checkWorkspaceComplete(): void {
|
|
1766
2450
|
let allDone = true;
|