@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.
@@ -11,14 +11,15 @@
11
11
  */
12
12
 
13
13
  import { EventEmitter } from 'node:events';
14
- import { readFileSync, existsSync } from 'node:fs';
15
- import { resolve } from 'node:path';
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
- // Restart watch if config changed
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, mode: entry.state.mode }
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', mode: 'auto', taskStatus: 'idle', history: entry.state.history, artifacts: [] };
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, mode: entry.state.mode, taskStatus: 'idle', history: entry.state.history, artifacts: [], cliSessionId: entry.state.cliSessionId };
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, mode: entry.state.mode, taskStatus: 'idle', history: entry.state.history, artifacts: [], cliSessionId: entry.state.cliSessionId };
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', mode: entry.state.mode } satisfies WorkerEvent);
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 consumption loop
800
- this.startMessageLoop(id);
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', mode: 'auto' } satisfies WorkerEvent);
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', mode: 'auto' } satisfies WorkerEvent);
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. Set smith down
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', mode: entry.state.mode } satisfies WorkerEvent);
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', mode: entry.state.mode } as any);
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', mode: entry.state.mode } as any);
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' && entry.state.mode === 'auto') {
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.mode === 'manual' || entry.state.taskStatus === 'running') {
1062
- console.log(`[watch] ${entry.config.label}: skipped analyze (mode=${entry.state.mode} task=${entry.state.taskStatus})`);
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
- // Skip if target already has a pending/running message from this watch
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 ${targetEntry.config.label} still processing previous message`);
1348
+ console.log(`[watch] ${entry.config.label}: skipping bus send — target still processing`);
1114
1349
  return;
1115
1350
  }
1116
1351
 
1117
- const prompt = entry.config.watch?.prompt || 'Watch detected changes, please review:';
1118
- this.bus.send(agentId, targetId, 'notify', {
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
- /** Switch an agent to manual mode (user operates in terminal) */
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
- entry.state.mode = 'manual';
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}" switched to manual mode`);
1438
+ console.log(`[workspace] Agent "${entry.config.label}" terminal opened`);
1201
1439
  }
1202
1440
 
1203
- /** Re-enter daemon mode for an agent after manual terminal is closed */
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 (resetAgent kills worker)
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', mode: 'auto' } satisfies WorkerEvent);
1458
+ this.emit('event', { type: 'smith_status', agentId, smithStatus: 'active' } satisfies WorkerEvent);
1220
1459
  this.emitAgentsChanged();
1221
1460
  }
1222
1461
 
1223
- /** Complete a manual agent — called by forge-done skill from terminal */
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, mode: 'auto' as const, taskStatus: 'idle' as const, history: [], artifacts: [] };
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
- if (processedMsg && !this.isUpstream(processedMsg.from, agentId)) {
1500
- // Processed a message from downstream no extra reply needed.
1501
- // The original message is already marked done via markMessageDone().
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 manual (user in terminal) or running (already busy)
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
- entry.worker.setProcessingMessage(nextMsg.id);
1737
- entry.worker.wake({ type: 'bus_message', messages: [logEntry] });
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;