@hamp10/agentforge 0.2.0 → 0.2.2

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.
package/bin/agentforge.js CHANGED
@@ -54,7 +54,7 @@ function saveConfig(config) {
54
54
  program
55
55
  .name('agentforge')
56
56
  .description('AgentForge worker - connect your machine to agentforge.ai')
57
- .version('0.1.0');
57
+ .version('0.2.1');
58
58
 
59
59
  program
60
60
  .command('login')
@@ -274,9 +274,9 @@ program
274
274
  const worker = new AgentForgeWorker(config.token, wsUrl, config);
275
275
 
276
276
  // Graceful shutdown
277
- process.on('SIGINT', () => { console.log('\n[SIGINT received]'); worker.shutdown(); });
278
- process.on('SIGTERM', () => { console.log('\n[SIGTERM received]'); worker.shutdown(); });
279
- process.on('SIGHUP', () => { console.log('\n[SIGHUP received — terminal closed]'); worker.shutdown(); });
277
+ process.on('SIGINT', () => { console.log('\n[SIGINT received — stopping]'); worker.shutdown(0); }); // Ctrl+C: clean stop
278
+ process.on('SIGTERM', () => { console.log('\n[SIGTERM received — restarting]'); worker.shutdown(1); }); // kill: supervisor restarts
279
+ process.on('SIGHUP', () => { console.log('\n[SIGHUP received — restarting]'); worker.shutdown(1); }); // terminal close: supervisor restarts
280
280
 
281
281
  try {
282
282
  await worker.initialize();
@@ -450,66 +450,276 @@ program
450
450
  console.log('');
451
451
  });
452
452
 
453
+ program
454
+ .command('doctor')
455
+ .description('Check your AgentForge worker health — diagnose any issues')
456
+ .action(async () => {
457
+ console.log('');
458
+ console.log('🩺 AgentForge Doctor');
459
+ console.log('================================');
460
+ console.log('');
461
+
462
+ let allGood = true;
463
+
464
+ // 1. Authentication
465
+ const config = loadConfig();
466
+ if (config.token) {
467
+ console.log('✅ Authenticated');
468
+ } else {
469
+ console.log('❌ Not authenticated');
470
+ console.log(' Fix: agentforge login');
471
+ allGood = false;
472
+ }
473
+
474
+ // 2. Server reachability
475
+ const serverUrl = config.url || 'https://agentforgeai-production.up.railway.app';
476
+ try {
477
+ const res = await fetch(`${serverUrl}/api/wsstatus`, { signal: AbortSignal.timeout(5000) });
478
+ if (res.ok || res.status === 401 || res.status === 403) {
479
+ console.log(`✅ Server reachable (${serverUrl})`);
480
+ } else {
481
+ console.log(`⚠️ Server returned ${res.status}`);
482
+ }
483
+ } catch (err) {
484
+ console.log(`❌ Cannot reach server: ${err.message}`);
485
+ allGood = false;
486
+ }
487
+
488
+ // 3. AI Backend
489
+ if (config.provider === 'local') {
490
+ const localUrl = config.localUrl || 'http://localhost:11434';
491
+ try {
492
+ const res = await fetch(`${localUrl}/v1/models`, { signal: AbortSignal.timeout(5000) });
493
+ if (res.ok) {
494
+ const data = await res.json();
495
+ const models = (data.data ?? data.models ?? []).map(m => m.id || m.name);
496
+ const configured = config.localModel || '(not set)';
497
+ const found = models.includes(configured);
498
+ console.log(`✅ Local model server running (${localUrl})`);
499
+ console.log(` Configured model: ${configured} ${found ? '✅' : '⚠️ (not found in model list)'}`);
500
+ if (!found && models.length > 0) {
501
+ console.log(` Available models: ${models.slice(0, 5).join(', ')}`);
502
+ console.log(` Fix: agentforge local --model ${models[0]}`);
503
+ allGood = false;
504
+ }
505
+ } else {
506
+ console.log(`❌ Local server at ${localUrl} returned ${res.status}`);
507
+ allGood = false;
508
+ }
509
+ } catch {
510
+ console.log(`❌ Local server not running at ${localUrl}`);
511
+ console.log(` Make sure Ollama/LM Studio/Jan is running, then: agentforge start`);
512
+ allGood = false;
513
+ }
514
+ } else if (OpenClawCLI.isAvailable()) {
515
+ console.log('✅ openclaw backend available');
516
+ } else {
517
+ // Auto-detect Ollama
518
+ try {
519
+ const res = await fetch('http://localhost:11434/v1/models', { signal: AbortSignal.timeout(2000) });
520
+ if (res.ok) {
521
+ const data = await res.json();
522
+ const models = (data.data ?? data.models ?? []).map(m => m.id || m.name);
523
+ console.log(`⚠️ Ollama is running but not configured as your backend`);
524
+ if (models.length > 0) console.log(` Models available: ${models.slice(0, 5).join(', ')}`);
525
+ console.log(` Fix: agentforge local --model ${models[0] || 'llama3.1:8b'}`);
526
+ allGood = false;
527
+ }
528
+ } catch {
529
+ console.log('❌ No AI backend configured');
530
+ console.log(' Fix: install Ollama (https://ollama.ai) then: agentforge local --model llama3.1:8b');
531
+ allGood = false;
532
+ }
533
+ }
534
+
535
+ // 4. Worker running
536
+ const supervisorPidFile = path.join(CONFIG_DIR, 'supervisor.pid');
537
+ const workerPidFile = path.join(CONFIG_DIR, 'worker.pid');
538
+ if (fs.existsSync(supervisorPidFile)) {
539
+ const pid = parseInt(fs.readFileSync(supervisorPidFile, 'utf8').trim());
540
+ try {
541
+ process.kill(pid, 0);
542
+ const wpid = fs.existsSync(workerPidFile) ? fs.readFileSync(workerPidFile, 'utf8').trim() : null;
543
+ console.log(`✅ Worker running (supervisor PID ${pid}${wpid ? ', worker PID ' + wpid : ''})`);
544
+ } catch {
545
+ console.log('⚠️ Supervisor PID file exists but process is not running');
546
+ console.log(' Fix: agentforge start');
547
+ allGood = false;
548
+ }
549
+ } else {
550
+ console.log('⚠️ Worker not running');
551
+ console.log(' Fix: agentforge start');
552
+ }
553
+
554
+ console.log('');
555
+ if (allGood) {
556
+ console.log('✅ Everything looks healthy!');
557
+ } else {
558
+ console.log('Fix the issues above, then run: agentforge start');
559
+ }
560
+ console.log('');
561
+ });
562
+
453
563
  program
454
564
  .command('setup')
455
- .description('Full device setup: AgentForge login + Anthropic auth + Tailscale')
565
+ .description('Interactive setup wizard gets AgentForge running in minutes')
456
566
  .option('--tailscale-key <key>', 'Tailscale auth key (from tailscale.com/admin/settings/keys)')
457
567
  .action(async (options) => {
458
568
  const { execSync, spawnSync } = await import('child_process');
569
+ const readline = await import('readline');
570
+
571
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
572
+ const ask = (q) => new Promise(resolve => rl.question(q, resolve));
459
573
 
460
574
  console.log('');
461
- console.log('🚀 AgentForge Device Setup');
575
+ console.log('🚀 AgentForge Setup');
462
576
  console.log('================================');
463
- console.log('');
577
+ console.log('Getting your machine ready to run AI agents.\n');
464
578
 
465
- // Step 1: AgentForge login
466
- console.log('Step 1/3: AgentForge authentication');
467
- console.log('Run: agentforge login');
468
- console.log('(Complete login, then come back here)');
469
- console.log('');
579
+ // ── Step 1: Authentication ──────────────────────────────────────────────
580
+ const config = loadConfig();
581
+ if (config.token) {
582
+ console.log(' Step 1/2: Already logged in\n');
583
+ } else {
584
+ console.log('Step 1/2: Log in to AgentForge\n');
585
+ console.log('A browser window will open — log in there and come back.\n');
586
+ rl.close();
587
+
588
+ // Spawn login as a child process with inherited stdio so the interactive
589
+ // OAuth flow works (readline on stdin, browser opens, polling, etc.)
590
+ const { spawnSync: sp } = await import('child_process');
591
+ const loginResult = sp(process.execPath, [process.argv[1], 'login'], { stdio: 'inherit' });
592
+ if (loginResult.status !== 0) {
593
+ console.error('\n❌ Login failed — run: agentforge login');
594
+ process.exit(1);
595
+ }
470
596
 
471
- // Step 2: openclaw + Anthropic token
472
- console.log('Step 2/3: Anthropic token setup');
473
- console.log('Run in a new terminal: claude setup-token');
474
- console.log('Then run: agentforge refresh-token');
475
- console.log('');
597
+ // Re-open readline for the rest of setup
598
+ const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
599
+ Object.assign(rl, rl2); // replace for remaining ask() calls (won't be used further)
600
+ console.log('');
601
+ }
476
602
 
477
- // Step 3: Tailscale
478
- console.log('Step 3/3: Tailscale (remote access from any network)');
479
- console.log('');
603
+ // ── Step 2: AI Backend ──────────────────────────────────────────────────
604
+ console.log('Step 2/2: AI Backend\n');
480
605
 
481
- const tailscaleInstalled = spawnSync('which', ['tailscale'], { encoding: 'utf-8' }).status === 0;
606
+ const freshConfig = loadConfig();
482
607
 
483
- if (tailscaleInstalled) {
484
- console.log('✅ Tailscale already installed');
485
- } else {
486
- console.log('Installing Tailscale...');
608
+ if (freshConfig.provider === 'local') {
609
+ const localUrl = freshConfig.localUrl || 'http://localhost:11434';
487
610
  try {
488
- execSync('brew install tailscale', { stdio: 'inherit' });
489
- console.log('✅ Tailscale installed');
490
- } catch (e) {
491
- console.error('❌ Failed to install Tailscale. Install manually: https://tailscale.com/download');
611
+ const res = await fetch(`${localUrl}/v1/models`, { signal: AbortSignal.timeout(3000) });
612
+ if (res.ok) {
613
+ const data = await res.json();
614
+ const models = (data.data ?? data.models ?? []).map(m => m.id || m.name);
615
+ console.log(`✅ Already configured: ${localUrl} with model "${freshConfig.localModel}"`);
616
+ if (models.length > 0 && !models.includes(freshConfig.localModel)) {
617
+ console.log(` ⚠️ Model "${freshConfig.localModel}" not found. Available: ${models.slice(0, 5).join(', ')}`);
618
+ }
619
+ console.log('');
620
+ }
621
+ } catch {
622
+ console.log(`⚠️ Configured local server at ${localUrl} is not reachable. Make sure it's running.\n`);
623
+ }
624
+ } else if (OpenClawCLI.isAvailable()) {
625
+ console.log('✅ openclaw detected and ready\n');
626
+ } else {
627
+ // Probe all common local model servers
628
+ const probes = [
629
+ { name: 'Ollama', url: 'http://localhost:11434' },
630
+ { name: 'LM Studio', url: 'http://localhost:1234' },
631
+ { name: 'Jan', url: 'http://localhost:1337' },
632
+ { name: 'llama.cpp', url: 'http://localhost:8080' },
633
+ { name: 'vLLM', url: 'http://localhost:8000' },
634
+ ];
635
+
636
+ const found = [];
637
+ process.stdout.write('Detecting local model servers...');
638
+ for (const probe of probes) {
639
+ try {
640
+ const res = await fetch(`${probe.url}/v1/models`, { signal: AbortSignal.timeout(1500) });
641
+ if (res.ok) {
642
+ const data = await res.json();
643
+ const models = (data.data ?? data.models ?? []).map(m => m.id || m.name).filter(Boolean);
644
+ found.push({ ...probe, models });
645
+ }
646
+ } catch {}
647
+ }
648
+ console.log('');
649
+
650
+ if (found.length === 0) {
651
+ console.log('');
652
+ console.log('No local model server detected.\n');
653
+ console.log('AgentForge needs an AI model to power your agents. The easiest option is Ollama:\n');
654
+ console.log(' 1. Go to https://ollama.ai and install it');
655
+ console.log(' 2. Run: ollama pull llama3.1:8b');
656
+ console.log(' 3. Run: agentforge setup (come back here when done)');
657
+ console.log('');
658
+ console.log('You can also use LM Studio, Jan, llama.cpp, or openclaw.');
659
+ rl.close();
660
+ process.exit(0);
661
+ }
662
+
663
+ // Let user pick if multiple found, or auto-select if only one
664
+ let chosen = found[0];
665
+ if (found.length > 1) {
666
+ console.log('\nFound these model servers:\n');
667
+ found.forEach((b, i) => {
668
+ console.log(` ${i + 1}. ${b.name} (${b.url}) — ${b.models.length} model(s): ${b.models.slice(0, 3).join(', ')}`);
669
+ });
670
+ const ans = await ask(`\nWhich one to use? [1]: `);
671
+ const idx = parseInt(ans.trim()) - 1;
672
+ chosen = found[isNaN(idx) || idx < 0 || idx >= found.length ? 0 : idx];
673
+ } else {
674
+ console.log(`\nFound: ${chosen.name} (${chosen.url}) with ${chosen.models.length} model(s)`);
492
675
  }
676
+
677
+ // Pick model
678
+ let model = chosen.models[0] || 'llama3.1:8b';
679
+ if (chosen.models.length > 1) {
680
+ console.log(`\nAvailable models: ${chosen.models.join(', ')}`);
681
+ const ans = await ask(`Model to use [${model}]: `);
682
+ if (ans.trim()) model = ans.trim();
683
+ } else if (chosen.models.length === 1) {
684
+ console.log(`Using model: ${model}`);
685
+ }
686
+
687
+ const newConfig = loadConfig();
688
+ newConfig.provider = 'local';
689
+ newConfig.localUrl = chosen.url;
690
+ newConfig.localModel = model;
691
+ saveConfig(newConfig);
692
+ console.log(`\n✅ Configured ${chosen.name} with model "${model}"\n`);
493
693
  }
494
694
 
695
+ rl.close();
696
+
697
+ // ── Optional: Tailscale (only shown if installed or key provided) ────────
698
+ const tailscaleInstalled = spawnSync('which', ['tailscale'], { encoding: 'utf-8' }).status === 0;
495
699
  if (options.tailscaleKey) {
496
700
  try {
497
701
  execSync(`sudo tailscale up --authkey=${options.tailscaleKey}`, { stdio: 'inherit' });
498
- console.log('✅ Tailscale connected');
499
702
  const result = spawnSync('tailscale', ['ip', '--4'], { encoding: 'utf-8' });
500
- if (result.stdout) console.log(` Tailscale IP: ${result.stdout.trim()}`);
501
- } catch (e) {
502
- console.error('Tailscale join failed. Run manually: sudo tailscale up');
703
+ console.log(`✅ Tailscale connected${result.stdout ? ' — IP: ' + result.stdout.trim() : ''}`);
704
+ } catch {
705
+ console.log('⚠️ Tailscale key failed run: sudo tailscale up');
503
706
  }
504
- } else {
505
- console.log('To join your Tailscale network:');
506
- console.log(' sudo tailscale up');
507
- console.log(' (or: agentforge setup --tailscale-key <key>)');
508
- console.log(' Get a key at: tailscale.com/admin/settings/keys');
707
+ } else if (tailscaleInstalled) {
708
+ console.log(' Tailscale installed (run "sudo tailscale up" to connect remotely)');
509
709
  }
510
710
 
511
711
  console.log('');
512
- console.log('✅ Setup complete! Run: agentforge start');
712
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
713
+ console.log('✅ Setup complete!');
714
+ console.log('');
715
+ console.log(' Run this to start your worker:');
716
+ console.log('');
717
+ console.log(' agentforge start');
718
+ console.log('');
719
+ console.log(' Run this to check your health:');
720
+ console.log('');
721
+ console.log(' agentforge doctor');
722
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
513
723
  console.log('');
514
724
  });
515
725
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hamp10/agentforge",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "AgentForge worker — connect your machine to agentforge.ai",
5
5
  "type": "module",
6
6
  "bin": {
@@ -79,6 +79,28 @@ const TOOLS = [
79
79
  required: ['url']
80
80
  }
81
81
  }
82
+ },
83
+ {
84
+ type: 'function',
85
+ function: {
86
+ name: 'take_screenshot',
87
+ description: 'Take a screenshot of the current screen or the agent browser (port 9223). Returns base64 image data you can analyze visually. Use this to check what a webpage looks like, verify a build result, or monitor a running process.',
88
+ parameters: {
89
+ type: 'object',
90
+ properties: {
91
+ target: {
92
+ type: 'string',
93
+ enum: ['screen', 'browser'],
94
+ description: 'screen = full screen capture. browser = screenshot of the agent browser (port 9223).'
95
+ },
96
+ url: {
97
+ type: 'string',
98
+ description: 'Optional: navigate the browser to this URL before taking the screenshot.'
99
+ }
100
+ },
101
+ required: ['target']
102
+ }
103
+ }
82
104
  }
83
105
  ];
84
106
 
@@ -146,25 +168,40 @@ export class OllamaAgent extends EventEmitter {
146
168
  console.log(` Task: ${task}`);
147
169
  console.log(` Working dir: ${workDir}`);
148
170
 
171
+ // Detect model capabilities
172
+ const isQwen3 = this.model.startsWith('qwen3');
173
+ const isVision = /vl|vision|llava|minicpm-v|moondream/i.test(this.model);
174
+
149
175
  try {
150
176
  // Load conversation history from disk (session persistence)
151
177
  const history = this._loadHistory(agentId, workDir, sessionId);
152
178
 
179
+ const systemPrompt = [
180
+ `You are an AI agent running on AgentForge.ai.`,
181
+ `Your working directory is: ${workDir}`,
182
+ ``,
183
+ `CRITICAL RULES — follow these exactly:`,
184
+ `1. Use the provided tools to complete the task. Do NOT write Python code, pseudo-code, or code blocks to simulate tool calls.`,
185
+ `2. To run a command, call the "bash" tool. To read a file, call "read_file". To write, call "write_file". To take a screenshot, call "take_screenshot".`,
186
+ `3. Every action must be a real tool call — not described in text, not shown as code.`,
187
+ `4. When you take a screenshot, you will receive the actual image back and can see it.`,
188
+ `5. When you are done, write a clear summary of what you accomplished.`,
189
+ `6. Do not ask for clarification — make your best judgment and act.`,
190
+ ].join('\n');
191
+
153
192
  const messages = [
154
- {
155
- role: 'system',
156
- content: [
157
- `You are an AI agent running on AgentForge.ai.`,
158
- `Your working directory is: ${workDir}`,
159
- `Use the available tools to complete the task autonomously.`,
160
- `When you are done, write a clear summary of what you accomplished.`,
161
- `Do not ask for clarification — make your best judgment and act.`
162
- ].join('\n')
163
- },
193
+ { role: 'system', content: systemPrompt },
164
194
  ...history,
165
- { role: 'user', content: task }
166
195
  ];
167
196
 
197
+ // Attach initial image to user message if provided
198
+ const userMessage = { role: 'user', content: task };
199
+ if (image && isVision) {
200
+ const base64 = image.replace(/^data:image\/\w+;base64,/, '');
201
+ userMessage.images = [base64];
202
+ }
203
+ messages.push(userMessage);
204
+
168
205
  let finalContent = '';
169
206
  const MAX_TURNS = 25;
170
207
 
@@ -175,18 +212,25 @@ export class OllamaAgent extends EventEmitter {
175
212
 
176
213
  let response;
177
214
  try {
178
- // OpenAI-compatible endpoint — works with Ollama, LM Studio, Jan, llama.cpp, vLLM, etc.
215
+ const requestBody = {
216
+ model: this.model,
217
+ messages,
218
+ tools: TOOLS,
219
+ tool_choice: 'auto',
220
+ stream: true,
221
+ };
222
+
223
+ // Disable thinking mode for qwen3 — prevents 3-minute silent think phases
224
+ // and makes tool-call JSON output reliable.
225
+ if (isQwen3) {
226
+ requestBody.options = { think: false };
227
+ }
228
+
179
229
  response = await fetch(`${this.baseUrl}/v1/chat/completions`, {
180
230
  method: 'POST',
181
231
  headers: { 'Content-Type': 'application/json' },
182
232
  signal: controller.signal,
183
- body: JSON.stringify({
184
- model: this.model,
185
- messages,
186
- tools: TOOLS,
187
- tool_choice: 'auto',
188
- stream: false
189
- })
233
+ body: JSON.stringify(requestBody)
190
234
  });
191
235
  } catch (fetchErr) {
192
236
  if (fetchErr.name === 'AbortError') break;
@@ -198,9 +242,86 @@ export class OllamaAgent extends EventEmitter {
198
242
  throw new Error(`Local model error ${response.status}: ${body}`);
199
243
  }
200
244
 
201
- const data = await response.json();
202
- // OpenAI-compat wraps in choices[0].message; Ollama native uses data.message
203
- const message = data.choices?.[0]?.message ?? data.message;
245
+ // ── Stream the SSE response ──
246
+ // Accumulate content and tool calls from streaming deltas.
247
+ // Filter out <think>...</think> blocks (qwen3 chain-of-thought) — never show to user.
248
+ let streamContent = '';
249
+ let streamToolCalls = {};
250
+ let inThinkBlock = false;
251
+ let thinkBuffer = '';
252
+
253
+ const reader = response.body.getReader();
254
+ const decoder = new TextDecoder();
255
+ let buf = '';
256
+
257
+ while (true) {
258
+ if (controller.signal.aborted) break;
259
+ const { done, value } = await reader.read();
260
+ if (done) break;
261
+
262
+ buf += decoder.decode(value, { stream: true });
263
+ const lines = buf.split('\n');
264
+ buf = lines.pop(); // keep incomplete line
265
+
266
+ for (const line of lines) {
267
+ if (!line.startsWith('data: ')) continue;
268
+ const payload = line.slice(6).trim();
269
+ if (payload === '[DONE]') continue;
270
+ let evt;
271
+ try { evt = JSON.parse(payload); } catch { continue; }
272
+
273
+ const delta = evt.choices?.[0]?.delta;
274
+ if (!delta) continue;
275
+
276
+ // Accumulate tool call deltas
277
+ if (delta.tool_calls) {
278
+ for (const tc of delta.tool_calls) {
279
+ const idx = tc.index ?? 0;
280
+ if (!streamToolCalls[idx]) streamToolCalls[idx] = { id: tc.id || '', type: 'function', function: { name: '', arguments: '' } };
281
+ if (tc.id) streamToolCalls[idx].id = tc.id;
282
+ if (tc.function?.name) streamToolCalls[idx].function.name += tc.function.name;
283
+ if (tc.function?.arguments) streamToolCalls[idx].function.arguments += tc.function.arguments;
284
+ }
285
+ }
286
+
287
+ // Stream content tokens, filtering <think>...</think> blocks
288
+ if (delta.content) {
289
+ thinkBuffer += delta.content;
290
+
291
+ // Process thinkBuffer to extract non-thinking text
292
+ let out = '';
293
+ let i = 0;
294
+ while (i < thinkBuffer.length) {
295
+ if (!inThinkBlock) {
296
+ const thinkStart = thinkBuffer.indexOf('<think>', i);
297
+ if (thinkStart === -1) {
298
+ out += thinkBuffer.slice(i);
299
+ i = thinkBuffer.length;
300
+ } else {
301
+ out += thinkBuffer.slice(i, thinkStart);
302
+ inThinkBlock = true;
303
+ i = thinkStart + 7;
304
+ }
305
+ } else {
306
+ const thinkEnd = thinkBuffer.indexOf('</think>', i);
307
+ if (thinkEnd === -1) {
308
+ // still inside think block, keep buffering
309
+ i = thinkBuffer.length;
310
+ } else {
311
+ inThinkBlock = false;
312
+ i = thinkEnd + 8;
313
+ }
314
+ }
315
+ }
316
+ thinkBuffer = inThinkBlock ? thinkBuffer.slice(thinkBuffer.lastIndexOf('<think>')) : '';
317
+
318
+ streamContent += out;
319
+ if (out) {
320
+ this.emit('agent_output', { agentId, output: out });
321
+ }
322
+ }
323
+ }
324
+ }
204
325
 
205
326
  this.emit('tool_activity', {
206
327
  agentId,
@@ -208,6 +329,14 @@ export class OllamaAgent extends EventEmitter {
208
329
  description: `✅ Ollama responded`
209
330
  });
210
331
 
332
+ // Reconstruct message from streamed parts
333
+ const toolCallsArray = Object.values(streamToolCalls);
334
+ const message = {
335
+ role: 'assistant',
336
+ content: streamContent || null,
337
+ tool_calls: toolCallsArray.length > 0 ? toolCallsArray : undefined
338
+ };
339
+
211
340
  messages.push(message);
212
341
 
213
342
  // ── Handle tool calls ──
@@ -236,23 +365,36 @@ export class OllamaAgent extends EventEmitter {
236
365
  description: `✓ ${name}`
237
366
  });
238
367
 
239
- messages.push({ role: 'tool', content: String(result) });
368
+ // If the tool returned an image (base64), push it as a vision message
369
+ // so the model can actually see what was captured.
370
+ const isImageResult = typeof result === 'string' && result.startsWith('data:image/');
371
+ if (isImageResult && isVision) {
372
+ messages.push({
373
+ role: 'tool',
374
+ tool_call_id: toolCall.id || undefined,
375
+ content: '[Screenshot captured — see image attached]'
376
+ });
377
+ const base64 = result.replace(/^data:image\/\w+;base64,/, '');
378
+ messages.push({
379
+ role: 'user',
380
+ content: 'Here is the screenshot:',
381
+ images: [base64]
382
+ });
383
+ } else {
384
+ messages.push({
385
+ role: 'tool',
386
+ tool_call_id: toolCall.id || undefined,
387
+ content: isImageResult ? '[Screenshot captured — install a vision model to analyze images]' : String(result)
388
+ });
389
+ }
240
390
  }
241
391
  // Loop back — model will respond to the tool results
242
392
  continue;
243
393
  }
244
394
 
245
- // ── No tool calls: this is the final answer ──
246
- if (message.content) {
247
- finalContent = message.content;
248
- // Stream the response in chunks so the UI feels live
249
- const words = message.content.split(' ');
250
- const CHUNK_SIZE = 8;
251
- for (let i = 0; i < words.length; i += CHUNK_SIZE) {
252
- if (controller.signal.aborted) break;
253
- const chunk = words.slice(i, i + CHUNK_SIZE).join(' ') + (i + CHUNK_SIZE < words.length ? ' ' : '');
254
- this.emit('agent_output', { agentId, output: chunk });
255
- }
395
+ // ── No tool calls: final answer already streamed above ──
396
+ if (streamContent) {
397
+ finalContent = streamContent;
256
398
  }
257
399
  break;
258
400
  }
@@ -357,6 +499,22 @@ export class OllamaAgent extends EventEmitter {
357
499
  return text.slice(0, 4000) + (text.length > 4000 ? '\n...(truncated)' : '');
358
500
  }
359
501
 
502
+ case 'take_screenshot': {
503
+ const target = args.target || 'screen';
504
+ const tmpFile = `/tmp/af_screenshot_${Date.now()}.png`;
505
+
506
+ if (target === 'browser') {
507
+ // Navigate + screenshot via CDP on agent browser (port 9223)
508
+ return await this._cdpScreenshot(args.url, tmpFile);
509
+ } else {
510
+ // Full screen capture
511
+ await execAsync(`screencapture -x "${tmpFile}"`);
512
+ const data = readFileSync(tmpFile).toString('base64');
513
+ try { await execAsync(`rm -f "${tmpFile}"`); } catch {}
514
+ return `data:image/png;base64,${data}`;
515
+ }
516
+ }
517
+
360
518
  default:
361
519
  return `Unknown tool: ${name}`;
362
520
  }
@@ -365,6 +523,69 @@ export class OllamaAgent extends EventEmitter {
365
523
  }
366
524
  }
367
525
 
526
+ // ─── CDP browser screenshot ───────────────────────────────────────────────
527
+
528
+ async _cdpScreenshot(navigateUrl, tmpFile) {
529
+ const CDP_PORT = 9223;
530
+ let tabId;
531
+
532
+ // Get or create a tab
533
+ const tabsRes = await fetch(`http://127.0.0.1:${CDP_PORT}/json`);
534
+ const tabs = await tabsRes.json();
535
+ const usable = tabs.find(t => t.type === 'page' && t.webSocketDebuggerUrl);
536
+
537
+ if (!usable) {
538
+ // Create new tab
539
+ const newTab = await fetch(`http://127.0.0.1:${CDP_PORT}/json/new`, { method: 'PUT' });
540
+ const newTabData = await newTab.json();
541
+ tabId = newTabData.id;
542
+ } else {
543
+ tabId = usable.id;
544
+ }
545
+
546
+ return new Promise((resolve, reject) => {
547
+ // Inline WebSocket CDP — no ws package dependency needed (Node 22 has WebSocket built in)
548
+ const ws = new WebSocket(`ws://127.0.0.1:${CDP_PORT}/devtools/page/${tabId}`);
549
+ let msgId = 1;
550
+ const pending = new Map();
551
+
552
+ const send = (method, params = {}) => new Promise((res, rej) => {
553
+ const id = msgId++;
554
+ pending.set(id, { resolve: res, reject: rej });
555
+ ws.send(JSON.stringify({ id, method, params }));
556
+ });
557
+
558
+ ws.addEventListener('message', (evt) => {
559
+ const msg = JSON.parse(evt.data);
560
+ if (msg.id && pending.has(msg.id)) {
561
+ const { resolve: res, reject: rej } = pending.get(msg.id);
562
+ pending.delete(msg.id);
563
+ if (msg.error) rej(new Error(msg.error.message));
564
+ else res(msg.result);
565
+ }
566
+ });
567
+
568
+ ws.addEventListener('open', async () => {
569
+ try {
570
+ if (navigateUrl) {
571
+ await send('Page.navigate', { url: navigateUrl });
572
+ // Wait for load
573
+ await new Promise(r => setTimeout(r, 3000));
574
+ }
575
+ const { data } = await send('Page.captureScreenshot', { format: 'png' });
576
+ ws.close();
577
+ resolve(`data:image/png;base64,${data}`);
578
+ } catch (err) {
579
+ ws.close();
580
+ reject(err);
581
+ }
582
+ });
583
+
584
+ ws.addEventListener('error', (err) => reject(new Error(`CDP WebSocket error: ${err.message}`)));
585
+ setTimeout(() => { ws.close(); reject(new Error('CDP screenshot timeout')); }, 20000);
586
+ });
587
+ }
588
+
368
589
  _resolvePath(p, workDir) {
369
590
  return path.isAbsolute(p) ? p : path.join(workDir, p);
370
591
  }
@@ -382,6 +603,8 @@ export class OllamaAgent extends EventEmitter {
382
603
  case 'web_fetch': {
383
604
  try { return `Fetching ${new URL(args.url).hostname}`; } catch { return 'Fetching URL'; }
384
605
  }
606
+ case 'take_screenshot':
607
+ return `Screenshot: ${args.url || args.target}`;
385
608
  default:
386
609
  return name;
387
610
  }
package/src/supervisor.js CHANGED
@@ -39,11 +39,18 @@ function removePid(file) {
39
39
  export async function runSupervisor(innerArgv) {
40
40
  writePid(PID_FILE, process.pid);
41
41
 
42
+ // SIGTERM on supervisor = intentional stop (from agentforge stop command)
42
43
  process.on('SIGTERM', () => {
43
44
  console.log('[supervisor] Received SIGTERM — shutting down');
44
45
  removePid(PID_FILE);
45
46
  process.exit(0);
46
47
  });
48
+ // SIGINT = Ctrl+C in foreground terminal = intentional stop
49
+ process.on('SIGINT', () => {
50
+ console.log('[supervisor] Received SIGINT — shutting down');
51
+ removePid(PID_FILE);
52
+ process.exit(0);
53
+ });
47
54
 
48
55
  let consecutiveCrashes = 0;
49
56
 
package/src/worker.js CHANGED
@@ -1756,12 +1756,12 @@ Review and add specific steps, pitfalls, and patterns that helped succeed.
1756
1756
  });
1757
1757
  }
1758
1758
 
1759
- async shutdown() {
1759
+ async shutdown(code = 1) {
1760
1760
  console.log('🛑 Shutting down worker...');
1761
1761
  if (this.ws) {
1762
1762
  this.ws.close();
1763
1763
  }
1764
- process.exit(0);
1764
+ process.exit(code);
1765
1765
  }
1766
1766
 
1767
1767
  // Find the AgentForge git repo root, regardless of whether worker is globally installed or run from source