@hamp10/agentforge 0.1.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.
@@ -0,0 +1,1520 @@
1
+ import { spawn } from 'child_process';
2
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'fs';
3
+ import { EventEmitter } from 'events';
4
+ import { homedir } from 'os';
5
+ import path from 'path';
6
+ import treeKill from 'tree-kill';
7
+
8
+ // Canary configuration
9
+ const CANARY_ENDPOINT = process.env.CANARY_ENDPOINT || 'https://canary.bot';
10
+ const CANARY_PARENT_API_KEY = process.env.CANARY_PARENT_API_KEY; // Optional: inherit ownership
11
+
12
+ /**
13
+ * Controls OpenClaw agents via CLI instead of WebSocket Gateway
14
+ */
15
+ export class OpenClawCLI extends EventEmitter {
16
+ static _findBin() {
17
+ const home = process.env.HOME || homedir();
18
+ const candidates = [
19
+ path.join(home, '.npm-global/bin/openclaw'),
20
+ '/usr/local/bin/openclaw',
21
+ '/opt/homebrew/bin/openclaw',
22
+ ];
23
+ for (const c of candidates) if (existsSync(c)) return c;
24
+ return null;
25
+ }
26
+
27
+ static isAvailable() {
28
+ return !!OpenClawCLI._findBin();
29
+ }
30
+
31
+ constructor() {
32
+ super();
33
+ this.activeAgents = new Map();
34
+ // Full absolute path to the openclaw script
35
+ this.bin = OpenClawCLI._findBin() || '/usr/local/bin/openclaw';
36
+ // Read valid Anthropic API key from local auth-profiles at startup
37
+ // This is passed as ANTHROPIC_API_KEY to every openclaw spawn, bypassing
38
+ // any broken auth-profiles.json on this machine (e.g. invalid default key)
39
+ this.anthropicApiKey = this._readAnthropicKey();
40
+ // OpenClaw Gateway streaming config — populated by worker.js on init
41
+ this.gatewayPort = null;
42
+ this.gatewayToken = null;
43
+ }
44
+
45
+ /**
46
+ * Run an agent task via the OpenClaw Gateway's OpenAI-compatible streaming endpoint.
47
+ * Returns the full response text, emitting agent_output events in real-time for each token.
48
+ * Falls back to null if the gateway is not configured or the request fails.
49
+ */
50
+ async _runAgentTaskStreaming(agentId, task, sessionId) {
51
+ if (!this.gatewayPort || !this.gatewayToken) return null;
52
+ const url = `http://127.0.0.1:${this.gatewayPort}/v1/chat/completions`;
53
+ const sessionKey = `agent:${agentId}:main`;
54
+ let fullText = '';
55
+
56
+ // Friendly names for tool calls shown as live messages to the user
57
+ const toolLabels = {
58
+ read: '📄 Reading file', write: '✏️ Writing file', edit: '✏️ Editing file',
59
+ bash: '⚡ Running command', exec: '⚡ Running command', process: '⚡ Running process',
60
+ web_search: '🔍 Searching the web', web_fetch: '🌐 Fetching page',
61
+ browser: '🌐 Browsing', image: '🖼️ Analyzing image',
62
+ memory_search: '🧠 Searching memory', memory_get: '🧠 Reading memory',
63
+ sessions_spawn: '🤖 Spawning sub-agent', sessions_send: '📤 Sending to agent',
64
+ tts: '🔊 Generating speech', canvas: '🎨 Updating canvas',
65
+ };
66
+
67
+ try {
68
+ const res = await fetch(url, {
69
+ method: 'POST',
70
+ headers: {
71
+ 'Authorization': `Bearer ${this.gatewayToken}`,
72
+ 'Content-Type': 'application/json',
73
+ // Target the agent's main session so history is preserved
74
+ 'x-openclaw-agent-id': agentId,
75
+ 'x-openclaw-session-key': sessionKey,
76
+ },
77
+ body: JSON.stringify({
78
+ model: `openclaw:${agentId}`,
79
+ messages: [{ role: 'user', content: task }],
80
+ stream: true,
81
+ }),
82
+ signal: AbortSignal.timeout(600_000), // 10 min max
83
+ });
84
+ if (!res.ok) {
85
+ console.warn(`[${agentId}] ⚠️ Streaming HTTP ${res.status} — falling back to subprocess`);
86
+ return null;
87
+ }
88
+
89
+ // Parse SSE stream — emit text tokens AND tool call activity
90
+ const decoder = new TextDecoder();
91
+ let buffer = '';
92
+ const seenToolCallIds = new Set(); // avoid duplicate tool_activity for same call_id
93
+ const pendingToolCalls = new Map(); // index -> { id, name, args }
94
+
95
+ for await (const rawChunk of res.body) {
96
+ buffer += decoder.decode(rawChunk, { stream: true });
97
+ const lines = buffer.split('\n');
98
+ buffer = lines.pop() ?? ''; // keep incomplete line
99
+ for (const line of lines) {
100
+ const trimmed = line.trim();
101
+ if (!trimmed.startsWith('data:')) continue;
102
+ const json = trimmed.slice(5).trim();
103
+ if (json === '[DONE]') break;
104
+ try {
105
+ const chunk = JSON.parse(json);
106
+ const choice = chunk?.choices?.[0];
107
+ if (!choice) continue;
108
+
109
+ // Text token — stream to dashboard
110
+ const delta = choice.delta?.content;
111
+ if (delta) {
112
+ fullText += delta;
113
+ this.emit('agent_output', { agentId, output: delta });
114
+ }
115
+
116
+ // Tool call delta — detect new tool calls and emit activity events
117
+ const toolCallDeltas = choice.delta?.tool_calls;
118
+ if (toolCallDeltas) {
119
+ for (const tc of toolCallDeltas) {
120
+ const idx = tc.index ?? 0;
121
+ if (!pendingToolCalls.has(idx)) {
122
+ pendingToolCalls.set(idx, { id: tc.id || '', name: tc.function?.name || '', args: '' });
123
+ } else {
124
+ const pending = pendingToolCalls.get(idx);
125
+ if (tc.function?.name && !pending.name) pending.name = tc.function.name;
126
+ if (tc.id && !pending.id) pending.id = tc.id;
127
+ if (tc.function?.arguments) pending.args += tc.function.arguments;
128
+ }
129
+
130
+ // Emit tool_activity as soon as we have a name and haven't emitted for this call yet
131
+ const pending = pendingToolCalls.get(idx);
132
+ const callKey = pending.id || `${idx}-${pending.name}`;
133
+ if (pending.name && !seenToolCallIds.has(callKey)) {
134
+ seenToolCallIds.add(callKey);
135
+ const label = toolLabels[pending.name] || `🔧 ${pending.name}`;
136
+ console.log(`[${agentId}] 🛠️ Tool call: ${pending.name}`);
137
+
138
+ // Emit tool_activity so the typing indicator + thinking log update
139
+ this.emit('tool_activity', {
140
+ agentId,
141
+ event: 'tool_start',
142
+ tool: pending.name,
143
+ description: label,
144
+ });
145
+ }
146
+ }
147
+ }
148
+
149
+ // Tool calls finishing — emit complete events
150
+ if (choice.finish_reason === 'tool_calls') {
151
+ for (const [, tc] of pendingToolCalls) {
152
+ if (tc.name) {
153
+ this.emit('tool_activity', { agentId, event: 'tool_end', tool: tc.name, description: `✓ ${tc.name}` });
154
+ }
155
+ }
156
+ pendingToolCalls.clear();
157
+ }
158
+ } catch { /* malformed chunk — skip */ }
159
+ }
160
+ }
161
+ // Return an object so callers can distinguish "success with no text" from "request failed"
162
+ return { text: fullText, succeeded: true };
163
+ } catch (err) {
164
+ console.warn(`[${agentId}] ⚠️ Streaming HTTP error: ${err.message} — falling back to subprocess`);
165
+ return null; // null = request failed, subprocess fallback needed
166
+ }
167
+ }
168
+
169
+ /** Read the first valid Anthropic API key from local auth-profiles (skips :default) */
170
+ _readAnthropicKey() {
171
+ // 1. Env var override
172
+ if (process.env.ANTHROPIC_API_KEY) {
173
+ console.log('🔑 Anthropic key loaded from ANTHROPIC_API_KEY env');
174
+ return process.env.ANTHROPIC_API_KEY;
175
+ }
176
+ // 2. ~/.agentforge/config.json anthropicToken field
177
+ try {
178
+ const cfg = path.join(homedir(), '.agentforge', 'config.json');
179
+ if (existsSync(cfg)) {
180
+ const d = JSON.parse(readFileSync(cfg, 'utf-8'));
181
+ if (d.anthropicToken) {
182
+ console.log('🔑 Anthropic key loaded from agentforge config');
183
+ return d.anthropicToken;
184
+ }
185
+ }
186
+ } catch {}
187
+ // 3. Scan agent auth-profiles
188
+ try {
189
+ const agentsDir = path.join(homedir(), '.openclaw', 'agents');
190
+ if (!existsSync(agentsDir)) return null;
191
+ for (const dir of readdirSync(agentsDir)) {
192
+ const authPath = path.join(agentsDir, dir, 'agent', 'auth-profiles.json');
193
+ if (!existsSync(authPath)) continue;
194
+ try {
195
+ const data = JSON.parse(readFileSync(authPath, 'utf-8'));
196
+ for (const [name, profile] of Object.entries(data?.profiles || {})) {
197
+ if (name !== 'anthropic:default' && profile?.provider === 'anthropic' && profile?.token) {
198
+ console.log(`🔑 Anthropic key loaded from profile: ${name}`);
199
+ return profile.token;
200
+ }
201
+ }
202
+ } catch {}
203
+ }
204
+ } catch (e) {
205
+ console.warn('⚠️ Could not read auth-profiles:', e.message);
206
+ }
207
+ return null;
208
+ }
209
+
210
+ /**
211
+ * Extract toolInput JSON from a verbose openclaw line.
212
+ * Handles brace-balanced extraction so nested objects parse correctly.
213
+ * Returns parsed object or null.
214
+ */
215
+ extractToolInput(line) {
216
+ const idx = line.indexOf('toolInput=');
217
+ if (idx === -1) return null;
218
+ const rest = line.slice(idx + 10);
219
+
220
+ // Object input: toolInput={...}
221
+ if (rest.startsWith('{')) {
222
+ let depth = 0, end = 0;
223
+ for (let i = 0; i < rest.length; i++) {
224
+ if (rest[i] === '{') depth++;
225
+ else if (rest[i] === '}') {
226
+ depth--;
227
+ if (depth === 0) { end = i + 1; break; }
228
+ }
229
+ }
230
+ if (end === 0) return null;
231
+ try {
232
+ return JSON.parse(rest.slice(0, end));
233
+ } catch (e) {
234
+ return null;
235
+ }
236
+ }
237
+
238
+ // String input: toolInput="some text" — wrap in {text:} so extractSpeechText can find it
239
+ if (rest.startsWith('"')) {
240
+ let end = 1;
241
+ while (end < rest.length) {
242
+ if (rest[end] === '"' && rest[end - 1] !== '\\') { end++; break; }
243
+ end++;
244
+ }
245
+ try {
246
+ const str = JSON.parse(rest.slice(0, end));
247
+ return { text: str };
248
+ } catch (e) {
249
+ return null;
250
+ }
251
+ }
252
+
253
+ // Unquoted text: toolInput=Hello world (no braces or quotes)
254
+ // Take everything up to the next key=value pattern or end of line
255
+ const unquotedMatch = rest.match(/^([^{"\s][^\n]*?)(?:\s+\w+=[^\s]|$)/);
256
+ if (unquotedMatch && unquotedMatch[1].trim()) {
257
+ return { text: unquotedMatch[1].trim() };
258
+ }
259
+
260
+ return null;
261
+ }
262
+
263
+ /**
264
+ * Try to extract tts speech text from a raw log line using multiple patterns.
265
+ * Fallback for when extractToolInput returns null (openclaw format varies).
266
+ */
267
+ extractTtsTextFromRaw(line) {
268
+ if (!line) return null;
269
+ // input="...", text="...", content="...", message="..."
270
+ const quoted = line.match(/(?:^|\s)(?:input|text|content|message|speech|utterance)\s*=\s*"((?:[^"\\]|\\.)*)"/i);
271
+ if (quoted) return quoted[1];
272
+ // input={text:"..."} or input={"text":"..."}
273
+ const jsonMatch = line.match(/(?:^|\s)(?:input|args)\s*=\s*(\{[^}]+\})/i);
274
+ if (jsonMatch) {
275
+ try {
276
+ const obj = JSON.parse(jsonMatch[1]);
277
+ if (obj.text) return obj.text;
278
+ if (obj.input) return obj.input;
279
+ } catch {}
280
+ }
281
+ return null;
282
+ }
283
+
284
+ /**
285
+ * Build a specific, readable description from a tool name + its parsed input.
286
+ * Returns a rich string like "📄 Read dashboard.js (L1-200)" or null if no useful info.
287
+ */
288
+ buildRichDescription(toolName, toolInput) {
289
+ if (!toolInput) return null;
290
+ switch (toolName) {
291
+ case 'read': {
292
+ const fp = toolInput.file_path || toolInput.path || '';
293
+ const base = fp ? path.basename(fp) : '';
294
+ if (!base) return null;
295
+ return `Reviewing ${base}`;
296
+ }
297
+ case 'write': {
298
+ const fp = toolInput.file_path || toolInput.path || '';
299
+ return fp ? `Writing ${path.basename(fp)}` : null;
300
+ }
301
+ case 'edit': {
302
+ const fp = toolInput.file_path || toolInput.path || '';
303
+ return fp ? `Updating ${path.basename(fp)}` : null;
304
+ }
305
+ case 'multiedit': {
306
+ const fp = toolInput.file_path || toolInput.path || '';
307
+ return fp ? `Updating ${path.basename(fp)}` : null;
308
+ }
309
+ case 'exec':
310
+ case 'bash': {
311
+ const cmd = (toolInput.command || toolInput.cmd || '').trim();
312
+ if (!cmd) return null;
313
+ // Interpret common commands naturally
314
+ if (/screencapture/.test(cmd)) return `Taking a screenshot`;
315
+ if (/^open\s/.test(cmd)) return `Opening in browser`;
316
+ if (/^npm\s+install/.test(cmd)) return `Installing dependencies`;
317
+ if (/^npm\s+run\s+(\S+)/.test(cmd)) return `Running ${cmd.match(/^npm\s+run\s+(\S+)/)[1]}`;
318
+ if (/^npm\s/.test(cmd)) return `Running npm`;
319
+ if (/^npx\s/.test(cmd)) return `Running npx`;
320
+ if (/^git\s+clone/.test(cmd)) return `Cloning repository`;
321
+ if (/^git\s+/.test(cmd)) return `Running git`;
322
+ if (/^curl\s/.test(cmd)) return `Making a request`;
323
+ if (/^python/.test(cmd)) return `Running Python script`;
324
+ if (/^node\s/.test(cmd)) return `Running Node.js`;
325
+ if (/^ls\s?/.test(cmd)) return `Listing files`;
326
+ if (/^mkdir/.test(cmd)) return `Creating directory`;
327
+ if (/^cp\s/.test(cmd)) return `Copying file`;
328
+ if (/^mv\s/.test(cmd)) return `Moving file`;
329
+ if (/^rm\s/.test(cmd)) return `Removing file`;
330
+ if (/^cat\s/.test(cmd)) return `Reading file`;
331
+ // Fallback: show trimmed command but no more than 60 chars
332
+ return cmd.length > 60 ? cmd.slice(0, 60) + '…' : cmd;
333
+ }
334
+ case 'web_search': {
335
+ const q = toolInput.query || '';
336
+ if (!q) return null;
337
+ return `Searching for "${q.length > 60 ? q.slice(0, 60) + '…' : q}"`;
338
+ }
339
+ case 'web_fetch':
340
+ case 'webfetch': {
341
+ const url = toolInput.url || '';
342
+ if (!url) return null;
343
+ try {
344
+ const u = new URL(url);
345
+ return `Reading ${u.hostname}`;
346
+ } catch {
347
+ return `Reading page`;
348
+ }
349
+ }
350
+ case 'glob': {
351
+ const pattern = toolInput.pattern || '';
352
+ return pattern ? `Finding files` : null;
353
+ }
354
+ case 'grep': {
355
+ const pat = toolInput.pattern || '';
356
+ if (!pat) return null;
357
+ return `Searching codebase`;
358
+ }
359
+ case 'browser': {
360
+ const action = (toolInput.action || '').toLowerCase();
361
+ if (action === 'navigate' || action === 'goto' || action === 'open') {
362
+ const url = toolInput.url || toolInput.targetUrl || '';
363
+ try { const u = new URL(url); return `Opening ${u.hostname}`; }
364
+ catch { return `Opening page`; }
365
+ }
366
+ if (action === 'click') return `Clicking on the page`;
367
+ if (action === 'screenshot' || action === 'snapshot') return `Taking a screenshot`;
368
+ if (action === 'type') return `Entering text`;
369
+ if (action === 'scroll') return `Scrolling`;
370
+ if (action === 'act') return `Interacting with page`;
371
+ return `Browsing`;
372
+ }
373
+ case 'todocreate':
374
+ case 'todowrite':
375
+ case 'todo_write':
376
+ return `Updating task list`;
377
+ case 'memory_search': {
378
+ const q = toolInput.query || '';
379
+ return q ? `Recalling "${q.slice(0, 45)}"` : `Searching memory`;
380
+ }
381
+ case 'sessions_spawn': {
382
+ return `Starting a sub-agent`;
383
+ }
384
+ case 'sessions_send': {
385
+ return `Sending message to agent`;
386
+ }
387
+ default: {
388
+ const firstStr = Object.values(toolInput).find(v => typeof v === 'string' && v.length > 0);
389
+ if (firstStr) return `${firstStr.slice(0, 60)}`;
390
+ return null;
391
+ }
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Parse tool activity from OpenClaw output
397
+ * Detects patterns like: "embedded run tool start: ... tool=read toolCallId=..."
398
+ * Also detects API calls, streaming, retries, and other lifecycle events
399
+ */
400
+ parseToolActivity(line) {
401
+ const trimmed = line.trim();
402
+
403
+ // Map tool names to friendly descriptions (fallback when toolInput is absent)
404
+ const toolDescriptions = {
405
+ 'read': 'Reviewing file',
406
+ 'write': 'Writing file',
407
+ 'edit': 'Updating file',
408
+ 'exec': 'Running command',
409
+ 'bash': 'Running command',
410
+ 'process': 'Running process',
411
+ 'web_search': 'Searching the web',
412
+ 'web_fetch': 'Reading page',
413
+ 'browser': 'Browsing',
414
+ 'image': 'Analyzing image',
415
+ 'memory_search': 'Searching memory',
416
+ 'memory_get': 'Reading memory',
417
+ 'message': 'Sending message',
418
+ 'cron': 'Scheduling task',
419
+ 'tts': 'Generating speech',
420
+ 'canvas': 'Updating canvas',
421
+ 'nodes': 'Checking nodes',
422
+ 'gateway': 'Gateway operation',
423
+ 'sessions_spawn': 'Starting a sub-agent',
424
+ 'sessions_send': 'Sending message to agent',
425
+ 'sessions_list': 'Checking active agents',
426
+ 'sessions_history': 'Reading conversation history',
427
+ 'session_status': 'Checking status',
428
+ 'agents_list': 'Listing agents',
429
+ };
430
+
431
+ // Match OpenClaw embedded tool start pattern: "tool start: ... tool=read"
432
+ const toolStartMatch = trimmed.match(/tool start:.*tool=(\w+)/i);
433
+ if (toolStartMatch) {
434
+ const toolName = toolStartMatch[1].toLowerCase();
435
+ const toolInput = this.extractToolInput(trimmed);
436
+ const richDesc = toolInput ? this.buildRichDescription(toolName, toolInput) : null;
437
+ return {
438
+ event: 'tool_start',
439
+ tool: toolName,
440
+ toolInput,
441
+ description: richDesc || toolDescriptions[toolName] || `Running ${toolName}...`,
442
+ raw: trimmed
443
+ };
444
+ }
445
+
446
+ // Match tool end pattern: "tool end: ... tool=read"
447
+ const toolEndMatch = trimmed.match(/tool end:.*tool=(\w+)/i);
448
+ if (toolEndMatch) {
449
+ const toolName = toolEndMatch[1].toLowerCase();
450
+ const durationMatch = trimmed.match(/durationMs=(\d+)/);
451
+ const durationMs = durationMatch ? parseInt(durationMatch[1]) : null;
452
+ return {
453
+ event: 'tool_end',
454
+ tool: toolName,
455
+ durationMs,
456
+ description: durationMs && durationMs > 2000
457
+ ? `✓ ${toolName} (${(durationMs / 1000).toFixed(1)}s)`
458
+ : `✓ ${toolName}`,
459
+ raw: trimmed
460
+ };
461
+ }
462
+
463
+ // Match toolResult in output (tool completed)
464
+ if (trimmed.includes('toolResult') || trimmed.includes('tool result')) {
465
+ return {
466
+ event: 'tool_result',
467
+ tool: null,
468
+ description: 'Processing tool result',
469
+ raw: trimmed
470
+ };
471
+ }
472
+
473
+ // === API/Model lifecycle events ===
474
+ // OpenClaw format: "embedded run <event>: runId=... key=value ..."
475
+
476
+ // Prompt start = calling the model API (format: "embedded run prompt start:")
477
+ if (trimmed.includes('run prompt start:') || trimmed.includes('prompt start:')) {
478
+ return {
479
+ event: 'api_call_start',
480
+ tool: null,
481
+ description: '🌐 Calling model API...',
482
+ raw: trimmed
483
+ };
484
+ }
485
+
486
+ // Prompt end = model response received (format: "embedded run prompt end: ... durationMs=N")
487
+ const promptEndMatch = trimmed.match(/(?:run )?prompt end:.*durationMs=(\d+)/i);
488
+ if (promptEndMatch) {
489
+ const durationMs = parseInt(promptEndMatch[1]);
490
+ return {
491
+ event: 'api_call_end',
492
+ tool: null,
493
+ description: `✅ Model responded (${(durationMs/1000).toFixed(1)}s)`,
494
+ durationMs,
495
+ raw: trimmed
496
+ };
497
+ }
498
+
499
+ // Run start (format: "embedded run start: runId=... provider=... model=...")
500
+ if (trimmed.includes('embedded run start:') || trimmed.includes('run start:')) {
501
+ const modelMatch = trimmed.match(/model=([^\s]+)/);
502
+ const providerMatch = trimmed.match(/provider=([^\s]+)/);
503
+ return {
504
+ event: 'run_start',
505
+ tool: null,
506
+ description: `🚀 Starting run (${providerMatch?.[1] || 'unknown'}/${modelMatch?.[1] || 'unknown'})`,
507
+ raw: trimmed
508
+ };
509
+ }
510
+
511
+ // Run done (format: "embedded run done: ... durationMs=N aborted=...")
512
+ if (trimmed.includes('embedded run done:') || trimmed.includes('run done:')) {
513
+ const durationMatch = trimmed.match(/durationMs=(\d+)/);
514
+ const abortedMatch = trimmed.match(/aborted=(true|false)/);
515
+ const durationMs = durationMatch ? parseInt(durationMatch[1]) : null;
516
+ const wasAborted = abortedMatch?.[1] === 'true';
517
+ return {
518
+ event: 'run_done',
519
+ tool: null,
520
+ description: durationMs
521
+ ? `🏁 Run ${wasAborted ? 'aborted' : 'complete'} (${(durationMs/1000).toFixed(1)}s total)`
522
+ : '🏁 Run complete',
523
+ durationMs,
524
+ aborted: wasAborted,
525
+ raw: trimmed
526
+ };
527
+ }
528
+
529
+ // Compaction (format: "embedded run compaction start:")
530
+ if (trimmed.includes('compaction start:')) {
531
+ return {
532
+ event: 'compaction_start',
533
+ tool: null,
534
+ description: '🗜️ Compacting context...',
535
+ raw: trimmed
536
+ };
537
+ }
538
+
539
+ // Compaction retry
540
+ if (trimmed.includes('compaction retry:')) {
541
+ return {
542
+ event: 'compaction_retry',
543
+ tool: null,
544
+ description: '🗜️ Retrying compaction...',
545
+ raw: trimmed
546
+ };
547
+ }
548
+
549
+ // Timeout (format: "embedded run timeout:")
550
+ if (trimmed.includes('run timeout:')) {
551
+ return {
552
+ event: 'timeout',
553
+ tool: null,
554
+ description: '⏰ Run timed out',
555
+ raw: trimmed
556
+ };
557
+ }
558
+
559
+ // Abort still streaming warning
560
+ if (trimmed.includes('abort still streaming:')) {
561
+ return {
562
+ event: 'abort_streaming',
563
+ tool: null,
564
+ description: '⚠️ Aborting while still streaming',
565
+ raw: trimmed
566
+ };
567
+ }
568
+
569
+ // Rate limits / retries (various patterns)
570
+ if (trimmed.includes('rate limit') || trimmed.includes('429') ||
571
+ trimmed.includes('retrying') || trimmed.includes('retry:')) {
572
+ return {
573
+ event: 'rate_limit',
574
+ tool: null,
575
+ description: '⏳ Rate limited, retrying...',
576
+ raw: trimmed
577
+ };
578
+ }
579
+
580
+ // Agent lifecycle (format: "embedded run agent start:" / "embedded run agent end:")
581
+ if (trimmed.includes('run agent start:') || trimmed.includes('agent start:')) {
582
+ return {
583
+ event: 'agent_start',
584
+ tool: null,
585
+ description: '🤖 Agent starting...',
586
+ raw: trimmed
587
+ };
588
+ }
589
+
590
+ if (trimmed.includes('run agent end:') || trimmed.includes('agent end:')) {
591
+ return {
592
+ event: 'agent_end',
593
+ tool: null,
594
+ description: '🤖 Agent finished',
595
+ raw: trimmed
596
+ };
597
+ }
598
+
599
+ return null;
600
+ }
601
+
602
+ /**
603
+ * Strip ANSI escape codes from a string
604
+ */
605
+ stripAnsi(str) {
606
+ // Matches ESC[ sequences (colors, cursor movement, etc.)
607
+ return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
608
+ .replace(/\x1b\][^\x07]*\x07/g, '') // OSC sequences
609
+ .replace(/\x1b[^[]/g, ''); // Other ESC sequences
610
+ }
611
+
612
+ /**
613
+ * Check if a line is a diagnostic/verbose log that should be hidden from chat
614
+ */
615
+ isDiagnosticLog(line) {
616
+ const trimmed = this.stripAnsi(line).trim();
617
+ return trimmed.includes('[agent/embedded]') ||
618
+ trimmed.includes('[diagnostic]') ||
619
+ trimmed.includes('lane enqueue:') ||
620
+ trimmed.includes('lane dequeue:') ||
621
+ trimmed.includes('lane task done:') ||
622
+ trimmed.includes('session state:') ||
623
+ trimmed.includes('run registered:') ||
624
+ trimmed.includes('run cleared:') ||
625
+ trimmed.includes('embedded run') ||
626
+ trimmed.includes('Registered plugin command:') ||
627
+ trimmed.includes('(plugin:') ||
628
+ // Filter out error messages - users shouldn't see technical errors
629
+ trimmed.startsWith('Error:') ||
630
+ trimmed.includes('Agent failed:') ||
631
+ trimmed.includes('[openclaw]') ||
632
+ trimmed.includes('Unhandled promise rejection') ||
633
+ trimmed.includes('shared_storage_worklet') ||
634
+ trimmed.includes('playwright-core') ||
635
+ trimmed.includes('ECONNREFUSED') ||
636
+ trimmed.includes('ETIMEDOUT') ||
637
+ trimmed.includes('Command exited with code') ||
638
+ trimmed.includes('targetInfo') ||
639
+ trimmed.includes('crBrowser') ||
640
+ trimmed.includes('crConnection') ||
641
+ // Image processing / tool internals
642
+ trimmed.startsWith('Optimized PNG') ||
643
+ trimmed.startsWith('Optimized JPEG') ||
644
+ trimmed.startsWith('Optimized image') ||
645
+ trimmed.includes('preserving alpha') ||
646
+ trimmed.includes('side≤') ||
647
+ trimmed.match(/^\d+x\d+px [\d.]+(KB|MB)/) ||
648
+ trimmed.includes('Image resized to fit') ||
649
+ trimmed.includes('→') && trimmed.includes('KB') && trimmed.includes('->') ||
650
+ trimmed.includes('(side≤');
651
+ }
652
+
653
+ /**
654
+ * Register an agent with Canary and get its API key
655
+ * Each agent gets its own identity in Canary for proper tracking
656
+ */
657
+ async registerWithCanary(agentId, workDir) {
658
+ try {
659
+ const response = await fetch(`${CANARY_ENDPOINT}/api/agents/register`, {
660
+ method: 'POST',
661
+ headers: { 'Content-Type': 'application/json' },
662
+ body: JSON.stringify({
663
+ name: `AgentForge: ${agentId}`,
664
+ description: `Auto-registered agent working on ${path.basename(workDir)}`,
665
+ platform: 'openclaw',
666
+ owner_hint: 'AgentForge spawned agent',
667
+ // If parent API key is set, sub-agents auto-inherit ownership
668
+ ...(CANARY_PARENT_API_KEY ? { parent_agent_api_key: CANARY_PARENT_API_KEY } : {})
669
+ })
670
+ });
671
+
672
+ if (!response.ok) {
673
+ console.log(`⚠️ Canary registration failed: ${response.status}`);
674
+ return null;
675
+ }
676
+
677
+ const data = await response.json();
678
+ console.log(`🐤 Registered with Canary: ${data.agent?.id || 'unknown'}`);
679
+
680
+ if (data.agent?.claim_url) {
681
+ console.log(` Claim URL: ${data.agent.claim_url}`);
682
+ }
683
+
684
+ return data.agent?.api_key || null;
685
+ } catch (error) {
686
+ console.log(`⚠️ Could not reach Canary: ${error.message}`);
687
+ return null;
688
+ }
689
+ }
690
+
691
+ /**
692
+ * Create a new isolated agent
693
+ */
694
+ async createAgent(agentId, workDir) {
695
+ // Ensure workspace directory exists before openclaw tries to use it
696
+ mkdirSync(workDir, { recursive: true });
697
+
698
+ // Register with Canary to get agent-specific API key
699
+ const canaryApiKey = await this.registerWithCanary(agentId, workDir);
700
+
701
+ // Store Canary API key in agent's workspace for persistence
702
+ if (canaryApiKey) {
703
+ const canaryConfigPath = path.join(workDir, '.canary');
704
+ writeFileSync(canaryConfigPath, JSON.stringify({
705
+ apiKey: canaryApiKey,
706
+ agentId,
707
+ registeredAt: new Date().toISOString()
708
+ }, null, 2));
709
+ }
710
+
711
+ return new Promise((resolve, reject) => {
712
+ console.log(`Creating OpenClaw agent: ${agentId}`);
713
+ console.log(` Working directory: ${workDir}\n`);
714
+
715
+ const proc = spawn(process.execPath, [this.bin,
716
+ 'agents', 'add', agentId, '--workspace', workDir, '--non-interactive'
717
+ ], { env: { ...process.env } });
718
+
719
+ let output = '';
720
+ let error = '';
721
+
722
+ proc.stdout.on('data', (data) => {
723
+ output += data.toString();
724
+ });
725
+
726
+ proc.stderr.on('data', (data) => {
727
+ error += data.toString();
728
+ });
729
+
730
+ proc.on('error', (err) => {
731
+ console.error(`⚠️ Failed to spawn openclaw for createAgent: ${err.message}`);
732
+ resolve({ agentId, workDir }); // Don't crash - agent may already exist
733
+ });
734
+
735
+ proc.on('close', async (code) => {
736
+ if (code === 0) {
737
+ console.log(`✓ Agent created: ${agentId}`);
738
+ } else {
739
+ // Agent might already exist, which is fine
740
+ console.log(`⚠️ Agent creation failed or already exists: ${agentId}`);
741
+ if (error) {
742
+ console.error(` Error: ${error.trim()}`);
743
+ }
744
+ }
745
+
746
+ // Copy AgentForge template files AFTER openclaw creates the agent
747
+ // This ensures MEMORY.md and memory/ dir exist for memory persistence
748
+ // Use bundled templates (packaged with worker) as primary source,
749
+ // fall back to /tmp if somehow missing
750
+ const bundledTemplateDir = path.join(path.dirname(new URL(import.meta.url).pathname), '../../templates/agent');
751
+ const templateDir = existsSync(bundledTemplateDir) ? bundledTemplateDir : '/tmp/agentforge/templates/agent';
752
+ console.log(`📁 Using templates from: ${templateDir}`);
753
+ try {
754
+ if (existsSync(templateDir)) {
755
+ const { execSync } = await import('child_process');
756
+
757
+ // Copy MEMORY.md if it doesn't exist
758
+ const memoryMdSrc = path.join(templateDir, 'MEMORY.md');
759
+ const memoryMdDst = path.join(workDir, 'MEMORY.md');
760
+ if (existsSync(memoryMdSrc) && !existsSync(memoryMdDst)) {
761
+ execSync(`cp "${memoryMdSrc}" "${memoryMdDst}"`, { stdio: 'ignore' });
762
+ console.log(`📁 Added MEMORY.md template`);
763
+ }
764
+
765
+ // Create memory/ directory if it doesn't exist
766
+ const memoryDir = path.join(workDir, 'memory');
767
+ if (!existsSync(memoryDir)) {
768
+ mkdirSync(memoryDir, { recursive: true });
769
+ console.log(`📁 Created memory/ directory`);
770
+ }
771
+
772
+ // Copy core identity/personality files — always overwrite so all machines stay in sync
773
+ for (const fname of ['AGENTS.md', 'SOUL.md', 'TOOLS.md']) {
774
+ const src = path.join(templateDir, fname);
775
+ const dst = path.join(workDir, fname);
776
+ if (existsSync(src)) {
777
+ execSync(`cp "${src}" "${dst}"`, { stdio: 'ignore' });
778
+ console.log(`📁 Applied ${fname} template`);
779
+ }
780
+ }
781
+
782
+ // Copy AGENTFORGE.md (platform guide with projects path)
783
+ const agentforgeMdSrc = path.join(templateDir, 'AGENTFORGE.md');
784
+ const agentforgeMdDst = path.join(workDir, 'AGENTFORGE.md');
785
+ if (existsSync(agentforgeMdSrc)) {
786
+ execSync(`cp "${agentforgeMdSrc}" "${agentforgeMdDst}"`, { stdio: 'ignore' });
787
+ console.log(`📁 Added AGENTFORGE.md platform guide`);
788
+ }
789
+ }
790
+ } catch (err) {
791
+ console.warn(`⚠️ Template setup failed: ${err.message}`);
792
+ }
793
+
794
+ resolve({ agentId, workDir });
795
+ });
796
+ });
797
+ }
798
+
799
+ /**
800
+ * Run an agent task
801
+ * Images are saved to workspace and referenced in message for vision model analysis
802
+ */
803
+ async runAgentTask(agentId, task, workDir, sessionId = null, image = null, browserProfile = null, imageWorkDir = null) {
804
+ // ── Gateway path disabled — subprocess shows live tool activity ──────────
805
+ // Gateway path: SSE token streaming — tokens arrive live as the model generates.
806
+ // Dashboard buffers tokens into sentences before showing each as a complete bubble.
807
+ if (!image && this.gatewayPort && this.gatewayToken) {
808
+ console.log(`\n🤖 Running agent (streaming): ${agentId}`);
809
+ console.log(` Task: ${task.slice(0, 120)}${task.length > 120 ? '…' : ''}`);
810
+ try {
811
+ const streamResult = await this._runAgentTaskStreaming(agentId, task, sessionId);
812
+ if (streamResult !== null) {
813
+ // Gateway request succeeded (streamResult.succeeded === true).
814
+ // Use the text response if any; if the agent only did tool work with no
815
+ // text output, return empty string — do NOT fall back to subprocess (which
816
+ // would re-run the same task a second time and corrupt the session state).
817
+ const responseText = streamResult.text || '';
818
+ if (!responseText) {
819
+ console.log(`[${agentId}] ✅ Gateway task completed with no text output (tool-only task)`);
820
+ }
821
+ let identity = { identityName: agentId, identityEmoji: '🤖' };
822
+ try { identity = await this.getAgentIdentity(agentId); } catch { /* ignore */ }
823
+ this.emit('agent_completed', { agentId, duration: 0, result: { output: responseText }, identity });
824
+ return { success: true, agentId, duration: 0, result: { output: responseText }, identity };
825
+ }
826
+ console.warn(`[${agentId}] ⚠️ Streaming request failed — falling back to subprocess`);
827
+ } catch (err) {
828
+ console.warn(`[${agentId}] ⚠️ Streaming failed (${err.message}) — falling back to subprocess`);
829
+ }
830
+ }
831
+ // ── Subprocess fallback ────────────────────────────────────────────────────
832
+ return new Promise(async (resolve, reject) => {
833
+ console.log(`\n🤖 Running agent: ${agentId}`);
834
+ console.log(` Task: ${task}`);
835
+ console.log(` Working dir: ${workDir}`);
836
+ if (sessionId) {
837
+ console.log(` Session: ${sessionId}`);
838
+ }
839
+ if (browserProfile) {
840
+ console.log(` Browser profile: ${browserProfile}`);
841
+ }
842
+
843
+ let imagePath = null;
844
+ let modifiedTask = task;
845
+
846
+ // Handle image by saving to workspace
847
+ if (image) {
848
+ try {
849
+ const fs = await import('fs/promises');
850
+ const path = await import('path');
851
+
852
+ // Use imageWorkDir (agent's actual workspace) if provided, otherwise fall back to workDir
853
+ const imageDir = imageWorkDir || workDir;
854
+
855
+ // Ensure workspace directory exists before saving image
856
+ await fs.mkdir(imageDir, { recursive: true });
857
+
858
+ // Extract base64 data (remove data:image/png;base64, prefix if present)
859
+ const base64Data = image.replace(/^data:image\/\w+;base64,/, '');
860
+ const buffer = Buffer.from(base64Data, 'base64');
861
+
862
+ // Save to workspace with timestamp to avoid conflicts
863
+ const timestamp = Date.now();
864
+ const imageFileName = `uploaded_image_${timestamp}.png`;
865
+ imagePath = path.join(imageDir, imageFileName);
866
+
867
+ await fs.writeFile(imagePath, buffer);
868
+ console.log(` 📷 Image saved to: ${imagePath}`);
869
+
870
+ // Modify task to reference the image
871
+ modifiedTask = `${task}\n\n[There is an image file in the workspace: ${imageFileName}]`;
872
+ } catch (error) {
873
+ console.error(` ⚠️ Failed to save image: ${error.message}`);
874
+ }
875
+ }
876
+
877
+ console.log('\n');
878
+
879
+ const startTime = Date.now();
880
+ let runCompleted = false;
881
+ let completionTimer = null;
882
+ let promiseSettled = false;
883
+ let agentEndSeen = false; // true once "agent end: isError=false" fires
884
+
885
+ // Build command arguments with potentially modified task
886
+ // --local is required since openclaw 2026.3.8 to run embedded instead of via Gateway
887
+ const args = [
888
+ 'agent',
889
+ '--local',
890
+ '--agent', agentId,
891
+ '--message', modifiedTask,
892
+ '--verbose', 'on' // Enable tool call output
893
+ ];
894
+
895
+ // Add session ID for conversation persistence
896
+ if (sessionId) {
897
+ args.push('--session-id', sessionId);
898
+ }
899
+
900
+ // Browser profile selection (cdp-url flag removed - not supported by openclaw agent)
901
+
902
+ // Ensure working directory exists before spawning
903
+ mkdirSync(workDir, { recursive: true });
904
+
905
+ // Load agent-specific Canary API key if available
906
+ let canaryApiKey = null;
907
+ try {
908
+ const canaryConfigPath = path.join(workDir, '.canary');
909
+ if (existsSync(canaryConfigPath)) {
910
+ const canaryConfig = JSON.parse(readFileSync(canaryConfigPath, 'utf-8'));
911
+ canaryApiKey = canaryConfig.apiKey;
912
+ }
913
+ } catch {
914
+ // No Canary config, that's fine
915
+ }
916
+
917
+ // Build environment with agent-specific Canary key + valid Anthropic API key
918
+ const agentEnv = { ...process.env };
919
+ if (canaryApiKey) {
920
+ agentEnv.CANARY_API_KEY = canaryApiKey;
921
+ console.log(` 🐤 Canary tracking: enabled (agent-specific key)`);
922
+ }
923
+ // Inject valid Anthropic key directly — bypasses broken auth-profiles.json on any machine.
924
+ // Prefer the locally cached key, but fall back to process.env which may be set later by the server.
925
+ const resolvedAnthropicKey = this.anthropicApiKey || process.env.ANTHROPIC_API_KEY || null;
926
+ if (resolvedAnthropicKey) {
927
+ agentEnv.ANTHROPIC_API_KEY = resolvedAnthropicKey;
928
+ // Keep the instance cache updated so subsequent spawns reuse the injected key without re-reading env
929
+ if (!this.anthropicApiKey) {
930
+ this.anthropicApiKey = resolvedAnthropicKey;
931
+ }
932
+ }
933
+
934
+ // Auto-fix auth-profiles.json before spawning: remove invalid anthropic:default key
935
+ // This self-heals any machine where the default key returns 401
936
+ try {
937
+ const authPath = path.join(homedir(), '.openclaw', 'agents', agentId, 'agent', 'auth-profiles.json');
938
+ if (existsSync(authPath)) {
939
+ const authData = JSON.parse(readFileSync(authPath, 'utf-8'));
940
+ if (authData?.profiles?.['anthropic:default']) {
941
+ delete authData.profiles['anthropic:default'];
942
+ if (!authData.lastGood) authData.lastGood = {};
943
+ authData.lastGood['anthropic'] = 'anthropic:manual';
944
+ writeFileSync(authPath, JSON.stringify(authData, null, 2));
945
+ console.log(`[${agentId}] 🔑 Auto-fixed auth: removed invalid anthropic:default key`);
946
+ }
947
+ }
948
+ } catch (authFixErr) {
949
+ // Non-fatal - openclaw will fail with 401 if key is bad, which is catchable
950
+ console.warn(`[${agentId}] ⚠️ Auth fix skipped: ${authFixErr.message}`);
951
+ }
952
+
953
+ // Kill any existing process for this agent before spawning — prevents race condition
954
+ // where task 1 resolves via runCompleted but its process is still alive for 5s,
955
+ // task 2 spawns a new process, both fight over session files, task 2 hangs.
956
+ const existingAgent = this.activeAgents.get(agentId);
957
+ if (existingAgent && existingAgent.proc && !existingAgent.proc.killed) {
958
+ console.log(`[${agentId}] 🔪 Killing lingering process (pid ${existingAgent.proc.pid}) before spawning new one`);
959
+ try { treeKill(existingAgent.proc.pid, 'SIGKILL'); } catch (e) { /* already dead */ }
960
+ this.activeAgents.delete(agentId);
961
+ // Wait for process to fully exit and release file locks before spawning
962
+ await new Promise(r => setTimeout(r, 800));
963
+ }
964
+
965
+ // Change to working directory and run agent
966
+ // Use process.execPath (node) directly to avoid shell metacharacter issues
967
+ // with user message content (quotes, apostrophes, etc.)
968
+ const proc = spawn(process.execPath, [this.bin, ...args], {
969
+ cwd: workDir,
970
+ env: agentEnv
971
+ });
972
+
973
+ let output = '';
974
+ let filteredOutput = ''; // user-facing output only — no diagnostic/system logs
975
+ let error = '';
976
+ let recentLines = []; // rolling buffer for TTS context debugging
977
+ let firstOutputSeen = false;
978
+
979
+ // Kill if openclaw produces zero output for 90s — means it's hung on API call
980
+ const firstOutputTimer = setTimeout(() => {
981
+ if (!firstOutputSeen && !promiseSettled) {
982
+ console.warn(`[${agentId}] ⚠️ No output in 90s — openclaw hung, killing`);
983
+ try { proc.kill('SIGKILL'); } catch (e) { /* already dead */ }
984
+ if (!promiseSettled) {
985
+ promiseSettled = true;
986
+ this.activeAgents.delete(agentId);
987
+ reject(new Error('openclaw produced no output within 90s — possible API hang'));
988
+ }
989
+ }
990
+ }, 90000);
991
+
992
+ proc.stdout.on('data', (data) => {
993
+ firstOutputSeen = true;
994
+ clearTimeout(firstOutputTimer);
995
+ const text = data.toString();
996
+ output += text;
997
+
998
+ // Process line by line to filter technical logs
999
+ const lines = text.split('\n');
1000
+ const filteredLines = [];
1001
+
1002
+ for (const line of lines) {
1003
+ const trimmed = this.stripAnsi(line).trim();
1004
+
1005
+ // Detect "agent end: isError=false" — the agent's actual work is done.
1006
+ // After this, openclaw may do compaction (LLM call) and then hang without
1007
+ // ever emitting "embedded run done". Start a 30s grace timer so if the
1008
+ // process doesn't exit cleanly we force-resolve and unblock the queue.
1009
+ if (!agentEndSeen && trimmed.includes('run agent end:') && trimmed.includes('isError=false')) {
1010
+ agentEndSeen = true;
1011
+ console.log(`[${agentId}] ✅ Agent task finished (agent end isError=false), starting 30s grace timer`);
1012
+ if (!completionTimer) {
1013
+ completionTimer = setTimeout(async () => {
1014
+ if (runCompleted || promiseSettled) return; // already handled
1015
+ console.log(`[${agentId}] ⚠️ Process still running 30s after agent end — force killing (compaction hung?)`);
1016
+ runCompleted = true;
1017
+ try { proc.kill('SIGTERM'); } catch (e) { /* already dead */ }
1018
+ setTimeout(() => { try { proc.kill('SIGKILL'); } catch (e) {} }, 1000);
1019
+ if (!promiseSettled) {
1020
+ promiseSettled = true;
1021
+ const duration = Date.now() - startTime;
1022
+ let identity = { identityName: agentId, identityEmoji: '🤖' };
1023
+ try {
1024
+ identity = await Promise.race([
1025
+ this.getAgentIdentity(agentId),
1026
+ new Promise(r => setTimeout(() => r(identity), 5000))
1027
+ ]);
1028
+ } catch (e) { /* use default */ }
1029
+ this.activeAgents.delete(agentId);
1030
+ let result;
1031
+ try { result = JSON.parse(output); } catch (e) { result = { output: filteredOutput }; }
1032
+ console.log(`\n✅ Agent ${agentId} (${identity.identityName}) completed in ${(duration / 1000).toFixed(2)}s (force-resolved after compaction hang)\n`);
1033
+ this.emit('agent_completed', { agentId, duration, result, identity });
1034
+ resolve({ success: true, agentId, duration, result, identity });
1035
+ }
1036
+ }, 30000);
1037
+ }
1038
+ }
1039
+
1040
+ // Detect when OpenClaw run completes - process should exit soon after
1041
+ // Pattern: "embedded run done" or "run_completed"
1042
+ if (trimmed.includes('embedded run done') || trimmed.includes('run_completed')) {
1043
+ if (!runCompleted) {
1044
+ runCompleted = true;
1045
+ // Cancel the agentEnd grace timer — clean exit happened
1046
+ if (completionTimer) { clearTimeout(completionTimer); completionTimer = null; }
1047
+ console.log(`[${agentId}] 🏁 Run completed, waiting for process exit...`);
1048
+
1049
+ // Give process 5 seconds to exit gracefully, then force kill and resolve
1050
+ completionTimer = setTimeout(async () => {
1051
+ if (proc && !proc.killed) {
1052
+ console.log(`[${agentId}] ⚠️ Process didn't exit after run completed, force killing`);
1053
+ try {
1054
+ proc.kill('SIGTERM');
1055
+ setTimeout(() => {
1056
+ if (!proc.killed) {
1057
+ proc.kill('SIGKILL');
1058
+ }
1059
+ }, 1000);
1060
+ } catch (e) {
1061
+ // Process might already be dead
1062
+ }
1063
+ }
1064
+ // Task completed successfully — resolve now instead of waiting for close event.
1065
+ // The close event can hang indefinitely if openclaw's child subprocesses keep
1066
+ // stdio pipes open after the parent is killed.
1067
+ if (!promiseSettled) {
1068
+ promiseSettled = true;
1069
+ const duration = Date.now() - startTime;
1070
+ let identity = { identityName: agentId, identityEmoji: '🤖' };
1071
+ try {
1072
+ identity = await Promise.race([
1073
+ this.getAgentIdentity(agentId),
1074
+ new Promise(r => setTimeout(() => r(identity), 5000))
1075
+ ]);
1076
+ } catch (e) { /* use default */ }
1077
+ this.activeAgents.delete(agentId);
1078
+ let result;
1079
+ try { result = JSON.parse(output); } catch (e) { result = { output: filteredOutput }; }
1080
+ console.log(`\n✅ Agent ${agentId} (${identity.identityName}) completed in ${(duration / 1000).toFixed(2)}s (force-resolved after kill)\n`);
1081
+ this.emit('agent_completed', { agentId, duration, result, identity });
1082
+ resolve({ success: true, agentId, duration, result, identity });
1083
+ }
1084
+ }, 5000);
1085
+ }
1086
+ }
1087
+
1088
+ // Rolling buffer of last 15 raw lines for TTS context debugging
1089
+ if (!recentLines) recentLines = [];
1090
+ recentLines.push(line);
1091
+ if (recentLines.length > 15) recentLines.shift();
1092
+
1093
+ // Only filter out EXACT system message patterns (starts with these)
1094
+ const isSystemLog = trimmed.startsWith('[Canary]') ||
1095
+ trimmed.startsWith('[agents/') ||
1096
+ trimmed.includes('Plugin registered') ||
1097
+ trimmed.includes('Registered plugin command:') ||
1098
+ trimmed.includes('inherited auth-profiles') ||
1099
+ trimmed.includes('gateway tool:') ||
1100
+ trimmed.includes('Debugger listening') ||
1101
+ trimmed.includes('[diagnostic]') ||
1102
+ trimmed.includes('[agent/embedded]') ||
1103
+ trimmed.includes('browser/service') ||
1104
+ trimmed.includes('Browser control service') ||
1105
+ trimmed.includes('profiles=');
1106
+
1107
+ // Detect AGENTFORGE_IMAGE:/path — agent wants to send a screenshot to the user's chat
1108
+ if (trimmed.startsWith('AGENTFORGE_IMAGE:')) {
1109
+ const imagePath = trimmed.slice('AGENTFORGE_IMAGE:'.length).trim();
1110
+ try {
1111
+ const imageData = readFileSync(imagePath);
1112
+ const ext = imagePath.split('.').pop().toLowerCase();
1113
+ const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'gif' ? 'image/gif' : 'image/png';
1114
+ const base64 = `data:${mime};base64,${imageData.toString('base64')}`;
1115
+ this.emit('agent_image', { agentId, image: base64 });
1116
+ console.log(`[${agentId}] 📸 Sending screenshot to chat (${Math.round(imageData.length / 1024)}KB)`);
1117
+ } catch (e) {
1118
+ console.warn(`[${agentId}] ⚠️ Could not read image ${imagePath}:`, e.message);
1119
+ }
1120
+ continue; // Don't show the marker line in chat
1121
+ }
1122
+
1123
+ // Always emit agent_alive + parse tool activity for ANY stdout line —
1124
+ // [agent/embedded] lines are system logs but still carry tool start/end events
1125
+ if (trimmed.length > 0) {
1126
+ this.emit('agent_alive', { agentId });
1127
+ const toolActivityAny = this.parseToolActivity(line);
1128
+ if (toolActivityAny) {
1129
+ if (toolActivityAny.tool === 'tts' && toolActivityAny.event === 'tool_start' && !toolActivityAny.toolInput) {
1130
+ this.emit('tts_context', { agentId, lines: [...recentLines] });
1131
+ }
1132
+ this.emit('tool_activity', { agentId, ...toolActivityAny });
1133
+ }
1134
+ }
1135
+
1136
+ if (!isSystemLog && trimmed.length > 0) {
1137
+ // Drop OpenClaw placeholder responses that confuse the UI
1138
+ if (/^no reply from agent\.?$/i.test(trimmed)) {
1139
+ continue;
1140
+ }
1141
+ // tool activity already emitted above for all lines
1142
+ const toolActivity = this.parseToolActivity(line);
1143
+
1144
+ // Filter out diagnostic/verbose logs from chat
1145
+ if (!this.isDiagnosticLog(line) && !toolActivity) {
1146
+ // Always push ANSI-stripped version so raw escape codes never reach the UI
1147
+ filteredLines.push(trimmed);
1148
+ }
1149
+ }
1150
+
1151
+ // Detect tool calls and emit current action
1152
+ const currentAction = this.parseCurrentAction(trimmed);
1153
+ if (currentAction) {
1154
+ this.emit('current_action', {
1155
+ agentId,
1156
+ action: currentAction
1157
+ });
1158
+ }
1159
+
1160
+ if (trimmed.length > 0) {
1161
+ console.log(`[${agentId}] ${trimmed}`);
1162
+ }
1163
+ }
1164
+
1165
+ // Emit filtered output and accumulate for task_complete response
1166
+ if (filteredLines.length > 0) {
1167
+ const filteredChunk = filteredLines.join('\n') + '\n';
1168
+ filteredOutput += (filteredOutput ? '\n' : '') + filteredChunk;
1169
+ this.emit('agent_output', {
1170
+ agentId,
1171
+ output: filteredChunk
1172
+ });
1173
+ }
1174
+ });
1175
+
1176
+ proc.on('error', (err) => {
1177
+ clearTimeout(firstOutputTimer);
1178
+ console.error(`❌ Failed to spawn openclaw for runAgentTask: ${err.message}`);
1179
+ this.activeAgents.delete(agentId);
1180
+ if (!promiseSettled) {
1181
+ promiseSettled = true;
1182
+ reject(new Error(`Failed to spawn openclaw: ${err.message}`));
1183
+ }
1184
+ });
1185
+
1186
+ proc.stderr.on('data', (data) => {
1187
+ const text = data.toString();
1188
+ error += text;
1189
+
1190
+ this.emit('agent_error', {
1191
+ agentId,
1192
+ error: text
1193
+ });
1194
+
1195
+ // Print stderr in real-time
1196
+ console.error(`[${agentId}] [stderr] ${text.trim()}`);
1197
+ });
1198
+
1199
+ proc.on('close', async (code) => {
1200
+ const duration = Date.now() - startTime;
1201
+ clearTimeout(firstOutputTimer);
1202
+
1203
+ // Clear the completion timer if it's still running
1204
+ if (completionTimer) {
1205
+ clearTimeout(completionTimer);
1206
+ completionTimer = null;
1207
+ }
1208
+
1209
+ // If promise already settled by force-resolve after kill, skip
1210
+ // IMPORTANT: only delete from activeAgents if this is still OUR process —
1211
+ // a newer task may have already replaced the entry with a new process
1212
+ if (promiseSettled) {
1213
+ const tracked = this.activeAgents.get(agentId);
1214
+ if (tracked && tracked.proc === proc) {
1215
+ this.activeAgents.delete(agentId);
1216
+ }
1217
+ return;
1218
+ }
1219
+ promiseSettled = true;
1220
+
1221
+ // Cleanup image file if it was created
1222
+ if (imagePath) {
1223
+ try {
1224
+ const fs = await import('fs/promises');
1225
+ await fs.unlink(imagePath);
1226
+ console.log(` 🗑️ Cleaned up image file`);
1227
+ } catch (error) {
1228
+ // Ignore cleanup errors
1229
+ }
1230
+ }
1231
+
1232
+ // Get agent identity info
1233
+ const identity = await this.getAgentIdentity(agentId);
1234
+
1235
+ // Clean up activeAgents so stale processes don't confuse future tasks
1236
+ this.activeAgents.delete(agentId);
1237
+
1238
+ if (code === 0) {
1239
+ console.log(`\n✅ Agent ${agentId} (${identity.identityName}) completed in ${(duration / 1000).toFixed(2)}s\n`);
1240
+
1241
+ let result;
1242
+ try {
1243
+ // Try to parse JSON output
1244
+ result = JSON.parse(output);
1245
+ } catch (e) {
1246
+ // Use filteredOutput (user-facing only) — raw `output` contains diagnostic logs
1247
+ result = { output: filteredOutput || output };
1248
+ }
1249
+
1250
+ this.emit('agent_completed', {
1251
+ agentId,
1252
+ duration,
1253
+ result,
1254
+ identity
1255
+ });
1256
+
1257
+ resolve({
1258
+ success: true,
1259
+ agentId,
1260
+ duration,
1261
+ result,
1262
+ identity
1263
+ });
1264
+ } else {
1265
+ console.error(`\n❌ Agent ${agentId} failed (exit code ${code})`);
1266
+ if (error) {
1267
+ console.error(`Error output:\n${error}`);
1268
+ }
1269
+ console.error('');
1270
+
1271
+ this.emit('agent_failed', {
1272
+ agentId,
1273
+ error,
1274
+ code
1275
+ });
1276
+
1277
+ reject(new Error(`Agent failed: ${error || 'Unknown error'}`));
1278
+ }
1279
+ });
1280
+
1281
+ // Track active agent
1282
+ this.activeAgents.set(agentId, {
1283
+ proc,
1284
+ startTime,
1285
+ task,
1286
+ workDir
1287
+ });
1288
+ });
1289
+ }
1290
+
1291
+ /**
1292
+ * Spawn and run multiple agents in parallel
1293
+ */
1294
+ async runMultipleAgents(agentConfigs) {
1295
+ console.log(`\n${'='.repeat(80)}`);
1296
+ console.log(`🚀 Spawning ${agentConfigs.length} OpenClaw agents in parallel`);
1297
+ console.log(`${'='.repeat(80)}\n`);
1298
+
1299
+ // First, ensure all agents exist
1300
+ const createPromises = agentConfigs.map(config =>
1301
+ this.createAgent(config.agentId, config.workDir)
1302
+ );
1303
+
1304
+ await Promise.allSettled(createPromises);
1305
+
1306
+ // Then run all agent tasks in parallel
1307
+ const runPromises = agentConfigs.map(config =>
1308
+ this.runAgentTask(config.agentId, config.task, config.workDir)
1309
+ );
1310
+
1311
+ const results = await Promise.allSettled(runPromises);
1312
+
1313
+ const successful = results.filter(r => r.status === 'fulfilled').length;
1314
+ const failed = results.filter(r => r.status === 'rejected').length;
1315
+
1316
+ console.log(`\n${'='.repeat(80)}`);
1317
+ console.log(`📊 Results: ${successful} succeeded, ${failed} failed`);
1318
+ console.log(`${'='.repeat(80)}\n`);
1319
+
1320
+ return results;
1321
+ }
1322
+
1323
+ /**
1324
+ * List all agents (with timeout to prevent hanging)
1325
+ */
1326
+ async listAgents() {
1327
+ return new Promise((resolve, reject) => {
1328
+ const proc = spawn(process.execPath, [this.bin, 'agents', 'list', '--json']);
1329
+ let output = '';
1330
+ let resolved = false;
1331
+
1332
+ // 10 second timeout - if this hangs, don't block forever
1333
+ const timeout = setTimeout(() => {
1334
+ if (!resolved) {
1335
+ resolved = true;
1336
+ console.log('⚠️ listAgents timed out after 10s');
1337
+ try { proc.kill('SIGKILL'); } catch (e) {}
1338
+ resolve([]);
1339
+ }
1340
+ }, 10000);
1341
+
1342
+ proc.stdout.on('data', (data) => {
1343
+ output += data.toString();
1344
+ });
1345
+
1346
+ proc.on('error', (err) => {
1347
+ if (!resolved) {
1348
+ resolved = true;
1349
+ clearTimeout(timeout);
1350
+ console.error('⚠️ listAgents error:', err.message);
1351
+ resolve([]);
1352
+ }
1353
+ });
1354
+
1355
+ proc.on('close', (code) => {
1356
+ if (!resolved) {
1357
+ resolved = true;
1358
+ clearTimeout(timeout);
1359
+ if (code === 0) {
1360
+ try {
1361
+ const agents = JSON.parse(output);
1362
+ resolve(agents);
1363
+ } catch (e) {
1364
+ resolve([]);
1365
+ }
1366
+ } else {
1367
+ resolve([]);
1368
+ }
1369
+ }
1370
+ });
1371
+ });
1372
+ }
1373
+
1374
+ /**
1375
+ * Get agent identity info (name, emoji) from OpenClaw
1376
+ * Has a 15s total timeout to prevent blocking
1377
+ */
1378
+ async getAgentIdentity(agentId) {
1379
+ try {
1380
+ // Race against a timeout
1381
+ const timeoutPromise = new Promise((resolve) => {
1382
+ setTimeout(() => resolve(null), 15000);
1383
+ });
1384
+
1385
+ const agents = await Promise.race([this.listAgents(), timeoutPromise]);
1386
+
1387
+ if (agents) {
1388
+ const agent = agents.find(a => a.id === agentId);
1389
+ if (agent) {
1390
+ return {
1391
+ identityName: agent.identityName || agent.name || agentId,
1392
+ identityEmoji: agent.identityEmoji || '🤖'
1393
+ };
1394
+ }
1395
+ }
1396
+ } catch (e) {
1397
+ console.error('Failed to get agent identity:', e);
1398
+ }
1399
+ return { identityName: agentId, identityEmoji: '🤖' };
1400
+ }
1401
+
1402
+ /**
1403
+ * Get active agents
1404
+ */
1405
+ getActiveAgents() {
1406
+ return Array.from(this.activeAgents.values());
1407
+ }
1408
+
1409
+ /**
1410
+ * Parse output line to detect current action/tool being used
1411
+ * Returns a human-readable action string or null
1412
+ */
1413
+ parseCurrentAction(line) {
1414
+ if (!line) return null;
1415
+
1416
+ // Tool call patterns - OpenClaw outputs these when calling tools
1417
+ const toolPatterns = [
1418
+ // Direct tool invocations
1419
+ { pattern: /\[tools?\]\s*(\w+)/i, format: (m) => this.formatToolName(m[1]) },
1420
+ { pattern: /calling\s+(\w+)/i, format: (m) => this.formatToolName(m[1]) },
1421
+ { pattern: /tool:\s*(\w+)/i, format: (m) => this.formatToolName(m[1]) },
1422
+
1423
+ // Browser actions
1424
+ { pattern: /browser.*?(snapshot|click|navigate|open|type|screenshot)/i, format: () => '🌐 Using browser' },
1425
+ { pattern: /opening.*?url/i, format: () => '🌐 Opening URL' },
1426
+ { pattern: /taking.*?screenshot/i, format: () => '📸 Taking screenshot' },
1427
+
1428
+ // File operations
1429
+ { pattern: /\bread\b.*?file|reading\s+\w+\.(js|ts|py|md|json|txt|html|css)/i, format: () => '📄 Reading file' },
1430
+ { pattern: /\bwrite\b.*?file|writing\s+to/i, format: () => '✏️ Writing file' },
1431
+ { pattern: /\bedit\b.*?file|editing/i, format: () => '✏️ Editing file' },
1432
+
1433
+ // Command execution
1434
+ { pattern: /\bexec\b|executing|running\s+command|spawn/i, format: () => '⚡ Running command' },
1435
+ { pattern: /\$\s*\w+|bash|shell/i, format: () => '⚡ Running command' },
1436
+
1437
+ // Search/web
1438
+ { pattern: /web_search|searching.*?web/i, format: () => '🔍 Searching web' },
1439
+ { pattern: /web_fetch|fetching.*?url/i, format: () => '🌐 Fetching URL' },
1440
+
1441
+ // Memory
1442
+ { pattern: /memory_search/i, format: () => '🧠 Searching memory' },
1443
+
1444
+ // Messages
1445
+ { pattern: /\bmessage\b.*?send|sending.*?message/i, format: () => '💬 Sending message' },
1446
+
1447
+ // Thinking indicator (Claude's extended thinking)
1448
+ { pattern: /thinking|reasoning/i, format: () => '🤔 Thinking' },
1449
+ ];
1450
+
1451
+ for (const { pattern, format } of toolPatterns) {
1452
+ const match = line.match(pattern);
1453
+ if (match) {
1454
+ return format(match);
1455
+ }
1456
+ }
1457
+
1458
+ return null;
1459
+ }
1460
+
1461
+ /**
1462
+ * Format tool name into human-readable action
1463
+ */
1464
+ formatToolName(toolName) {
1465
+ const toolLabels = {
1466
+ 'read': '📄 Reading file',
1467
+ 'write': '✏️ Writing file',
1468
+ 'edit': '✏️ Editing file',
1469
+ 'exec': '⚡ Running command',
1470
+ 'browser': '🌐 Using browser',
1471
+ 'web_search': '🔍 Searching web',
1472
+ 'web_fetch': '🌐 Fetching URL',
1473
+ 'memory_search': '🧠 Searching memory',
1474
+ 'memory_get': '🧠 Reading memory',
1475
+ 'message': '💬 Messaging',
1476
+ 'image': '🖼️ Analyzing image',
1477
+ 'tts': '🔊 Text to speech',
1478
+ 'cron': '⏰ Managing schedule',
1479
+ 'nodes': '📱 Using devices',
1480
+ 'canvas': '🎨 Using canvas',
1481
+ 'sessions_spawn': '🤖 Spawning agent',
1482
+ 'sessions_send': '💬 Messaging agent',
1483
+ };
1484
+
1485
+ const lower = toolName.toLowerCase();
1486
+ return toolLabels[lower] || `🔧 ${toolName}`;
1487
+ }
1488
+
1489
+ /**
1490
+ * Cancel a running agent task by killing the process tree immediately
1491
+ */
1492
+ cancelAgent(agentId) {
1493
+ const agentInfo = this.activeAgents.get(agentId);
1494
+ if (!agentInfo || !agentInfo.proc) {
1495
+ console.log(`⚠️ No running process found for agent ${agentId}`);
1496
+ return false;
1497
+ }
1498
+
1499
+ const { proc } = agentInfo;
1500
+ const pid = proc.pid;
1501
+
1502
+ console.log(`🛑 Killing process tree for agent ${agentId} (PID: ${pid})`);
1503
+
1504
+ // Clean up tracking immediately
1505
+ this.activeAgents.delete(agentId);
1506
+
1507
+ // Use tree-kill to kill the entire process tree with SIGKILL (immediate, no grace period)
1508
+ treeKill(pid, 'SIGKILL', (err) => {
1509
+ if (err) {
1510
+ console.log(`⚠️ tree-kill error (process may already be dead): ${err.message}`);
1511
+ } else {
1512
+ console.log(`✅ Process tree ${pid} killed successfully`);
1513
+ }
1514
+ });
1515
+
1516
+ this.emit('agent_cancelled', { agentId });
1517
+
1518
+ return true;
1519
+ }
1520
+ }