@hamp10/agentforge 0.1.1 → 0.2.1

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
@@ -8,6 +8,8 @@ import open from 'open';
8
8
  import { execSync, spawn } from 'child_process';
9
9
  import { AgentForgeWorker } from '../src/worker.js';
10
10
  import { OpenClawCLI } from '../src/OpenClawCLI.js';
11
+ import { checkAndUpdate } from '../src/selfUpdate.js';
12
+ import { runSupervisor, detachSupervisor, stopSupervisor } from '../src/supervisor.js';
11
13
 
12
14
  const CONFIG_DIR = path.join(os.homedir(), '.agentforge');
13
15
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
@@ -52,7 +54,7 @@ function saveConfig(config) {
52
54
  program
53
55
  .name('agentforge')
54
56
  .description('AgentForge worker - connect your machine to agentforge.ai')
55
- .version('0.1.0');
57
+ .version('0.2.1');
56
58
 
57
59
  program
58
60
  .command('login')
@@ -184,7 +186,14 @@ program
184
186
  .command('start')
185
187
  .description('Start worker and connect to AgentForge')
186
188
  .option('-u, --url <url>', 'Custom AgentForge URL (overrides saved config)')
189
+ .option('--detach', 'Run worker in background (returns shell prompt immediately)')
190
+ .option('--no-daemon', 'Run worker directly without supervisor (internal use)')
187
191
  .action(async (options) => {
192
+ // Self-update: check for newer package version before doing anything else.
193
+ // Skipped when AGENTFORGE_SKIP_UPDATE=1 (set by supervisor on re-exec).
194
+ const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
195
+ await checkAndUpdate(pkg.name, pkg.version);
196
+
188
197
  const config = loadConfig();
189
198
 
190
199
  if (!config.token) {
@@ -207,6 +216,27 @@ program
207
216
  process.exit(1);
208
217
  }
209
218
 
219
+ // Supervisor routing:
220
+ // --detach → spawn background supervisor and return shell immediately
221
+ // (default) → run foreground supervisor that auto-restarts on crash
222
+ // --no-daemon → skip supervisor entirely (used by supervisor internally)
223
+ if (options.detach) {
224
+ // Build the argv for the background supervisor process (no --detach flag so it
225
+ // enters runSupervisor and manages workers; AGENTFORGE_SKIP_UPDATE=1 is set by detachSupervisor)
226
+ const supervisorArgv = ['start', ...(options.url ? ['--url', options.url] : [])];
227
+ detachSupervisor(supervisorArgv);
228
+ process.exit(0);
229
+ }
230
+
231
+ if (options.daemon !== false) {
232
+ // Foreground supervisor mode — re-exec self with --no-daemon, restart on crash
233
+ const innerArgv = ['start', '--no-daemon', ...(options.url ? ['--url', options.url] : [])];
234
+ await runSupervisor(innerArgv);
235
+ return; // runSupervisor exits the process — this is just a safety return
236
+ }
237
+
238
+ // --no-daemon: run the actual worker directly (no supervision wrapper)
239
+
210
240
  // Use saved URL from login, or override with --url flag
211
241
  const baseUrl = options.url || config.url || process.env.AGENTFORGE_URL || 'https://agentforgeai-production.up.railway.app';
212
242
  const wsUrl = baseUrl.replace(/^http/, 'ws') + '/socket';
@@ -420,66 +450,276 @@ program
420
450
  console.log('');
421
451
  });
422
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
+
423
563
  program
424
564
  .command('setup')
425
- .description('Full device setup: AgentForge login + Anthropic auth + Tailscale')
565
+ .description('Interactive setup wizard gets AgentForge running in minutes')
426
566
  .option('--tailscale-key <key>', 'Tailscale auth key (from tailscale.com/admin/settings/keys)')
427
567
  .action(async (options) => {
428
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));
429
573
 
430
574
  console.log('');
431
- console.log('🚀 AgentForge Device Setup');
575
+ console.log('🚀 AgentForge Setup');
432
576
  console.log('================================');
433
- console.log('');
577
+ console.log('Getting your machine ready to run AI agents.\n');
434
578
 
435
- // Step 1: AgentForge login
436
- console.log('Step 1/3: AgentForge authentication');
437
- console.log('Run: agentforge login');
438
- console.log('(Complete login, then come back here)');
439
- 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
+ }
440
596
 
441
- // Step 2: openclaw + Anthropic token
442
- console.log('Step 2/3: Anthropic token setup');
443
- console.log('Run in a new terminal: claude setup-token');
444
- console.log('Then run: agentforge refresh-token');
445
- 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
+ }
446
602
 
447
- // Step 3: Tailscale
448
- console.log('Step 3/3: Tailscale (remote access from any network)');
449
- console.log('');
603
+ // ── Step 2: AI Backend ──────────────────────────────────────────────────
604
+ console.log('Step 2/2: AI Backend\n');
450
605
 
451
- const tailscaleInstalled = spawnSync('which', ['tailscale'], { encoding: 'utf-8' }).status === 0;
606
+ const freshConfig = loadConfig();
452
607
 
453
- if (tailscaleInstalled) {
454
- console.log('✅ Tailscale already installed');
455
- } else {
456
- console.log('Installing Tailscale...');
608
+ if (freshConfig.provider === 'local') {
609
+ const localUrl = freshConfig.localUrl || 'http://localhost:11434';
457
610
  try {
458
- execSync('brew install tailscale', { stdio: 'inherit' });
459
- console.log('✅ Tailscale installed');
460
- } catch (e) {
461
- 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`);
462
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)`);
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`);
463
693
  }
464
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;
465
699
  if (options.tailscaleKey) {
466
700
  try {
467
701
  execSync(`sudo tailscale up --authkey=${options.tailscaleKey}`, { stdio: 'inherit' });
468
- console.log('✅ Tailscale connected');
469
702
  const result = spawnSync('tailscale', ['ip', '--4'], { encoding: 'utf-8' });
470
- if (result.stdout) console.log(` Tailscale IP: ${result.stdout.trim()}`);
471
- } catch (e) {
472
- 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');
473
706
  }
474
- } else {
475
- console.log('To join your Tailscale network:');
476
- console.log(' sudo tailscale up');
477
- console.log(' (or: agentforge setup --tailscale-key <key>)');
478
- 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)');
479
709
  }
480
710
 
481
711
  console.log('');
482
- 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('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
483
723
  console.log('');
484
724
  });
485
725
 
@@ -864,4 +1104,11 @@ program
864
1104
  }
865
1105
  });
866
1106
 
1107
+ program
1108
+ .command('stop')
1109
+ .description('Stop a running background supervisor (started with --detach)')
1110
+ .action(() => {
1111
+ stopSupervisor();
1112
+ });
1113
+
867
1114
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hamp10/agentforge",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "AgentForge worker — connect your machine to agentforge.ai",
5
5
  "type": "module",
6
6
  "bin": {
@@ -185,7 +185,7 @@ export class OllamaAgent extends EventEmitter {
185
185
  messages,
186
186
  tools: TOOLS,
187
187
  tool_choice: 'auto',
188
- stream: false
188
+ stream: true
189
189
  })
190
190
  });
191
191
  } catch (fetchErr) {
@@ -198,9 +198,86 @@ export class OllamaAgent extends EventEmitter {
198
198
  throw new Error(`Local model error ${response.status}: ${body}`);
199
199
  }
200
200
 
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;
201
+ // ── Stream the SSE response ──
202
+ // Accumulate content and tool calls from streaming deltas.
203
+ // Filter out <think>...</think> blocks (qwen3 chain-of-thought) — never show to user.
204
+ let streamContent = '';
205
+ let streamToolCalls = {};
206
+ let inThinkBlock = false;
207
+ let thinkBuffer = '';
208
+
209
+ const reader = response.body.getReader();
210
+ const decoder = new TextDecoder();
211
+ let buf = '';
212
+
213
+ while (true) {
214
+ if (controller.signal.aborted) break;
215
+ const { done, value } = await reader.read();
216
+ if (done) break;
217
+
218
+ buf += decoder.decode(value, { stream: true });
219
+ const lines = buf.split('\n');
220
+ buf = lines.pop(); // keep incomplete line
221
+
222
+ for (const line of lines) {
223
+ if (!line.startsWith('data: ')) continue;
224
+ const payload = line.slice(6).trim();
225
+ if (payload === '[DONE]') continue;
226
+ let evt;
227
+ try { evt = JSON.parse(payload); } catch { continue; }
228
+
229
+ const delta = evt.choices?.[0]?.delta;
230
+ if (!delta) continue;
231
+
232
+ // Accumulate tool call deltas
233
+ if (delta.tool_calls) {
234
+ for (const tc of delta.tool_calls) {
235
+ const idx = tc.index ?? 0;
236
+ if (!streamToolCalls[idx]) streamToolCalls[idx] = { id: tc.id || '', type: 'function', function: { name: '', arguments: '' } };
237
+ if (tc.id) streamToolCalls[idx].id = tc.id;
238
+ if (tc.function?.name) streamToolCalls[idx].function.name += tc.function.name;
239
+ if (tc.function?.arguments) streamToolCalls[idx].function.arguments += tc.function.arguments;
240
+ }
241
+ }
242
+
243
+ // Stream content tokens, filtering <think>...</think> blocks
244
+ if (delta.content) {
245
+ thinkBuffer += delta.content;
246
+
247
+ // Process thinkBuffer to extract non-thinking text
248
+ let out = '';
249
+ let i = 0;
250
+ while (i < thinkBuffer.length) {
251
+ if (!inThinkBlock) {
252
+ const thinkStart = thinkBuffer.indexOf('<think>', i);
253
+ if (thinkStart === -1) {
254
+ out += thinkBuffer.slice(i);
255
+ i = thinkBuffer.length;
256
+ } else {
257
+ out += thinkBuffer.slice(i, thinkStart);
258
+ inThinkBlock = true;
259
+ i = thinkStart + 7;
260
+ }
261
+ } else {
262
+ const thinkEnd = thinkBuffer.indexOf('</think>', i);
263
+ if (thinkEnd === -1) {
264
+ // still inside think block, keep buffering
265
+ i = thinkBuffer.length;
266
+ } else {
267
+ inThinkBlock = false;
268
+ i = thinkEnd + 8;
269
+ }
270
+ }
271
+ }
272
+ thinkBuffer = inThinkBlock ? thinkBuffer.slice(thinkBuffer.lastIndexOf('<think>')) : '';
273
+
274
+ streamContent += out;
275
+ if (out) {
276
+ this.emit('agent_output', { agentId, output: out });
277
+ }
278
+ }
279
+ }
280
+ }
204
281
 
205
282
  this.emit('tool_activity', {
206
283
  agentId,
@@ -208,6 +285,14 @@ export class OllamaAgent extends EventEmitter {
208
285
  description: `✅ Ollama responded`
209
286
  });
210
287
 
288
+ // Reconstruct message from streamed parts
289
+ const toolCallsArray = Object.values(streamToolCalls);
290
+ const message = {
291
+ role: 'assistant',
292
+ content: streamContent || null,
293
+ tool_calls: toolCallsArray.length > 0 ? toolCallsArray : undefined
294
+ };
295
+
211
296
  messages.push(message);
212
297
 
213
298
  // ── Handle tool calls ──
@@ -242,17 +327,9 @@ export class OllamaAgent extends EventEmitter {
242
327
  continue;
243
328
  }
244
329
 
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
- }
330
+ // ── No tool calls: final answer already streamed above ──
331
+ if (streamContent) {
332
+ finalContent = streamContent;
256
333
  }
257
334
  break;
258
335
  }
@@ -3,6 +3,7 @@ import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, unlink
3
3
  import { EventEmitter } from 'events';
4
4
  import { homedir } from 'os';
5
5
  import path from 'path';
6
+ import { resolveOpenclawBin } from './resolveOpenclaw.js';
6
7
  import treeKill from 'tree-kill';
7
8
 
8
9
  // Canary configuration
@@ -14,14 +15,7 @@ const CANARY_PARENT_API_KEY = process.env.CANARY_PARENT_API_KEY; // Optional: in
14
15
  */
15
16
  export class OpenClawCLI extends EventEmitter {
16
17
  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;
18
+ return resolveOpenclawBin();
25
19
  }
26
20
 
27
21
  static isAvailable() {
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Resolves the openclaw binary and module index.js path on any machine,
3
+ * regardless of install method (npm -g, nvm, homebrew, system).
4
+ *
5
+ * Priority:
6
+ * 1. OPENCLAW_PATH env var (explicit override)
7
+ * 2. `which openclaw` — asks the shell's PATH, works for any install method
8
+ * 3. `readlink -f` on the bin to resolve symlinks, then walk up to dist/index.js
9
+ * 4. `npm root -g` — finds global node_modules regardless of prefix
10
+ * 5. Static candidates — final fallback for known install locations
11
+ */
12
+
13
+ import { execSync } from 'child_process';
14
+ import { existsSync } from 'fs';
15
+ import { homedir } from 'os';
16
+ import path from 'path';
17
+
18
+ function tryExec(cmd) {
19
+ try {
20
+ return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ // Walk up from a binary path to find dist/index.js in the containing package.
27
+ function findModuleIndex(binPath) {
28
+ if (!binPath) return null;
29
+ // If it already ends in index.js, use it directly.
30
+ if (binPath.endsWith('index.js') && existsSync(binPath)) return binPath;
31
+ let dir = path.dirname(binPath);
32
+ for (let i = 0; i < 5; i++) {
33
+ const candidate = path.join(dir, 'dist', 'index.js');
34
+ if (existsSync(candidate)) return candidate;
35
+ const up = path.dirname(dir);
36
+ if (up === dir) break;
37
+ dir = up;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ /**
43
+ * Returns the path to openclaw's dist/index.js suitable for spawning
44
+ * via `node dist/index.js gateway ...`.
45
+ * Returns null if openclaw is not found.
46
+ */
47
+ export function resolveOpenclawModule() {
48
+ // 1. Explicit env override
49
+ if (process.env.OPENCLAW_PATH) {
50
+ const p = process.env.OPENCLAW_PATH;
51
+ return existsSync(p) ? p : null;
52
+ }
53
+
54
+ // 2. which openclaw → resolve symlink → find dist/index.js
55
+ const which = tryExec('which openclaw');
56
+ if (which && existsSync(which)) {
57
+ const real = tryExec(`readlink -f "${which}"`) || which;
58
+ const idx = findModuleIndex(real);
59
+ if (idx) return idx;
60
+ // which found the binary but we couldn't find index.js — try npm root
61
+ }
62
+
63
+ // 3. npm root -g
64
+ const npmRoot = tryExec('npm root -g');
65
+ if (npmRoot) {
66
+ const candidate = path.join(npmRoot, 'openclaw', 'dist', 'index.js');
67
+ if (existsSync(candidate)) return candidate;
68
+ }
69
+
70
+ // 4. Static fallbacks for known install locations
71
+ const home = process.env.HOME || homedir();
72
+ const statics = [
73
+ '/usr/local/lib/node_modules/openclaw/dist/index.js',
74
+ path.join(home, '.npm-global/lib/node_modules/openclaw/dist/index.js'),
75
+ '/opt/homebrew/lib/node_modules/openclaw/dist/index.js',
76
+ // nvm — try the active version via node path
77
+ path.join(path.dirname(process.execPath), '..', 'lib', 'node_modules', 'openclaw', 'dist', 'index.js'),
78
+ ].map(p => path.normalize(p));
79
+
80
+ for (const p of statics) {
81
+ if (existsSync(p)) return p;
82
+ }
83
+
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * Returns the openclaw binary path (for running as a CLI command).
89
+ * Returns null if not found.
90
+ */
91
+ export function resolveOpenclawBin() {
92
+ if (process.env.OPENCLAW_BIN) return process.env.OPENCLAW_BIN;
93
+
94
+ const which = tryExec('which openclaw');
95
+ if (which && existsSync(which)) return which;
96
+
97
+ const home = process.env.HOME || homedir();
98
+ const statics = [
99
+ '/usr/local/bin/openclaw',
100
+ path.join(home, '.npm-global/bin/openclaw'),
101
+ '/opt/homebrew/bin/openclaw',
102
+ path.join(path.dirname(process.execPath), 'openclaw'),
103
+ ];
104
+ return statics.find(p => existsSync(p)) || null;
105
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Self-update: checks npm registry for a newer version of this package
3
+ * and auto-updates + re-execs if one is found.
4
+ *
5
+ * Set AGENTFORGE_SKIP_UPDATE=1 to bypass (used internally after update to
6
+ * prevent infinite re-exec loops).
7
+ */
8
+
9
+ import { execSync, spawn } from 'child_process';
10
+
11
+ function parseVersion(v) {
12
+ return (v || '0.0.0').split('.').map(Number);
13
+ }
14
+
15
+ function isNewer(latest, current) {
16
+ const l = parseVersion(latest);
17
+ const c = parseVersion(current);
18
+ for (let i = 0; i < 3; i++) {
19
+ if (l[i] > c[i]) return true;
20
+ if (l[i] < c[i]) return false;
21
+ }
22
+ return false;
23
+ }
24
+
25
+ export async function checkAndUpdate(packageName, currentVersion) {
26
+ if (process.env.AGENTFORGE_SKIP_UPDATE === '1') return;
27
+
28
+ let latestVersion;
29
+ try {
30
+ const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`, {
31
+ signal: AbortSignal.timeout(5000),
32
+ });
33
+ if (!res.ok) return;
34
+ const data = await res.json();
35
+ latestVersion = data.version;
36
+ } catch {
37
+ // Registry unreachable — continue with current version
38
+ return;
39
+ }
40
+
41
+ if (!isNewer(latestVersion, currentVersion)) return;
42
+
43
+ console.log(`\n🔄 Update available: ${currentVersion} → ${latestVersion}`);
44
+ console.log(` Updating ${packageName}...`);
45
+
46
+ try {
47
+ execSync(`npm install -g ${packageName}@${latestVersion}`, { stdio: 'inherit' });
48
+ } catch {
49
+ console.warn('⚠️ Auto-update failed — continuing with current version');
50
+ return;
51
+ }
52
+
53
+ console.log(`✅ Updated to ${latestVersion}. Restarting...\n`);
54
+
55
+ // Re-exec with the new binary, skipping update on the next run
56
+ const child = spawn(process.execPath, [process.argv[1], ...process.argv.slice(2)], {
57
+ stdio: 'inherit',
58
+ detached: false,
59
+ env: { ...process.env, AGENTFORGE_SKIP_UPDATE: '1' },
60
+ });
61
+
62
+ child.on('exit', (code) => process.exit(code ?? 0));
63
+
64
+ // Park the parent — child owns the terminal from here
65
+ await new Promise(() => {});
66
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Supervisor: spawns the worker process and restarts it on crash.
3
+ *
4
+ * Usage modes (controlled by bin/agentforge.js):
5
+ * agentforge start — foreground supervisor (Ctrl+C stops everything)
6
+ * agentforge start --detach — background supervisor (shell returns immediately)
7
+ * agentforge start --no-daemon — raw worker, no supervision (internal use only)
8
+ *
9
+ * The supervisor re-execs this same binary with --no-daemon so the actual
10
+ * worker code path is unchanged — only the lifecycle wrapper is new.
11
+ */
12
+
13
+ import { spawn } from 'child_process';
14
+ import { mkdirSync, writeFileSync, readFileSync, unlinkSync, existsSync } from 'fs';
15
+ import { homedir } from 'os';
16
+ import path from 'path';
17
+
18
+ const BACKOFF_MS = [1000, 2000, 5000, 15000, 30000];
19
+ const CRASH_WINDOW = 10_000; // process lived < 10s → count as crash
20
+ const CONFIG_DIR = path.join(homedir(), '.agentforge');
21
+ const PID_FILE = path.join(CONFIG_DIR, 'supervisor.pid');
22
+ const WORKER_PID_FILE = path.join(CONFIG_DIR, 'worker.pid');
23
+
24
+ function writePid(file, pid) {
25
+ try {
26
+ mkdirSync(path.dirname(file), { recursive: true });
27
+ writeFileSync(file, String(pid));
28
+ } catch {}
29
+ }
30
+
31
+ function removePid(file) {
32
+ try { unlinkSync(file); } catch {}
33
+ }
34
+
35
+ /**
36
+ * Run the supervisor in the foreground. Blocks until a clean exit (code 0).
37
+ * @param {string[]} innerArgv argv to pass to the worker process (must include --no-daemon)
38
+ */
39
+ export async function runSupervisor(innerArgv) {
40
+ writePid(PID_FILE, process.pid);
41
+
42
+ process.on('SIGTERM', () => {
43
+ console.log('[supervisor] Received SIGTERM — shutting down');
44
+ removePid(PID_FILE);
45
+ process.exit(0);
46
+ });
47
+
48
+ let consecutiveCrashes = 0;
49
+
50
+ while (true) {
51
+ const startTime = Date.now();
52
+
53
+ const child = spawn(process.execPath, [process.argv[1], ...innerArgv], {
54
+ stdio: 'inherit',
55
+ detached: false,
56
+ env: { ...process.env, AGENTFORGE_SKIP_UPDATE: '1' },
57
+ });
58
+
59
+ writePid(WORKER_PID_FILE, child.pid);
60
+
61
+ const exitCode = await new Promise(resolve => {
62
+ child.on('exit', resolve);
63
+ child.on('error', () => resolve(1));
64
+ });
65
+
66
+ removePid(WORKER_PID_FILE);
67
+
68
+ // Clean exit (0) = user Ctrl+C or graceful shutdown — stop supervising
69
+ if (exitCode === 0) {
70
+ removePid(PID_FILE);
71
+ process.exit(0);
72
+ }
73
+
74
+ const elapsed = Date.now() - startTime;
75
+ if (elapsed < CRASH_WINDOW) {
76
+ consecutiveCrashes++;
77
+ } else {
78
+ consecutiveCrashes = 0; // ran long enough — reset crash counter
79
+ }
80
+
81
+ const backoff = BACKOFF_MS[Math.min(consecutiveCrashes - 1, BACKOFF_MS.length - 1)] ?? BACKOFF_MS[0];
82
+ console.log(`\n[supervisor] Worker exited (code ${exitCode ?? 'null'}), crash #${consecutiveCrashes} — restarting in ${backoff}ms...`);
83
+ await new Promise(r => setTimeout(r, backoff));
84
+ console.log('[supervisor] Restarting worker...\n');
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Detach: spawn this binary as a background supervisor, then return control
90
+ * to the shell immediately. The background process runs runSupervisor().
91
+ * @param {string[]} supervisorArgv argv for the background supervisor (without --detach)
92
+ */
93
+ export function detachSupervisor(supervisorArgv) {
94
+ const logFile = path.join(CONFIG_DIR, 'worker.log');
95
+ mkdirSync(CONFIG_DIR, { recursive: true });
96
+
97
+ const child = spawn(process.execPath, [process.argv[1], ...supervisorArgv], {
98
+ detached: true,
99
+ stdio: ['ignore', 'ignore', 'ignore'],
100
+ env: { ...process.env, AGENTFORGE_SKIP_UPDATE: '1' },
101
+ });
102
+
103
+ child.unref();
104
+
105
+ writePid(PID_FILE, child.pid);
106
+ console.log(`✅ AgentForge worker started in background (PID ${child.pid})`);
107
+ console.log(` Logs: ${logFile}`);
108
+ console.log(` Stop: agentforge stop`);
109
+ }
110
+
111
+ /**
112
+ * Stop a running background supervisor by sending SIGTERM to the PID in the pid file.
113
+ */
114
+ export function stopSupervisor() {
115
+ if (!existsSync(PID_FILE)) {
116
+ console.log('No running supervisor found.');
117
+ return;
118
+ }
119
+ try {
120
+ const pid = parseInt(readFileSync(PID_FILE, 'utf8'));
121
+ process.kill(pid, 'SIGTERM');
122
+ removePid(PID_FILE);
123
+ console.log(`Stopped supervisor (PID ${pid})`);
124
+ } catch (e) {
125
+ console.log('Supervisor not running or already stopped.');
126
+ removePid(PID_FILE);
127
+ }
128
+ }
package/src/worker.js CHANGED
@@ -4,6 +4,7 @@ import WebSocket from 'ws';
4
4
  import { OpenClawCLI } from './OpenClawCLI.js';
5
5
  import { HampAgentCLI } from './HampAgentCLI.js';
6
6
  import { OllamaAgent } from './OllamaAgent.js';
7
+ import { resolveOpenclawModule } from './resolveOpenclaw.js';
7
8
  import EventEmitter from 'events';
8
9
  import path from 'path';
9
10
  import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, copyFileSync, statSync, unlinkSync, openSync } from 'fs';
@@ -184,8 +185,11 @@ export class AgentForgeWorker extends EventEmitter {
184
185
  // Spawn openclaw-gateway as a child of this worker process.
185
186
  // When the worker exits (Ctrl+C in terminal), the gateway dies with it.
186
187
  // No LaunchAgent needed — the terminal session owns everything.
187
- const openclaw = process.env.OPENCLAW_PATH ||
188
- '/Users/hamp/.npm-global/lib/node_modules/openclaw/dist/index.js';
188
+ const openclaw = resolveOpenclawModule();
189
+ if (!openclaw) {
190
+ console.warn('⚠️ openclaw not found — browser tools unavailable. Install with: npm install -g openclaw');
191
+ return;
192
+ }
189
193
  const port = 18789;
190
194
  const logDir = path.join(homedir(), '.openclaw', 'logs');
191
195
  mkdirSync(logDir, { recursive: true });
@@ -1128,7 +1132,7 @@ export class AgentForgeWorker extends EventEmitter {
1128
1132
  `- VIEWING/TESTING A WEB APP: Always check for a deployed URL first — look in the project for railway.toml, vercel.json, netlify.toml, .env, README.md, or package.json for a live URL. Open the deployed app in the browser. Only spin up a local server if there is genuinely no deployed version. Never default to localhost when a live URL might exist.`,
1129
1133
  `- LOCAL SERVERS: If you must use localhost, try http://127.0.0.1:PORT if http://localhost:PORT fails. Do not stop and ask — just try both.`,
1130
1134
  `- CREATING AGENTS AND CHATTING WITH THEM: Open the browser to https://agentforgeai-production.up.railway.app/dashboard. Click + to create a new agent. Click into that new agent's chat panel. Type a message into the chat input and send it — IN THE BROWSER, not via sessions_send. NEVER use sessions_send or sessions_spawn for this — those do not open a visible chat. The entire interaction happens inside the browser UI, start to finish, exactly like a human clicking around the dashboard.`,
1131
- `- Your runner: ${useHampagent ? 'Hampagent' : 'OpenClaw'}.`,
1135
+ `- Your runner: ${useHampagent ? 'Hampagent' : 'OpenClaw'}. Running on: ${homedir().split('/').pop()}@${hostname()}.`,
1132
1136
  (!conversationHistory || conversationHistory.length === 0)
1133
1137
  ? `- This is the first message. When greeting, say: "I'm [your name] — your ${useHampagent ? 'Hampagent' : 'OpenClaw'} agent running on AgentForge." Never say "autonomous AI agent". Never list capabilities in an intro.`
1134
1138
  : `- This is a continuing conversation. Do NOT re-introduce yourself.`,