@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,667 @@
1
+ /**
2
+ * AgentWorker — manages the lifecycle of a single workspace agent.
3
+ *
4
+ * Responsibilities:
5
+ * - Multi-step execution with context accumulation
6
+ * - Pause / resume / stop
7
+ * - Bus message injection between steps
8
+ * - Event emission for UI and orchestrator
9
+ */
10
+
11
+ import { EventEmitter } from 'node:events';
12
+ import type {
13
+ WorkspaceAgentConfig,
14
+ AgentState,
15
+ TaskStatus,
16
+ SmithStatus,
17
+ AgentMode,
18
+ AgentBackend,
19
+ WorkerEvent,
20
+ Artifact,
21
+ BusMessage,
22
+ DaemonWakeReason,
23
+ } from './types';
24
+ import type { TaskLogEntry } from '@/src/types';
25
+
26
+ export interface AgentWorkerOptions {
27
+ config: WorkspaceAgentConfig;
28
+ backend: AgentBackend;
29
+ projectPath: string;
30
+ workspaceId?: string;
31
+ initialTaskStatus?: 'idle' | 'running' | 'done' | 'failed';
32
+ // Bus communication callbacks (injected by orchestrator)
33
+ onBusSend?: (to: string, content: string) => void;
34
+ onBusRequest?: (to: string, question: string) => Promise<string>;
35
+ peerAgentIds?: string[];
36
+ // Message status callback — smith marks its own messages
37
+ onMessageDone?: (messageId: string) => void;
38
+ onMessageFailed?: (messageId: string) => void;
39
+ // Memory (injected by orchestrator)
40
+ memoryContext?: string;
41
+ onMemoryUpdate?: (stepResults: string[]) => void;
42
+ }
43
+
44
+ export class AgentWorker extends EventEmitter {
45
+ readonly config: WorkspaceAgentConfig;
46
+ private state: AgentState;
47
+ private backend: AgentBackend;
48
+ private projectPath: string;
49
+ private workspaceId?: string;
50
+ private busCallbacks: {
51
+ onBusSend?: (to: string, content: string) => void;
52
+ onBusRequest?: (to: string, question: string) => Promise<string>;
53
+ peerAgentIds?: string[];
54
+ };
55
+
56
+ // Control
57
+ private abortController: AbortController | null = null;
58
+ private paused = false;
59
+ private pauseResolve: (() => void) | null = null;
60
+
61
+ // Bus messages queued between steps
62
+ private pendingMessages: TaskLogEntry[] = [];
63
+
64
+ // Memory
65
+ private memoryContext?: string;
66
+ private onMemoryUpdate?: (stepResults: string[]) => void;
67
+ private stepResults: string[] = [];
68
+
69
+ // Daemon mode
70
+ private wakeResolve: ((reason: DaemonWakeReason) => void) | null = null;
71
+ private pendingWake: DaemonWakeReason | null = null;
72
+ private daemonRetryCount = 0;
73
+ private currentMessageId: string | null = null; // ID of the bus message being processed
74
+ private onMessageDone?: (messageId: string) => void;
75
+ private onMessageFailed?: (messageId: string) => void;
76
+
77
+ constructor(opts: AgentWorkerOptions) {
78
+ super();
79
+ this.config = opts.config;
80
+ this.backend = opts.backend;
81
+ this.projectPath = opts.projectPath;
82
+ this.busCallbacks = {
83
+ onBusSend: opts.onBusSend,
84
+ onBusRequest: opts.onBusRequest,
85
+ peerAgentIds: opts.peerAgentIds,
86
+ };
87
+ this.workspaceId = opts.workspaceId;
88
+ this.memoryContext = opts.memoryContext;
89
+ this.onMemoryUpdate = opts.onMemoryUpdate;
90
+ this.onMessageDone = opts.onMessageDone;
91
+ this.onMessageFailed = opts.onMessageFailed;
92
+ this.state = {
93
+ smithStatus: 'down',
94
+ mode: 'auto',
95
+ taskStatus: opts.initialTaskStatus || 'idle',
96
+ history: [],
97
+ artifacts: [],
98
+ };
99
+ }
100
+
101
+ // ─── Public API ──────────────────────────────────────
102
+
103
+ /**
104
+ * Execute all steps starting from `startStep`.
105
+ * For recovery, pass `lastCheckpoint + 1`.
106
+ */
107
+ async execute(startStep = 0, upstreamContext?: string): Promise<void> {
108
+ const { steps } = this.config;
109
+ if (steps.length === 0) {
110
+ this.setTaskStatus('done');
111
+ this.emitEvent({ type: 'done', agentId: this.config.id, summary: 'No steps defined' });
112
+ return;
113
+ }
114
+
115
+ // Prepend memory to upstream context
116
+ if (this.memoryContext) {
117
+ upstreamContext = upstreamContext
118
+ ? this.memoryContext + '\n\n---\n\n' + upstreamContext
119
+ : this.memoryContext;
120
+ }
121
+
122
+ this.stepResults = [];
123
+ this.abortController = new AbortController();
124
+ this.setTaskStatus('running');
125
+ this.state.startedAt = Date.now();
126
+
127
+ for (let i = startStep; i < steps.length; i++) {
128
+ // Check pause
129
+ await this.waitIfPaused();
130
+
131
+ // Check abort
132
+ if (this.abortController.signal.aborted) {
133
+ console.log(`[worker] ${this.config.label}: abort detected before step ${i} (signal already aborted)`);
134
+ this.markMessageFailed();
135
+ this.setTaskStatus('failed', 'Interrupted');
136
+ return;
137
+ }
138
+
139
+ const step = steps[i];
140
+ this.state.currentStep = i;
141
+ this.emitEvent({ type: 'step', agentId: this.config.id, stepIndex: i, stepLabel: step.label });
142
+
143
+ // Consume pending bus messages → append to history as context
144
+ if (this.pendingMessages.length > 0) {
145
+ for (const msg of this.pendingMessages) {
146
+ this.state.history.push(msg);
147
+ }
148
+ this.pendingMessages = [];
149
+ }
150
+
151
+ try {
152
+ const result = await this.backend.executeStep({
153
+ config: this.config,
154
+ step,
155
+ stepIndex: i,
156
+ history: this.state.history,
157
+ projectPath: this.projectPath,
158
+ workspaceId: this.workspaceId,
159
+ upstreamContext: i === startStep ? upstreamContext : undefined,
160
+ onBusSend: this.busCallbacks.onBusSend,
161
+ onBusRequest: this.busCallbacks.onBusRequest,
162
+ peerAgentIds: this.busCallbacks.peerAgentIds,
163
+ abortSignal: this.abortController.signal,
164
+ onLog: (entry) => {
165
+ this.state.history.push(entry);
166
+ this.emitEvent({ type: 'log', agentId: this.config.id, entry });
167
+ },
168
+ });
169
+
170
+ // Validate result — detect if the agent hit an error but backend didn't catch it
171
+ const failureCheck = detectStepFailure(result.response);
172
+ if (failureCheck) {
173
+ throw new Error(failureCheck);
174
+ }
175
+
176
+ // Record the assistant's final response for this step
177
+ this.state.history.push({
178
+ type: 'result',
179
+ subtype: 'step_complete',
180
+ content: result.response,
181
+ timestamp: new Date().toISOString(),
182
+ });
183
+
184
+ // Collect artifacts
185
+ for (const artifact of result.artifacts) {
186
+ this.state.artifacts.push(artifact);
187
+ this.emitEvent({ type: 'artifact', agentId: this.config.id, artifact });
188
+ }
189
+
190
+ // Emit step summary (compact, human-friendly)
191
+ const stepSummary = summarizeStepResult(step.label, result.response, result.artifacts);
192
+ this.emitEvent({
193
+ type: 'log', agentId: this.config.id,
194
+ entry: { type: 'system', subtype: 'step_summary', content: stepSummary, timestamp: new Date().toISOString() },
195
+ });
196
+
197
+ // Collect step result for memory update
198
+ this.stepResults.push(result.response);
199
+
200
+ // Checkpoint: this step succeeded
201
+ this.state.lastCheckpoint = i;
202
+
203
+ } catch (err: any) {
204
+ const msg = err?.message || String(err);
205
+ // Aborted = graceful stop (SIGTERM/SIGINT), not an error
206
+ if (msg === 'Aborted' || this.abortController?.signal.aborted) {
207
+ console.log(`[worker] ${this.config.label}: step catch — msg="${msg}", aborted=${this.abortController?.signal.aborted}`);
208
+ this.markMessageFailed();
209
+ this.setTaskStatus('failed', 'Interrupted');
210
+ return;
211
+ }
212
+ this.markMessageFailed();
213
+ this.setTaskStatus('failed', msg);
214
+ this.emitEvent({ type: 'error', agentId: this.config.id, error: this.state.error! });
215
+ return;
216
+ }
217
+ }
218
+
219
+ // All steps done
220
+ this.markMessageDone(); // mark trigger message before emitting done
221
+ this.setTaskStatus('done');
222
+ this.state.completedAt = Date.now();
223
+
224
+ // Trigger memory update (orchestrator handles the actual LLM call)
225
+ if (this.onMemoryUpdate && this.stepResults.length > 0) {
226
+ try { this.onMemoryUpdate(this.stepResults); } catch {}
227
+ }
228
+
229
+ // Emit final summary
230
+ const finalSummary = buildFinalSummary(this.config.label, this.config.steps, this.stepResults, this.state.artifacts);
231
+ this.emitEvent({
232
+ type: 'log', agentId: this.config.id,
233
+ entry: { type: 'result', subtype: 'final_summary', content: finalSummary, timestamp: new Date().toISOString() },
234
+ });
235
+
236
+ const summary = this.state.artifacts.length > 0
237
+ ? `Completed. Artifacts: ${this.state.artifacts.map(a => a.path || a.summary).join(', ')}`
238
+ : 'Completed.';
239
+ this.emitEvent({ type: 'done', agentId: this.config.id, summary });
240
+ }
241
+
242
+ /** Stop execution (abort current step and daemon loop) */
243
+ stop(): void {
244
+ console.log(`[worker] stop() called for ${this.config.label} (task=${this.state.taskStatus}, smith=${this.state.smithStatus})`, new Error().stack?.split('\n').slice(1, 4).join(' <- '));
245
+ this.abortController?.abort();
246
+ this.backend.abort();
247
+ this.setSmithStatus('down');
248
+ // Don't change taskStatus — keep done/failed/idle as-is
249
+ // Only change running → done (graceful stop of an in-progress task)
250
+ if (this.state.taskStatus === 'running') {
251
+ this.setTaskStatus('failed', 'Interrupted');
252
+ }
253
+ // If paused, release the pause wait
254
+ if (this.pauseResolve) {
255
+ this.pauseResolve();
256
+ this.pauseResolve = null;
257
+ }
258
+ // If in daemon wait, wake with abort
259
+ if (this.wakeResolve) {
260
+ this.wakeResolve({ type: 'abort' });
261
+ this.wakeResolve = null;
262
+ }
263
+ }
264
+
265
+ /** Pause after current step completes */
266
+ pause(): void {
267
+ if (this.state.taskStatus !== 'running') return;
268
+ this.paused = true;
269
+ // Paused is a sub-state of running, no separate taskStatus
270
+ }
271
+
272
+ /** Resume from paused state */
273
+ resume(): void {
274
+ if (!this.paused) return;
275
+ this.paused = false;
276
+ if (this.pauseResolve) {
277
+ this.pauseResolve();
278
+ this.pauseResolve = null;
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Inject a message from the bus (or human) into the pending queue.
284
+ * Will be consumed at the start of the next step.
285
+ */
286
+ injectMessage(entry: TaskLogEntry): void {
287
+ this.pendingMessages.push(entry);
288
+ }
289
+
290
+ // ─── Daemon Mode ──────────────────────────────────────
291
+
292
+ /**
293
+ * Execute steps then enter daemon loop — agent stays alive waiting for events.
294
+ * Errors do NOT kill the daemon; agent retries with backoff.
295
+ */
296
+ async executeDaemon(startStep = 0, upstreamContext?: string, skipSteps = false): Promise<void> {
297
+ if (!skipSteps) {
298
+ // Run initial steps
299
+ await this.execute(startStep, upstreamContext);
300
+
301
+ // If aborted, don't enter daemon loop
302
+ if (this.abortController?.signal.aborted) return;
303
+ } else {
304
+ // Skip steps — just prepare for daemon loop
305
+ this.abortController = new AbortController();
306
+ }
307
+
308
+ // Enter daemon mode: smith = active, task keeps its status (done/failed/idle)
309
+ this.state.daemonIteration = 0;
310
+ this.daemonRetryCount = 0;
311
+ this.setSmithStatus('active');
312
+
313
+ this.emitEvent({
314
+ type: 'log', agentId: this.config.id,
315
+ entry: { type: 'system', subtype: 'daemon', content: `[Smith] Active — listening for messages`, timestamp: new Date().toISOString() },
316
+ });
317
+
318
+ // Daemon loop — wait for messages, execute, repeat
319
+ while (!this.abortController?.signal.aborted) {
320
+ const reason = await this.waitForWake();
321
+
322
+ if (reason.type === 'abort') break;
323
+
324
+ this.state.daemonIteration = (this.state.daemonIteration || 0) + 1;
325
+ this.daemonRetryCount = 0;
326
+
327
+ try {
328
+ await this.executeDaemonStep(reason);
329
+ this.setTaskStatus('done');
330
+ // Emit done BEFORE markMessageDone — handleAgentDone needs getCurrentMessageId for causedBy
331
+ this.emitEvent({ type: 'done', agentId: this.config.id, summary: `Daemon iteration ${this.state.daemonIteration}` });
332
+ this.markMessageDone();
333
+ } catch (err: any) {
334
+ const msg = err?.message || String(err);
335
+
336
+ // Aborted = graceful stop, exit daemon loop
337
+ if (msg === 'Aborted' || this.abortController?.signal.aborted) break;
338
+
339
+ // Real errors: mark message failed, then backoff
340
+ this.markMessageFailed();
341
+ this.setTaskStatus('failed', msg);
342
+ this.emitEvent({ type: 'error', agentId: this.config.id, error: msg });
343
+ this.emitEvent({
344
+ type: 'log', agentId: this.config.id,
345
+ entry: { type: 'system', subtype: 'daemon', content: `[Smith] Error: ${msg}. Waiting for next event.`, timestamp: new Date().toISOString() },
346
+ });
347
+
348
+ const backoffMs = Math.min(5000 * Math.pow(2, this.daemonRetryCount++), 60_000);
349
+ await this.sleep(backoffMs);
350
+
351
+ if (this.abortController?.signal.aborted) break;
352
+ // Keep failed status — next wake event will set running again
353
+ }
354
+ }
355
+
356
+ // Exiting daemon loop
357
+ this.setSmithStatus('down');
358
+ }
359
+
360
+ /** Wake the daemon from listening state */
361
+ wake(reason: DaemonWakeReason): void {
362
+ if (this.wakeResolve) {
363
+ this.wakeResolve(reason);
364
+ this.wakeResolve = null;
365
+ } else {
366
+ // Worker hasn't entered waitForWake yet — buffer the wake
367
+ this.pendingWake = reason;
368
+ }
369
+ }
370
+
371
+ /** Check if smith is active and idle (ready to receive messages) */
372
+ isListening(): boolean {
373
+ return this.state.smithStatus === 'active' && this.state.taskStatus !== 'running';
374
+ }
375
+
376
+ /** Set the bus message ID being processed — smith marks it done/failed on completion */
377
+ setProcessingMessage(messageId: string): void {
378
+ this.currentMessageId = messageId;
379
+ this.state.currentMessageId = messageId;
380
+ }
381
+
382
+ /** Get the current message ID being processed */
383
+ getCurrentMessageId(): string | null {
384
+ return this.currentMessageId;
385
+ }
386
+
387
+ /** Mark current message as done — keep messageId for causedBy tracing */
388
+ private markMessageDone(): void {
389
+ if (this.currentMessageId && this.onMessageDone) {
390
+ this.onMessageDone(this.currentMessageId);
391
+ }
392
+ // Don't clear currentMessageId — it's needed by handleAgentDone for causedBy.
393
+ // It gets overwritten when next message is set via setProcessingMessage().
394
+ }
395
+
396
+ /** Mark current message as failed — keep messageId for error tracing */
397
+ private markMessageFailed(): void {
398
+ if (this.currentMessageId && this.onMessageFailed) {
399
+ this.onMessageFailed(this.currentMessageId);
400
+ }
401
+ // Don't clear — same reason as markMessageDone.
402
+ }
403
+
404
+ private waitForWake(): Promise<DaemonWakeReason> {
405
+ // Check if a wake was buffered while we were busy
406
+ if (this.pendingWake) {
407
+ const reason = this.pendingWake;
408
+ this.pendingWake = null;
409
+ return Promise.resolve(reason);
410
+ }
411
+ return new Promise<DaemonWakeReason>((resolve) => {
412
+ this.wakeResolve = resolve;
413
+ // Also resolve on abort
414
+ const onAbort = () => resolve({ type: 'abort' });
415
+ this.abortController?.signal.addEventListener('abort', onAbort, { once: true });
416
+ });
417
+ }
418
+
419
+ private async executeDaemonStep(reason: DaemonWakeReason): Promise<void> {
420
+ if (reason.type === 'abort') return;
421
+
422
+ this.setTaskStatus('running');
423
+
424
+ // Build prompt based on wake reason
425
+ let prompt: string;
426
+ switch (reason.type) {
427
+ case 'bus_message':
428
+ prompt = `You received new messages from other agents:\n${reason.messages.map(m => m.content).join('\n')}\n\nReact accordingly — update your work, respond, or take action as needed.`;
429
+ break;
430
+ case 'upstream_changed':
431
+ prompt = `Your upstream dependency (agent ${reason.agentId}) has produced new output: ${reason.files.join(', ')}.\n\nRe-analyze and update your work based on the new upstream output.`;
432
+ break;
433
+ case 'input_changed':
434
+ prompt = `New requirements have been provided:\n${reason.content}\n\nUpdate your work based on these new requirements.`;
435
+ break;
436
+ case 'user_message':
437
+ prompt = `User message: ${reason.content}\n\nRespond and take action as needed.`;
438
+ break;
439
+ }
440
+
441
+ // Consume any pending bus messages
442
+ const contextMessages = [...this.pendingMessages];
443
+ this.pendingMessages = [];
444
+
445
+ for (const msg of contextMessages) {
446
+ this.state.history.push(msg);
447
+ }
448
+
449
+ // Execute using the last step definition as template (or first if no steps)
450
+ const stepTemplate = this.config.steps[this.config.steps.length - 1] || this.config.steps[0];
451
+ if (!stepTemplate) {
452
+ // No steps defined — just log
453
+ this.state.history.push({
454
+ type: 'system', subtype: 'daemon',
455
+ content: `[Daemon] Wake: ${reason.type} — no steps defined to execute`,
456
+ timestamp: new Date().toISOString(),
457
+ });
458
+ return;
459
+ }
460
+
461
+ const step = {
462
+ ...stepTemplate,
463
+ id: `daemon-${this.state.daemonIteration}`,
464
+ label: `Daemon iteration ${this.state.daemonIteration}`,
465
+ prompt,
466
+ };
467
+
468
+ this.emitEvent({ type: 'step', agentId: this.config.id, stepIndex: -1, stepLabel: step.label });
469
+
470
+ const result = await this.backend.executeStep({
471
+ config: this.config,
472
+ step,
473
+ stepIndex: -1,
474
+ history: this.state.history,
475
+ projectPath: this.projectPath,
476
+ workspaceId: this.workspaceId,
477
+ onBusSend: this.busCallbacks.onBusSend,
478
+ onBusRequest: this.busCallbacks.onBusRequest,
479
+ peerAgentIds: this.busCallbacks.peerAgentIds,
480
+ abortSignal: this.abortController?.signal,
481
+ onLog: (entry) => {
482
+ this.state.history.push(entry);
483
+ this.emitEvent({ type: 'log', agentId: this.config.id, entry });
484
+ },
485
+ });
486
+
487
+ // Validate result
488
+ const failureCheck = detectStepFailure(result.response);
489
+ if (failureCheck) {
490
+ throw new Error(failureCheck);
491
+ }
492
+
493
+ // Record result
494
+ this.state.history.push({
495
+ type: 'result', subtype: 'daemon_step',
496
+ content: result.response,
497
+ timestamp: new Date().toISOString(),
498
+ });
499
+
500
+ // Collect artifacts
501
+ for (const artifact of result.artifacts) {
502
+ this.state.artifacts.push(artifact);
503
+ this.emitEvent({ type: 'artifact', agentId: this.config.id, artifact });
504
+ }
505
+
506
+ const stepSummary = summarizeStepResult(step.label, result.response, result.artifacts);
507
+ this.emitEvent({
508
+ type: 'log', agentId: this.config.id,
509
+ entry: { type: 'system', subtype: 'step_summary', content: stepSummary, timestamp: new Date().toISOString() },
510
+ });
511
+ }
512
+
513
+ private sleep(ms: number): Promise<void> {
514
+ return new Promise(resolve => setTimeout(resolve, ms));
515
+ }
516
+
517
+ /** Get current state snapshot (immutable copy) */
518
+ getState(): Readonly<AgentState> {
519
+ return { ...this.state, currentMessageId: this.currentMessageId || this.state.currentMessageId };
520
+ }
521
+
522
+ /** Get the config */
523
+ getConfig(): Readonly<WorkspaceAgentConfig> {
524
+ return this.config;
525
+ }
526
+
527
+ // ─── Private ─────────────────────────────────────────
528
+
529
+ private setTaskStatus(taskStatus: TaskStatus, error?: string): void {
530
+ this.state.taskStatus = taskStatus;
531
+ this.state.error = error;
532
+ this.emitEvent({ type: 'task_status', agentId: this.config.id, taskStatus, error });
533
+ }
534
+
535
+ private setSmithStatus(smithStatus: SmithStatus, mode?: AgentMode): void {
536
+ this.state.smithStatus = smithStatus;
537
+ if (mode) this.state.mode = mode;
538
+ this.emitEvent({ type: 'smith_status', agentId: this.config.id, smithStatus, mode: this.state.mode });
539
+ }
540
+
541
+ private emitEvent(event: WorkerEvent): void {
542
+ this.emit('event', event);
543
+ }
544
+
545
+ private waitIfPaused(): Promise<void> {
546
+ if (!this.paused) return Promise.resolve();
547
+ return new Promise<void>(resolve => {
548
+ this.pauseResolve = resolve;
549
+ });
550
+ }
551
+ }
552
+
553
+ // ─── Summary helpers (no LLM, pure heuristic) ────────────
554
+
555
+ /** Extract a compact step summary from raw output */
556
+ /**
557
+ * Detect if a step's result indicates failure — covers all agent types:
558
+ * - Rate/usage limits (codex, claude, API)
559
+ * - Auth failures
560
+ * - Subscription limits (claude code)
561
+ * - Empty/meaningless output
562
+ * Returns error message if failure detected, null if OK.
563
+ */
564
+ function detectStepFailure(response: string): string | null {
565
+ if (!response || response.trim().length === 0) {
566
+ return 'Agent produced no output — step may not have executed';
567
+ }
568
+
569
+ const patterns: [RegExp, string][] = [
570
+ // Usage/rate limits
571
+ [/usage limit/i, 'Usage limit reached'],
572
+ [/rate limit/i, 'Rate limit reached'],
573
+ [/hit your.*limit/i, 'Account limit reached'],
574
+ [/upgrade to (plus|pro|max)/i, 'Subscription upgrade required'],
575
+ [/try again (at|in|after)/i, 'Temporarily unavailable — try again later'],
576
+ // Claude Code specific
577
+ [/exceeded.*monthly.*limit/i, 'Monthly usage limit exceeded'],
578
+ [/opus limit|sonnet limit/i, 'Model usage limit reached'],
579
+ [/you've been rate limited/i, 'Rate limited'],
580
+ // API errors
581
+ [/api key.*invalid/i, 'Invalid API key'],
582
+ [/authentication failed/i, 'Authentication failed'],
583
+ [/insufficient.*quota/i, 'Insufficient API quota'],
584
+ [/billing.*not.*active/i, 'Billing not active'],
585
+ [/overloaded|server error|503|502/i, 'Service temporarily unavailable'],
586
+ ];
587
+
588
+ for (const [pattern, msg] of patterns) {
589
+ if (pattern.test(response)) {
590
+ // Extract the actual error line for context
591
+ const errorLine = response.split('\n').find(l => pattern.test(l))?.trim();
592
+ return `${msg}${errorLine ? ': ' + errorLine.slice(0, 150) : ''}`;
593
+ }
594
+ }
595
+
596
+ // Check for very short output that's just noise (spinner artifacts, etc.)
597
+ const meaningful = response.replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, '').trim();
598
+ if (meaningful.length < 10 && response.length > 50) {
599
+ return 'Agent output appears to be only terminal noise — execution may have failed';
600
+ }
601
+
602
+ return null;
603
+ }
604
+
605
+ function summarizeStepResult(stepLabel: string, rawResult: string, artifacts: { path?: string; summary?: string }[]): string {
606
+ const lines: string[] = [];
607
+ lines.push(`✅ Step "${stepLabel}" done`);
608
+
609
+ // Extract key sentences (first meaningful line, skip noise)
610
+ const meaningful = rawResult
611
+ .split('\n')
612
+ .map(l => l.trim())
613
+ .filter(l => l.length > 15 && l.length < 300)
614
+ .filter(l => !/^[#\-*>|`]/.test(l)) // skip markdown headers, bullets, code blocks
615
+ .filter(l => !/^(Working|W$|Wo$|•)/.test(l)); // skip codex noise
616
+
617
+ if (meaningful.length > 0) {
618
+ lines.push(` ${meaningful[0].slice(0, 120)}`);
619
+ }
620
+
621
+ // List artifacts
622
+ const filePaths = artifacts.filter(a => a.path).map(a => a.path!);
623
+ if (filePaths.length > 0) {
624
+ lines.push(` Files: ${filePaths.join(', ')}`);
625
+ }
626
+
627
+ return lines.join('\n');
628
+ }
629
+
630
+ /** Build a final summary after all steps complete */
631
+ function buildFinalSummary(
632
+ agentLabel: string,
633
+ steps: { label: string }[],
634
+ stepResults: string[],
635
+ artifacts: { path?: string; summary?: string }[],
636
+ ): string {
637
+ const lines: string[] = [];
638
+ lines.push(`══════════════════════════════════════`);
639
+ lines.push(`📊 ${agentLabel} — Summary`);
640
+ lines.push(`──────────────────────────────────────`);
641
+
642
+ // Steps completed
643
+ lines.push(`Steps: ${steps.map(s => s.label).join(' → ')}`);
644
+
645
+ // Key output per step (one line each)
646
+ for (let i = 0; i < steps.length; i++) {
647
+ const result = stepResults[i] || '';
648
+ const firstLine = result
649
+ .split('\n')
650
+ .map(l => l.trim())
651
+ .filter(l => l.length > 15 && l.length < 200)
652
+ .filter(l => !/^[#\-*>|`]/.test(l))
653
+ .filter(l => !/^(Working|W$|Wo$|•)/.test(l))[0];
654
+ if (firstLine) {
655
+ lines.push(` ${steps[i].label}: ${firstLine.slice(0, 100)}`);
656
+ }
657
+ }
658
+
659
+ // All artifacts
660
+ const files = artifacts.filter(a => a.path).map(a => a.path!);
661
+ if (files.length > 0) {
662
+ lines.push(`Produced: ${files.join(', ')}`);
663
+ }
664
+
665
+ lines.push(`══════════════════════════════════════`);
666
+ return lines.join('\n');
667
+ }