@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.
Files changed (40) hide show
  1. package/.forge/agent-context.json +1 -1
  2. package/RELEASE_NOTES.md +32 -6
  3. package/app/api/code/route.ts +10 -4
  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 +160 -66
  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 +371 -87
  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 +414 -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/next-env.d.ts +1 -1
  39. package/package.json +1 -1
  40. 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
- // Rebuild worker + message loop
345
- this.enterDaemonListening(id);
346
- entry.state.smithStatus = 'active';
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
- // 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 {
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 (this.daemonActive && entry.state.smithStatus !== 'active') {
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
- 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
- }
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
- 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 {}
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
- if (entry.state.smithStatus !== 'active') {
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.isListening()) {
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 (entry.config.persistentSession && !entry.state.tmuxSession) {
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 = log.some(r =>
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. ${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.'}`;
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
- 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) {
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 this.bus.getLog()) {
1987
- if (m.from === completedAgentId && m.to === id && m.status === 'pending' && m.payload?.action === 'upstream_complete') {
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 raw = readFileSync(projectSettingsFile, 'utf-8');
2032
- JSON.parse(raw);
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
- const { mkdirSync: mkdirS } = require('node:fs');
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
- 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)
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 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 {
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
- if (info.model) modelFlag = ` --model ${info.model}`;
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
- const { mkdirSync: mkdirS } = await import('node:fs');
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
- // 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
- }
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
- // Start session monitor only for newly created sessions (not existing ones)
2265
- if (!sessionAlreadyExists) {
2266
- this.startAgentSessionMonitor(agentId, config);
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 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`);
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
- } catch {}
2291
- }, bindDelay);
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
- const hasRunning = this.bus.getLog().some(m => m.to === agentId && m.status === 'running' && m.type !== 'ack');
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;