@aion0/forge 0.5.21 → 0.5.23
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/agent-context.json +1 -1
- package/.forge/mcp.json +1 -1
- package/RELEASE_NOTES.md +6 -10
- package/app/api/plugins/route.ts +75 -0
- package/components/Dashboard.tsx +1 -0
- package/components/PipelineEditor.tsx +135 -9
- package/components/PluginsPanel.tsx +472 -0
- package/components/ProjectDetail.tsx +36 -98
- package/components/SessionView.tsx +4 -4
- package/components/SettingsModal.tsx +166 -67
- package/components/SkillsPanel.tsx +14 -5
- package/components/TerminalLauncher.tsx +398 -0
- package/components/WebTerminal.tsx +84 -84
- package/components/WorkspaceView.tsx +256 -76
- package/lib/agents/index.ts +7 -4
- package/lib/builtin-plugins/docker.yaml +70 -0
- package/lib/builtin-plugins/http.yaml +66 -0
- package/lib/builtin-plugins/jenkins.yaml +92 -0
- package/lib/builtin-plugins/llm-vision.yaml +85 -0
- package/lib/builtin-plugins/playwright.yaml +111 -0
- package/lib/builtin-plugins/shell-command.yaml +60 -0
- package/lib/builtin-plugins/slack.yaml +48 -0
- package/lib/builtin-plugins/webhook.yaml +56 -0
- package/lib/forge-mcp-server.ts +116 -2
- package/lib/pipeline.ts +62 -5
- package/lib/plugins/executor.ts +347 -0
- package/lib/plugins/registry.ts +228 -0
- package/lib/plugins/types.ts +103 -0
- package/lib/project-sessions.ts +7 -2
- package/lib/session-utils.ts +7 -3
- package/lib/terminal-standalone.ts +6 -34
- package/lib/workspace/agent-worker.ts +1 -1
- package/lib/workspace/orchestrator.ts +443 -136
- package/lib/workspace/presets.ts +5 -3
- package/lib/workspace/session-monitor.ts +14 -10
- package/lib/workspace/types.ts +3 -1
- package/lib/workspace-standalone.ts +38 -21
- package/package.json +1 -1
- package/qa/.forge/agent-context.json +1 -1
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { EventEmitter } from 'node:events';
|
|
14
|
-
import { readFileSync, existsSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
14
|
+
import { readFileSync, existsSync, writeFileSync, unlinkSync, mkdirSync, statSync, readdirSync } from 'node:fs';
|
|
15
15
|
import { execSync } from 'node:child_process';
|
|
16
16
|
import { resolve, join } from 'node:path';
|
|
17
17
|
import { homedir } from 'node:os';
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
loadMemory, saveMemory, createMemory, formatMemoryForPrompt,
|
|
40
40
|
addObservation, addSessionSummary, parseStepToObservations, buildSessionSummary,
|
|
41
41
|
} from './smith-memory';
|
|
42
|
+
import { getFixedSession } from '../project-sessions';
|
|
42
43
|
|
|
43
44
|
// ─── Orchestrator Events ─────────────────────────────────
|
|
44
45
|
|
|
@@ -66,6 +67,9 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
66
67
|
private daemonActive = false;
|
|
67
68
|
private createdAt = Date.now();
|
|
68
69
|
private healthCheckTimer: NodeJS.Timeout | null = null;
|
|
70
|
+
private settingsValidCache = new Map<string, number>(); // filePath → mtime (validated ok)
|
|
71
|
+
private agentRunningMsg = new Map<string, string>(); // agentId → messageId currently being processed
|
|
72
|
+
private reconcileTick = 0; // counts health check ticks for 60s reconcile
|
|
69
73
|
|
|
70
74
|
/** Emit a log event (auto-persisted via constructor listener) */
|
|
71
75
|
emitLog(agentId: string, entry: any): void {
|
|
@@ -304,6 +308,18 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
304
308
|
updateAgentConfig(id: string, config: WorkspaceAgentConfig): void {
|
|
305
309
|
const entry = this.agents.get(id);
|
|
306
310
|
if (!entry) return;
|
|
311
|
+
// Validate agentId exists — fallback to default if deleted from Settings
|
|
312
|
+
if (config.agentId) {
|
|
313
|
+
try {
|
|
314
|
+
const { listAgents, getDefaultAgentId } = require('../agents/index');
|
|
315
|
+
const validAgents = new Set((listAgents() as any[]).map((a: any) => a.id));
|
|
316
|
+
if (!validAgents.has(config.agentId)) {
|
|
317
|
+
const fallback = getDefaultAgentId() || 'claude';
|
|
318
|
+
console.log(`[workspace] ${config.label}: agent "${config.agentId}" not found, falling back to "${fallback}"`);
|
|
319
|
+
config.agentId = fallback;
|
|
320
|
+
}
|
|
321
|
+
} catch {}
|
|
322
|
+
}
|
|
307
323
|
const conflict = this.validateOutputs(config, id);
|
|
308
324
|
if (conflict) throw new Error(conflict);
|
|
309
325
|
const cycleErr = this.detectCycle(id, config.dependsOn);
|
|
@@ -328,6 +344,11 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
328
344
|
}
|
|
329
345
|
entry.state.tmuxSession = undefined;
|
|
330
346
|
config.boundSessionId = undefined;
|
|
347
|
+
} else {
|
|
348
|
+
// Preserve server-managed fields the client doesn't track
|
|
349
|
+
if (!config.boundSessionId && entry.config.boundSessionId) {
|
|
350
|
+
config.boundSessionId = entry.config.boundSessionId;
|
|
351
|
+
}
|
|
331
352
|
}
|
|
332
353
|
|
|
333
354
|
entry.config = config;
|
|
@@ -341,19 +362,23 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
341
362
|
entry.worker = null;
|
|
342
363
|
|
|
343
364
|
if (this.daemonActive) {
|
|
344
|
-
//
|
|
345
|
-
|
|
346
|
-
entry.state.smithStatus = '
|
|
365
|
+
// Set 'starting' BEFORE creating worker — worker.executeDaemon emits 'active' synchronously
|
|
366
|
+
// which would cause a race: frontend sees active before boundSessionId is ready
|
|
367
|
+
entry.state.smithStatus = 'starting';
|
|
368
|
+
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'starting' } as any);
|
|
347
369
|
// Restart watch if config changed
|
|
348
370
|
this.watchManager.startWatch(id, config);
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
371
|
+
this.ensurePersistentSession(id, config).then(() => {
|
|
372
|
+
const e = this.agents.get(id);
|
|
373
|
+
if (e) {
|
|
374
|
+
// Rebuild worker + message loop AFTER session is ready (boundSessionId set)
|
|
375
|
+
this.enterDaemonListening(id);
|
|
376
|
+
e.state.smithStatus = 'active';
|
|
377
|
+
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'active' } as any);
|
|
378
|
+
this.emitAgentsChanged();
|
|
379
|
+
}
|
|
355
380
|
this.startMessageLoop(id);
|
|
356
|
-
}
|
|
381
|
+
});
|
|
357
382
|
}
|
|
358
383
|
this.saveNow();
|
|
359
384
|
this.emitAgentsChanged();
|
|
@@ -706,7 +731,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
706
731
|
}
|
|
707
732
|
|
|
708
733
|
// Ensure smith is active when daemon starts this agent
|
|
709
|
-
if
|
|
734
|
+
// Skip if 'starting': ensurePersistentSession is in progress and will set 'active' when done.
|
|
735
|
+
if (this.daemonActive && entry.state.smithStatus !== 'active' && entry.state.smithStatus !== 'starting') {
|
|
710
736
|
entry.state.smithStatus = 'active';
|
|
711
737
|
this.emit('event', { type: 'smith_status', agentId, smithStatus: 'active' } satisfies WorkerEvent);
|
|
712
738
|
}
|
|
@@ -730,6 +756,9 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
730
756
|
}
|
|
731
757
|
}
|
|
732
758
|
|
|
759
|
+
// Sync role → CLAUDE.md so CLI agents (Claude Code) see it as system instructions
|
|
760
|
+
this.syncRoleToClaudeMd(config);
|
|
761
|
+
|
|
733
762
|
let upstreamContext = this.buildUpstreamContext(config);
|
|
734
763
|
if (userInput) {
|
|
735
764
|
const prefix = '## Additional Instructions:\n' + userInput;
|
|
@@ -816,6 +845,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
816
845
|
this.handleAgentDone(agentId, entry, event.summary);
|
|
817
846
|
}
|
|
818
847
|
if (event.type === 'error') {
|
|
848
|
+
this.agentRunningMsg.delete(agentId);
|
|
819
849
|
this.bus.notifyError(agentId, event.error);
|
|
820
850
|
this.emitWorkspaceStatus();
|
|
821
851
|
}
|
|
@@ -839,6 +869,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
839
869
|
// Execute in daemon mode (non-blocking)
|
|
840
870
|
worker.executeDaemon(0, upstreamContext).catch(err => {
|
|
841
871
|
if (entry.state.taskStatus !== 'failed') {
|
|
872
|
+
this.agentRunningMsg.delete(agentId);
|
|
842
873
|
entry.state.taskStatus = 'failed';
|
|
843
874
|
entry.state.error = err?.message || String(err);
|
|
844
875
|
this.emit('event', { type: 'error', agentId, error: entry.state.error! } satisfies WorkerEvent);
|
|
@@ -860,6 +891,23 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
860
891
|
installForgeSkills(this.projectPath, this.workspaceId, '', Number(process.env.PORT) || 8403);
|
|
861
892
|
} catch {}
|
|
862
893
|
|
|
894
|
+
// Validate agent IDs — fallback to default if configured agent was deleted from Settings
|
|
895
|
+
let defaultAgentId = 'claude';
|
|
896
|
+
try {
|
|
897
|
+
const { listAgents, getDefaultAgentId } = await import('../agents/index') as any;
|
|
898
|
+
const validAgents = new Set((listAgents() as any[]).map(a => a.id));
|
|
899
|
+
defaultAgentId = getDefaultAgentId() || 'claude';
|
|
900
|
+
for (const [id, entry] of this.agents) {
|
|
901
|
+
if (entry.config.type === 'input') continue;
|
|
902
|
+
if (entry.config.agentId && !validAgents.has(entry.config.agentId)) {
|
|
903
|
+
console.log(`[daemon] ${entry.config.label}: agent "${entry.config.agentId}" not found, falling back to "${defaultAgentId}"`);
|
|
904
|
+
entry.config.agentId = defaultAgentId;
|
|
905
|
+
this.emit('event', { type: 'log', agentId: id, entry: { type: 'system', subtype: 'warning', content: `Agent "${entry.config.agentId}" not found in Settings — using default "${defaultAgentId}"`, timestamp: new Date().toISOString() } } as any);
|
|
906
|
+
this.saveNow();
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
} catch {}
|
|
910
|
+
|
|
863
911
|
// Start each smith one by one, verify each starts correctly
|
|
864
912
|
let started = 0;
|
|
865
913
|
let failed = 0;
|
|
@@ -1033,6 +1081,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1033
1081
|
this.handleAgentDone(agentId, entry, event.summary);
|
|
1034
1082
|
}
|
|
1035
1083
|
if (event.type === 'error') {
|
|
1084
|
+
this.agentRunningMsg.delete(agentId);
|
|
1036
1085
|
this.bus.notifyError(agentId, event.error);
|
|
1037
1086
|
}
|
|
1038
1087
|
});
|
|
@@ -1100,6 +1149,10 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1100
1149
|
if (this.sessionMonitor) { this.sessionMonitor.stopAll(); this.sessionMonitor = null; }
|
|
1101
1150
|
this.stopHealthCheck();
|
|
1102
1151
|
this.forgeActedMessages.clear();
|
|
1152
|
+
this.busMarkerScanned.clear();
|
|
1153
|
+
this.forgeAgentStartTime = 0;
|
|
1154
|
+
this.agentRunningMsg.clear();
|
|
1155
|
+
this.reconcileTick = 0;
|
|
1103
1156
|
console.log('[workspace] Daemon stopped');
|
|
1104
1157
|
}
|
|
1105
1158
|
|
|
@@ -1168,16 +1221,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1168
1221
|
let sessionId: string | undefined;
|
|
1169
1222
|
|
|
1170
1223
|
if (config.primary) {
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
if (existsSync(psPath)) {
|
|
1174
|
-
const psData = JSON.parse(readFileSync(psPath, 'utf-8'));
|
|
1175
|
-
sessionId = psData[this.projectPath];
|
|
1176
|
-
}
|
|
1177
|
-
console.log(`[session-monitor] ${config.label}: primary fixedSession=${sessionId || 'NONE'}`);
|
|
1178
|
-
} catch (err: any) {
|
|
1179
|
-
console.log(`[session-monitor] ${config.label}: failed to read fixedSession: ${err.message}`);
|
|
1180
|
-
}
|
|
1224
|
+
sessionId = getFixedSession(this.projectPath);
|
|
1225
|
+
console.log(`[session-monitor] ${config.label}: primary fixedSession=${sessionId || 'NONE'}`);
|
|
1181
1226
|
} else {
|
|
1182
1227
|
sessionId = config.boundSessionId;
|
|
1183
1228
|
console.log(`[session-monitor] ${config.label}: boundSession=${sessionId || 'NONE'}`);
|
|
@@ -1185,23 +1230,12 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1185
1230
|
|
|
1186
1231
|
if (!sessionId) {
|
|
1187
1232
|
// Try to auto-bind from session files on disk
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
.map((f: string) => ({ name: f, mtime: require('node:fs').statSync(join(sessionDir, f)).mtimeMs }))
|
|
1195
|
-
.sort((a: any, b: any) => b.mtime - a.mtime);
|
|
1196
|
-
sessionId = sorted[0].name.replace('.jsonl', '');
|
|
1197
|
-
if (!config.primary) {
|
|
1198
|
-
config.boundSessionId = sessionId;
|
|
1199
|
-
this.saveNow();
|
|
1200
|
-
console.log(`[session-monitor] ${config.label}: auto-bound to ${sessionId}`);
|
|
1201
|
-
}
|
|
1202
|
-
}
|
|
1203
|
-
}
|
|
1204
|
-
} catch {}
|
|
1233
|
+
sessionId = this.getLatestSessionId(config.workDir);
|
|
1234
|
+
if (sessionId && !config.primary) {
|
|
1235
|
+
config.boundSessionId = sessionId;
|
|
1236
|
+
this.saveNow();
|
|
1237
|
+
console.log(`[session-monitor] ${config.label}: auto-bound to ${sessionId}`);
|
|
1238
|
+
}
|
|
1205
1239
|
if (!sessionId) {
|
|
1206
1240
|
console.log(`[session-monitor] ${config.label}: no sessionId, skipping`);
|
|
1207
1241
|
return;
|
|
@@ -1231,6 +1265,20 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1231
1265
|
private runHealthCheck(): void {
|
|
1232
1266
|
if (!this.daemonActive) return;
|
|
1233
1267
|
|
|
1268
|
+
// Every 60s (6 ticks × 10s): reconcile agentRunningMsg cache with actual bus log
|
|
1269
|
+
this.reconcileTick++;
|
|
1270
|
+
if (this.reconcileTick >= 6) {
|
|
1271
|
+
this.reconcileTick = 0;
|
|
1272
|
+
const log = this.bus.getLog();
|
|
1273
|
+
for (const [agentId, messageId] of this.agentRunningMsg) {
|
|
1274
|
+
const msg = log.find(m => m.id === messageId);
|
|
1275
|
+
if (!msg || msg.status !== 'running') {
|
|
1276
|
+
console.log(`[health] reconcile: clearing stale agentRunningMsg for ${agentId} (msg=${messageId.slice(0, 8)}, status=${msg?.status || 'not found'})`);
|
|
1277
|
+
this.agentRunningMsg.delete(agentId);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1234
1282
|
for (const [id, entry] of this.agents) {
|
|
1235
1283
|
if (entry.config.type === 'input') continue;
|
|
1236
1284
|
|
|
@@ -1244,7 +1292,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1244
1292
|
}
|
|
1245
1293
|
|
|
1246
1294
|
// Check 2: SmithStatus should be active
|
|
1247
|
-
|
|
1295
|
+
// Skip: 'starting' means ensurePersistentSession is in progress — overriding would race with it.
|
|
1296
|
+
if (entry.state.smithStatus !== 'active' && entry.state.smithStatus !== 'starting') {
|
|
1248
1297
|
console.log(`[health] ${entry.config.label}: smith=${entry.state.smithStatus} — setting active`);
|
|
1249
1298
|
entry.state.smithStatus = 'active';
|
|
1250
1299
|
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'active' } as any);
|
|
@@ -1272,7 +1321,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1272
1321
|
// Check 5: Pending messages but agent idle — try wake
|
|
1273
1322
|
if (entry.state.taskStatus !== 'running') {
|
|
1274
1323
|
const pending = this.bus.getPendingMessagesFor(id).filter(m => m.from !== id && m.type !== 'ack');
|
|
1275
|
-
if (pending.length > 0 && entry.worker
|
|
1324
|
+
if (pending.length > 0 && entry.worker?.isListening()) {
|
|
1276
1325
|
// Message loop should handle this, but if it didn't, log it
|
|
1277
1326
|
const age = Date.now() - pending[0].timestamp;
|
|
1278
1327
|
if (age > 30_000) { // stuck for 30+ seconds
|
|
@@ -1282,7 +1331,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1282
1331
|
}
|
|
1283
1332
|
|
|
1284
1333
|
// Check 6: persistentSession agent without tmux → auto-restart terminal
|
|
1285
|
-
if
|
|
1334
|
+
// Skip if smithStatus='starting': ensurePersistentSession is already in progress.
|
|
1335
|
+
if (entry.config.persistentSession && !entry.state.tmuxSession && entry.state.smithStatus === 'active') {
|
|
1286
1336
|
console.log(`[health] ${entry.config.label}: persistentSession but no tmux — restarting terminal`);
|
|
1287
1337
|
this.ensurePersistentSession(id, entry.config).catch(err => {
|
|
1288
1338
|
console.error(`[health] ${entry.config.label}: failed to restart terminal: ${err.message}`);
|
|
@@ -1304,6 +1354,16 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1304
1354
|
const log = this.bus.getLog();
|
|
1305
1355
|
const now = Date.now();
|
|
1306
1356
|
|
|
1357
|
+
// Pre-build reply index: "from→to" → latest non-ack message timestamp (only after daemon start)
|
|
1358
|
+
// Used for O(1) hasReply lookups instead of O(n) log.some() per message
|
|
1359
|
+
const replyIndex = new Map<string, number>();
|
|
1360
|
+
for (const r of log) {
|
|
1361
|
+
if (r.timestamp < this.forgeAgentStartTime) continue;
|
|
1362
|
+
if (r.type === 'ack') continue;
|
|
1363
|
+
const key = `${r.from}→${r.to}`;
|
|
1364
|
+
if ((replyIndex.get(key) || 0) < r.timestamp) replyIndex.set(key, r.timestamp);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1307
1367
|
// Only scan messages from after daemon start (skip all history)
|
|
1308
1368
|
for (const msg of log) {
|
|
1309
1369
|
if (msg.timestamp < this.forgeAgentStartTime) continue;
|
|
@@ -1323,10 +1383,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1323
1383
|
const nudgeKey = `nudge-${msg.to}->${msg.from}`;
|
|
1324
1384
|
if (this.forgeActedMessages.has(nudgeKey)) { this.forgeActedMessages.add(msg.id); continue; }
|
|
1325
1385
|
|
|
1326
|
-
const hasReply =
|
|
1327
|
-
r.from === msg.to && r.to === msg.from &&
|
|
1328
|
-
r.timestamp > msg.timestamp && r.type !== 'ack'
|
|
1329
|
-
);
|
|
1386
|
+
const hasReply = (replyIndex.get(`${msg.to}→${msg.from}`) || 0) > msg.timestamp;
|
|
1330
1387
|
if (!hasReply) {
|
|
1331
1388
|
const senderLabel = this.agents.get(msg.from)?.config.label || msg.from;
|
|
1332
1389
|
const targetEntry = this.agents.get(msg.to);
|
|
@@ -1812,6 +1869,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1812
1869
|
|
|
1813
1870
|
/** Stop all agents, save final state, and clean up */
|
|
1814
1871
|
shutdown(): void {
|
|
1872
|
+
this.daemonActive = false;
|
|
1873
|
+
this.stopHealthCheck();
|
|
1815
1874
|
this.stopAllMessageLoops();
|
|
1816
1875
|
stopAutoSave(this.workspaceId);
|
|
1817
1876
|
// Sync save — must complete before process exits
|
|
@@ -1826,6 +1885,72 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1826
1885
|
|
|
1827
1886
|
// ─── Private ───────────────────────────────────────────
|
|
1828
1887
|
|
|
1888
|
+
/** Sync agent role description into CLAUDE.md so CLI agents read it natively */
|
|
1889
|
+
private syncRoleToClaudeMd(config: WorkspaceAgentConfig): void {
|
|
1890
|
+
if (!config.role?.trim()) return;
|
|
1891
|
+
const workDir = join(this.projectPath, config.workDir || '');
|
|
1892
|
+
const claudeMdPath = join(workDir, 'CLAUDE.md');
|
|
1893
|
+
|
|
1894
|
+
// Build plugin docs from agent's plugins
|
|
1895
|
+
let pluginDocs = '';
|
|
1896
|
+
if (config.plugins?.length) {
|
|
1897
|
+
try {
|
|
1898
|
+
const { getInstalledPlugin, listInstalledPlugins } = require('../plugins/registry');
|
|
1899
|
+
const installed = listInstalledPlugins();
|
|
1900
|
+
const agentPlugins = config.plugins;
|
|
1901
|
+
// Find all instances that match agent's plugin list (by source or direct id)
|
|
1902
|
+
const relevant = installed.filter((p: any) =>
|
|
1903
|
+
agentPlugins.includes(p.id) || agentPlugins.includes(p.source || p.id)
|
|
1904
|
+
);
|
|
1905
|
+
if (relevant.length > 0) {
|
|
1906
|
+
pluginDocs = '\n\n## Available Plugins (via MCP run_plugin)\n';
|
|
1907
|
+
for (const p of relevant) {
|
|
1908
|
+
const def = p.definition;
|
|
1909
|
+
const name = p.instanceName || def.name;
|
|
1910
|
+
pluginDocs += `\n### ${def.icon} ${name} (id: "${p.id}")\n`;
|
|
1911
|
+
if (def.description) pluginDocs += `${def.description}\n`;
|
|
1912
|
+
pluginDocs += '\nActions:\n';
|
|
1913
|
+
for (const [actionName, action] of Object.entries(def.actions) as any[]) {
|
|
1914
|
+
pluginDocs += `- **${actionName}** (${action.run})`;
|
|
1915
|
+
if (def.defaultAction === actionName) pluginDocs += ' [default]';
|
|
1916
|
+
pluginDocs += '\n';
|
|
1917
|
+
}
|
|
1918
|
+
if (Object.keys(def.params).length > 0) {
|
|
1919
|
+
pluginDocs += 'Params: ' + Object.entries(def.params).map(([k, v]: any) =>
|
|
1920
|
+
`${k}${v.required ? '*' : ''} (${v.type})`
|
|
1921
|
+
).join(', ') + '\n';
|
|
1922
|
+
}
|
|
1923
|
+
pluginDocs += `\nUsage: run_plugin({ plugin: "${p.id}", action: "<action>", params: { ... } })\n`;
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
} catch {}
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
const MARKER_START = '<!-- forge:agent-role -->';
|
|
1930
|
+
const MARKER_END = '<!-- /forge:agent-role -->';
|
|
1931
|
+
const roleBlock = `${MARKER_START}\n## Agent Role (managed by Forge)\n\n${config.role.trim()}${pluginDocs}\n${MARKER_END}`;
|
|
1932
|
+
|
|
1933
|
+
try {
|
|
1934
|
+
let content = '';
|
|
1935
|
+
if (existsSync(claudeMdPath)) {
|
|
1936
|
+
content = readFileSync(claudeMdPath, 'utf-8');
|
|
1937
|
+
// Replace existing forge block
|
|
1938
|
+
const regex = new RegExp(`${MARKER_START}[\\s\\S]*?${MARKER_END}`);
|
|
1939
|
+
if (regex.test(content)) {
|
|
1940
|
+
const updated = content.replace(regex, roleBlock);
|
|
1941
|
+
if (updated !== content) writeFileSync(claudeMdPath, updated);
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
// Append
|
|
1946
|
+
mkdirSync(workDir, { recursive: true });
|
|
1947
|
+
const separator = content && !content.endsWith('\n') ? '\n\n' : content ? '\n' : '';
|
|
1948
|
+
writeFileSync(claudeMdPath, content + separator + roleBlock + '\n');
|
|
1949
|
+
} catch (err) {
|
|
1950
|
+
console.warn(`[workspace] Failed to sync role to CLAUDE.md for ${config.label}:`, err);
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1829
1954
|
private createBackend(config: WorkspaceAgentConfig, agentId?: string) {
|
|
1830
1955
|
switch (config.backend) {
|
|
1831
1956
|
case 'api':
|
|
@@ -1932,6 +2057,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1932
2057
|
|
|
1933
2058
|
/** Unified done handler: broadcast downstream or reply to sender based on message source */
|
|
1934
2059
|
private handleAgentDone(agentId: string, entry: { config: WorkspaceAgentConfig; worker: AgentWorker | null; state: AgentState }, summary?: string): void {
|
|
2060
|
+
this.agentRunningMsg.delete(agentId);
|
|
1935
2061
|
const files = entry.state.artifacts.filter(a => a.path).map(a => a.path!);
|
|
1936
2062
|
console.log(`[workspace] Agent "${entry.config.label}" (${agentId}) completed. Artifacts: ${files.length}.`);
|
|
1937
2063
|
|
|
@@ -1959,12 +2085,24 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1959
2085
|
.filter(h => h.subtype === 'final_summary' || h.subtype === 'step_summary')
|
|
1960
2086
|
.slice(-1)[0]?.content || '';
|
|
1961
2087
|
|
|
2088
|
+
// Keep notification concise — agent can read files/git diff for details
|
|
2089
|
+
const shortSummary = summary.split('\n')[0]?.slice(0, 100) || '';
|
|
1962
2090
|
const content = files.length > 0
|
|
1963
|
-
? `${completedLabel} completed: ${files.length} files changed
|
|
1964
|
-
: `${completedLabel} completed
|
|
2091
|
+
? `${completedLabel} completed: ${files.length} files changed.${shortSummary ? ' ' + shortSummary : ''} Run \`git diff --stat HEAD~1\` for details.`
|
|
2092
|
+
: `${completedLabel} completed.${shortSummary ? ' ' + shortSummary : ''}`;
|
|
1965
2093
|
|
|
1966
2094
|
// Find all downstream agents — skip if already sent upstream_complete recently (60s)
|
|
1967
2095
|
const now = Date.now();
|
|
2096
|
+
|
|
2097
|
+
// Pre-scan log once: build dedup set and pending-to-complete list
|
|
2098
|
+
const recentSentTo = new Set<string>(); // agentIds that received upstream_complete within 60s
|
|
2099
|
+
const pendingToComplete: { m: any; to: string }[] = [];
|
|
2100
|
+
for (const m of this.bus.getLog()) {
|
|
2101
|
+
if (m.from !== completedAgentId || m.payload?.action !== 'upstream_complete') continue;
|
|
2102
|
+
if (now - m.timestamp < 60_000) recentSentTo.add(m.to);
|
|
2103
|
+
if (m.status === 'pending') pendingToComplete.push({ m, to: m.to });
|
|
2104
|
+
}
|
|
2105
|
+
|
|
1968
2106
|
let sent = 0;
|
|
1969
2107
|
for (const [id, entry] of this.agents) {
|
|
1970
2108
|
if (id === completedAgentId) continue;
|
|
@@ -1972,19 +2110,14 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1972
2110
|
if (!entry.config.dependsOn.includes(completedAgentId)) continue;
|
|
1973
2111
|
|
|
1974
2112
|
// Dedup: skip if upstream_complete was sent to this target within last 60s
|
|
1975
|
-
|
|
1976
|
-
m.from === completedAgentId && m.to === id &&
|
|
1977
|
-
m.payload?.action === 'upstream_complete' &&
|
|
1978
|
-
now - m.timestamp < 60_000
|
|
1979
|
-
);
|
|
1980
|
-
if (recentDup) {
|
|
2113
|
+
if (recentSentTo.has(id)) {
|
|
1981
2114
|
console.log(`[bus] ${completedLabel} → ${entry.config.label}: upstream_complete skipped (sent <60s ago)`);
|
|
1982
2115
|
continue;
|
|
1983
2116
|
}
|
|
1984
2117
|
|
|
1985
|
-
// Merge: auto-complete older pending upstream_complete from same sender
|
|
1986
|
-
for (const m of
|
|
1987
|
-
if (
|
|
2118
|
+
// Merge: auto-complete older pending upstream_complete from same sender to this target
|
|
2119
|
+
for (const { m, to } of pendingToComplete) {
|
|
2120
|
+
if (to === id) {
|
|
1988
2121
|
m.status = 'done' as any;
|
|
1989
2122
|
this.emit('event', { type: 'bus_message_status', messageId: m.id, status: 'done' } as any);
|
|
1990
2123
|
}
|
|
@@ -2017,20 +2150,109 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2017
2150
|
return join(homedir(), '.claude', 'projects', encoded);
|
|
2018
2151
|
}
|
|
2019
2152
|
|
|
2153
|
+
/** Return the latest session ID (by mtime) from the CLI session dir, or undefined if none. */
|
|
2154
|
+
private getLatestSessionId(workDir?: string): string | undefined {
|
|
2155
|
+
try {
|
|
2156
|
+
const sessionDir = this.getCliSessionDir(workDir);
|
|
2157
|
+
if (!existsSync(sessionDir)) return undefined;
|
|
2158
|
+
const files = readdirSync(sessionDir).filter(f => f.endsWith('.jsonl'));
|
|
2159
|
+
if (files.length === 0) return undefined;
|
|
2160
|
+
const latest = files
|
|
2161
|
+
.map(f => ({ name: f, mtime: statSync(join(sessionDir, f)).mtimeMs }))
|
|
2162
|
+
.sort((a, b) => b.mtime - a.mtime)[0];
|
|
2163
|
+
return latest.name.replace('.jsonl', '');
|
|
2164
|
+
} catch {
|
|
2165
|
+
return undefined;
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
/** Lightweight session bind for non-persistentSession agents.
|
|
2170
|
+
* Checks if the tmux session already exists and sets entry.state.tmuxSession.
|
|
2171
|
+
* Also auto-binds boundSessionId from the latest session file if not already set.
|
|
2172
|
+
* Does NOT create any session or run any launch script.
|
|
2173
|
+
*/
|
|
2174
|
+
private tryBindExistingSession(agentId: string, config: WorkspaceAgentConfig): void {
|
|
2175
|
+
const entry = this.agents.get(agentId);
|
|
2176
|
+
if (!entry) return;
|
|
2177
|
+
|
|
2178
|
+
const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
|
|
2179
|
+
const sessionName = `mw-forge-${safeName(this.projectName)}-${safeName(config.label)}`;
|
|
2180
|
+
|
|
2181
|
+
// Check if tmux session is alive → set tmuxSession so open_terminal can return it
|
|
2182
|
+
try {
|
|
2183
|
+
execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`, { timeout: 3000 });
|
|
2184
|
+
if (entry.state.tmuxSession !== sessionName) {
|
|
2185
|
+
entry.state.tmuxSession = sessionName;
|
|
2186
|
+
this.saveNow();
|
|
2187
|
+
console.log(`[daemon] ${config.label}: found existing tmux session, bound tmuxSession`);
|
|
2188
|
+
}
|
|
2189
|
+
} catch {
|
|
2190
|
+
// No existing session — leave tmuxSession undefined, open_terminal will let FloatingTerminal create one
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
// Auto-bind boundSessionId from latest session file if not already set
|
|
2194
|
+
if (!config.boundSessionId) {
|
|
2195
|
+
const sessionId = this.getLatestSessionId(config.workDir);
|
|
2196
|
+
if (sessionId) {
|
|
2197
|
+
config.boundSessionId = sessionId;
|
|
2198
|
+
this.saveNow();
|
|
2199
|
+
console.log(`[daemon] ${config.label}: auto-bound boundSessionId=${sessionId}`);
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
/**
|
|
2205
|
+
* Create or attach to the tmux session for an agent (terminal-open path).
|
|
2206
|
+
* Unlike ensurePersistentSession, skips the 3s startup verification so the
|
|
2207
|
+
* HTTP response is fast. Returns the session name, or null on failure.
|
|
2208
|
+
*/
|
|
2209
|
+
async openTerminalSession(agentId: string, forceRestart = false): Promise<string | null> {
|
|
2210
|
+
const entry = this.agents.get(agentId);
|
|
2211
|
+
if (!entry || entry.config.type === 'input') return null;
|
|
2212
|
+
const config = entry.config;
|
|
2213
|
+
|
|
2214
|
+
const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
|
|
2215
|
+
const sessionName = `mw-forge-${safeName(this.projectName)}-${safeName(config.label)}`;
|
|
2216
|
+
|
|
2217
|
+
if (forceRestart) {
|
|
2218
|
+
// Kill existing tmux session so ensurePersistentSession rewrites the launch script
|
|
2219
|
+
// with the current boundSessionId (--resume flag). Safe — claude session data is in jsonl files.
|
|
2220
|
+
try { execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null`, { timeout: 3000 }); } catch {}
|
|
2221
|
+
entry.state.tmuxSession = undefined;
|
|
2222
|
+
} else if (entry.state.tmuxSession) {
|
|
2223
|
+
// Attach to existing session if still alive
|
|
2224
|
+
try {
|
|
2225
|
+
execSync(`tmux has-session -t "${entry.state.tmuxSession}" 2>/dev/null`, { timeout: 3000 });
|
|
2226
|
+
return entry.state.tmuxSession;
|
|
2227
|
+
} catch {
|
|
2228
|
+
entry.state.tmuxSession = undefined;
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
// Create (or recreate) session without startup verification delay
|
|
2233
|
+
await this.ensurePersistentSession(agentId, config, true);
|
|
2234
|
+
return entry.state.tmuxSession || null;
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2020
2237
|
/** Create a persistent tmux session with the CLI agent */
|
|
2021
|
-
private async ensurePersistentSession(agentId: string, config: WorkspaceAgentConfig): Promise<void> {
|
|
2238
|
+
private async ensurePersistentSession(agentId: string, config: WorkspaceAgentConfig, skipStartupCheck = false): Promise<void> {
|
|
2022
2239
|
const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
|
|
2023
2240
|
const sessionName = `mw-forge-${safeName(this.projectName)}-${safeName(config.label)}`;
|
|
2024
2241
|
|
|
2025
|
-
// Pre-flight: check project's .claude/settings.json is valid
|
|
2242
|
+
// Pre-flight: check project's .claude/settings.json is valid (cached by mtime)
|
|
2026
2243
|
const workDir = config.workDir && config.workDir !== './' && config.workDir !== '.'
|
|
2027
2244
|
? `${this.projectPath}/${config.workDir}` : this.projectPath;
|
|
2028
2245
|
const projectSettingsFile = join(workDir, '.claude', 'settings.json');
|
|
2029
2246
|
if (existsSync(projectSettingsFile)) {
|
|
2030
2247
|
try {
|
|
2031
|
-
const
|
|
2032
|
-
|
|
2248
|
+
const mtime = statSync(projectSettingsFile).mtimeMs;
|
|
2249
|
+
if (this.settingsValidCache.get(projectSettingsFile) !== mtime) {
|
|
2250
|
+
const raw = readFileSync(projectSettingsFile, 'utf-8');
|
|
2251
|
+
JSON.parse(raw);
|
|
2252
|
+
this.settingsValidCache.set(projectSettingsFile, mtime);
|
|
2253
|
+
}
|
|
2033
2254
|
} catch (err: any) {
|
|
2255
|
+
this.settingsValidCache.delete(projectSettingsFile);
|
|
2034
2256
|
const errorMsg = `Invalid .claude/settings.json: ${err.message}`;
|
|
2035
2257
|
console.error(`[daemon] ${config.label}: ${errorMsg}`);
|
|
2036
2258
|
const entry = this.agents.get(agentId);
|
|
@@ -2048,8 +2270,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2048
2270
|
// Write agent context file for hooks to read (workDir/.forge/agent-context.json)
|
|
2049
2271
|
try {
|
|
2050
2272
|
const forgeDir = join(workDir, '.forge');
|
|
2051
|
-
|
|
2052
|
-
mkdirS(forgeDir, { recursive: true });
|
|
2273
|
+
mkdirSync(forgeDir, { recursive: true });
|
|
2053
2274
|
const ctxPath = join(forgeDir, 'agent-context.json');
|
|
2054
2275
|
writeFileSync(ctxPath, JSON.stringify({
|
|
2055
2276
|
workspaceId: this.workspaceId,
|
|
@@ -2062,22 +2283,53 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2062
2283
|
console.error(`[daemon] ${config.label}: failed to write agent-context.json: ${err.message}`);
|
|
2063
2284
|
}
|
|
2064
2285
|
|
|
2065
|
-
// Check if tmux session already exists
|
|
2286
|
+
// Check if tmux session already exists and Claude is still alive inside
|
|
2066
2287
|
let sessionAlreadyExists = false;
|
|
2288
|
+
let tmuxSessionExists = false;
|
|
2289
|
+
console.log(`[daemon] ${config.label}: ensurePersistentSession called — sessionName=${sessionName} boundSessionId=${config.boundSessionId || 'NONE'} skipStartupCheck=${skipStartupCheck}`);
|
|
2067
2290
|
try {
|
|
2068
2291
|
execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`, { timeout: 3000 });
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2292
|
+
tmuxSessionExists = true;
|
|
2293
|
+
} catch {}
|
|
2294
|
+
console.log(`[daemon] ${config.label}: tmuxSessionExists=${tmuxSessionExists}`);
|
|
2295
|
+
|
|
2296
|
+
if (tmuxSessionExists) {
|
|
2297
|
+
// Check if Claude process is still alive inside the tmux pane
|
|
2298
|
+
let claudeAlive = true;
|
|
2073
2299
|
try {
|
|
2074
|
-
execSync(`tmux
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2300
|
+
const paneContent = execSync(`tmux capture-pane -t "${sessionName}" -p -S -5`, { timeout: 3000, encoding: 'utf-8' });
|
|
2301
|
+
const exitedPatterns = [/^\$\s*$/, /\$ $/m, /Process exited/i, /command not found/i];
|
|
2302
|
+
if (exitedPatterns.some(p => p.test(paneContent))) {
|
|
2303
|
+
console.log(`[daemon] ${config.label}: Claude appears to have exited, recreating session`);
|
|
2304
|
+
try { execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null`, { timeout: 3000 }); } catch {}
|
|
2305
|
+
claudeAlive = false;
|
|
2306
|
+
}
|
|
2307
|
+
} catch {
|
|
2308
|
+
// pane capture failed — assume alive
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
if (claudeAlive) {
|
|
2312
|
+
sessionAlreadyExists = true;
|
|
2313
|
+
console.log(`[daemon] ${config.label}: persistent session alive (${sessionName}) — skipping script generation`);
|
|
2314
|
+
// Ensure FORGE env vars are set in the tmux session environment
|
|
2315
|
+
try {
|
|
2316
|
+
execSync(`tmux set-environment -t "${sessionName}" FORGE_WORKSPACE_ID "${this.workspaceId}" && tmux set-environment -t "${sessionName}" FORGE_AGENT_ID "${config.id}" && tmux set-environment -t "${sessionName}" FORGE_PORT "${Number(process.env.PORT) || 8403}"`, { timeout: 5000 });
|
|
2317
|
+
} catch {}
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
if (!sessionAlreadyExists) {
|
|
2322
|
+
// Pre-bind: find existing session file BEFORE starting CLI so --resume is available from the start.
|
|
2323
|
+
// This avoids starting a fresh CLI then restarting it after polling finds the session.
|
|
2324
|
+
if (!config.primary && !config.boundSessionId) {
|
|
2325
|
+
const existingSessionId = this.getLatestSessionId(config.workDir);
|
|
2326
|
+
if (existingSessionId) {
|
|
2327
|
+
config.boundSessionId = existingSessionId;
|
|
2328
|
+
this.saveNow();
|
|
2329
|
+
console.log(`[daemon] ${config.label}: pre-bound to existing session ${existingSessionId}`);
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2081
2333
|
// Create new tmux session and start the CLI agent
|
|
2082
2334
|
try {
|
|
2083
2335
|
// Resolve agent launch info
|
|
@@ -2103,7 +2355,9 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2103
2355
|
.join(' && ');
|
|
2104
2356
|
if (envExports) envExports += ' && ';
|
|
2105
2357
|
}
|
|
2106
|
-
|
|
2358
|
+
// Workspace agent model takes priority over profile/settings model
|
|
2359
|
+
const effectiveModel = config.model || info.model;
|
|
2360
|
+
if (effectiveModel) modelFlag = ` --model ${effectiveModel}`;
|
|
2107
2361
|
} catch {}
|
|
2108
2362
|
|
|
2109
2363
|
// Generate MCP config for Claude Code agents
|
|
@@ -2120,8 +2374,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2120
2374
|
},
|
|
2121
2375
|
},
|
|
2122
2376
|
};
|
|
2123
|
-
|
|
2124
|
-
mkdirS(join(workDir, '.forge'), { recursive: true });
|
|
2377
|
+
mkdirSync(join(workDir, '.forge'), { recursive: true });
|
|
2125
2378
|
writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2));
|
|
2126
2379
|
mcpConfigFlag = ` --mcp-config "${mcpConfigPath}"`;
|
|
2127
2380
|
} catch (err: any) {
|
|
@@ -2150,27 +2403,22 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2150
2403
|
if (supportsSession) {
|
|
2151
2404
|
let sessionId: string | undefined;
|
|
2152
2405
|
if (config.primary) {
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
const psPath = join(homedir(), '.forge', 'data', 'project-sessions.json');
|
|
2156
|
-
if (existsSync(psPath)) {
|
|
2157
|
-
const psData = JSON.parse(readFileSync(psPath, 'utf-8'));
|
|
2158
|
-
sessionId = psData[this.projectPath];
|
|
2159
|
-
}
|
|
2160
|
-
console.log(`[daemon] ${config.label}: fixedSession=${sessionId || 'NONE'} for ${this.projectPath}`);
|
|
2161
|
-
} catch (err: any) {
|
|
2162
|
-
console.error(`[daemon] ${config.label}: failed to read fixedSession: ${err.message}`);
|
|
2163
|
-
}
|
|
2406
|
+
sessionId = getFixedSession(this.projectPath);
|
|
2407
|
+
console.log(`[daemon] ${config.label}: fixedSession=${sessionId || 'NONE'} for ${this.projectPath}`);
|
|
2164
2408
|
} else {
|
|
2165
2409
|
sessionId = config.boundSessionId;
|
|
2410
|
+
console.log(`[daemon] ${config.label}: script-gen boundSessionId=${sessionId || 'NONE'}`);
|
|
2166
2411
|
}
|
|
2167
2412
|
if (sessionId) {
|
|
2168
2413
|
const sessionFile = join(this.getCliSessionDir(config.workDir), `${sessionId}.jsonl`);
|
|
2169
2414
|
if (existsSync(sessionFile)) {
|
|
2170
2415
|
cmd += ` --resume ${sessionId}`;
|
|
2416
|
+
console.log(`[daemon] ${config.label}: script-gen adding --resume ${sessionId}`);
|
|
2171
2417
|
} else {
|
|
2172
2418
|
console.log(`[daemon] ${config.label}: bound session ${sessionId} missing, starting fresh`);
|
|
2173
2419
|
}
|
|
2420
|
+
} else {
|
|
2421
|
+
console.log(`[daemon] ${config.label}: script-gen no boundSessionId → no --resume (skipStartupCheck=${skipStartupCheck})`);
|
|
2174
2422
|
}
|
|
2175
2423
|
}
|
|
2176
2424
|
if (modelFlag) cmd += modelFlag;
|
|
@@ -2180,12 +2428,54 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2180
2428
|
|
|
2181
2429
|
// Write script and execute in tmux
|
|
2182
2430
|
const scriptPath = `/tmp/forge-launch-${config.id.replace(/[^a-z0-9-]/g, '')}.sh`;
|
|
2431
|
+
console.log(`[daemon] ${config.label}: writing launch script ${scriptPath}`);
|
|
2432
|
+
console.log(`[daemon] ${config.label}: exec line → ${cmd}`);
|
|
2183
2433
|
writeFileSync(scriptPath, scriptLines.join('\n'), { mode: 0o755 });
|
|
2184
2434
|
execSync(`tmux send-keys -t "${sessionName}" 'bash ${scriptPath}' Enter`, { timeout: 5000 });
|
|
2185
2435
|
|
|
2186
2436
|
console.log(`[daemon] ${config.label}: persistent session created (${sessionName}) [${cliType}: ${cliCmd}]`);
|
|
2187
2437
|
|
|
2188
2438
|
// Verify CLI started successfully (check after 3s if process is still alive)
|
|
2439
|
+
// Skip when called from openTerminalSession (terminal-open path) for fast response.
|
|
2440
|
+
if (skipStartupCheck) {
|
|
2441
|
+
// Set tmuxSession here only — normal path sets it after startup verification passes.
|
|
2442
|
+
const entrySkip = this.agents.get(agentId);
|
|
2443
|
+
if (entrySkip) {
|
|
2444
|
+
entrySkip.state.tmuxSession = sessionName;
|
|
2445
|
+
this.saveNow();
|
|
2446
|
+
this.emitAgentsChanged();
|
|
2447
|
+
}
|
|
2448
|
+
// Fire boundSessionId binding in background (no await — don't block terminal open)
|
|
2449
|
+
if (!config.primary && !config.boundSessionId) {
|
|
2450
|
+
const bgScriptPath = `/tmp/forge-launch-${config.id.replace(/[^a-z0-9-]/g, '')}.sh`;
|
|
2451
|
+
setTimeout(() => {
|
|
2452
|
+
const sid = this.getLatestSessionId(config.workDir);
|
|
2453
|
+
if (sid) {
|
|
2454
|
+
config.boundSessionId = sid;
|
|
2455
|
+
this.saveNow();
|
|
2456
|
+
console.log(`[daemon] ${config.label}: background bound to session ${sid}`);
|
|
2457
|
+
// Also update launch script for future restarts
|
|
2458
|
+
if (existsSync(bgScriptPath)) {
|
|
2459
|
+
try {
|
|
2460
|
+
const lines = readFileSync(bgScriptPath, 'utf-8').split('\n');
|
|
2461
|
+
const execIdx = lines.findIndex(l => l.startsWith('exec '));
|
|
2462
|
+
if (execIdx >= 0) {
|
|
2463
|
+
const withoutResume = lines[execIdx].replace(/\s--resume\s+\S+/, '');
|
|
2464
|
+
const afterExec = withoutResume.startsWith('exec ') ? 5 : 0;
|
|
2465
|
+
const cmdEnd = withoutResume.indexOf(' ', afterExec);
|
|
2466
|
+
lines[execIdx] = cmdEnd >= 0
|
|
2467
|
+
? withoutResume.slice(0, cmdEnd) + ` --resume ${sid}` + withoutResume.slice(cmdEnd)
|
|
2468
|
+
: withoutResume + ` --resume ${sid}`;
|
|
2469
|
+
writeFileSync(bgScriptPath, lines.join('\n'), { mode: 0o755 });
|
|
2470
|
+
console.log(`[daemon] ${config.label}: background updated launch script with --resume ${sid}`);
|
|
2471
|
+
}
|
|
2472
|
+
} catch {}
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
}, 5000);
|
|
2476
|
+
}
|
|
2477
|
+
return;
|
|
2478
|
+
}
|
|
2189
2479
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
2190
2480
|
try {
|
|
2191
2481
|
const paneContent = execSync(`tmux capture-pane -t "${sessionName}" -p -S -20`, { timeout: 3000, encoding: 'utf-8' });
|
|
@@ -2220,26 +2510,6 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2220
2510
|
return;
|
|
2221
2511
|
}
|
|
2222
2512
|
} catch {}
|
|
2223
|
-
// Auto-bind session: if no boundSessionId, detect new session file after 5s
|
|
2224
|
-
if (!config.primary && !config.boundSessionId && supportsSession) {
|
|
2225
|
-
setTimeout(() => {
|
|
2226
|
-
try {
|
|
2227
|
-
const sessionDir = this.getCliSessionDir(config.workDir);
|
|
2228
|
-
if (existsSync(sessionDir)) {
|
|
2229
|
-
const { readdirSync, statSync: statS } = require('node:fs');
|
|
2230
|
-
const files = readdirSync(sessionDir).filter((f: string) => f.endsWith('.jsonl'));
|
|
2231
|
-
if (files.length > 0) {
|
|
2232
|
-
const latest = files
|
|
2233
|
-
.map((f: string) => ({ name: f, mtime: statS(join(sessionDir, f)).mtimeMs }))
|
|
2234
|
-
.sort((a: any, b: any) => b.mtime - a.mtime)[0];
|
|
2235
|
-
config.boundSessionId = latest.name.replace('.jsonl', '');
|
|
2236
|
-
this.saveNow();
|
|
2237
|
-
console.log(`[daemon] ${config.label}: auto-bound to session ${config.boundSessionId}`);
|
|
2238
|
-
}
|
|
2239
|
-
}
|
|
2240
|
-
} catch {}
|
|
2241
|
-
}, 5000);
|
|
2242
|
-
}
|
|
2243
2513
|
} catch (err: any) {
|
|
2244
2514
|
console.error(`[daemon] ${config.label}: failed to create persistent session: ${err.message}`);
|
|
2245
2515
|
const entry = this.agents.get(agentId);
|
|
@@ -2261,34 +2531,66 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2261
2531
|
this.emitAgentsChanged();
|
|
2262
2532
|
}
|
|
2263
2533
|
|
|
2264
|
-
//
|
|
2265
|
-
if
|
|
2266
|
-
|
|
2534
|
+
// Ensure boundSessionId is set before returning (required for session monitor + --resume)
|
|
2535
|
+
// Also re-bind if existing boundSessionId points to a deleted session file
|
|
2536
|
+
if (!config.primary && config.boundSessionId) {
|
|
2537
|
+
const boundFile = join(this.getCliSessionDir(config.workDir), `${config.boundSessionId}.jsonl`);
|
|
2538
|
+
if (!existsSync(boundFile)) {
|
|
2539
|
+
console.log(`[daemon] ${config.label}: boundSession ${config.boundSessionId} file missing, re-binding`);
|
|
2540
|
+
config.boundSessionId = undefined;
|
|
2541
|
+
}
|
|
2267
2542
|
}
|
|
2268
|
-
|
|
2269
|
-
// Ensure boundSessionId is set (required for session monitor + --resume)
|
|
2270
2543
|
if (!config.primary && !config.boundSessionId) {
|
|
2271
|
-
const
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
if (
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2544
|
+
const pollStart = Date.now();
|
|
2545
|
+
const pollDelay = sessionAlreadyExists ? 500 : 2000;
|
|
2546
|
+
await new Promise<void>(resolve => {
|
|
2547
|
+
const attempt = () => {
|
|
2548
|
+
if (!this.daemonActive) { resolve(); return; }
|
|
2549
|
+
const sessionId = this.getLatestSessionId(config.workDir);
|
|
2550
|
+
if (sessionId) {
|
|
2551
|
+
config.boundSessionId = sessionId;
|
|
2552
|
+
this.saveNow();
|
|
2553
|
+
console.log(`[daemon] ${config.label}: bound to session ${config.boundSessionId}`);
|
|
2554
|
+
// Update launch script with the now-known sessionId — script was written before
|
|
2555
|
+
// polling completed so it had no --resume flag. Fix it for future restarts.
|
|
2556
|
+
const scriptPath = `/tmp/forge-launch-${config.id.replace(/[^a-z0-9-]/g, '')}.sh`;
|
|
2557
|
+
if (existsSync(scriptPath)) {
|
|
2558
|
+
try {
|
|
2559
|
+
const lines = readFileSync(scriptPath, 'utf-8').split('\n');
|
|
2560
|
+
const execIdx = lines.findIndex(l => l.startsWith('exec '));
|
|
2561
|
+
if (execIdx >= 0) {
|
|
2562
|
+
// Remove any existing --resume flag, then re-insert after command name.
|
|
2563
|
+
// Line format: 'exec <cmd> [args...]'
|
|
2564
|
+
// Insert after cmd (skip 'exec ' prefix + cmd word) so bash doesn't
|
|
2565
|
+
// misinterpret --resume as an exec builtin option.
|
|
2566
|
+
const withoutResume = lines[execIdx].replace(/\s--resume\s+\S+/, '');
|
|
2567
|
+
const afterExec = withoutResume.startsWith('exec ') ? 5 : 0;
|
|
2568
|
+
const cmdEnd = withoutResume.indexOf(' ', afterExec);
|
|
2569
|
+
lines[execIdx] = cmdEnd >= 0
|
|
2570
|
+
? withoutResume.slice(0, cmdEnd) + ` --resume ${sessionId}` + withoutResume.slice(cmdEnd)
|
|
2571
|
+
: withoutResume + ` --resume ${sessionId}`;
|
|
2572
|
+
writeFileSync(scriptPath, lines.join('\n'), { mode: 0o755 });
|
|
2573
|
+
console.log(`[daemon] ${config.label}: updated launch script with --resume ${sessionId}`);
|
|
2574
|
+
}
|
|
2575
|
+
} catch {}
|
|
2288
2576
|
}
|
|
2577
|
+
return resolve();
|
|
2289
2578
|
}
|
|
2290
|
-
|
|
2291
|
-
|
|
2579
|
+
if (Date.now() - pollStart < 10_000) {
|
|
2580
|
+
setTimeout(attempt, 1000);
|
|
2581
|
+
} else {
|
|
2582
|
+
console.log(`[daemon] ${config.label}: no session file after 10s, skipping bind`);
|
|
2583
|
+
const entry = this.agents.get(agentId);
|
|
2584
|
+
if (entry) {
|
|
2585
|
+
entry.state.error = 'Terminal session not ready — click "Open Terminal" to start it manually';
|
|
2586
|
+
this.emit('event', { type: 'smith_status', agentId, smithStatus: entry.state.smithStatus } as any);
|
|
2587
|
+
this.emitAgentsChanged();
|
|
2588
|
+
}
|
|
2589
|
+
resolve();
|
|
2590
|
+
}
|
|
2591
|
+
};
|
|
2592
|
+
setTimeout(attempt, pollDelay);
|
|
2593
|
+
});
|
|
2292
2594
|
}
|
|
2293
2595
|
}
|
|
2294
2596
|
|
|
@@ -2465,9 +2767,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2465
2767
|
// Skip if already busy
|
|
2466
2768
|
if (entry.state.taskStatus === 'running') return;
|
|
2467
2769
|
|
|
2468
|
-
// Skip if any message is already running for this agent
|
|
2469
|
-
|
|
2470
|
-
if (hasRunning) return;
|
|
2770
|
+
// Skip if any message is already running for this agent (O(1) cache lookup)
|
|
2771
|
+
if (this.agentRunningMsg.has(agentId)) return;
|
|
2471
2772
|
|
|
2472
2773
|
// Execution path determined by config, not runtime tmux state
|
|
2473
2774
|
const isTerminalMode = entry.config.persistentSession;
|
|
@@ -2558,6 +2859,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2558
2859
|
|
|
2559
2860
|
// Mark message as running (being processed)
|
|
2560
2861
|
nextMsg.status = 'running' as any;
|
|
2862
|
+
this.agentRunningMsg.set(agentId, nextMsg.id);
|
|
2561
2863
|
this.emit('event', { type: 'bus_message_status', messageId: nextMsg.id, status: 'running' } as any);
|
|
2562
2864
|
|
|
2563
2865
|
const logEntry = {
|
|
@@ -2571,6 +2873,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2571
2873
|
if (isTerminalMode) {
|
|
2572
2874
|
const injected = this.injectIntoSession(agentId, nextMsg.payload.content || nextMsg.payload.action);
|
|
2573
2875
|
if (injected) {
|
|
2876
|
+
entry.state.taskStatus = 'running';
|
|
2877
|
+
this.emit('event', { type: 'task_status', agentId, taskStatus: 'running' } as any);
|
|
2574
2878
|
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);
|
|
2575
2879
|
console.log(`[inbox] ${entry.config.label}: injected into terminal, starting completion monitor`);
|
|
2576
2880
|
entry.state.currentMessageId = nextMsg.id;
|
|
@@ -2580,6 +2884,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2580
2884
|
// Health check will auto-restart the terminal session
|
|
2581
2885
|
entry.state.tmuxSession = undefined;
|
|
2582
2886
|
nextMsg.status = 'pending' as any; // revert to pending for retry
|
|
2887
|
+
this.agentRunningMsg.delete(agentId);
|
|
2583
2888
|
this.emit('event', { type: 'bus_message_status', messageId: nextMsg.id, status: 'pending' } as any);
|
|
2584
2889
|
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);
|
|
2585
2890
|
console.log(`[inbox] ${entry.config.label}: terminal inject failed, cleared session — waiting for health check restart`);
|
|
@@ -2668,6 +2973,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2668
2973
|
if (promptCount >= CONFIRM_CHECKS) {
|
|
2669
2974
|
clearInterval(timer);
|
|
2670
2975
|
this.terminalMonitors.delete(agentId);
|
|
2976
|
+
this.agentRunningMsg.delete(agentId);
|
|
2671
2977
|
|
|
2672
2978
|
// Extract output summary (skip prompt lines)
|
|
2673
2979
|
const contentLines = lines.filter(l => !PROMPT_PATTERNS.some(p => p.test(l)));
|
|
@@ -2698,6 +3004,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2698
3004
|
// Session died
|
|
2699
3005
|
clearInterval(timer);
|
|
2700
3006
|
this.terminalMonitors.delete(agentId);
|
|
3007
|
+
this.agentRunningMsg.delete(agentId);
|
|
2701
3008
|
const msg = this.bus.getLog().find(m => m.id === messageId);
|
|
2702
3009
|
if (msg && msg.status !== 'done' && msg.status !== 'failed') {
|
|
2703
3010
|
msg.status = 'failed' as any;
|