@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.
Files changed (39) hide show
  1. package/.forge/agent-context.json +1 -1
  2. package/.forge/mcp.json +1 -1
  3. package/RELEASE_NOTES.md +6 -10
  4. package/app/api/plugins/route.ts +75 -0
  5. package/components/Dashboard.tsx +1 -0
  6. package/components/PipelineEditor.tsx +135 -9
  7. package/components/PluginsPanel.tsx +472 -0
  8. package/components/ProjectDetail.tsx +36 -98
  9. package/components/SessionView.tsx +4 -4
  10. package/components/SettingsModal.tsx +166 -67
  11. package/components/SkillsPanel.tsx +14 -5
  12. package/components/TerminalLauncher.tsx +398 -0
  13. package/components/WebTerminal.tsx +84 -84
  14. package/components/WorkspaceView.tsx +256 -76
  15. package/lib/agents/index.ts +7 -4
  16. package/lib/builtin-plugins/docker.yaml +70 -0
  17. package/lib/builtin-plugins/http.yaml +66 -0
  18. package/lib/builtin-plugins/jenkins.yaml +92 -0
  19. package/lib/builtin-plugins/llm-vision.yaml +85 -0
  20. package/lib/builtin-plugins/playwright.yaml +111 -0
  21. package/lib/builtin-plugins/shell-command.yaml +60 -0
  22. package/lib/builtin-plugins/slack.yaml +48 -0
  23. package/lib/builtin-plugins/webhook.yaml +56 -0
  24. package/lib/forge-mcp-server.ts +116 -2
  25. package/lib/pipeline.ts +62 -5
  26. package/lib/plugins/executor.ts +347 -0
  27. package/lib/plugins/registry.ts +228 -0
  28. package/lib/plugins/types.ts +103 -0
  29. package/lib/project-sessions.ts +7 -2
  30. package/lib/session-utils.ts +7 -3
  31. package/lib/terminal-standalone.ts +6 -34
  32. package/lib/workspace/agent-worker.ts +1 -1
  33. package/lib/workspace/orchestrator.ts +443 -136
  34. package/lib/workspace/presets.ts +5 -3
  35. package/lib/workspace/session-monitor.ts +14 -10
  36. package/lib/workspace/types.ts +3 -1
  37. package/lib/workspace-standalone.ts +38 -21
  38. package/package.json +1 -1
  39. 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
- // Rebuild worker + message loop
345
- this.enterDaemonListening(id);
346
- entry.state.smithStatus = 'active';
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
- // Create persistent session if configured (before message loop so inject works)
350
- if (config.persistentSession) {
351
- this.ensurePersistentSession(id, config).then(() => {
352
- this.startMessageLoop(id);
353
- });
354
- } else {
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 (this.daemonActive && entry.state.smithStatus !== 'active') {
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
- try {
1172
- const psPath = join(homedir(), '.forge', 'data', 'project-sessions.json');
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
- try {
1189
- const sessionDir = this.getCliSessionDir(config.workDir);
1190
- if (existsSync(sessionDir)) {
1191
- const files = require('node:fs').readdirSync(sessionDir).filter((f: string) => f.endsWith('.jsonl'));
1192
- if (files.length > 0) {
1193
- const sorted = files
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
- if (entry.state.smithStatus !== 'active') {
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.isListening()) {
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 (entry.config.persistentSession && !entry.state.tmuxSession) {
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 = log.some(r =>
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. ${summary.slice(0, 200)}. If you are currently processing a task or have seen this before, ignore this notification.`
1964
- : `${completedLabel} completed. ${summary.slice(0, 300) || 'If you are currently processing a task or have seen this before, ignore this notification.'}`;
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
- const recentDup = this.bus.getLog().some(m =>
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 this.bus.getLog()) {
1987
- if (m.from === completedAgentId && m.to === id && m.status === 'pending' && m.payload?.action === 'upstream_complete') {
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 raw = readFileSync(projectSettingsFile, 'utf-8');
2032
- JSON.parse(raw);
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
- const { mkdirSync: mkdirS } = require('node:fs');
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
- sessionAlreadyExists = true;
2070
- console.log(`[daemon] ${config.label}: persistent session already exists (${sessionName})`);
2071
- // Ensure FORGE env vars are set in the tmux session environment
2072
- // (for hooks that read them — set-environment makes them available to new processes in this session)
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 set-environment -t "${sessionName}" FORGE_WORKSPACE_ID "${this.workspaceId}"`, { timeout: 3000 });
2075
- execSync(`tmux set-environment -t "${sessionName}" FORGE_AGENT_ID "${config.id}"`, { timeout: 3000 });
2076
- execSync(`tmux set-environment -t "${sessionName}" FORGE_PORT "${Number(process.env.PORT) || 8403}"`, { timeout: 3000 });
2077
- } catch {}
2078
- // Note: set-environment affects new processes in this tmux session.
2079
- // Claude Code hooks run as child processes of the shell, which inherits tmux environment.
2080
- } catch {
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
- if (info.model) modelFlag = ` --model ${info.model}`;
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
- const { mkdirSync: mkdirS } = await import('node:fs');
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
- // Read fixedSessionId directly from file (avoid dynamic import issues in production build)
2154
- try {
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
- // Start session monitor only for newly created sessions (not existing ones)
2265
- if (!sessionAlreadyExists) {
2266
- this.startAgentSessionMonitor(agentId, config);
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 bindDelay = sessionAlreadyExists ? 500 : 5000;
2272
- setTimeout(() => {
2273
- try {
2274
- const sessionDir = this.getCliSessionDir(config.workDir);
2275
- if (existsSync(sessionDir)) {
2276
- const { readdirSync, statSync: statS } = require('node:fs');
2277
- const files = readdirSync(sessionDir).filter((f: string) => f.endsWith('.jsonl'));
2278
- if (files.length > 0) {
2279
- const latest = files
2280
- .map((f: string) => ({ name: f, mtime: statS(join(sessionDir, f)).mtimeMs }))
2281
- .sort((a: any, b: any) => b.mtime - a.mtime)[0];
2282
- config.boundSessionId = latest.name.replace('.jsonl', '');
2283
- this.saveNow();
2284
- console.log(`[daemon] ${config.label}: bound to session ${config.boundSessionId}`);
2285
- this.startAgentSessionMonitor(agentId, config);
2286
- } else {
2287
- console.log(`[daemon] ${config.label}: no session files yet, will bind on next check`);
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
- } catch {}
2291
- }, bindDelay);
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
- const hasRunning = this.bus.getLog().some(m => m.to === agentId && m.status === 'running' && m.type !== 'ack');
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;