@aion0/forge 0.4.16 → 0.5.0

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 (92) hide show
  1. package/README.md +1 -1
  2. package/RELEASE_NOTES.md +170 -14
  3. package/app/api/agents/route.ts +17 -0
  4. package/app/api/delivery/[id]/route.ts +62 -0
  5. package/app/api/delivery/route.ts +40 -0
  6. package/app/api/mobile-chat/route.ts +13 -7
  7. package/app/api/monitor/route.ts +10 -6
  8. package/app/api/pipelines/[id]/route.ts +16 -3
  9. package/app/api/tasks/route.ts +2 -1
  10. package/app/api/workspace/[id]/agents/route.ts +35 -0
  11. package/app/api/workspace/[id]/memory/route.ts +23 -0
  12. package/app/api/workspace/[id]/smith/route.ts +22 -0
  13. package/app/api/workspace/[id]/stream/route.ts +28 -0
  14. package/app/api/workspace/route.ts +100 -0
  15. package/app/global-error.tsx +10 -4
  16. package/app/icon.ico +0 -0
  17. package/app/layout.tsx +2 -2
  18. package/app/login/LoginForm.tsx +96 -0
  19. package/app/login/page.tsx +7 -98
  20. package/app/page.tsx +2 -2
  21. package/bin/forge-server.mjs +13 -1
  22. package/check-forge-status.sh +9 -0
  23. package/components/ConversationEditor.tsx +411 -0
  24. package/components/ConversationGraphView.tsx +347 -0
  25. package/components/ConversationTerminalView.tsx +303 -0
  26. package/components/Dashboard.tsx +36 -39
  27. package/components/DashboardWrapper.tsx +9 -0
  28. package/components/DeliveryFlowEditor.tsx +491 -0
  29. package/components/DeliveryList.tsx +230 -0
  30. package/components/DeliveryWorkspace.tsx +589 -0
  31. package/components/DocTerminal.tsx +10 -2
  32. package/components/DocsViewer.tsx +10 -2
  33. package/components/HelpTerminal.tsx +11 -6
  34. package/components/InlinePipelineView.tsx +111 -0
  35. package/components/MobileView.tsx +20 -0
  36. package/components/MonitorPanel.tsx +9 -4
  37. package/components/NewTaskModal.tsx +32 -0
  38. package/components/PipelineEditor.tsx +49 -6
  39. package/components/PipelineView.tsx +482 -64
  40. package/components/ProjectDetail.tsx +314 -56
  41. package/components/ProjectManager.tsx +49 -4
  42. package/components/SessionView.tsx +27 -13
  43. package/components/SettingsModal.tsx +790 -124
  44. package/components/SkillsPanel.tsx +31 -8
  45. package/components/TaskBoard.tsx +3 -0
  46. package/components/WebTerminal.tsx +257 -43
  47. package/components/WorkspaceTree.tsx +221 -0
  48. package/components/WorkspaceView.tsx +2224 -0
  49. package/install.sh +2 -2
  50. package/lib/agents/claude-adapter.ts +104 -0
  51. package/lib/agents/generic-adapter.ts +64 -0
  52. package/lib/agents/index.ts +242 -0
  53. package/lib/agents/types.ts +70 -0
  54. package/lib/artifacts.ts +106 -0
  55. package/lib/delivery.ts +787 -0
  56. package/lib/forge-skills/forge-inbox.md +37 -0
  57. package/lib/forge-skills/forge-send.md +40 -0
  58. package/lib/forge-skills/forge-status.md +32 -0
  59. package/lib/forge-skills/forge-workspace-sync.md +37 -0
  60. package/lib/help-docs/00-overview.md +7 -1
  61. package/lib/help-docs/01-settings.md +159 -2
  62. package/lib/help-docs/05-pipelines.md +89 -0
  63. package/lib/help-docs/07-projects.md +35 -1
  64. package/lib/help-docs/11-workspace.md +204 -0
  65. package/lib/help-docs/CLAUDE.md +5 -2
  66. package/lib/init.ts +60 -10
  67. package/lib/pipeline.ts +537 -1
  68. package/lib/settings.ts +115 -22
  69. package/lib/skills.ts +249 -372
  70. package/lib/task-manager.ts +113 -33
  71. package/lib/telegram-bot.ts +33 -1
  72. package/lib/workspace/__tests__/state-machine.test.ts +388 -0
  73. package/lib/workspace/__tests__/workspace.test.ts +311 -0
  74. package/lib/workspace/agent-bus.ts +416 -0
  75. package/lib/workspace/agent-worker.ts +667 -0
  76. package/lib/workspace/backends/api-backend.ts +262 -0
  77. package/lib/workspace/backends/cli-backend.ts +479 -0
  78. package/lib/workspace/index.ts +82 -0
  79. package/lib/workspace/manager.ts +136 -0
  80. package/lib/workspace/orchestrator.ts +1804 -0
  81. package/lib/workspace/persistence.ts +310 -0
  82. package/lib/workspace/presets.ts +170 -0
  83. package/lib/workspace/skill-installer.ts +188 -0
  84. package/lib/workspace/smith-memory.ts +498 -0
  85. package/lib/workspace/types.ts +231 -0
  86. package/lib/workspace/watch-manager.ts +288 -0
  87. package/lib/workspace-standalone.ts +790 -0
  88. package/middleware.ts +1 -0
  89. package/package.json +4 -1
  90. package/src/config/index.ts +12 -1
  91. package/src/core/db/database.ts +1 -0
  92. package/start.sh +7 -0
@@ -0,0 +1,1804 @@
1
+ /**
2
+ * WorkspaceOrchestrator — manages a group of agents within a workspace.
3
+ *
4
+ * Responsibilities:
5
+ * - Create/remove agents
6
+ * - Run agents (auto-select backend, inject upstream context)
7
+ * - Listen to agent events → trigger downstream agents
8
+ * - Approval gating
9
+ * - Parallel execution (independent agents run concurrently)
10
+ * - Error recovery (restart from lastCheckpoint)
11
+ */
12
+
13
+ import { EventEmitter } from 'node:events';
14
+ import { readFileSync, existsSync } from 'node:fs';
15
+ import { resolve } from 'node:path';
16
+ import type {
17
+ WorkspaceAgentConfig,
18
+ AgentState,
19
+ SmithStatus,
20
+ TaskStatus,
21
+ AgentMode,
22
+ WorkerEvent,
23
+ BusMessage,
24
+ Artifact,
25
+ WorkspaceState,
26
+ DaemonWakeReason,
27
+ } from './types';
28
+ import { AgentWorker } from './agent-worker';
29
+ import { AgentBus } from './agent-bus';
30
+ import { WatchManager } from './watch-manager';
31
+ import { ApiBackend } from './backends/api-backend';
32
+ import { CliBackend } from './backends/cli-backend';
33
+ import { appendAgentLog, saveWorkspace, saveWorkspaceSync, startAutoSave, stopAutoSave } from './persistence';
34
+ import { hasForgeSkills, installForgeSkills } from './skill-installer';
35
+ import {
36
+ loadMemory, saveMemory, createMemory, formatMemoryForPrompt,
37
+ addObservation, addSessionSummary, parseStepToObservations, buildSessionSummary,
38
+ } from './smith-memory';
39
+
40
+ // ─── Orchestrator Events ─────────────────────────────────
41
+
42
+ export type OrchestratorEvent =
43
+ | WorkerEvent
44
+ | { type: 'bus_message'; message: BusMessage }
45
+ | { type: 'approval_required'; agentId: string; upstreamId: string }
46
+ | { type: 'user_input_request'; agentId: string; fromAgent: string; question: string }
47
+ | { type: 'workspace_status'; running: number; done: number; total: number }
48
+ | { type: 'workspace_complete' }
49
+ | { type: 'watch_alert'; agentId: string; changes: any[]; summary: string; timestamp: number };
50
+
51
+ // ─── Orchestrator class ──────────────────────────────────
52
+
53
+ export class WorkspaceOrchestrator extends EventEmitter {
54
+ readonly workspaceId: string;
55
+ readonly projectPath: string;
56
+ readonly projectName: string;
57
+
58
+ private agents = new Map<string, { config: WorkspaceAgentConfig; worker: AgentWorker | null; state: AgentState }>();
59
+ private bus: AgentBus;
60
+ private watchManager: WatchManager;
61
+ private approvalQueue = new Set<string>();
62
+ private daemonActive = false;
63
+ private createdAt = Date.now();
64
+
65
+ constructor(workspaceId: string, projectPath: string, projectName: string) {
66
+ super();
67
+ this.workspaceId = workspaceId;
68
+ this.projectPath = projectPath;
69
+ this.projectName = projectName;
70
+ this.bus = new AgentBus();
71
+ this.watchManager = new WatchManager(workspaceId, projectPath, () => this.agents as any);
72
+ // Handle watch events
73
+ this.watchManager.on('watch_alert', (event) => {
74
+ this.emit('event', event);
75
+ // Push alert to agent history so Log panel shows it
76
+ const alertEntry = this.agents.get(event.agentId);
77
+ if (alertEntry && event.entry) {
78
+ alertEntry.state.history.push(event.entry);
79
+ this.emit('event', { type: 'log', agentId: event.agentId, entry: event.entry } as any);
80
+ }
81
+ this.handleWatchAlert(event.agentId, event.summary);
82
+ });
83
+ // Note: watch_heartbeat (no changes) only logs to console, not to agent history/logs.jsonl
84
+
85
+ // Forward bus messages as orchestrator events (after dedup, skip ACKs)
86
+ this.bus.on('message', (msg: BusMessage) => {
87
+ if (msg.type === 'ack') return; // ACKs are internal, don't emit to UI
88
+ if (msg.to === '_system') {
89
+ this.emit('event', { type: 'bus_message', message: msg } satisfies OrchestratorEvent);
90
+ return;
91
+ }
92
+ this.handleBusMessage(msg);
93
+ });
94
+
95
+ // Start auto-save (every 10 seconds)
96
+ startAutoSave(workspaceId, () => this.getFullState());
97
+ }
98
+
99
+ // ─── Agent Management ──────────────────────────────────
100
+
101
+ /** Check if agent outputs or workDir conflict with existing agents */
102
+ private validateOutputs(config: WorkspaceAgentConfig, excludeId?: string): string | null {
103
+ if (config.type === 'input') return null;
104
+
105
+ const normalize = (p: string) => p.replace(/^\.?\//, '').replace(/\/$/, '') || '.';
106
+
107
+ // Validate workDir is within project (no ../ escape)
108
+ if (config.workDir) {
109
+ const relativeDir = config.workDir.replace(/^\.?\//, '');
110
+ if (relativeDir.includes('..')) {
111
+ return `Work directory "${config.workDir}" contains "..". Must be a subdirectory of the project.`;
112
+ }
113
+ const projectRoot = this.projectPath.endsWith('/') ? this.projectPath : this.projectPath + '/';
114
+ const resolved = resolve(this.projectPath, relativeDir);
115
+ if (resolved !== this.projectPath && !resolved.startsWith(projectRoot)) {
116
+ return `Work directory "${config.workDir}" is outside the project. Must be a subdirectory.`;
117
+ }
118
+ }
119
+
120
+ // Every non-input smith must have a unique workDir
121
+ const newDir = normalize(config.workDir || '.');
122
+
123
+ for (const [id, entry] of this.agents) {
124
+ if (id === excludeId || entry.config.type === 'input') continue;
125
+
126
+ const existingDir = normalize(entry.config.workDir || '.');
127
+
128
+ // Same workDir → conflict
129
+ if (newDir === existingDir) {
130
+ return `Work directory conflict: "${config.label}" and "${entry.config.label}" both use "${newDir === '.' ? 'project root' : newDir}/". Each smith must have a unique work directory.`;
131
+ }
132
+
133
+ // One is parent of the other → conflict (e.g., "src" and "src/components")
134
+ if (newDir.startsWith(existingDir + '/') || existingDir.startsWith(newDir + '/')) {
135
+ return `Work directory conflict: "${config.label}" (${newDir}/) overlaps with "${entry.config.label}" (${existingDir}/). Nested directories not allowed.`;
136
+ }
137
+
138
+ // Check output path overlap
139
+ for (const out of config.outputs) {
140
+ for (const existing of entry.config.outputs) {
141
+ if (normalize(out) === normalize(existing)) {
142
+ return `Output conflict: "${config.label}" and "${entry.config.label}" both output to "${out}"`;
143
+ }
144
+ }
145
+ }
146
+ }
147
+ return null;
148
+ }
149
+
150
+ /** Detect if adding dependsOn edges would create a cycle in the DAG */
151
+ private detectCycle(agentId: string, dependsOn: string[]): string | null {
152
+ // Build adjacency: agent → agents it depends on
153
+ const deps = new Map<string, string[]>();
154
+ for (const [id, entry] of this.agents) {
155
+ if (id !== agentId) deps.set(id, [...entry.config.dependsOn]);
156
+ }
157
+ deps.set(agentId, [...dependsOn]);
158
+
159
+ // DFS cycle detection
160
+ const visited = new Set<string>();
161
+ const inStack = new Set<string>();
162
+
163
+ const dfs = (node: string): string | null => {
164
+ if (inStack.has(node)) return node; // cycle found
165
+ if (visited.has(node)) return null;
166
+ visited.add(node);
167
+ inStack.add(node);
168
+ for (const dep of deps.get(node) || []) {
169
+ const cycle = dfs(dep);
170
+ if (cycle) return cycle;
171
+ }
172
+ inStack.delete(node);
173
+ return null;
174
+ };
175
+
176
+ for (const id of deps.keys()) {
177
+ const cycle = dfs(id);
178
+ if (cycle) {
179
+ const cycleName = this.agents.get(cycle)?.config.label || cycle;
180
+ return `Circular dependency detected involving "${cycleName}". Dependencies must form a DAG (no cycles).`;
181
+ }
182
+ }
183
+ return null;
184
+ }
185
+
186
+ /** Check if agentA is upstream of agentB (A is in B's dependency chain) */
187
+ isUpstream(agentA: string, agentB: string): boolean {
188
+ const visited = new Set<string>();
189
+ const check = (current: string): boolean => {
190
+ if (current === agentA) return true;
191
+ if (visited.has(current)) return false;
192
+ visited.add(current);
193
+ const entry = this.agents.get(current);
194
+ if (!entry) return false;
195
+ return entry.config.dependsOn.some(dep => check(dep));
196
+ };
197
+ return check(agentB);
198
+ }
199
+
200
+ addAgent(config: WorkspaceAgentConfig): void {
201
+ const conflict = this.validateOutputs(config);
202
+ if (conflict) throw new Error(conflict);
203
+
204
+ // Check DAG cycle before adding
205
+ const cycleErr = this.detectCycle(config.id, config.dependsOn);
206
+ if (cycleErr) throw new Error(cycleErr);
207
+
208
+ const state: AgentState = {
209
+ smithStatus: 'down',
210
+ mode: 'auto',
211
+ taskStatus: 'idle',
212
+ history: [],
213
+ artifacts: [],
214
+ };
215
+ this.agents.set(config.id, { config, worker: null, state });
216
+ this.saveNow();
217
+ this.emitAgentsChanged();
218
+ }
219
+
220
+ removeAgent(id: string): void {
221
+ const entry = this.agents.get(id);
222
+ if (entry?.worker) {
223
+ entry.worker.stop();
224
+ }
225
+ this.agents.delete(id);
226
+ this.approvalQueue.delete(id);
227
+
228
+ // Clean up dangling dependsOn references in other agents
229
+ for (const [, other] of this.agents) {
230
+ const idx = other.config.dependsOn.indexOf(id);
231
+ if (idx !== -1) {
232
+ other.config.dependsOn.splice(idx, 1);
233
+ }
234
+ }
235
+
236
+ this.saveNow();
237
+ this.emitAgentsChanged();
238
+ }
239
+
240
+ updateAgentConfig(id: string, config: WorkspaceAgentConfig): void {
241
+ const entry = this.agents.get(id);
242
+ if (!entry) return;
243
+ const conflict = this.validateOutputs(config, id);
244
+ if (conflict) throw new Error(conflict);
245
+ const cycleErr = this.detectCycle(id, config.dependsOn);
246
+ if (cycleErr) throw new Error(cycleErr);
247
+ if (entry.worker && entry.state.taskStatus === 'running') {
248
+ entry.worker.stop();
249
+ }
250
+ entry.config = config;
251
+ // Reset status but keep history/artifacts (don't wipe logs)
252
+ entry.state.taskStatus = 'idle';
253
+ entry.state.error = undefined;
254
+ entry.worker = null;
255
+ // Restart watch if config changed
256
+ if (this.daemonActive) {
257
+ this.watchManager.startWatch(id, config);
258
+ }
259
+ this.saveNow();
260
+ this.emitAgentsChanged();
261
+ // Push status update so frontend reflects the reset
262
+ this.emit('event', { type: 'task_status', agentId: id, taskStatus: 'idle' } satisfies WorkerEvent);
263
+ }
264
+
265
+ getAgentState(id: string): Readonly<AgentState> | undefined {
266
+ return this.agents.get(id)?.state;
267
+ }
268
+
269
+ getAllAgentStates(): Record<string, AgentState> {
270
+ const result: Record<string, AgentState> = {};
271
+ for (const [id, entry] of this.agents) {
272
+ const workerState = entry.worker?.getState();
273
+ // Merge: worker state for task/smith, entry.state for mode (orchestrator controls mode)
274
+ result[id] = workerState
275
+ ? { ...workerState, mode: entry.state.mode }
276
+ : entry.state;
277
+ }
278
+ return result;
279
+ }
280
+
281
+ // ─── Execution ─────────────────────────────────────────
282
+
283
+ /**
284
+ * Complete an Input node — set its content and mark as done.
285
+ * If re-submitted, resets downstream agents so they can re-run.
286
+ */
287
+ completeInput(agentId: string, content: string): void {
288
+ const entry = this.agents.get(agentId);
289
+ if (!entry || entry.config.type !== 'input') return;
290
+
291
+ const isUpdate = entry.state.taskStatus === 'done';
292
+
293
+ // Append to entries (incremental, not overwrite)
294
+ if (!entry.config.entries) entry.config.entries = [];
295
+ entry.config.entries.push({ content, timestamp: Date.now() });
296
+ // Keep bounded — max 100 entries, oldest removed
297
+ if (entry.config.entries.length > 100) {
298
+ entry.config.entries = entry.config.entries.slice(-100);
299
+ }
300
+ // Also set content to latest for backward compat
301
+ entry.config.content = content;
302
+
303
+ entry.state.taskStatus = 'done';
304
+ entry.state.completedAt = Date.now();
305
+ entry.state.artifacts = [{ type: 'text', summary: content.slice(0, 200) }];
306
+
307
+ this.emit('event', { type: 'task_status', agentId, taskStatus: 'done' } satisfies WorkerEvent);
308
+ this.emit('event', { type: 'done', agentId, summary: 'Input provided' } satisfies WorkerEvent);
309
+ this.emitAgentsChanged(); // push updated entries to frontend
310
+ this.bus.notifyTaskComplete(agentId, [], content.slice(0, 200));
311
+
312
+ // Send input_updated messages to downstream agents via bus
313
+ // routeMessageToAgent handles auto-execution for active smiths
314
+ for (const [id, downstream] of this.agents) {
315
+ if (downstream.config.type === 'input') continue;
316
+ if (!downstream.config.dependsOn.includes(agentId)) continue;
317
+ this.bus.send(agentId, id, 'notify', {
318
+ action: 'input_updated',
319
+ content: content.slice(0, 500),
320
+ });
321
+ console.log(`[bus] Input → ${downstream.config.label}: input_updated`);
322
+ }
323
+
324
+ this.saveNow();
325
+ }
326
+
327
+ /** Reset an agent and all its downstream to idle (for re-run) */
328
+ resetAgent(agentId: string): void {
329
+ const entry = this.agents.get(agentId);
330
+ if (!entry) return;
331
+ if (entry.worker) entry.worker.stop();
332
+ entry.worker = null;
333
+ // Kill orphaned tmux session if manual agent
334
+ if (entry.state.tmuxSession) {
335
+ try {
336
+ const { execSync } = require('node:child_process');
337
+ execSync(`tmux kill-session -t "${entry.state.tmuxSession}" 2>/dev/null`, { timeout: 3000 });
338
+ console.log(`[workspace] Killed tmux session ${entry.state.tmuxSession}`);
339
+ } catch {} // session might already be dead
340
+ }
341
+ entry.state = { smithStatus: 'down', mode: 'auto', taskStatus: 'idle', history: entry.state.history, artifacts: [] };
342
+ this.emit('event', { type: 'task_status', agentId, taskStatus: 'idle' } satisfies WorkerEvent);
343
+ this.emitAgentsChanged();
344
+ this.saveNow();
345
+ }
346
+
347
+ /** Reset all agents that depend on the given agent (recursively) */
348
+ private resetDownstream(agentId: string, visited = new Set<string>()): void {
349
+ if (visited.has(agentId)) return; // cycle protection
350
+ visited.add(agentId);
351
+
352
+ for (const [id, entry] of this.agents) {
353
+ if (id === agentId) continue;
354
+ if (!entry.config.dependsOn.includes(agentId)) continue;
355
+ if (entry.state.taskStatus === 'idle') continue;
356
+ console.log(`[workspace] Resetting ${entry.config.label} (${id}) to idle (upstream ${agentId} changed)`);
357
+ if (entry.worker) entry.worker.stop();
358
+ entry.worker = null;
359
+ entry.state = { smithStatus: entry.state.smithStatus, mode: entry.state.mode, taskStatus: 'idle', history: entry.state.history, artifacts: [], cliSessionId: entry.state.cliSessionId };
360
+ this.emit('event', { type: 'task_status', agentId: id, taskStatus: 'idle' } satisfies WorkerEvent);
361
+ this.resetDownstream(id, visited);
362
+ }
363
+ }
364
+
365
+ /** Validate that an agent can run (sync check). Throws on error. */
366
+ validateCanRun(agentId: string): void {
367
+ const entry = this.agents.get(agentId);
368
+ if (!entry) throw new Error(`Agent "${agentId}" not found`);
369
+ if (entry.config.type === 'input') return;
370
+ if (entry.state.taskStatus === 'running') throw new Error(`Agent "${entry.config.label}" is already running`);
371
+ for (const depId of entry.config.dependsOn) {
372
+ const dep = this.agents.get(depId);
373
+ if (!dep) throw new Error(`Dependency "${depId}" not found (deleted?). Edit the agent to fix.`);
374
+ if (dep.state.taskStatus !== 'done') {
375
+ const hint = dep.state.taskStatus === 'idle' ? ' (never executed — run it first)'
376
+ : dep.state.taskStatus === 'failed' ? ' (failed — retry it first)'
377
+ : dep.state.taskStatus === 'running' ? ' (still running — wait for it to finish)'
378
+ : '';
379
+ throw new Error(`Dependency "${dep.config.label}" not completed yet${hint}`);
380
+ }
381
+ }
382
+ }
383
+
384
+ /** Run a specific agent. Requires daemon mode. force=true bypasses status checks (for retry). */
385
+ async runAgent(agentId: string, userInput?: string, force = false): Promise<void> {
386
+ if (!this.daemonActive) {
387
+ throw new Error('Start daemon first before running agents');
388
+ }
389
+ const label = this.agents.get(agentId)?.config.label || agentId;
390
+ console.log(`[workspace] runAgent(${label}, force=${force})`, new Error().stack?.split('\n').slice(2, 5).join(' <- '));
391
+ return this.runAgentDaemon(agentId, userInput, force);
392
+ }
393
+
394
+ /** @deprecated Use runAgent (which now delegates to daemon mode) */
395
+ private async runAgentLegacy(agentId: string, userInput?: string): Promise<void> {
396
+ const entry = this.agents.get(agentId);
397
+ if (!entry) throw new Error(`Agent "${agentId}" not found`);
398
+
399
+ // Input nodes are completed via completeInput(), not run
400
+ if (entry.config.type === 'input') {
401
+ if (userInput) this.completeInput(agentId, userInput);
402
+ return;
403
+ }
404
+
405
+ if (entry.state.taskStatus === 'running') return;
406
+
407
+ // Allow re-running done/failed/idle(was interrupted)/waiting_approval agents — reset them first
408
+ let resumeFromCheckpoint = false;
409
+ if (entry.state.taskStatus === 'done' || entry.state.taskStatus === 'failed' || entry.state.taskStatus === 'idle' || this.approvalQueue.has(agentId)) {
410
+ this.approvalQueue.delete(agentId);
411
+ console.log(`[workspace] Re-running ${entry.config.label} (was taskStatus=${entry.state.taskStatus})`);
412
+ // For failed: keep lastCheckpoint for resume
413
+ resumeFromCheckpoint = (entry.state.taskStatus === 'failed')
414
+ && entry.state.lastCheckpoint !== undefined;
415
+ if (entry.worker) entry.worker.stop();
416
+ entry.worker = null;
417
+ if (!resumeFromCheckpoint) {
418
+ entry.state = { smithStatus: entry.state.smithStatus, mode: entry.state.mode, taskStatus: 'idle', history: entry.state.history, artifacts: [], cliSessionId: entry.state.cliSessionId };
419
+ } else {
420
+ entry.state.taskStatus = 'idle';
421
+ entry.state.error = undefined;
422
+ entry.state.mode = 'auto';
423
+ }
424
+ }
425
+
426
+ const { config } = entry;
427
+
428
+ // Check if all dependencies are done
429
+ for (const depId of config.dependsOn) {
430
+ const dep = this.agents.get(depId);
431
+ if (!dep || dep.state.taskStatus !== 'done') {
432
+ throw new Error(`Dependency "${dep?.config.label || depId}" not completed yet`);
433
+ }
434
+ }
435
+
436
+ // Build upstream context from dependencies (includes Input node content)
437
+ let upstreamContext = this.buildUpstreamContext(config);
438
+ if (userInput) {
439
+ const prefix = '## Additional Instructions:\n' + userInput;
440
+ upstreamContext = upstreamContext ? prefix + '\n\n---\n\n' + upstreamContext : prefix;
441
+ }
442
+
443
+ // Create backend
444
+ const backend = this.createBackend(config, agentId);
445
+
446
+ // Create worker with bus callbacks for inter-agent communication
447
+ // Load agent memory
448
+ const memory = loadMemory(this.workspaceId, agentId);
449
+ const memoryContext = formatMemoryForPrompt(memory);
450
+
451
+ const peerAgentIds = Array.from(this.agents.keys()).filter(id => id !== agentId);
452
+ const worker = new AgentWorker({
453
+ config,
454
+ backend,
455
+ projectPath: this.projectPath, workspaceId: this.workspaceId,
456
+ peerAgentIds,
457
+ memoryContext: memoryContext || undefined,
458
+ onBusSend: (to, content) => {
459
+ this.bus.send(agentId, to, 'notify', { action: 'agent_message', content });
460
+ },
461
+ onBusRequest: async (to, question) => {
462
+ const response = await this.bus.request(agentId, to, { action: 'question', content: question });
463
+ return response.payload.content || '(no response)';
464
+ },
465
+ onMemoryUpdate: (stepResults) => {
466
+ this.updateAgentMemory(agentId, config, stepResults);
467
+ },
468
+ });
469
+ entry.worker = worker;
470
+
471
+ // Forward worker events
472
+ worker.on('event', (event: WorkerEvent) => {
473
+ // Sync state
474
+ entry.state = worker.getState() as AgentState;
475
+
476
+ // Persist log entries to disk
477
+ if (event.type === 'log') {
478
+ appendAgentLog(this.workspaceId, agentId, event.entry).catch(() => {});
479
+ }
480
+
481
+ this.emit('event', event);
482
+
483
+ // Update liveness
484
+ if (event.type === 'task_status' || event.type === 'smith_status') {
485
+ this.updateAgentLiveness(agentId);
486
+ }
487
+
488
+ // On step complete → capture observation + notify bus
489
+ if (event.type === 'step') {
490
+ const step = config.steps[event.stepIndex];
491
+ if (step) {
492
+ this.bus.notifyStepComplete(agentId, step.label);
493
+
494
+ // Capture memory observation from the previous step's result
495
+ const prevStepIdx = event.stepIndex - 1;
496
+ if (prevStepIdx >= 0) {
497
+ const prevStep = config.steps[prevStepIdx];
498
+ const prevResult = entry.state.history
499
+ .filter(h => h.type === 'result' && h.subtype === 'step_complete')
500
+ .slice(-1)[0];
501
+ if (prevResult && prevStep) {
502
+ const obs = parseStepToObservations(prevStep.label, prevResult.content, entry.state.artifacts);
503
+ for (const o of obs) {
504
+ addObservation(this.workspaceId, agentId, config.label, config.role, o).catch(() => {});
505
+ }
506
+ }
507
+ }
508
+ }
509
+ }
510
+
511
+ // On done → notify + trigger downstream (or reply to sender if from downstream)
512
+ if (event.type === 'done') {
513
+ this.handleAgentDone(agentId, entry, event.summary);
514
+
515
+ this.emitWorkspaceStatus();
516
+ this.checkWorkspaceComplete();
517
+
518
+ // Note: no auto-rerun. Bus messages that need re-run go through user approval.
519
+ }
520
+
521
+ // On error → notify bus
522
+ if (event.type === 'error') {
523
+ this.bus.notifyError(agentId, event.error);
524
+ this.emitWorkspaceStatus();
525
+ }
526
+ });
527
+
528
+ // Inject only undelivered (pending) bus messages addressed to this agent
529
+ const pendingMsgs = this.bus.getPendingMessagesFor(agentId)
530
+ .filter(m => m.from !== agentId); // don't inject own messages
531
+ for (const msg of pendingMsgs) {
532
+ const fromLabel = this.agents.get(msg.from)?.config.label || msg.from;
533
+ worker.injectMessage({
534
+ type: 'system',
535
+ subtype: 'bus_message',
536
+ content: `[From ${fromLabel}]: ${msg.payload.content || msg.payload.action}`,
537
+ timestamp: new Date(msg.timestamp).toISOString(),
538
+ });
539
+ // Mark as delivered + ACK so sender knows it was received
540
+ msg.status = 'done';
541
+ }
542
+
543
+ // Start from checkpoint if recovering from failure
544
+ const startStep = resumeFromCheckpoint && entry.state.lastCheckpoint !== undefined
545
+ ? entry.state.lastCheckpoint + 1
546
+ : 0;
547
+
548
+ this.emitWorkspaceStatus();
549
+
550
+ // Execute (non-blocking — fire and forget, events handle the rest)
551
+ worker.execute(startStep, upstreamContext).catch(err => {
552
+ // Only set failed if worker didn't already handle it (avoid duplicate error events)
553
+ if (entry.state.taskStatus !== 'failed') {
554
+ entry.state.taskStatus = 'failed';
555
+ entry.state.error = err?.message || String(err);
556
+ this.emit('event', { type: 'error', agentId, error: entry.state.error! } satisfies WorkerEvent);
557
+ }
558
+ });
559
+ }
560
+
561
+ /** Run all agents — starts daemon if not active, then runs all ready agents */
562
+ async runAll(): Promise<void> {
563
+ if (!this.daemonActive) {
564
+ return this.startDaemon();
565
+ }
566
+ const ready = this.getDaemonReadyAgents();
567
+ await Promise.all(ready.map(id => this.runAgentDaemon(id)));
568
+ }
569
+
570
+ /** Run a single agent in daemon mode. force=true resets failed/interrupted agents. triggerMessageId tracks which bus message started this. */
571
+ async runAgentDaemon(agentId: string, userInput?: string, force = false, triggerMessageId?: string): Promise<void> {
572
+ const entry = this.agents.get(agentId);
573
+ if (!entry) throw new Error(`Agent "${agentId}" not found`);
574
+
575
+ if (entry.config.type === 'input') {
576
+ if (userInput) this.completeInput(agentId, userInput);
577
+ return;
578
+ }
579
+
580
+ if (entry.state.taskStatus === 'running' && !force) return;
581
+ // Already has a daemon worker running → skip (unless force retry)
582
+ if (entry.worker && entry.state.smithStatus === 'active' && !force) return;
583
+
584
+ // Already done → enter daemon listening directly (don't re-run steps)
585
+ if (entry.state.taskStatus === 'done' && !force) {
586
+ return this.enterDaemonListening(agentId);
587
+ }
588
+
589
+ if (!force) {
590
+ // Failed → leave as-is, user must retry explicitly
591
+ if (entry.state.taskStatus === 'failed') return;
592
+ // waiting_approval → leave as-is
593
+ if (this.approvalQueue.has(agentId)) return;
594
+ }
595
+
596
+ // Reset state for fresh start — preserve smithStatus and mode
597
+ if (entry.state.taskStatus !== 'idle') {
598
+ this.approvalQueue.delete(agentId);
599
+ if (entry.worker) entry.worker.stop();
600
+ entry.worker = null;
601
+ entry.state = {
602
+ smithStatus: entry.state.smithStatus,
603
+ mode: entry.state.mode,
604
+ taskStatus: 'idle',
605
+ history: [],
606
+ artifacts: [],
607
+ cliSessionId: entry.state.cliSessionId, // preserve session for --resume
608
+ };
609
+ }
610
+
611
+ // Ensure smith is active when daemon starts this agent
612
+ if (this.daemonActive && entry.state.smithStatus !== 'active') {
613
+ entry.state.smithStatus = 'active';
614
+ this.emit('event', { type: 'smith_status', agentId, smithStatus: 'active', mode: entry.state.mode } satisfies WorkerEvent);
615
+ }
616
+
617
+ const { config } = entry;
618
+
619
+ // Check dependencies
620
+ for (const depId of config.dependsOn) {
621
+ const dep = this.agents.get(depId);
622
+ if (!dep) throw new Error(`Dependency "${depId}" not found`);
623
+ if (force) {
624
+ // Manual trigger: only require upstream smith to be active (online)
625
+ if (dep.config.type !== 'input' && dep.state.smithStatus !== 'active') {
626
+ throw new Error(`Dependency "${dep.config.label}" smith is not active — start daemon first`);
627
+ }
628
+ } else {
629
+ // Auto trigger: require upstream task completed
630
+ if (dep.state.taskStatus !== 'done') {
631
+ throw new Error(`Dependency "${dep.config.label}" not completed yet`);
632
+ }
633
+ }
634
+ }
635
+
636
+ let upstreamContext = this.buildUpstreamContext(config);
637
+ if (userInput) {
638
+ const prefix = '## Additional Instructions:\n' + userInput;
639
+ upstreamContext = upstreamContext ? prefix + '\n\n---\n\n' + upstreamContext : prefix;
640
+ }
641
+
642
+ const backend = this.createBackend(config, agentId);
643
+ const memory = loadMemory(this.workspaceId, agentId);
644
+ const memoryContext = formatMemoryForPrompt(memory);
645
+ const peerAgentIds = Array.from(this.agents.keys()).filter(id => id !== agentId);
646
+
647
+ const worker = new AgentWorker({
648
+ config, backend,
649
+ projectPath: this.projectPath, workspaceId: this.workspaceId,
650
+ peerAgentIds,
651
+ memoryContext: memoryContext || undefined,
652
+ onBusSend: (to, content) => {
653
+ this.bus.send(agentId, to, 'notify', { action: 'agent_message', content });
654
+ },
655
+ onBusRequest: async (to, question) => {
656
+ const response = await this.bus.request(agentId, to, { action: 'question', content: question });
657
+ return response.payload.content || '(no response)';
658
+ },
659
+ onMessageDone: (messageId) => {
660
+ const busMsg = this.bus.getLog().find(m => m.id === messageId);
661
+ if (busMsg) {
662
+ busMsg.status = 'done';
663
+ this.emit('event', { type: 'bus_message_status', messageId, status: 'done' } as any);
664
+ this.emitAgentsChanged();
665
+ }
666
+ },
667
+ onMessageFailed: (messageId) => {
668
+ const busMsg = this.bus.getLog().find(m => m.id === messageId);
669
+ if (busMsg) {
670
+ busMsg.status = 'failed';
671
+ this.emit('event', { type: 'bus_message_status', messageId, status: 'failed' } as any);
672
+ this.emitAgentsChanged();
673
+ }
674
+ },
675
+ onMemoryUpdate: (stepResults) => {
676
+ try {
677
+ const observations = stepResults.flatMap((r, i) =>
678
+ parseStepToObservations(config.steps[i]?.label || `Step ${i}`, r, entry.state.artifacts)
679
+ );
680
+ for (const obs of observations) addObservation(this.workspaceId, agentId, config.label, config.role, obs);
681
+ const stepLabels = config.steps.map(s => s.label);
682
+ const summary = buildSessionSummary(stepLabels, stepResults, entry.state.artifacts);
683
+ addSessionSummary(this.workspaceId, agentId, summary);
684
+ } catch {}
685
+ },
686
+ });
687
+
688
+ entry.worker = worker;
689
+
690
+ // Track trigger message so smith can mark it done/failed on completion
691
+ if (triggerMessageId) {
692
+ worker.setProcessingMessage(triggerMessageId);
693
+ }
694
+
695
+ // Forward events (same as runAgent)
696
+ worker.on('event', (event: WorkerEvent) => {
697
+ if (event.type === 'task_status') {
698
+ entry.state.taskStatus = event.taskStatus;
699
+ entry.state.error = event.error;
700
+ if (event.taskStatus === 'running') entry.state.startedAt = Date.now();
701
+ const workerState = worker.getState();
702
+ entry.state.daemonIteration = workerState.daemonIteration;
703
+ }
704
+ if (event.type === 'smith_status') {
705
+ entry.state.smithStatus = event.smithStatus;
706
+ entry.state.mode = event.mode;
707
+ }
708
+ if (event.type === 'log') {
709
+ appendAgentLog(this.workspaceId, agentId, event.entry).catch(() => {});
710
+ }
711
+ this.emit('event', event);
712
+ if (event.type === 'task_status' || event.type === 'smith_status') {
713
+ this.updateAgentLiveness(agentId);
714
+ }
715
+ if (event.type === 'step' && event.stepIndex >= 0) {
716
+ const step = config.steps[event.stepIndex];
717
+ if (step) this.bus.notifyStepComplete(agentId, step.label);
718
+ }
719
+ if (event.type === 'done') {
720
+ this.handleAgentDone(agentId, entry, event.summary);
721
+ }
722
+ if (event.type === 'error') {
723
+ this.bus.notifyError(agentId, event.error);
724
+ this.emitWorkspaceStatus();
725
+ }
726
+ });
727
+
728
+ // Inject pending messages
729
+ const pendingMsgs = this.bus.getPendingMessagesFor(agentId)
730
+ .filter(m => m.from !== agentId);
731
+ for (const msg of pendingMsgs) {
732
+ const fromLabel = this.agents.get(msg.from)?.config.label || msg.from;
733
+ worker.injectMessage({
734
+ type: 'system', subtype: 'bus_message',
735
+ content: `[From ${fromLabel}]: ${msg.payload.content || msg.payload.action}`,
736
+ timestamp: new Date(msg.timestamp).toISOString(),
737
+ });
738
+ msg.status = 'done';
739
+ }
740
+
741
+ this.emitWorkspaceStatus();
742
+
743
+ // Execute in daemon mode (non-blocking)
744
+ worker.executeDaemon(0, upstreamContext).catch(err => {
745
+ if (entry.state.taskStatus !== 'failed') {
746
+ entry.state.taskStatus = 'failed';
747
+ entry.state.error = err?.message || String(err);
748
+ this.emit('event', { type: 'error', agentId, error: entry.state.error! } satisfies WorkerEvent);
749
+ }
750
+ });
751
+ }
752
+
753
+ /** Start all agents in daemon mode — orchestrator manages each smith's lifecycle */
754
+ async startDaemon(): Promise<void> {
755
+ if (this.daemonActive) return;
756
+ this.daemonActive = true;
757
+ console.log(`[workspace] Starting daemon mode...`);
758
+
759
+ // Clean up stale state from previous run
760
+ this.bus.markAllRunningAsFailed();
761
+
762
+ // Install forge skills globally (once per daemon start)
763
+ try {
764
+ installForgeSkills(this.projectPath, this.workspaceId, '', Number(process.env.PORT) || 8403);
765
+ } catch {}
766
+
767
+ // Start each smith one by one, verify each starts correctly
768
+ let started = 0;
769
+ let failed = 0;
770
+ for (const [id, entry] of this.agents) {
771
+ if (entry.config.type === 'input') continue;
772
+
773
+ // Kill any stale worker from previous run
774
+ if (entry.worker) {
775
+ entry.worker.stop();
776
+ entry.worker = null;
777
+ }
778
+
779
+ // Stop any existing message loop
780
+ this.stopMessageLoop(id);
781
+
782
+ try {
783
+ // 1. Start daemon listening loop (creates worker)
784
+ this.enterDaemonListening(id);
785
+
786
+ // 2. Verify worker was created
787
+ if (!entry.worker) {
788
+ throw new Error('Worker not created');
789
+ }
790
+
791
+ // 3. Set smith status to active
792
+ entry.state.smithStatus = 'active';
793
+ entry.state.mode = 'auto';
794
+ entry.state.error = undefined;
795
+
796
+ // 4. Start message consumption loop
797
+ this.startMessageLoop(id);
798
+
799
+ // 5. Update liveness for bus routing
800
+ this.updateAgentLiveness(id);
801
+
802
+ // 6. Notify frontend
803
+ this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'active', mode: 'auto' } satisfies WorkerEvent);
804
+
805
+ started++;
806
+ console.log(`[daemon] ✓ ${entry.config.label}: active (task=${entry.state.taskStatus})`);
807
+ } catch (err: any) {
808
+ entry.state.smithStatus = 'down';
809
+ entry.state.error = `Failed to start: ${err.message}`;
810
+ this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'down', mode: 'auto' } satisfies WorkerEvent);
811
+ failed++;
812
+ console.error(`[daemon] ✗ ${entry.config.label}: failed — ${err.message}`);
813
+ }
814
+ }
815
+
816
+ // Start watch loops for agents with watch config
817
+ this.watchManager.start();
818
+
819
+ console.log(`[workspace] Daemon started: ${started} smiths active, ${failed} failed`);
820
+ this.emitAgentsChanged();
821
+ }
822
+
823
+ /** Get agents that can start in daemon mode (idle, done — with deps met) */
824
+ private getDaemonReadyAgents(): string[] {
825
+ const ready: string[] = [];
826
+ for (const [id, entry] of this.agents) {
827
+ if (entry.config.type === 'input') continue;
828
+ if (entry.state.taskStatus === 'running' || entry.state.smithStatus === 'active') {
829
+ console.log(`[daemon] ${entry.config.label}: already smithStatus=${entry.state.smithStatus} taskStatus=${entry.state.taskStatus}`);
830
+ continue;
831
+ }
832
+ const allDepsDone = entry.config.dependsOn.every(depId => {
833
+ const dep = this.agents.get(depId);
834
+ return dep && (dep.state.taskStatus === 'done');
835
+ });
836
+ if (allDepsDone) {
837
+ console.log(`[daemon] ${entry.config.label}: ready (taskStatus=${entry.state.taskStatus})`);
838
+ ready.push(id);
839
+ } else {
840
+ const unmet = entry.config.dependsOn.filter(d => {
841
+ const dep = this.agents.get(d);
842
+ return !dep || (dep.state.taskStatus !== 'done');
843
+ }).map(d => this.agents.get(d)?.config.label || d);
844
+ console.log(`[daemon] ${entry.config.label}: not ready — deps unmet: ${unmet.join(', ')} (taskStatus=${entry.state.taskStatus})`);
845
+ }
846
+ }
847
+ return ready;
848
+ }
849
+
850
+ /** Put a done agent into daemon listening mode without re-running steps */
851
+ private enterDaemonListening(agentId: string): void {
852
+ const entry = this.agents.get(agentId);
853
+ if (!entry) return;
854
+
855
+ const { config } = entry;
856
+
857
+ // TODO: per-smith install hook for future use (commands, custom skills, etc.)
858
+ // Skills are installed globally in startDaemon, not per-smith
859
+
860
+ const backend = this.createBackend(config, agentId);
861
+ const peerAgentIds = Array.from(this.agents.keys()).filter(id => id !== agentId);
862
+
863
+ const worker = new AgentWorker({
864
+ config, backend,
865
+ projectPath: this.projectPath, workspaceId: this.workspaceId,
866
+ peerAgentIds,
867
+ initialTaskStatus: entry.state.taskStatus, // preserve current task status
868
+ onBusSend: (to, content) => {
869
+ this.bus.send(agentId, to, 'notify', { action: 'agent_message', content });
870
+ },
871
+ onBusRequest: async (to, question) => {
872
+ const response = await this.bus.request(agentId, to, { action: 'question', content: question });
873
+ return response.payload.content || '(no response)';
874
+ },
875
+ onMessageDone: (messageId) => {
876
+ const busMsg = this.bus.getLog().find(m => m.id === messageId);
877
+ if (busMsg) {
878
+ busMsg.status = 'done';
879
+ this.emit('event', { type: 'bus_message_status', messageId, status: 'done' } as any);
880
+ this.emitAgentsChanged();
881
+ }
882
+ },
883
+ onMessageFailed: (messageId) => {
884
+ const busMsg = this.bus.getLog().find(m => m.id === messageId);
885
+ if (busMsg) {
886
+ busMsg.status = 'failed';
887
+ this.emit('event', { type: 'bus_message_status', messageId, status: 'failed' } as any);
888
+ this.emitAgentsChanged();
889
+ }
890
+ },
891
+ });
892
+
893
+ entry.worker = worker;
894
+
895
+ // Forward events (same handler as runAgentDaemon)
896
+ worker.on('event', (event: WorkerEvent) => {
897
+ if (event.type === 'task_status') {
898
+ entry.state.taskStatus = event.taskStatus;
899
+ entry.state.error = event.error;
900
+ const workerState = worker.getState();
901
+ entry.state.daemonIteration = workerState.daemonIteration;
902
+ }
903
+ if (event.type === 'smith_status') {
904
+ entry.state.smithStatus = event.smithStatus;
905
+ entry.state.mode = event.mode;
906
+ }
907
+ if (event.type === 'log') {
908
+ appendAgentLog(this.workspaceId, agentId, event.entry).catch(() => {});
909
+ }
910
+ this.emit('event', event);
911
+ if (event.type === 'task_status' || event.type === 'smith_status') {
912
+ this.updateAgentLiveness(agentId);
913
+ }
914
+ if (event.type === 'done') {
915
+ this.handleAgentDone(agentId, entry, event.summary);
916
+ }
917
+ if (event.type === 'error') {
918
+ this.bus.notifyError(agentId, event.error);
919
+ }
920
+ });
921
+
922
+ // Message loop (startMessageLoop) handles auto-consumption of pending messages
923
+
924
+ console.log(`[workspace] Agent "${config.label}" entering daemon listening (task=${entry.state.taskStatus})`);
925
+
926
+ // executeDaemon with skipSteps=true → goes directly to listening loop
927
+ worker.executeDaemon(0, undefined, true).catch(err => {
928
+ console.error(`[workspace] enterDaemonListening error for ${config.label}:`, err.message);
929
+ });
930
+ }
931
+
932
+ /** Stop all agents (exit daemon mode) */
933
+ /** Stop all agents — orchestrator shuts down each smith */
934
+ stopDaemon(): void {
935
+ this.daemonActive = false;
936
+ console.log('[workspace] Stopping daemon...');
937
+
938
+ for (const [id, entry] of this.agents) {
939
+ if (entry.config.type === 'input') continue;
940
+
941
+ // 1. Stop message loop
942
+ this.stopMessageLoop(id);
943
+
944
+ // 2. Stop worker
945
+ if (entry.worker) {
946
+ entry.worker.stop();
947
+ entry.worker = null;
948
+ }
949
+
950
+ // 3. Set smith down
951
+ entry.state.smithStatus = 'down';
952
+ entry.state.error = undefined;
953
+ this.updateAgentLiveness(id);
954
+ this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'down', mode: entry.state.mode } satisfies WorkerEvent);
955
+
956
+ console.log(`[daemon] ■ ${entry.config.label}: stopped`);
957
+ }
958
+
959
+ // Mark running messages as failed
960
+ this.bus.markAllRunningAsFailed();
961
+ this.emitAgentsChanged();
962
+ this.watchManager.stop();
963
+ console.log('[workspace] Daemon stopped');
964
+ }
965
+
966
+ /** Handle watch alert based on agent's configured action */
967
+ private handleWatchAlert(agentId: string, summary: string): void {
968
+ const entry = this.agents.get(agentId);
969
+ if (!entry) return;
970
+ const action = entry.config.watch?.action || 'log';
971
+
972
+ if (action === 'log') {
973
+ // Already logged by watch-manager, nothing more to do
974
+ return;
975
+ }
976
+
977
+ if (action === 'analyze') {
978
+ // Auto-wake agent to analyze changes (skip if busy/manual)
979
+ if (entry.state.mode === 'manual' || entry.state.taskStatus === 'running') return;
980
+ if (!entry.worker?.isListening()) return;
981
+
982
+ const prompt = entry.config.watch?.prompt || 'Analyze the following changes and produce a report:';
983
+ const logEntry = {
984
+ type: 'system' as const,
985
+ subtype: 'watch_trigger',
986
+ content: `[Watch] ${prompt}\n\n${summary}`,
987
+ timestamp: new Date().toISOString(),
988
+ };
989
+ entry.worker.wake({ type: 'bus_message', messages: [logEntry] });
990
+ console.log(`[watch] ${entry.config.label}: auto-analyzing detected changes`);
991
+ return;
992
+ }
993
+
994
+ if (action === 'approve') {
995
+ // Create pending approval — user must click to trigger analysis
996
+ this.bus.send('_watch', agentId, 'notify', {
997
+ action: 'watch_changes',
998
+ content: `Watch detected changes (awaiting approval):\n${summary}`,
999
+ });
1000
+ this.approvalQueue.add(agentId);
1001
+ this.emit('event', { type: 'approval_required', agentId, upstreamId: '_watch' } satisfies OrchestratorEvent);
1002
+ console.log(`[watch] ${entry.config.label}: changes detected, awaiting approval`);
1003
+ }
1004
+ }
1005
+
1006
+ /** Check if daemon mode is active */
1007
+ isDaemonActive(): boolean {
1008
+ return this.daemonActive;
1009
+ }
1010
+
1011
+ /** Pause a running agent */
1012
+ pauseAgent(agentId: string): void {
1013
+ const entry = this.agents.get(agentId);
1014
+ entry?.worker?.pause();
1015
+ }
1016
+
1017
+ /** Resume a paused agent */
1018
+ resumeAgent(agentId: string): void {
1019
+ const entry = this.agents.get(agentId);
1020
+ entry?.worker?.resume();
1021
+ }
1022
+
1023
+ /** Stop a running agent */
1024
+ stopAgent(agentId: string): void {
1025
+ const entry = this.agents.get(agentId);
1026
+ entry?.worker?.stop();
1027
+ }
1028
+
1029
+ /** Retry a failed agent from its last checkpoint */
1030
+ async retryAgent(agentId: string): Promise<void> {
1031
+ const entry = this.agents.get(agentId);
1032
+ if (!entry) throw new Error(`Agent "${agentId}" not found`);
1033
+ if (entry.state.taskStatus === 'running') {
1034
+ throw new Error(`Agent "${entry.config.label}" is already running`);
1035
+ }
1036
+ if (entry.state.taskStatus !== 'failed') {
1037
+ throw new Error(`Agent "${entry.config.label}" is ${entry.state.taskStatus}, not failed`);
1038
+ }
1039
+ // force=true: skip dep taskStatus check, only require upstream smith active
1040
+ await this.runAgent(agentId, undefined, true);
1041
+ }
1042
+
1043
+ /** Send a message to a running agent (human intervention) */
1044
+ /** Send a message to a smith — becomes a pending inbox message, processed by message loop */
1045
+ sendMessageToAgent(agentId: string, content: string): void {
1046
+ const entry = this.agents.get(agentId);
1047
+ if (!entry) return;
1048
+
1049
+ // Send via bus → becomes pending inbox message → message loop will consume it
1050
+ this.bus.send('user', agentId, 'notify', {
1051
+ action: 'user_message',
1052
+ content,
1053
+ });
1054
+ }
1055
+
1056
+ /** Approve a waiting agent to start execution */
1057
+ approveAgent(agentId: string): void {
1058
+ if (!this.approvalQueue.has(agentId)) return;
1059
+ this.approvalQueue.delete(agentId);
1060
+ this.runAgent(agentId).catch(() => {});
1061
+ }
1062
+
1063
+ /** Save tmux session name for an agent (for reattach after refresh) */
1064
+ setTmuxSession(agentId: string, sessionName: string): void {
1065
+ const entry = this.agents.get(agentId);
1066
+ if (!entry) return;
1067
+ entry.state.tmuxSession = sessionName;
1068
+ this.saveNow();
1069
+ this.emitAgentsChanged();
1070
+ }
1071
+
1072
+ /** Switch an agent to manual mode (user operates in terminal) */
1073
+ setManualMode(agentId: string): void {
1074
+ const entry = this.agents.get(agentId);
1075
+ if (!entry) return;
1076
+ entry.state.mode = 'manual';
1077
+ this.emit('event', { type: 'smith_status', agentId, smithStatus: entry.state.smithStatus, mode: 'manual' } satisfies WorkerEvent);
1078
+ this.emitAgentsChanged();
1079
+ this.saveNow();
1080
+ console.log(`[workspace] Agent "${entry.config.label}" switched to manual mode`);
1081
+ }
1082
+
1083
+ /** Re-enter daemon mode for an agent after manual terminal is closed */
1084
+ restartAgentDaemon(agentId: string): void {
1085
+ if (!this.daemonActive) return;
1086
+ const entry = this.agents.get(agentId);
1087
+ if (!entry || entry.config.type === 'input') return;
1088
+
1089
+ entry.state.mode = 'auto';
1090
+ entry.state.error = undefined;
1091
+
1092
+ // Recreate worker if needed (resetAgent kills worker)
1093
+ if (!entry.worker) {
1094
+ this.enterDaemonListening(agentId);
1095
+ this.startMessageLoop(agentId);
1096
+ }
1097
+
1098
+ entry.state.smithStatus = 'active';
1099
+ this.emit('event', { type: 'smith_status', agentId, smithStatus: 'active', mode: 'auto' } satisfies WorkerEvent);
1100
+ this.emitAgentsChanged();
1101
+ }
1102
+
1103
+ /** Complete a manual agent — called by forge-done skill from terminal */
1104
+ completeManualAgent(agentId: string, changedFiles: string[]): void {
1105
+ const entry = this.agents.get(agentId);
1106
+ if (!entry) return;
1107
+
1108
+ entry.state.taskStatus = 'done';
1109
+ entry.state.mode = 'auto'; // clear manual mode
1110
+ entry.state.completedAt = Date.now();
1111
+ entry.state.artifacts = changedFiles.map(f => ({ type: 'file' as const, path: f }));
1112
+
1113
+ console.log(`[workspace] Manual agent "${entry.config.label}" marked done. ${changedFiles.length} files changed.`);
1114
+
1115
+ this.emit('event', { type: 'task_status', agentId, taskStatus: 'done' } satisfies WorkerEvent);
1116
+ this.emit('event', { type: 'done', agentId, summary: `Manual: ${changedFiles.length} files changed` } satisfies WorkerEvent);
1117
+ this.emitAgentsChanged();
1118
+
1119
+ // Notify ALL agents that depend on this one (not just direct downstream)
1120
+ this.bus.notifyTaskComplete(agentId, changedFiles, `Manual work: ${changedFiles.length} files`);
1121
+
1122
+ // Send individual bus messages to all downstream agents so they know
1123
+ for (const [id, other] of this.agents) {
1124
+ if (id === agentId || other.config.type === 'input') continue;
1125
+ if (other.config.dependsOn.includes(agentId)) {
1126
+ this.bus.send(agentId, id, 'notify', {
1127
+ action: 'update_notify',
1128
+ content: `${entry.config.label} completed manual work: ${changedFiles.length} files changed`,
1129
+ files: changedFiles,
1130
+ });
1131
+ }
1132
+ }
1133
+
1134
+ if (this.daemonActive) {
1135
+ this.broadcastCompletion(agentId);
1136
+ }
1137
+ this.notifyDownstreamForRevalidation(agentId, changedFiles);
1138
+ this.emitWorkspaceStatus();
1139
+ this.checkWorkspaceComplete();
1140
+ this.saveNow();
1141
+ }
1142
+
1143
+ /** Reject an approval (set agent back to idle) */
1144
+ rejectApproval(agentId: string): void {
1145
+ this.approvalQueue.delete(agentId);
1146
+ const entry = this.agents.get(agentId);
1147
+ if (entry) {
1148
+ entry.state.taskStatus = 'idle';
1149
+ this.emit('event', { type: 'task_status', agentId, taskStatus: 'idle' } satisfies WorkerEvent);
1150
+ }
1151
+ }
1152
+
1153
+ // ─── Bus Access ────────────────────────────────────────
1154
+
1155
+ getBus(): AgentBus {
1156
+ return this.bus;
1157
+ }
1158
+
1159
+ getBusLog(): readonly BusMessage[] {
1160
+ return this.bus.getLog();
1161
+ }
1162
+
1163
+ // ─── State Snapshot (for persistence) ──────────────────
1164
+
1165
+ /** Get full workspace state for auto-save */
1166
+ getFullState(): WorkspaceState {
1167
+ return {
1168
+ id: this.workspaceId,
1169
+ projectPath: this.projectPath,
1170
+ projectName: this.projectName,
1171
+ agents: Array.from(this.agents.values()).map(e => e.config),
1172
+ agentStates: this.getAllAgentStates(),
1173
+ nodePositions: {},
1174
+ busLog: [...this.bus.getLog()],
1175
+ busOutbox: this.bus.getAllOutbox(),
1176
+ createdAt: this.createdAt,
1177
+ updatedAt: Date.now(),
1178
+ };
1179
+ }
1180
+
1181
+ getSnapshot(): {
1182
+ agents: WorkspaceAgentConfig[];
1183
+ agentStates: Record<string, AgentState>;
1184
+ busLog: BusMessage[];
1185
+ daemonActive: boolean;
1186
+ } {
1187
+ return {
1188
+ agents: Array.from(this.agents.values()).map(e => e.config),
1189
+ agentStates: this.getAllAgentStates(),
1190
+ busLog: [...this.bus.getLog()],
1191
+ daemonActive: this.daemonActive,
1192
+ };
1193
+ }
1194
+
1195
+ /** Restore from persisted state */
1196
+ loadSnapshot(data: {
1197
+ agents: WorkspaceAgentConfig[];
1198
+ agentStates: Record<string, AgentState>;
1199
+ busLog: BusMessage[];
1200
+ busOutbox?: Record<string, BusMessage[]>;
1201
+ }): void {
1202
+ this.agents.clear();
1203
+ this.daemonActive = false; // Reset daemon — user must click Start Daemon again after restart
1204
+ for (const config of data.agents) {
1205
+ const state = data.agentStates[config.id] || { smithStatus: 'down' as const, mode: 'auto' as const, taskStatus: 'idle' as const, history: [], artifacts: [] };
1206
+
1207
+ // Migrate old format if loading from pre-two-layer state
1208
+ if ('status' in state && !('smithStatus' in state)) {
1209
+ const oldStatus = (state as any).status;
1210
+ (state as any).smithStatus = 'down';
1211
+ (state as any).mode = (state as any).runMode || 'auto';
1212
+ (state as any).taskStatus = (oldStatus === 'running' || oldStatus === 'listening') ? 'idle' :
1213
+ (oldStatus === 'interrupted') ? 'idle' :
1214
+ (oldStatus === 'waiting_approval') ? 'idle' :
1215
+ (oldStatus === 'paused') ? 'idle' :
1216
+ oldStatus;
1217
+ delete (state as any).status;
1218
+ delete (state as any).runMode;
1219
+ delete (state as any).daemonMode;
1220
+ }
1221
+
1222
+ // Mark running agents as failed (interrupted by restart)
1223
+ if (state.taskStatus === 'running') {
1224
+ state.taskStatus = 'failed';
1225
+ state.error = 'Interrupted by restart';
1226
+ }
1227
+ // Smith is down after restart (no daemon loop running)
1228
+ state.smithStatus = 'down';
1229
+ state.daemonIteration = undefined;
1230
+ this.agents.set(config.id, { config, worker: null, state });
1231
+ }
1232
+ this.bus.loadLog(data.busLog);
1233
+ if (data.busOutbox) {
1234
+ this.bus.loadOutbox(data.busOutbox);
1235
+ }
1236
+
1237
+ // Mark all pending messages as failed (they were lost on shutdown)
1238
+ // Users can retry agents manually if needed
1239
+ // Running messages from before crash → failed (pending stays pending for retry)
1240
+ this.bus.markAllRunningAsFailed();
1241
+
1242
+ // Initialize liveness for all loaded agents so bus delivery works
1243
+ for (const [agentId] of this.agents) {
1244
+ this.updateAgentLiveness(agentId);
1245
+ }
1246
+ }
1247
+
1248
+ /** Stop all agents, save final state, and clean up */
1249
+ shutdown(): void {
1250
+ this.stopAllMessageLoops();
1251
+ stopAutoSave(this.workspaceId);
1252
+ // Sync save — must complete before process exits
1253
+ try { saveWorkspaceSync(this.getFullState()); } catch (err) {
1254
+ console.error(`[workspace] Failed to save on shutdown:`, err);
1255
+ }
1256
+ for (const [, entry] of this.agents) {
1257
+ entry.worker?.stop();
1258
+ }
1259
+ this.bus.clear();
1260
+ }
1261
+
1262
+ // ─── Private ───────────────────────────────────────────
1263
+
1264
+ private createBackend(config: WorkspaceAgentConfig, agentId?: string) {
1265
+ switch (config.backend) {
1266
+ case 'api':
1267
+ return new ApiBackend();
1268
+ case 'cli':
1269
+ default: {
1270
+ // Resume existing claude session if available
1271
+ const existingSessionId = agentId ? this.agents.get(agentId)?.state.cliSessionId : undefined;
1272
+ const backend = new CliBackend(existingSessionId);
1273
+ // Persist new sessionId back to agent state
1274
+ if (agentId) {
1275
+ backend.onSessionId = (id) => {
1276
+ const entry = this.agents.get(agentId);
1277
+ if (entry) entry.state.cliSessionId = id;
1278
+ };
1279
+ }
1280
+ return backend;
1281
+ }
1282
+ }
1283
+ }
1284
+
1285
+ /** Build context string from upstream agents' outputs */
1286
+ private buildUpstreamContext(config: WorkspaceAgentConfig): string | undefined {
1287
+ if (config.dependsOn.length === 0) return undefined;
1288
+
1289
+ const sections: string[] = [];
1290
+
1291
+ for (const depId of config.dependsOn) {
1292
+ const dep = this.agents.get(depId);
1293
+ if (!dep || (dep.state.taskStatus !== 'done')) continue;
1294
+
1295
+ const label = dep.config.label;
1296
+
1297
+ // Input nodes: only send latest entry (not full history)
1298
+ if (dep.config.type === 'input') {
1299
+ const entries = dep.config.entries;
1300
+ if (entries && entries.length > 0) {
1301
+ const latest = entries[entries.length - 1];
1302
+ sections.push(`### ${label} (latest input):\n${latest.content}`);
1303
+ } else if (dep.config.content) {
1304
+ // Legacy fallback
1305
+ sections.push(`### ${label}:\n${dep.config.content}`);
1306
+ }
1307
+ continue;
1308
+ }
1309
+
1310
+ const artifacts = dep.state.artifacts.filter(a => a.path);
1311
+
1312
+ if (artifacts.length === 0) {
1313
+ const lastResult = [...dep.state.history].reverse().find(h => h.type === 'result');
1314
+ if (lastResult) {
1315
+ sections.push(`### From ${label}:\n${lastResult.content}`);
1316
+ }
1317
+ continue;
1318
+ }
1319
+
1320
+ // Read file artifacts
1321
+ for (const artifact of artifacts) {
1322
+ if (!artifact.path) continue;
1323
+ const fullPath = resolve(this.projectPath, artifact.path);
1324
+ try {
1325
+ if (existsSync(fullPath)) {
1326
+ const content = readFileSync(fullPath, 'utf-8');
1327
+ const truncated = content.length > 10000
1328
+ ? content.slice(0, 10000) + '\n... (truncated)'
1329
+ : content;
1330
+ sections.push(`### From ${label} — ${artifact.path}:\n${truncated}`);
1331
+ }
1332
+ } catch {
1333
+ sections.push(`### From ${label} — ${artifact.path}: (could not read file)`);
1334
+ }
1335
+ }
1336
+ }
1337
+
1338
+ if (sections.length === 0) return undefined;
1339
+
1340
+ let combined = sections.join('\n\n---\n\n');
1341
+
1342
+ // Cap total upstream context to ~50K chars (~12K tokens) to prevent token explosion
1343
+ const MAX_UPSTREAM_CHARS = 50000;
1344
+ if (combined.length > MAX_UPSTREAM_CHARS) {
1345
+ combined = combined.slice(0, MAX_UPSTREAM_CHARS) + '\n\n... (upstream context truncated, ' + combined.length + ' chars total)';
1346
+ }
1347
+
1348
+ return combined;
1349
+ }
1350
+
1351
+ /** After an agent completes, check if any downstream agents should be triggered */
1352
+ /**
1353
+ * Broadcast completion to all downstream agents via bus messages.
1354
+ * Replaces direct triggerDownstream — all execution is now message-driven.
1355
+ * If no artifacts/changes, no message is sent → downstream stays idle.
1356
+ */
1357
+ /** Build causedBy from the message currently being processed */
1358
+ private buildCausedBy(agentId: string, entry: { worker: AgentWorker | null }): BusMessage['causedBy'] | undefined {
1359
+ const msgId = entry.worker?.getCurrentMessageId?.();
1360
+ if (!msgId) return undefined;
1361
+ const msg = this.bus.getLog().find(m => m.id === msgId);
1362
+ if (!msg) return undefined;
1363
+ return { messageId: msg.id, from: msg.from, to: msg.to };
1364
+ }
1365
+
1366
+ /** Unified done handler: broadcast downstream or reply to sender based on message source */
1367
+ private handleAgentDone(agentId: string, entry: { config: WorkspaceAgentConfig; worker: AgentWorker | null; state: AgentState }, summary?: string): void {
1368
+ const files = entry.state.artifacts.filter(a => a.path).map(a => a.path!);
1369
+ console.log(`[workspace] Agent "${entry.config.label}" (${agentId}) completed. Artifacts: ${files.length}.`);
1370
+
1371
+ this.bus.notifyTaskComplete(agentId, files, summary);
1372
+
1373
+ // Check what message triggered this execution
1374
+ const causedBy = this.buildCausedBy(agentId, entry);
1375
+ const processedMsg = causedBy ? this.bus.getLog().find(m => m.id === causedBy.messageId) : null;
1376
+
1377
+ if (processedMsg && !this.isUpstream(processedMsg.from, agentId)) {
1378
+ // Processed a message from downstream — no extra reply needed.
1379
+ // The original message is already marked done via markMessageDone().
1380
+ // Sender can check their outbox message status. Only broadcast to downstream.
1381
+ const senderLabel = this.agents.get(processedMsg.from)?.config.label || processedMsg.from;
1382
+ console.log(`[bus] ${entry.config.label}: processed request from ${senderLabel} — marked done, no reply`);
1383
+ // Still broadcast to own downstream (e.g., QA processed Engineer's msg → notify Reviewer)
1384
+ this.broadcastCompletion(agentId, causedBy);
1385
+ } else {
1386
+ // Normal upstream completion or initial execution → broadcast to all downstream
1387
+ this.broadcastCompletion(agentId, causedBy);
1388
+ // notifyDownstreamForRevalidation removed — causes duplicate messages and re-execution loops
1389
+ // Downstream agents that already completed will be handled in future iteration mode
1390
+ }
1391
+
1392
+ this.emitWorkspaceStatus();
1393
+ this.checkWorkspaceComplete?.();
1394
+ }
1395
+
1396
+ private broadcastCompletion(completedAgentId: string, causedBy?: BusMessage['causedBy']): void {
1397
+ const completed = this.agents.get(completedAgentId);
1398
+ if (!completed) return;
1399
+
1400
+ const completedLabel = completed.config.label;
1401
+ const files = completed.state.artifacts.filter(a => a.path).map(a => a.path!);
1402
+ const summary = completed.state.history
1403
+ .filter(h => h.subtype === 'final_summary' || h.subtype === 'step_summary')
1404
+ .slice(-1)[0]?.content || '';
1405
+
1406
+ const content = files.length > 0
1407
+ ? `${completedLabel} completed: ${files.length} files changed. ${summary.slice(0, 200)}`
1408
+ : `${completedLabel} completed. ${summary.slice(0, 300) || 'Check upstream outputs for updates.'}`;
1409
+
1410
+ // Find all downstream agents that depend on this one
1411
+ let sent = 0;
1412
+ for (const [id, entry] of this.agents) {
1413
+ if (id === completedAgentId) continue;
1414
+ if (entry.config.type === 'input') continue;
1415
+ if (!entry.config.dependsOn.includes(completedAgentId)) continue;
1416
+
1417
+ this.bus.send(completedAgentId, id, 'notify', {
1418
+ action: 'upstream_complete',
1419
+ content,
1420
+ files,
1421
+ }, { category: 'notification', causedBy });
1422
+ sent++;
1423
+ console.log(`[bus] ${completedLabel} → ${entry.config.label}: upstream_complete (${files.length} files)`);
1424
+ }
1425
+
1426
+ if (sent === 0) {
1427
+ console.log(`[bus] ${completedLabel} completed — no downstream agents`);
1428
+ }
1429
+ }
1430
+
1431
+ // ─── Agent liveness ─────────────────────────────────────
1432
+
1433
+ private updateAgentLiveness(agentId: string): void {
1434
+ const entry = this.agents.get(agentId);
1435
+ if (!entry) {
1436
+ this.bus.setAgentStatus(agentId, 'down');
1437
+ return;
1438
+ }
1439
+ if (entry.state.taskStatus === 'running') this.bus.setAgentStatus(agentId, 'busy');
1440
+ else if (entry.state.smithStatus === 'active') this.bus.setAgentStatus(agentId, 'alive');
1441
+ else this.bus.setAgentStatus(agentId, 'down');
1442
+ }
1443
+
1444
+ // ─── Bus message handling ──────────────────────────────
1445
+
1446
+ private handleBusMessage(msg: BusMessage): void {
1447
+ // Dedup
1448
+ if (this.bus.isDuplicate(msg.id)) return;
1449
+
1450
+ // Emit to UI after dedup (no duplicates, no ACKs)
1451
+ this.emit('event', { type: 'bus_message', message: msg } satisfies OrchestratorEvent);
1452
+
1453
+ // Route to target
1454
+ this.routeMessageToAgent(msg.to, msg);
1455
+ this.checkWorkspaceComplete();
1456
+ }
1457
+
1458
+ private routeMessageToAgent(targetId: string, msg: BusMessage): void {
1459
+ const target = this.agents.get(targetId);
1460
+ if (!target) return;
1461
+
1462
+ const fromLabel = this.agents.get(msg.from)?.config.label || msg.from;
1463
+ const action = msg.payload.action;
1464
+ const content = msg.payload.content || '';
1465
+
1466
+ console.log(`[bus] ${fromLabel} → ${target.config.label}: ${action} "${content.slice(0, 80)}"`);
1467
+
1468
+ const logEntry = {
1469
+ type: 'system' as const,
1470
+ subtype: 'bus_message',
1471
+ content: `[From ${fromLabel}]: ${content || action}`,
1472
+ timestamp: new Date(msg.timestamp).toISOString(),
1473
+ };
1474
+
1475
+ // Helper: mark message as processed when actually consumed
1476
+ const ackAndDeliver = () => {
1477
+ msg.status = 'done';
1478
+ };
1479
+
1480
+ // ── Input node: request user input ──
1481
+ if (target.config.type === 'input') {
1482
+ if (action === 'info_request' || action === 'question') {
1483
+ ackAndDeliver();
1484
+ this.emit('event', {
1485
+ type: 'user_input_request',
1486
+ agentId: targetId,
1487
+ fromAgent: msg.from,
1488
+ question: content,
1489
+ } satisfies OrchestratorEvent);
1490
+ }
1491
+ return;
1492
+ }
1493
+
1494
+ // ── Store message in agent history ──
1495
+ target.state.history.push(logEntry);
1496
+
1497
+ // ── Manual mode → store in inbox (user handles in terminal) ──
1498
+ if (target.state.mode === 'manual') {
1499
+ ackAndDeliver();
1500
+ console.log(`[bus] ${target.config.label}: received ${action} in manual mode — stored in inbox`);
1501
+ return;
1502
+ }
1503
+
1504
+ // ── Message stays pending — message loop will consume it when smith is ready ──
1505
+ console.log(`[bus] ${target.config.label}: received ${action} — queued in inbox (${msg.status})`);
1506
+ }
1507
+
1508
+ // ─── Message consumption loop ─────────────────────────
1509
+ private messageLoopTimers = new Map<string, NodeJS.Timeout>();
1510
+
1511
+ /** Start the message consumption loop for a smith */
1512
+ private startMessageLoop(agentId: string): void {
1513
+ if (this.messageLoopTimers.has(agentId)) return; // already running
1514
+
1515
+ let debugTick = 0;
1516
+ const tick = () => {
1517
+ const entry = this.agents.get(agentId);
1518
+ if (!entry || entry.state.smithStatus !== 'active') {
1519
+ this.stopMessageLoop(agentId);
1520
+ return;
1521
+ }
1522
+
1523
+ // Skip if manual (user in terminal) or running (already busy)
1524
+ if (entry.state.mode === 'manual') return;
1525
+ if (entry.state.taskStatus === 'running') return;
1526
+
1527
+ // Skip if no worker ready
1528
+ if (!entry.worker?.isListening()) {
1529
+ if (++debugTick % 15 === 0) {
1530
+ console.log(`[inbox] ${entry.config.label}: not listening (worker=${!!entry.worker} smith=${entry.state.smithStatus} task=${entry.state.taskStatus})`);
1531
+ }
1532
+ return;
1533
+ }
1534
+
1535
+ // Skip if worker is currently processing a message
1536
+ if (entry.worker?.getCurrentMessageId()) {
1537
+ const currentMsg = this.bus.getLog().find(m => m.id === entry.worker!.getCurrentMessageId());
1538
+ if (currentMsg && currentMsg.status === 'running') return;
1539
+ }
1540
+
1541
+ // Find next pending message, applying causedBy rules
1542
+ const allPending = this.bus.getPendingMessagesFor(agentId).filter(m => m.from !== agentId && m.type !== 'ack');
1543
+ const pending = allPending.filter(m => {
1544
+ // Tickets: accepted but check retry limit
1545
+ if (m.category === 'ticket') {
1546
+ const maxRetries = m.maxRetries ?? 3;
1547
+ if ((m.ticketRetries || 0) >= maxRetries) {
1548
+ console.log(`[inbox] ${entry.config.label}: ticket ${m.id.slice(0, 8)} exceeded max retries (${maxRetries}), marking failed`);
1549
+ m.status = 'failed' as any;
1550
+ m.ticketStatus = 'closed';
1551
+ this.emit('event', { type: 'bus_message_status', messageId: m.id, status: 'failed' } as any);
1552
+ return false;
1553
+ }
1554
+ return true;
1555
+ }
1556
+
1557
+ // Notifications: check causedBy for loop prevention
1558
+ if (m.causedBy) {
1559
+ // Rule 1: Is this a response to something I sent? → accept (for verification)
1560
+ const myOutbox = this.bus.getOutboxFor(agentId);
1561
+ if (myOutbox.some(o => o.id === m.causedBy!.messageId)) return true;
1562
+
1563
+ // Rule 2: Notification from downstream → discard (prevents reverse flow)
1564
+ if (!this.isUpstream(m.from, agentId)) {
1565
+ console.log(`[inbox] ${entry.config.label}: discarding notification from downstream ${this.agents.get(m.from)?.config.label || m.from}`);
1566
+ m.status = 'done' as any; // silently consume
1567
+ return false;
1568
+ }
1569
+ }
1570
+
1571
+ // Default: accept (upstream notifications, no causedBy = initial trigger)
1572
+ return true;
1573
+ });
1574
+ if (pending.length === 0) return;
1575
+
1576
+ const nextMsg = pending[0];
1577
+ const fromLabel = this.agents.get(nextMsg.from)?.config.label || nextMsg.from;
1578
+ console.log(`[inbox] ${entry.config.label}: consuming message from ${fromLabel} (${nextMsg.payload.action})`);
1579
+
1580
+ // Mark message as running (being processed)
1581
+ nextMsg.status = 'running' as any;
1582
+ this.emit('event', { type: 'bus_message_status', messageId: nextMsg.id, status: 'running' } as any);
1583
+
1584
+ const logEntry = {
1585
+ type: 'system' as const,
1586
+ subtype: 'bus_message',
1587
+ content: `[From ${fromLabel}]: ${nextMsg.payload.content || nextMsg.payload.action}`,
1588
+ timestamp: new Date(nextMsg.timestamp).toISOString(),
1589
+ };
1590
+
1591
+ entry.worker.setProcessingMessage(nextMsg.id);
1592
+ entry.worker.wake({ type: 'bus_message', messages: [logEntry] });
1593
+ };
1594
+
1595
+ // Check every 2 seconds
1596
+ const timer = setInterval(tick, 2000);
1597
+ timer.unref(); // Don't prevent process exit in tests
1598
+ this.messageLoopTimers.set(agentId, timer);
1599
+ // Also run immediately
1600
+ tick();
1601
+ }
1602
+
1603
+ /** Stop the message consumption loop for a smith */
1604
+ private stopMessageLoop(agentId: string): void {
1605
+ const timer = this.messageLoopTimers.get(agentId);
1606
+ if (timer) {
1607
+ clearInterval(timer);
1608
+ this.messageLoopTimers.delete(agentId);
1609
+ }
1610
+ }
1611
+
1612
+ /** Stop all message loops */
1613
+ private stopAllMessageLoops(): void {
1614
+ for (const [id] of this.messageLoopTimers) {
1615
+ this.stopMessageLoop(id);
1616
+ }
1617
+ }
1618
+
1619
+ /** Check if all agents are done and no pending work remains */
1620
+ private checkWorkspaceComplete(): void {
1621
+ let allDone = true;
1622
+ for (const [id, entry] of this.agents) {
1623
+ const ws = entry.worker?.getState();
1624
+ const taskSt = ws?.taskStatus ?? entry.state.taskStatus;
1625
+ if (taskSt === 'running' || this.approvalQueue.has(id)) {
1626
+ allDone = false;
1627
+ break;
1628
+ }
1629
+ // idle agents with unmet deps don't block completion
1630
+ if (taskSt === 'idle' && entry.config.dependsOn.length > 0) {
1631
+ const allDepsDone = entry.config.dependsOn.every(depId => {
1632
+ const dep = this.agents.get(depId);
1633
+ return dep && (dep.state.taskStatus === 'done');
1634
+ });
1635
+ if (allDepsDone) {
1636
+ allDone = false; // idle but ready to run = not complete
1637
+ break;
1638
+ }
1639
+ }
1640
+ }
1641
+
1642
+ if (allDone && this.agents.size > 0) {
1643
+ const hasPendingRequests = this.bus.getLog().some(m =>
1644
+ m.type === 'request' && !this.bus.getLog().some(r =>
1645
+ r.type === 'response' && r.payload.replyTo === m.id
1646
+ )
1647
+ );
1648
+ if (!hasPendingRequests) {
1649
+ console.log('[workspace] All agents complete, no pending requests. Workspace done.');
1650
+ this.emit('event', { type: 'workspace_complete' } satisfies OrchestratorEvent);
1651
+ }
1652
+ }
1653
+ }
1654
+
1655
+ /** Get agents that are idle and have all dependencies met */
1656
+ private getReadyAgents(): string[] {
1657
+ const ready: string[] = [];
1658
+ for (const [id, entry] of this.agents) {
1659
+ if (entry.state.taskStatus !== 'idle') continue;
1660
+ const allDepsDone = entry.config.dependsOn.every(depId => {
1661
+ const dep = this.agents.get(depId);
1662
+ return dep && dep.state.taskStatus === 'done';
1663
+ });
1664
+ if (allDepsDone) ready.push(id);
1665
+ }
1666
+ return ready;
1667
+ }
1668
+
1669
+ /**
1670
+ * Parse CLI agent output for bus message markers.
1671
+ * Format: [SEND:TargetLabel:action] content
1672
+ * Example: [SEND:Engineer:fix_request] SQL injection found in auth module
1673
+ */
1674
+ /**
1675
+ * After an agent completes, notify downstream agents that already ran (done/failed)
1676
+ * to re-validate their work. Sets them to waiting_approval so user decides.
1677
+ */
1678
+ private notifyDownstreamForRevalidation(completedAgentId: string, files: string[]): void {
1679
+ const completedLabel = this.agents.get(completedAgentId)?.config.label || completedAgentId;
1680
+
1681
+ for (const [id, entry] of this.agents) {
1682
+ if (id === completedAgentId) continue;
1683
+ if (!entry.config.dependsOn.includes(completedAgentId)) continue;
1684
+
1685
+ // Only notify agents that already completed — they need to re-validate
1686
+ if (entry.state.taskStatus !== 'done' && entry.state.taskStatus !== 'failed') continue;
1687
+
1688
+ console.log(`[workspace] ${completedLabel} changed → ${entry.config.label} needs re-validation`);
1689
+
1690
+ // Send bus message
1691
+ this.bus.send(completedAgentId, id, 'notify', {
1692
+ action: 'update_notify',
1693
+ content: `${completedLabel} completed with changes. Please re-validate.`,
1694
+ files,
1695
+ });
1696
+
1697
+ // Set to waiting_approval so user confirms re-run
1698
+ entry.state.taskStatus = 'idle';
1699
+ entry.state.history.push({
1700
+ type: 'system',
1701
+ subtype: 'revalidation_request',
1702
+ content: `[${completedLabel}] completed with changes — approve to re-run validation`,
1703
+ timestamp: new Date().toISOString(),
1704
+ });
1705
+ this.approvalQueue.add(id);
1706
+ this.emit('event', { type: 'task_status', agentId: id, taskStatus: 'idle' } satisfies WorkerEvent);
1707
+ this.emit('event', {
1708
+ type: 'approval_required',
1709
+ agentId: id,
1710
+ upstreamId: completedAgentId,
1711
+ } satisfies OrchestratorEvent);
1712
+ }
1713
+ }
1714
+
1715
+ /** Track how many history entries have been scanned per agent to avoid re-parsing */
1716
+ private busMarkerScanned = new Map<string, number>();
1717
+
1718
+ private parseBusMarkers(fromAgentId: string, history: { type: string; content: string }[]): void {
1719
+ const markerRegex = /\[SEND:([^:]+):([^\]]+)\]\s*(.+)/g;
1720
+ const labelToId = new Map<string, string>();
1721
+ for (const [id, e] of this.agents) {
1722
+ labelToId.set(e.config.label.toLowerCase(), id);
1723
+ }
1724
+
1725
+ // Only scan new entries since last parse (avoid re-sending from old history)
1726
+ const lastScanned = this.busMarkerScanned.get(fromAgentId) || 0;
1727
+ const newEntries = history.slice(lastScanned);
1728
+ this.busMarkerScanned.set(fromAgentId, history.length);
1729
+
1730
+ for (const entry of newEntries) {
1731
+ let match;
1732
+ while ((match = markerRegex.exec(entry.content)) !== null) {
1733
+ const targetLabel = match[1].trim();
1734
+ const action = match[2].trim();
1735
+ const content = match[3].trim();
1736
+ const targetId = labelToId.get(targetLabel.toLowerCase());
1737
+
1738
+ if (targetId && targetId !== fromAgentId) {
1739
+ console.log(`[bus] Parsed marker from ${fromAgentId}: → ${targetLabel} (${action}): ${content.slice(0, 60)}`);
1740
+ this.bus.send(fromAgentId, targetId, 'notify', { action, content });
1741
+ }
1742
+ }
1743
+ }
1744
+ }
1745
+
1746
+ private saveNow(): void {
1747
+ saveWorkspace(this.getFullState()).catch(() => {});
1748
+ }
1749
+
1750
+ /** Emit agents_changed so SSE pushes the updated list to frontend */
1751
+ private emitAgentsChanged(): void {
1752
+ const agents = Array.from(this.agents.values()).map(e => e.config);
1753
+ const agentStates = this.getAllAgentStates();
1754
+ this.emit('event', { type: 'agents_changed', agents, agentStates } satisfies WorkerEvent);
1755
+ }
1756
+
1757
+ private emitWorkspaceStatus(): void {
1758
+ let running = 0, done = 0;
1759
+ for (const [, entry] of this.agents) {
1760
+ const ws = entry.worker?.getState();
1761
+ const taskSt = ws?.taskStatus ?? entry.state.taskStatus;
1762
+ if (taskSt === 'running') running++;
1763
+ if (taskSt === 'done') done++;
1764
+ }
1765
+ this.emit('event', {
1766
+ type: 'workspace_status',
1767
+ running,
1768
+ done,
1769
+ total: this.agents.size,
1770
+ } satisfies OrchestratorEvent);
1771
+ }
1772
+
1773
+ /**
1774
+ * Update agent memory after execution completes.
1775
+ * Parses step results into structured memory entries.
1776
+ */
1777
+ private async updateAgentMemory(agentId: string, config: WorkspaceAgentConfig, stepResults: string[]): Promise<void> {
1778
+ try {
1779
+ const entry = this.agents.get(agentId);
1780
+
1781
+ // Capture observation from the last step (previous steps captured in 'step' event handler)
1782
+ const lastStep = config.steps[config.steps.length - 1];
1783
+ const lastResult = stepResults[stepResults.length - 1];
1784
+ if (lastStep && lastResult) {
1785
+ const obs = parseStepToObservations(lastStep.label, lastResult, entry?.state.artifacts || []);
1786
+ for (const o of obs) {
1787
+ await addObservation(this.workspaceId, agentId, config.label, config.role, o);
1788
+ }
1789
+ }
1790
+
1791
+ // Add session summary
1792
+ const summary = buildSessionSummary(
1793
+ config.steps.map(s => s.label),
1794
+ stepResults,
1795
+ entry?.state.artifacts || [],
1796
+ );
1797
+ await addSessionSummary(this.workspaceId, agentId, summary);
1798
+
1799
+ console.log(`[workspace] Updated memory for ${config.label}`);
1800
+ } catch (err: any) {
1801
+ console.error(`[workspace] Failed to update memory for ${config.label}:`, err.message);
1802
+ }
1803
+ }
1804
+ }