@aion0/forge 0.5.20 → 0.5.22
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/RELEASE_NOTES.md +32 -6
- package/app/api/code/route.ts +10 -4
- 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 +160 -66
- package/components/SkillsPanel.tsx +14 -5
- package/components/TerminalLauncher.tsx +398 -0
- package/components/WebTerminal.tsx +84 -84
- package/components/WorkspaceView.tsx +371 -87
- 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 +414 -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/next-env.d.ts +1 -1
- 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 {
|
|
@@ -328,6 +332,11 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
328
332
|
}
|
|
329
333
|
entry.state.tmuxSession = undefined;
|
|
330
334
|
config.boundSessionId = undefined;
|
|
335
|
+
} else {
|
|
336
|
+
// Preserve server-managed fields the client doesn't track
|
|
337
|
+
if (!config.boundSessionId && entry.config.boundSessionId) {
|
|
338
|
+
config.boundSessionId = entry.config.boundSessionId;
|
|
339
|
+
}
|
|
331
340
|
}
|
|
332
341
|
|
|
333
342
|
entry.config = config;
|
|
@@ -341,19 +350,23 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
341
350
|
entry.worker = null;
|
|
342
351
|
|
|
343
352
|
if (this.daemonActive) {
|
|
344
|
-
//
|
|
345
|
-
|
|
346
|
-
entry.state.smithStatus = '
|
|
353
|
+
// Set 'starting' BEFORE creating worker — worker.executeDaemon emits 'active' synchronously
|
|
354
|
+
// which would cause a race: frontend sees active before boundSessionId is ready
|
|
355
|
+
entry.state.smithStatus = 'starting';
|
|
356
|
+
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'starting' } as any);
|
|
347
357
|
// Restart watch if config changed
|
|
348
358
|
this.watchManager.startWatch(id, config);
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
359
|
+
this.ensurePersistentSession(id, config).then(() => {
|
|
360
|
+
const e = this.agents.get(id);
|
|
361
|
+
if (e) {
|
|
362
|
+
// Rebuild worker + message loop AFTER session is ready (boundSessionId set)
|
|
363
|
+
this.enterDaemonListening(id);
|
|
364
|
+
e.state.smithStatus = 'active';
|
|
365
|
+
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'active' } as any);
|
|
366
|
+
this.emitAgentsChanged();
|
|
367
|
+
}
|
|
355
368
|
this.startMessageLoop(id);
|
|
356
|
-
}
|
|
369
|
+
});
|
|
357
370
|
}
|
|
358
371
|
this.saveNow();
|
|
359
372
|
this.emitAgentsChanged();
|
|
@@ -706,7 +719,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
706
719
|
}
|
|
707
720
|
|
|
708
721
|
// Ensure smith is active when daemon starts this agent
|
|
709
|
-
if
|
|
722
|
+
// Skip if 'starting': ensurePersistentSession is in progress and will set 'active' when done.
|
|
723
|
+
if (this.daemonActive && entry.state.smithStatus !== 'active' && entry.state.smithStatus !== 'starting') {
|
|
710
724
|
entry.state.smithStatus = 'active';
|
|
711
725
|
this.emit('event', { type: 'smith_status', agentId, smithStatus: 'active' } satisfies WorkerEvent);
|
|
712
726
|
}
|
|
@@ -730,6 +744,9 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
730
744
|
}
|
|
731
745
|
}
|
|
732
746
|
|
|
747
|
+
// Sync role → CLAUDE.md so CLI agents (Claude Code) see it as system instructions
|
|
748
|
+
this.syncRoleToClaudeMd(config);
|
|
749
|
+
|
|
733
750
|
let upstreamContext = this.buildUpstreamContext(config);
|
|
734
751
|
if (userInput) {
|
|
735
752
|
const prefix = '## Additional Instructions:\n' + userInput;
|
|
@@ -816,6 +833,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
816
833
|
this.handleAgentDone(agentId, entry, event.summary);
|
|
817
834
|
}
|
|
818
835
|
if (event.type === 'error') {
|
|
836
|
+
this.agentRunningMsg.delete(agentId);
|
|
819
837
|
this.bus.notifyError(agentId, event.error);
|
|
820
838
|
this.emitWorkspaceStatus();
|
|
821
839
|
}
|
|
@@ -839,6 +857,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
839
857
|
// Execute in daemon mode (non-blocking)
|
|
840
858
|
worker.executeDaemon(0, upstreamContext).catch(err => {
|
|
841
859
|
if (entry.state.taskStatus !== 'failed') {
|
|
860
|
+
this.agentRunningMsg.delete(agentId);
|
|
842
861
|
entry.state.taskStatus = 'failed';
|
|
843
862
|
entry.state.error = err?.message || String(err);
|
|
844
863
|
this.emit('event', { type: 'error', agentId, error: entry.state.error! } satisfies WorkerEvent);
|
|
@@ -1033,6 +1052,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1033
1052
|
this.handleAgentDone(agentId, entry, event.summary);
|
|
1034
1053
|
}
|
|
1035
1054
|
if (event.type === 'error') {
|
|
1055
|
+
this.agentRunningMsg.delete(agentId);
|
|
1036
1056
|
this.bus.notifyError(agentId, event.error);
|
|
1037
1057
|
}
|
|
1038
1058
|
});
|
|
@@ -1100,6 +1120,10 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1100
1120
|
if (this.sessionMonitor) { this.sessionMonitor.stopAll(); this.sessionMonitor = null; }
|
|
1101
1121
|
this.stopHealthCheck();
|
|
1102
1122
|
this.forgeActedMessages.clear();
|
|
1123
|
+
this.busMarkerScanned.clear();
|
|
1124
|
+
this.forgeAgentStartTime = 0;
|
|
1125
|
+
this.agentRunningMsg.clear();
|
|
1126
|
+
this.reconcileTick = 0;
|
|
1103
1127
|
console.log('[workspace] Daemon stopped');
|
|
1104
1128
|
}
|
|
1105
1129
|
|
|
@@ -1168,16 +1192,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1168
1192
|
let sessionId: string | undefined;
|
|
1169
1193
|
|
|
1170
1194
|
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
|
-
}
|
|
1195
|
+
sessionId = getFixedSession(this.projectPath);
|
|
1196
|
+
console.log(`[session-monitor] ${config.label}: primary fixedSession=${sessionId || 'NONE'}`);
|
|
1181
1197
|
} else {
|
|
1182
1198
|
sessionId = config.boundSessionId;
|
|
1183
1199
|
console.log(`[session-monitor] ${config.label}: boundSession=${sessionId || 'NONE'}`);
|
|
@@ -1185,23 +1201,12 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1185
1201
|
|
|
1186
1202
|
if (!sessionId) {
|
|
1187
1203
|
// 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 {}
|
|
1204
|
+
sessionId = this.getLatestSessionId(config.workDir);
|
|
1205
|
+
if (sessionId && !config.primary) {
|
|
1206
|
+
config.boundSessionId = sessionId;
|
|
1207
|
+
this.saveNow();
|
|
1208
|
+
console.log(`[session-monitor] ${config.label}: auto-bound to ${sessionId}`);
|
|
1209
|
+
}
|
|
1205
1210
|
if (!sessionId) {
|
|
1206
1211
|
console.log(`[session-monitor] ${config.label}: no sessionId, skipping`);
|
|
1207
1212
|
return;
|
|
@@ -1231,6 +1236,20 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1231
1236
|
private runHealthCheck(): void {
|
|
1232
1237
|
if (!this.daemonActive) return;
|
|
1233
1238
|
|
|
1239
|
+
// Every 60s (6 ticks × 10s): reconcile agentRunningMsg cache with actual bus log
|
|
1240
|
+
this.reconcileTick++;
|
|
1241
|
+
if (this.reconcileTick >= 6) {
|
|
1242
|
+
this.reconcileTick = 0;
|
|
1243
|
+
const log = this.bus.getLog();
|
|
1244
|
+
for (const [agentId, messageId] of this.agentRunningMsg) {
|
|
1245
|
+
const msg = log.find(m => m.id === messageId);
|
|
1246
|
+
if (!msg || msg.status !== 'running') {
|
|
1247
|
+
console.log(`[health] reconcile: clearing stale agentRunningMsg for ${agentId} (msg=${messageId.slice(0, 8)}, status=${msg?.status || 'not found'})`);
|
|
1248
|
+
this.agentRunningMsg.delete(agentId);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1234
1253
|
for (const [id, entry] of this.agents) {
|
|
1235
1254
|
if (entry.config.type === 'input') continue;
|
|
1236
1255
|
|
|
@@ -1244,7 +1263,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1244
1263
|
}
|
|
1245
1264
|
|
|
1246
1265
|
// Check 2: SmithStatus should be active
|
|
1247
|
-
|
|
1266
|
+
// Skip: 'starting' means ensurePersistentSession is in progress — overriding would race with it.
|
|
1267
|
+
if (entry.state.smithStatus !== 'active' && entry.state.smithStatus !== 'starting') {
|
|
1248
1268
|
console.log(`[health] ${entry.config.label}: smith=${entry.state.smithStatus} — setting active`);
|
|
1249
1269
|
entry.state.smithStatus = 'active';
|
|
1250
1270
|
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'active' } as any);
|
|
@@ -1272,7 +1292,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1272
1292
|
// Check 5: Pending messages but agent idle — try wake
|
|
1273
1293
|
if (entry.state.taskStatus !== 'running') {
|
|
1274
1294
|
const pending = this.bus.getPendingMessagesFor(id).filter(m => m.from !== id && m.type !== 'ack');
|
|
1275
|
-
if (pending.length > 0 && entry.worker
|
|
1295
|
+
if (pending.length > 0 && entry.worker?.isListening()) {
|
|
1276
1296
|
// Message loop should handle this, but if it didn't, log it
|
|
1277
1297
|
const age = Date.now() - pending[0].timestamp;
|
|
1278
1298
|
if (age > 30_000) { // stuck for 30+ seconds
|
|
@@ -1282,7 +1302,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1282
1302
|
}
|
|
1283
1303
|
|
|
1284
1304
|
// Check 6: persistentSession agent without tmux → auto-restart terminal
|
|
1285
|
-
if
|
|
1305
|
+
// Skip if smithStatus='starting': ensurePersistentSession is already in progress.
|
|
1306
|
+
if (entry.config.persistentSession && !entry.state.tmuxSession && entry.state.smithStatus === 'active') {
|
|
1286
1307
|
console.log(`[health] ${entry.config.label}: persistentSession but no tmux — restarting terminal`);
|
|
1287
1308
|
this.ensurePersistentSession(id, entry.config).catch(err => {
|
|
1288
1309
|
console.error(`[health] ${entry.config.label}: failed to restart terminal: ${err.message}`);
|
|
@@ -1304,6 +1325,16 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1304
1325
|
const log = this.bus.getLog();
|
|
1305
1326
|
const now = Date.now();
|
|
1306
1327
|
|
|
1328
|
+
// Pre-build reply index: "from→to" → latest non-ack message timestamp (only after daemon start)
|
|
1329
|
+
// Used for O(1) hasReply lookups instead of O(n) log.some() per message
|
|
1330
|
+
const replyIndex = new Map<string, number>();
|
|
1331
|
+
for (const r of log) {
|
|
1332
|
+
if (r.timestamp < this.forgeAgentStartTime) continue;
|
|
1333
|
+
if (r.type === 'ack') continue;
|
|
1334
|
+
const key = `${r.from}→${r.to}`;
|
|
1335
|
+
if ((replyIndex.get(key) || 0) < r.timestamp) replyIndex.set(key, r.timestamp);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1307
1338
|
// Only scan messages from after daemon start (skip all history)
|
|
1308
1339
|
for (const msg of log) {
|
|
1309
1340
|
if (msg.timestamp < this.forgeAgentStartTime) continue;
|
|
@@ -1323,10 +1354,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1323
1354
|
const nudgeKey = `nudge-${msg.to}->${msg.from}`;
|
|
1324
1355
|
if (this.forgeActedMessages.has(nudgeKey)) { this.forgeActedMessages.add(msg.id); continue; }
|
|
1325
1356
|
|
|
1326
|
-
const hasReply =
|
|
1327
|
-
r.from === msg.to && r.to === msg.from &&
|
|
1328
|
-
r.timestamp > msg.timestamp && r.type !== 'ack'
|
|
1329
|
-
);
|
|
1357
|
+
const hasReply = (replyIndex.get(`${msg.to}→${msg.from}`) || 0) > msg.timestamp;
|
|
1330
1358
|
if (!hasReply) {
|
|
1331
1359
|
const senderLabel = this.agents.get(msg.from)?.config.label || msg.from;
|
|
1332
1360
|
const targetEntry = this.agents.get(msg.to);
|
|
@@ -1812,6 +1840,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1812
1840
|
|
|
1813
1841
|
/** Stop all agents, save final state, and clean up */
|
|
1814
1842
|
shutdown(): void {
|
|
1843
|
+
this.daemonActive = false;
|
|
1844
|
+
this.stopHealthCheck();
|
|
1815
1845
|
this.stopAllMessageLoops();
|
|
1816
1846
|
stopAutoSave(this.workspaceId);
|
|
1817
1847
|
// Sync save — must complete before process exits
|
|
@@ -1826,6 +1856,72 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1826
1856
|
|
|
1827
1857
|
// ─── Private ───────────────────────────────────────────
|
|
1828
1858
|
|
|
1859
|
+
/** Sync agent role description into CLAUDE.md so CLI agents read it natively */
|
|
1860
|
+
private syncRoleToClaudeMd(config: WorkspaceAgentConfig): void {
|
|
1861
|
+
if (!config.role?.trim()) return;
|
|
1862
|
+
const workDir = join(this.projectPath, config.workDir || '');
|
|
1863
|
+
const claudeMdPath = join(workDir, 'CLAUDE.md');
|
|
1864
|
+
|
|
1865
|
+
// Build plugin docs from agent's plugins
|
|
1866
|
+
let pluginDocs = '';
|
|
1867
|
+
if (config.plugins?.length) {
|
|
1868
|
+
try {
|
|
1869
|
+
const { getInstalledPlugin, listInstalledPlugins } = require('../plugins/registry');
|
|
1870
|
+
const installed = listInstalledPlugins();
|
|
1871
|
+
const agentPlugins = config.plugins;
|
|
1872
|
+
// Find all instances that match agent's plugin list (by source or direct id)
|
|
1873
|
+
const relevant = installed.filter((p: any) =>
|
|
1874
|
+
agentPlugins.includes(p.id) || agentPlugins.includes(p.source || p.id)
|
|
1875
|
+
);
|
|
1876
|
+
if (relevant.length > 0) {
|
|
1877
|
+
pluginDocs = '\n\n## Available Plugins (via MCP run_plugin)\n';
|
|
1878
|
+
for (const p of relevant) {
|
|
1879
|
+
const def = p.definition;
|
|
1880
|
+
const name = p.instanceName || def.name;
|
|
1881
|
+
pluginDocs += `\n### ${def.icon} ${name} (id: "${p.id}")\n`;
|
|
1882
|
+
if (def.description) pluginDocs += `${def.description}\n`;
|
|
1883
|
+
pluginDocs += '\nActions:\n';
|
|
1884
|
+
for (const [actionName, action] of Object.entries(def.actions) as any[]) {
|
|
1885
|
+
pluginDocs += `- **${actionName}** (${action.run})`;
|
|
1886
|
+
if (def.defaultAction === actionName) pluginDocs += ' [default]';
|
|
1887
|
+
pluginDocs += '\n';
|
|
1888
|
+
}
|
|
1889
|
+
if (Object.keys(def.params).length > 0) {
|
|
1890
|
+
pluginDocs += 'Params: ' + Object.entries(def.params).map(([k, v]: any) =>
|
|
1891
|
+
`${k}${v.required ? '*' : ''} (${v.type})`
|
|
1892
|
+
).join(', ') + '\n';
|
|
1893
|
+
}
|
|
1894
|
+
pluginDocs += `\nUsage: run_plugin({ plugin: "${p.id}", action: "<action>", params: { ... } })\n`;
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
} catch {}
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
const MARKER_START = '<!-- forge:agent-role -->';
|
|
1901
|
+
const MARKER_END = '<!-- /forge:agent-role -->';
|
|
1902
|
+
const roleBlock = `${MARKER_START}\n## Agent Role (managed by Forge)\n\n${config.role.trim()}${pluginDocs}\n${MARKER_END}`;
|
|
1903
|
+
|
|
1904
|
+
try {
|
|
1905
|
+
let content = '';
|
|
1906
|
+
if (existsSync(claudeMdPath)) {
|
|
1907
|
+
content = readFileSync(claudeMdPath, 'utf-8');
|
|
1908
|
+
// Replace existing forge block
|
|
1909
|
+
const regex = new RegExp(`${MARKER_START}[\\s\\S]*?${MARKER_END}`);
|
|
1910
|
+
if (regex.test(content)) {
|
|
1911
|
+
const updated = content.replace(regex, roleBlock);
|
|
1912
|
+
if (updated !== content) writeFileSync(claudeMdPath, updated);
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
// Append
|
|
1917
|
+
mkdirSync(workDir, { recursive: true });
|
|
1918
|
+
const separator = content && !content.endsWith('\n') ? '\n\n' : content ? '\n' : '';
|
|
1919
|
+
writeFileSync(claudeMdPath, content + separator + roleBlock + '\n');
|
|
1920
|
+
} catch (err) {
|
|
1921
|
+
console.warn(`[workspace] Failed to sync role to CLAUDE.md for ${config.label}:`, err);
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1829
1925
|
private createBackend(config: WorkspaceAgentConfig, agentId?: string) {
|
|
1830
1926
|
switch (config.backend) {
|
|
1831
1927
|
case 'api':
|
|
@@ -1932,6 +2028,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1932
2028
|
|
|
1933
2029
|
/** Unified done handler: broadcast downstream or reply to sender based on message source */
|
|
1934
2030
|
private handleAgentDone(agentId: string, entry: { config: WorkspaceAgentConfig; worker: AgentWorker | null; state: AgentState }, summary?: string): void {
|
|
2031
|
+
this.agentRunningMsg.delete(agentId);
|
|
1935
2032
|
const files = entry.state.artifacts.filter(a => a.path).map(a => a.path!);
|
|
1936
2033
|
console.log(`[workspace] Agent "${entry.config.label}" (${agentId}) completed. Artifacts: ${files.length}.`);
|
|
1937
2034
|
|
|
@@ -1959,12 +2056,24 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1959
2056
|
.filter(h => h.subtype === 'final_summary' || h.subtype === 'step_summary')
|
|
1960
2057
|
.slice(-1)[0]?.content || '';
|
|
1961
2058
|
|
|
2059
|
+
// Keep notification concise — agent can read files/git diff for details
|
|
2060
|
+
const shortSummary = summary.split('\n')[0]?.slice(0, 100) || '';
|
|
1962
2061
|
const content = files.length > 0
|
|
1963
|
-
? `${completedLabel} completed: ${files.length} files changed
|
|
1964
|
-
: `${completedLabel} completed
|
|
2062
|
+
? `${completedLabel} completed: ${files.length} files changed.${shortSummary ? ' ' + shortSummary : ''} Run \`git diff --stat HEAD~1\` for details.`
|
|
2063
|
+
: `${completedLabel} completed.${shortSummary ? ' ' + shortSummary : ''}`;
|
|
1965
2064
|
|
|
1966
2065
|
// Find all downstream agents — skip if already sent upstream_complete recently (60s)
|
|
1967
2066
|
const now = Date.now();
|
|
2067
|
+
|
|
2068
|
+
// Pre-scan log once: build dedup set and pending-to-complete list
|
|
2069
|
+
const recentSentTo = new Set<string>(); // agentIds that received upstream_complete within 60s
|
|
2070
|
+
const pendingToComplete: { m: any; to: string }[] = [];
|
|
2071
|
+
for (const m of this.bus.getLog()) {
|
|
2072
|
+
if (m.from !== completedAgentId || m.payload?.action !== 'upstream_complete') continue;
|
|
2073
|
+
if (now - m.timestamp < 60_000) recentSentTo.add(m.to);
|
|
2074
|
+
if (m.status === 'pending') pendingToComplete.push({ m, to: m.to });
|
|
2075
|
+
}
|
|
2076
|
+
|
|
1968
2077
|
let sent = 0;
|
|
1969
2078
|
for (const [id, entry] of this.agents) {
|
|
1970
2079
|
if (id === completedAgentId) continue;
|
|
@@ -1972,19 +2081,14 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1972
2081
|
if (!entry.config.dependsOn.includes(completedAgentId)) continue;
|
|
1973
2082
|
|
|
1974
2083
|
// 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) {
|
|
2084
|
+
if (recentSentTo.has(id)) {
|
|
1981
2085
|
console.log(`[bus] ${completedLabel} → ${entry.config.label}: upstream_complete skipped (sent <60s ago)`);
|
|
1982
2086
|
continue;
|
|
1983
2087
|
}
|
|
1984
2088
|
|
|
1985
|
-
// Merge: auto-complete older pending upstream_complete from same sender
|
|
1986
|
-
for (const m of
|
|
1987
|
-
if (
|
|
2089
|
+
// Merge: auto-complete older pending upstream_complete from same sender to this target
|
|
2090
|
+
for (const { m, to } of pendingToComplete) {
|
|
2091
|
+
if (to === id) {
|
|
1988
2092
|
m.status = 'done' as any;
|
|
1989
2093
|
this.emit('event', { type: 'bus_message_status', messageId: m.id, status: 'done' } as any);
|
|
1990
2094
|
}
|
|
@@ -2017,20 +2121,109 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2017
2121
|
return join(homedir(), '.claude', 'projects', encoded);
|
|
2018
2122
|
}
|
|
2019
2123
|
|
|
2124
|
+
/** Return the latest session ID (by mtime) from the CLI session dir, or undefined if none. */
|
|
2125
|
+
private getLatestSessionId(workDir?: string): string | undefined {
|
|
2126
|
+
try {
|
|
2127
|
+
const sessionDir = this.getCliSessionDir(workDir);
|
|
2128
|
+
if (!existsSync(sessionDir)) return undefined;
|
|
2129
|
+
const files = readdirSync(sessionDir).filter(f => f.endsWith('.jsonl'));
|
|
2130
|
+
if (files.length === 0) return undefined;
|
|
2131
|
+
const latest = files
|
|
2132
|
+
.map(f => ({ name: f, mtime: statSync(join(sessionDir, f)).mtimeMs }))
|
|
2133
|
+
.sort((a, b) => b.mtime - a.mtime)[0];
|
|
2134
|
+
return latest.name.replace('.jsonl', '');
|
|
2135
|
+
} catch {
|
|
2136
|
+
return undefined;
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
/** Lightweight session bind for non-persistentSession agents.
|
|
2141
|
+
* Checks if the tmux session already exists and sets entry.state.tmuxSession.
|
|
2142
|
+
* Also auto-binds boundSessionId from the latest session file if not already set.
|
|
2143
|
+
* Does NOT create any session or run any launch script.
|
|
2144
|
+
*/
|
|
2145
|
+
private tryBindExistingSession(agentId: string, config: WorkspaceAgentConfig): void {
|
|
2146
|
+
const entry = this.agents.get(agentId);
|
|
2147
|
+
if (!entry) return;
|
|
2148
|
+
|
|
2149
|
+
const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
|
|
2150
|
+
const sessionName = `mw-forge-${safeName(this.projectName)}-${safeName(config.label)}`;
|
|
2151
|
+
|
|
2152
|
+
// Check if tmux session is alive → set tmuxSession so open_terminal can return it
|
|
2153
|
+
try {
|
|
2154
|
+
execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`, { timeout: 3000 });
|
|
2155
|
+
if (entry.state.tmuxSession !== sessionName) {
|
|
2156
|
+
entry.state.tmuxSession = sessionName;
|
|
2157
|
+
this.saveNow();
|
|
2158
|
+
console.log(`[daemon] ${config.label}: found existing tmux session, bound tmuxSession`);
|
|
2159
|
+
}
|
|
2160
|
+
} catch {
|
|
2161
|
+
// No existing session — leave tmuxSession undefined, open_terminal will let FloatingTerminal create one
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
// Auto-bind boundSessionId from latest session file if not already set
|
|
2165
|
+
if (!config.boundSessionId) {
|
|
2166
|
+
const sessionId = this.getLatestSessionId(config.workDir);
|
|
2167
|
+
if (sessionId) {
|
|
2168
|
+
config.boundSessionId = sessionId;
|
|
2169
|
+
this.saveNow();
|
|
2170
|
+
console.log(`[daemon] ${config.label}: auto-bound boundSessionId=${sessionId}`);
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
/**
|
|
2176
|
+
* Create or attach to the tmux session for an agent (terminal-open path).
|
|
2177
|
+
* Unlike ensurePersistentSession, skips the 3s startup verification so the
|
|
2178
|
+
* HTTP response is fast. Returns the session name, or null on failure.
|
|
2179
|
+
*/
|
|
2180
|
+
async openTerminalSession(agentId: string, forceRestart = false): Promise<string | null> {
|
|
2181
|
+
const entry = this.agents.get(agentId);
|
|
2182
|
+
if (!entry || entry.config.type === 'input') return null;
|
|
2183
|
+
const config = entry.config;
|
|
2184
|
+
|
|
2185
|
+
const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
|
|
2186
|
+
const sessionName = `mw-forge-${safeName(this.projectName)}-${safeName(config.label)}`;
|
|
2187
|
+
|
|
2188
|
+
if (forceRestart) {
|
|
2189
|
+
// Kill existing tmux session so ensurePersistentSession rewrites the launch script
|
|
2190
|
+
// with the current boundSessionId (--resume flag). Safe — claude session data is in jsonl files.
|
|
2191
|
+
try { execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null`, { timeout: 3000 }); } catch {}
|
|
2192
|
+
entry.state.tmuxSession = undefined;
|
|
2193
|
+
} else if (entry.state.tmuxSession) {
|
|
2194
|
+
// Attach to existing session if still alive
|
|
2195
|
+
try {
|
|
2196
|
+
execSync(`tmux has-session -t "${entry.state.tmuxSession}" 2>/dev/null`, { timeout: 3000 });
|
|
2197
|
+
return entry.state.tmuxSession;
|
|
2198
|
+
} catch {
|
|
2199
|
+
entry.state.tmuxSession = undefined;
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
// Create (or recreate) session without startup verification delay
|
|
2204
|
+
await this.ensurePersistentSession(agentId, config, true);
|
|
2205
|
+
return entry.state.tmuxSession || null;
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2020
2208
|
/** Create a persistent tmux session with the CLI agent */
|
|
2021
|
-
private async ensurePersistentSession(agentId: string, config: WorkspaceAgentConfig): Promise<void> {
|
|
2209
|
+
private async ensurePersistentSession(agentId: string, config: WorkspaceAgentConfig, skipStartupCheck = false): Promise<void> {
|
|
2022
2210
|
const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
|
|
2023
2211
|
const sessionName = `mw-forge-${safeName(this.projectName)}-${safeName(config.label)}`;
|
|
2024
2212
|
|
|
2025
|
-
// Pre-flight: check project's .claude/settings.json is valid
|
|
2213
|
+
// Pre-flight: check project's .claude/settings.json is valid (cached by mtime)
|
|
2026
2214
|
const workDir = config.workDir && config.workDir !== './' && config.workDir !== '.'
|
|
2027
2215
|
? `${this.projectPath}/${config.workDir}` : this.projectPath;
|
|
2028
2216
|
const projectSettingsFile = join(workDir, '.claude', 'settings.json');
|
|
2029
2217
|
if (existsSync(projectSettingsFile)) {
|
|
2030
2218
|
try {
|
|
2031
|
-
const
|
|
2032
|
-
|
|
2219
|
+
const mtime = statSync(projectSettingsFile).mtimeMs;
|
|
2220
|
+
if (this.settingsValidCache.get(projectSettingsFile) !== mtime) {
|
|
2221
|
+
const raw = readFileSync(projectSettingsFile, 'utf-8');
|
|
2222
|
+
JSON.parse(raw);
|
|
2223
|
+
this.settingsValidCache.set(projectSettingsFile, mtime);
|
|
2224
|
+
}
|
|
2033
2225
|
} catch (err: any) {
|
|
2226
|
+
this.settingsValidCache.delete(projectSettingsFile);
|
|
2034
2227
|
const errorMsg = `Invalid .claude/settings.json: ${err.message}`;
|
|
2035
2228
|
console.error(`[daemon] ${config.label}: ${errorMsg}`);
|
|
2036
2229
|
const entry = this.agents.get(agentId);
|
|
@@ -2048,8 +2241,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2048
2241
|
// Write agent context file for hooks to read (workDir/.forge/agent-context.json)
|
|
2049
2242
|
try {
|
|
2050
2243
|
const forgeDir = join(workDir, '.forge');
|
|
2051
|
-
|
|
2052
|
-
mkdirS(forgeDir, { recursive: true });
|
|
2244
|
+
mkdirSync(forgeDir, { recursive: true });
|
|
2053
2245
|
const ctxPath = join(forgeDir, 'agent-context.json');
|
|
2054
2246
|
writeFileSync(ctxPath, JSON.stringify({
|
|
2055
2247
|
workspaceId: this.workspaceId,
|
|
@@ -2062,22 +2254,53 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2062
2254
|
console.error(`[daemon] ${config.label}: failed to write agent-context.json: ${err.message}`);
|
|
2063
2255
|
}
|
|
2064
2256
|
|
|
2065
|
-
// Check if tmux session already exists
|
|
2257
|
+
// Check if tmux session already exists and Claude is still alive inside
|
|
2066
2258
|
let sessionAlreadyExists = false;
|
|
2259
|
+
let tmuxSessionExists = false;
|
|
2260
|
+
console.log(`[daemon] ${config.label}: ensurePersistentSession called — sessionName=${sessionName} boundSessionId=${config.boundSessionId || 'NONE'} skipStartupCheck=${skipStartupCheck}`);
|
|
2067
2261
|
try {
|
|
2068
2262
|
execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`, { timeout: 3000 });
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2263
|
+
tmuxSessionExists = true;
|
|
2264
|
+
} catch {}
|
|
2265
|
+
console.log(`[daemon] ${config.label}: tmuxSessionExists=${tmuxSessionExists}`);
|
|
2266
|
+
|
|
2267
|
+
if (tmuxSessionExists) {
|
|
2268
|
+
// Check if Claude process is still alive inside the tmux pane
|
|
2269
|
+
let claudeAlive = true;
|
|
2073
2270
|
try {
|
|
2074
|
-
execSync(`tmux
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2271
|
+
const paneContent = execSync(`tmux capture-pane -t "${sessionName}" -p -S -5`, { timeout: 3000, encoding: 'utf-8' });
|
|
2272
|
+
const exitedPatterns = [/^\$\s*$/, /\$ $/m, /Process exited/i, /command not found/i];
|
|
2273
|
+
if (exitedPatterns.some(p => p.test(paneContent))) {
|
|
2274
|
+
console.log(`[daemon] ${config.label}: Claude appears to have exited, recreating session`);
|
|
2275
|
+
try { execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null`, { timeout: 3000 }); } catch {}
|
|
2276
|
+
claudeAlive = false;
|
|
2277
|
+
}
|
|
2278
|
+
} catch {
|
|
2279
|
+
// pane capture failed — assume alive
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
if (claudeAlive) {
|
|
2283
|
+
sessionAlreadyExists = true;
|
|
2284
|
+
console.log(`[daemon] ${config.label}: persistent session alive (${sessionName}) — skipping script generation`);
|
|
2285
|
+
// Ensure FORGE env vars are set in the tmux session environment
|
|
2286
|
+
try {
|
|
2287
|
+
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 });
|
|
2288
|
+
} catch {}
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
if (!sessionAlreadyExists) {
|
|
2293
|
+
// Pre-bind: find existing session file BEFORE starting CLI so --resume is available from the start.
|
|
2294
|
+
// This avoids starting a fresh CLI then restarting it after polling finds the session.
|
|
2295
|
+
if (!config.primary && !config.boundSessionId) {
|
|
2296
|
+
const existingSessionId = this.getLatestSessionId(config.workDir);
|
|
2297
|
+
if (existingSessionId) {
|
|
2298
|
+
config.boundSessionId = existingSessionId;
|
|
2299
|
+
this.saveNow();
|
|
2300
|
+
console.log(`[daemon] ${config.label}: pre-bound to existing session ${existingSessionId}`);
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2081
2304
|
// Create new tmux session and start the CLI agent
|
|
2082
2305
|
try {
|
|
2083
2306
|
// Resolve agent launch info
|
|
@@ -2103,7 +2326,9 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2103
2326
|
.join(' && ');
|
|
2104
2327
|
if (envExports) envExports += ' && ';
|
|
2105
2328
|
}
|
|
2106
|
-
|
|
2329
|
+
// Workspace agent model takes priority over profile/settings model
|
|
2330
|
+
const effectiveModel = config.model || info.model;
|
|
2331
|
+
if (effectiveModel) modelFlag = ` --model ${effectiveModel}`;
|
|
2107
2332
|
} catch {}
|
|
2108
2333
|
|
|
2109
2334
|
// Generate MCP config for Claude Code agents
|
|
@@ -2120,8 +2345,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2120
2345
|
},
|
|
2121
2346
|
},
|
|
2122
2347
|
};
|
|
2123
|
-
|
|
2124
|
-
mkdirS(join(workDir, '.forge'), { recursive: true });
|
|
2348
|
+
mkdirSync(join(workDir, '.forge'), { recursive: true });
|
|
2125
2349
|
writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2));
|
|
2126
2350
|
mcpConfigFlag = ` --mcp-config "${mcpConfigPath}"`;
|
|
2127
2351
|
} catch (err: any) {
|
|
@@ -2150,27 +2374,22 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2150
2374
|
if (supportsSession) {
|
|
2151
2375
|
let sessionId: string | undefined;
|
|
2152
2376
|
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
|
-
}
|
|
2377
|
+
sessionId = getFixedSession(this.projectPath);
|
|
2378
|
+
console.log(`[daemon] ${config.label}: fixedSession=${sessionId || 'NONE'} for ${this.projectPath}`);
|
|
2164
2379
|
} else {
|
|
2165
2380
|
sessionId = config.boundSessionId;
|
|
2381
|
+
console.log(`[daemon] ${config.label}: script-gen boundSessionId=${sessionId || 'NONE'}`);
|
|
2166
2382
|
}
|
|
2167
2383
|
if (sessionId) {
|
|
2168
2384
|
const sessionFile = join(this.getCliSessionDir(config.workDir), `${sessionId}.jsonl`);
|
|
2169
2385
|
if (existsSync(sessionFile)) {
|
|
2170
2386
|
cmd += ` --resume ${sessionId}`;
|
|
2387
|
+
console.log(`[daemon] ${config.label}: script-gen adding --resume ${sessionId}`);
|
|
2171
2388
|
} else {
|
|
2172
2389
|
console.log(`[daemon] ${config.label}: bound session ${sessionId} missing, starting fresh`);
|
|
2173
2390
|
}
|
|
2391
|
+
} else {
|
|
2392
|
+
console.log(`[daemon] ${config.label}: script-gen no boundSessionId → no --resume (skipStartupCheck=${skipStartupCheck})`);
|
|
2174
2393
|
}
|
|
2175
2394
|
}
|
|
2176
2395
|
if (modelFlag) cmd += modelFlag;
|
|
@@ -2180,12 +2399,54 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2180
2399
|
|
|
2181
2400
|
// Write script and execute in tmux
|
|
2182
2401
|
const scriptPath = `/tmp/forge-launch-${config.id.replace(/[^a-z0-9-]/g, '')}.sh`;
|
|
2402
|
+
console.log(`[daemon] ${config.label}: writing launch script ${scriptPath}`);
|
|
2403
|
+
console.log(`[daemon] ${config.label}: exec line → ${cmd}`);
|
|
2183
2404
|
writeFileSync(scriptPath, scriptLines.join('\n'), { mode: 0o755 });
|
|
2184
2405
|
execSync(`tmux send-keys -t "${sessionName}" 'bash ${scriptPath}' Enter`, { timeout: 5000 });
|
|
2185
2406
|
|
|
2186
2407
|
console.log(`[daemon] ${config.label}: persistent session created (${sessionName}) [${cliType}: ${cliCmd}]`);
|
|
2187
2408
|
|
|
2188
2409
|
// Verify CLI started successfully (check after 3s if process is still alive)
|
|
2410
|
+
// Skip when called from openTerminalSession (terminal-open path) for fast response.
|
|
2411
|
+
if (skipStartupCheck) {
|
|
2412
|
+
// Set tmuxSession here only — normal path sets it after startup verification passes.
|
|
2413
|
+
const entrySkip = this.agents.get(agentId);
|
|
2414
|
+
if (entrySkip) {
|
|
2415
|
+
entrySkip.state.tmuxSession = sessionName;
|
|
2416
|
+
this.saveNow();
|
|
2417
|
+
this.emitAgentsChanged();
|
|
2418
|
+
}
|
|
2419
|
+
// Fire boundSessionId binding in background (no await — don't block terminal open)
|
|
2420
|
+
if (!config.primary && !config.boundSessionId) {
|
|
2421
|
+
const bgScriptPath = `/tmp/forge-launch-${config.id.replace(/[^a-z0-9-]/g, '')}.sh`;
|
|
2422
|
+
setTimeout(() => {
|
|
2423
|
+
const sid = this.getLatestSessionId(config.workDir);
|
|
2424
|
+
if (sid) {
|
|
2425
|
+
config.boundSessionId = sid;
|
|
2426
|
+
this.saveNow();
|
|
2427
|
+
console.log(`[daemon] ${config.label}: background bound to session ${sid}`);
|
|
2428
|
+
// Also update launch script for future restarts
|
|
2429
|
+
if (existsSync(bgScriptPath)) {
|
|
2430
|
+
try {
|
|
2431
|
+
const lines = readFileSync(bgScriptPath, 'utf-8').split('\n');
|
|
2432
|
+
const execIdx = lines.findIndex(l => l.startsWith('exec '));
|
|
2433
|
+
if (execIdx >= 0) {
|
|
2434
|
+
const withoutResume = lines[execIdx].replace(/\s--resume\s+\S+/, '');
|
|
2435
|
+
const afterExec = withoutResume.startsWith('exec ') ? 5 : 0;
|
|
2436
|
+
const cmdEnd = withoutResume.indexOf(' ', afterExec);
|
|
2437
|
+
lines[execIdx] = cmdEnd >= 0
|
|
2438
|
+
? withoutResume.slice(0, cmdEnd) + ` --resume ${sid}` + withoutResume.slice(cmdEnd)
|
|
2439
|
+
: withoutResume + ` --resume ${sid}`;
|
|
2440
|
+
writeFileSync(bgScriptPath, lines.join('\n'), { mode: 0o755 });
|
|
2441
|
+
console.log(`[daemon] ${config.label}: background updated launch script with --resume ${sid}`);
|
|
2442
|
+
}
|
|
2443
|
+
} catch {}
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
}, 5000);
|
|
2447
|
+
}
|
|
2448
|
+
return;
|
|
2449
|
+
}
|
|
2189
2450
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
2190
2451
|
try {
|
|
2191
2452
|
const paneContent = execSync(`tmux capture-pane -t "${sessionName}" -p -S -20`, { timeout: 3000, encoding: 'utf-8' });
|
|
@@ -2220,26 +2481,6 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2220
2481
|
return;
|
|
2221
2482
|
}
|
|
2222
2483
|
} 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
2484
|
} catch (err: any) {
|
|
2244
2485
|
console.error(`[daemon] ${config.label}: failed to create persistent session: ${err.message}`);
|
|
2245
2486
|
const entry = this.agents.get(agentId);
|
|
@@ -2261,34 +2502,66 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2261
2502
|
this.emitAgentsChanged();
|
|
2262
2503
|
}
|
|
2263
2504
|
|
|
2264
|
-
//
|
|
2265
|
-
if
|
|
2266
|
-
|
|
2505
|
+
// Ensure boundSessionId is set before returning (required for session monitor + --resume)
|
|
2506
|
+
// Also re-bind if existing boundSessionId points to a deleted session file
|
|
2507
|
+
if (!config.primary && config.boundSessionId) {
|
|
2508
|
+
const boundFile = join(this.getCliSessionDir(config.workDir), `${config.boundSessionId}.jsonl`);
|
|
2509
|
+
if (!existsSync(boundFile)) {
|
|
2510
|
+
console.log(`[daemon] ${config.label}: boundSession ${config.boundSessionId} file missing, re-binding`);
|
|
2511
|
+
config.boundSessionId = undefined;
|
|
2512
|
+
}
|
|
2267
2513
|
}
|
|
2268
|
-
|
|
2269
|
-
// Ensure boundSessionId is set (required for session monitor + --resume)
|
|
2270
2514
|
if (!config.primary && !config.boundSessionId) {
|
|
2271
|
-
const
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
if (
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2515
|
+
const pollStart = Date.now();
|
|
2516
|
+
const pollDelay = sessionAlreadyExists ? 500 : 2000;
|
|
2517
|
+
await new Promise<void>(resolve => {
|
|
2518
|
+
const attempt = () => {
|
|
2519
|
+
if (!this.daemonActive) { resolve(); return; }
|
|
2520
|
+
const sessionId = this.getLatestSessionId(config.workDir);
|
|
2521
|
+
if (sessionId) {
|
|
2522
|
+
config.boundSessionId = sessionId;
|
|
2523
|
+
this.saveNow();
|
|
2524
|
+
console.log(`[daemon] ${config.label}: bound to session ${config.boundSessionId}`);
|
|
2525
|
+
// Update launch script with the now-known sessionId — script was written before
|
|
2526
|
+
// polling completed so it had no --resume flag. Fix it for future restarts.
|
|
2527
|
+
const scriptPath = `/tmp/forge-launch-${config.id.replace(/[^a-z0-9-]/g, '')}.sh`;
|
|
2528
|
+
if (existsSync(scriptPath)) {
|
|
2529
|
+
try {
|
|
2530
|
+
const lines = readFileSync(scriptPath, 'utf-8').split('\n');
|
|
2531
|
+
const execIdx = lines.findIndex(l => l.startsWith('exec '));
|
|
2532
|
+
if (execIdx >= 0) {
|
|
2533
|
+
// Remove any existing --resume flag, then re-insert after command name.
|
|
2534
|
+
// Line format: 'exec <cmd> [args...]'
|
|
2535
|
+
// Insert after cmd (skip 'exec ' prefix + cmd word) so bash doesn't
|
|
2536
|
+
// misinterpret --resume as an exec builtin option.
|
|
2537
|
+
const withoutResume = lines[execIdx].replace(/\s--resume\s+\S+/, '');
|
|
2538
|
+
const afterExec = withoutResume.startsWith('exec ') ? 5 : 0;
|
|
2539
|
+
const cmdEnd = withoutResume.indexOf(' ', afterExec);
|
|
2540
|
+
lines[execIdx] = cmdEnd >= 0
|
|
2541
|
+
? withoutResume.slice(0, cmdEnd) + ` --resume ${sessionId}` + withoutResume.slice(cmdEnd)
|
|
2542
|
+
: withoutResume + ` --resume ${sessionId}`;
|
|
2543
|
+
writeFileSync(scriptPath, lines.join('\n'), { mode: 0o755 });
|
|
2544
|
+
console.log(`[daemon] ${config.label}: updated launch script with --resume ${sessionId}`);
|
|
2545
|
+
}
|
|
2546
|
+
} catch {}
|
|
2288
2547
|
}
|
|
2548
|
+
return resolve();
|
|
2289
2549
|
}
|
|
2290
|
-
|
|
2291
|
-
|
|
2550
|
+
if (Date.now() - pollStart < 10_000) {
|
|
2551
|
+
setTimeout(attempt, 1000);
|
|
2552
|
+
} else {
|
|
2553
|
+
console.log(`[daemon] ${config.label}: no session file after 10s, skipping bind`);
|
|
2554
|
+
const entry = this.agents.get(agentId);
|
|
2555
|
+
if (entry) {
|
|
2556
|
+
entry.state.error = 'Terminal session not ready — click "Open Terminal" to start it manually';
|
|
2557
|
+
this.emit('event', { type: 'smith_status', agentId, smithStatus: entry.state.smithStatus } as any);
|
|
2558
|
+
this.emitAgentsChanged();
|
|
2559
|
+
}
|
|
2560
|
+
resolve();
|
|
2561
|
+
}
|
|
2562
|
+
};
|
|
2563
|
+
setTimeout(attempt, pollDelay);
|
|
2564
|
+
});
|
|
2292
2565
|
}
|
|
2293
2566
|
}
|
|
2294
2567
|
|
|
@@ -2465,9 +2738,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2465
2738
|
// Skip if already busy
|
|
2466
2739
|
if (entry.state.taskStatus === 'running') return;
|
|
2467
2740
|
|
|
2468
|
-
// Skip if any message is already running for this agent
|
|
2469
|
-
|
|
2470
|
-
if (hasRunning) return;
|
|
2741
|
+
// Skip if any message is already running for this agent (O(1) cache lookup)
|
|
2742
|
+
if (this.agentRunningMsg.has(agentId)) return;
|
|
2471
2743
|
|
|
2472
2744
|
// Execution path determined by config, not runtime tmux state
|
|
2473
2745
|
const isTerminalMode = entry.config.persistentSession;
|
|
@@ -2558,6 +2830,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2558
2830
|
|
|
2559
2831
|
// Mark message as running (being processed)
|
|
2560
2832
|
nextMsg.status = 'running' as any;
|
|
2833
|
+
this.agentRunningMsg.set(agentId, nextMsg.id);
|
|
2561
2834
|
this.emit('event', { type: 'bus_message_status', messageId: nextMsg.id, status: 'running' } as any);
|
|
2562
2835
|
|
|
2563
2836
|
const logEntry = {
|
|
@@ -2571,6 +2844,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2571
2844
|
if (isTerminalMode) {
|
|
2572
2845
|
const injected = this.injectIntoSession(agentId, nextMsg.payload.content || nextMsg.payload.action);
|
|
2573
2846
|
if (injected) {
|
|
2847
|
+
entry.state.taskStatus = 'running';
|
|
2848
|
+
this.emit('event', { type: 'task_status', agentId, taskStatus: 'running' } as any);
|
|
2574
2849
|
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
2850
|
console.log(`[inbox] ${entry.config.label}: injected into terminal, starting completion monitor`);
|
|
2576
2851
|
entry.state.currentMessageId = nextMsg.id;
|
|
@@ -2580,6 +2855,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2580
2855
|
// Health check will auto-restart the terminal session
|
|
2581
2856
|
entry.state.tmuxSession = undefined;
|
|
2582
2857
|
nextMsg.status = 'pending' as any; // revert to pending for retry
|
|
2858
|
+
this.agentRunningMsg.delete(agentId);
|
|
2583
2859
|
this.emit('event', { type: 'bus_message_status', messageId: nextMsg.id, status: 'pending' } as any);
|
|
2584
2860
|
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
2861
|
console.log(`[inbox] ${entry.config.label}: terminal inject failed, cleared session — waiting for health check restart`);
|
|
@@ -2668,6 +2944,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2668
2944
|
if (promptCount >= CONFIRM_CHECKS) {
|
|
2669
2945
|
clearInterval(timer);
|
|
2670
2946
|
this.terminalMonitors.delete(agentId);
|
|
2947
|
+
this.agentRunningMsg.delete(agentId);
|
|
2671
2948
|
|
|
2672
2949
|
// Extract output summary (skip prompt lines)
|
|
2673
2950
|
const contentLines = lines.filter(l => !PROMPT_PATTERNS.some(p => p.test(l)));
|
|
@@ -2698,6 +2975,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2698
2975
|
// Session died
|
|
2699
2976
|
clearInterval(timer);
|
|
2700
2977
|
this.terminalMonitors.delete(agentId);
|
|
2978
|
+
this.agentRunningMsg.delete(agentId);
|
|
2701
2979
|
const msg = this.bus.getLog().find(m => m.id === messageId);
|
|
2702
2980
|
if (msg && msg.status !== 'done' && msg.status !== 'failed') {
|
|
2703
2981
|
msg.status = 'failed' as any;
|