@hamp10/agentforge 0.1.0 → 0.2.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.
@@ -1,8 +1,9 @@
1
1
  import { spawn } from 'child_process';
2
- import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'fs';
2
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, unlinkSync } from 'fs';
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() {
@@ -40,6 +34,8 @@ export class OpenClawCLI extends EventEmitter {
40
34
  // OpenClaw Gateway streaming config — populated by worker.js on init
41
35
  this.gatewayPort = null;
42
36
  this.gatewayToken = null;
37
+ // AbortControllers for in-flight gateway requests — keyed by agentId
38
+ this.gatewayAbortControllers = new Map();
43
39
  }
44
40
 
45
41
  /**
@@ -52,6 +48,11 @@ export class OpenClawCLI extends EventEmitter {
52
48
  const url = `http://127.0.0.1:${this.gatewayPort}/v1/chat/completions`;
53
49
  const sessionKey = `agent:${agentId}:main`;
54
50
  let fullText = '';
51
+ const seenToolCallIds = new Set(); // also used in catch for timeout detection
52
+
53
+ // Register an AbortController so cancelAgent() can abort this fetch
54
+ const abortController = new AbortController();
55
+ this.gatewayAbortControllers.set(agentId, abortController);
55
56
 
56
57
  // Friendly names for tool calls shown as live messages to the user
57
58
  const toolLabels = {
@@ -67,6 +68,7 @@ export class OpenClawCLI extends EventEmitter {
67
68
  try {
68
69
  const res = await fetch(url, {
69
70
  method: 'POST',
71
+ signal: abortController.signal,
70
72
  headers: {
71
73
  'Authorization': `Bearer ${this.gatewayToken}`,
72
74
  'Content-Type': 'application/json',
@@ -79,7 +81,7 @@ export class OpenClawCLI extends EventEmitter {
79
81
  messages: [{ role: 'user', content: task }],
80
82
  stream: true,
81
83
  }),
82
- signal: AbortSignal.timeout(600_000), // 10 min max
84
+ // No timeout let agents run as long as needed
83
85
  });
84
86
  if (!res.ok) {
85
87
  console.warn(`[${agentId}] ⚠️ Streaming HTTP ${res.status} — falling back to subprocess`);
@@ -89,7 +91,7 @@ export class OpenClawCLI extends EventEmitter {
89
91
  // Parse SSE stream — emit text tokens AND tool call activity
90
92
  const decoder = new TextDecoder();
91
93
  let buffer = '';
92
- const seenToolCallIds = new Set(); // avoid duplicate tool_activity for same call_id
94
+ // seenToolCallIds declared above try block
93
95
  const pendingToolCalls = new Map(); // index -> { id, name, args }
94
96
 
95
97
  for await (const rawChunk of res.body) {
@@ -159,8 +161,23 @@ export class OpenClawCLI extends EventEmitter {
159
161
  }
160
162
  }
161
163
  // Return an object so callers can distinguish "success with no text" from "request failed"
162
- return { text: fullText, succeeded: true };
164
+ this.gatewayAbortControllers.delete(agentId);
165
+ return { text: fullText, succeeded: true, hadToolCalls: seenToolCallIds.size > 0 };
163
166
  } catch (err) {
167
+ this.gatewayAbortControllers.delete(agentId);
168
+ // Explicit cancel via cancelAgent() — treat as clean stop
169
+ if (err.name === 'AbortError') {
170
+ console.log(`[${agentId}] 🛑 Gateway request aborted by cancelAgent`);
171
+ return { text: fullText, succeeded: false, cancelled: true };
172
+ }
173
+ const isTimeout = err.name === 'TimeoutError' || err.message?.includes('timeout') || err.message?.includes('aborted');
174
+ if (isTimeout) {
175
+ console.warn(`[${agentId}] ⚠️ Streaming timed out after ${Math.round(fullText.length / 4)} tokens — gateway still processing`);
176
+ if (fullText.length > 0 || seenToolCallIds.size > 0) {
177
+ return { text: fullText, succeeded: true, hadToolCalls: seenToolCallIds.size > 0 };
178
+ }
179
+ return { text: '', succeeded: false, timedOut: true };
180
+ }
164
181
  console.warn(`[${agentId}] ⚠️ Streaming HTTP error: ${err.message} — falling back to subprocess`);
165
182
  return null; // null = request failed, subprocess fallback needed
166
183
  }
@@ -747,7 +764,7 @@ export class OpenClawCLI extends EventEmitter {
747
764
  // This ensures MEMORY.md and memory/ dir exist for memory persistence
748
765
  // Use bundled templates (packaged with worker) as primary source,
749
766
  // fall back to /tmp if somehow missing
750
- const bundledTemplateDir = path.join(path.dirname(new URL(import.meta.url).pathname), '../../templates/agent');
767
+ const bundledTemplateDir = path.join(path.dirname(new URL(import.meta.url).pathname), '../templates/agent');
751
768
  const templateDir = existsSync(bundledTemplateDir) ? bundledTemplateDir : '/tmp/agentforge/templates/agent';
752
769
  console.log(`📁 Using templates from: ${templateDir}`);
753
770
  try {
@@ -800,7 +817,11 @@ export class OpenClawCLI extends EventEmitter {
800
817
  * Run an agent task
801
818
  * Images are saved to workspace and referenced in message for vision model analysis
802
819
  */
803
- async runAgentTask(agentId, task, workDir, sessionId = null, image = null, browserProfile = null, imageWorkDir = null) {
820
+ async runAgentTask(agentId, task, workDir, sessionId = null, image = null, browserProfile = null, imageWorkDir = null, agentModel = null) {
821
+ // Apply per-agent model override before running (writes to openclaw.json + signals gateway)
822
+ if (agentModel) {
823
+ await this.setAgentModel(agentId, agentModel);
824
+ }
804
825
  // ── Gateway path disabled — subprocess shows live tool activity ──────────
805
826
  // Gateway path: SSE token streaming — tokens arrive live as the model generates.
806
827
  // Dashboard buffers tokens into sentences before showing each as a complete bubble.
@@ -808,20 +829,41 @@ export class OpenClawCLI extends EventEmitter {
808
829
  console.log(`\n🤖 Running agent (streaming): ${agentId}`);
809
830
  console.log(` Task: ${task.slice(0, 120)}${task.length > 120 ? '…' : ''}`);
810
831
  try {
811
- const streamResult = await this._runAgentTaskStreaming(agentId, task, sessionId);
832
+ // Retry up to 3 times on empty responses (API overloaded/rate limit)
833
+ let streamResult = null;
834
+ for (let attempt = 1; attempt <= 3; attempt++) {
835
+ streamResult = await this._runAgentTaskStreaming(agentId, task, sessionId);
836
+ if (streamResult === null) break; // connection failure — fall back to subprocess
837
+ if (streamResult.timedOut) break; // timeout — don't retry
838
+ if (streamResult.text || streamResult.hadToolCalls) break; // got real output — done
839
+ // Empty response — API overloaded, wait and retry
840
+ console.warn(`[${agentId}] ⚠️ Gateway returned empty response (attempt ${attempt}/3) — retrying in ${attempt * 5}s`);
841
+ if (attempt < 3) await new Promise(r => setTimeout(r, attempt * 5000));
842
+ }
812
843
  if (streamResult !== null) {
844
+ // Streaming timed out with zero output — gateway still processing, can't fall back
845
+ if (streamResult.timedOut) {
846
+ const errorText = "Agent timed out: the model took too long to respond. The gateway is still processing — please wait a moment and try again.";
847
+ let identity = { identityName: agentId, identityEmoji: '🤖' };
848
+ try { identity = await this.getAgentIdentity(agentId); } catch { /* ignore */ }
849
+ this.emit('agent_completed', { agentId, duration: 0, result: { output: errorText }, identity });
850
+ return { success: true, agentId, duration: 0, result: { output: errorText }, identity };
851
+ }
813
852
  // 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
853
  const responseText = streamResult.text || '';
818
- if (!responseText) {
819
- console.log(`[${agentId}] Gateway task completed with no text output (tool-only task)`);
854
+ if (!responseText && !streamResult.hadToolCalls) {
855
+ // Still empty after retries API is down, fall back to subprocess
856
+ console.warn(`[${agentId}] ⚠️ Gateway returned no content after 3 attempts — falling back to subprocess`);
857
+ // fall through to subprocess path below
858
+ } else {
859
+ if (!responseText) {
860
+ console.log(`[${agentId}] ✅ Gateway task completed with no text output (tool-only task)`);
861
+ }
862
+ let identity = { identityName: agentId, identityEmoji: '🤖' };
863
+ try { identity = await this.getAgentIdentity(agentId); } catch { /* ignore */ }
864
+ this.emit('agent_completed', { agentId, duration: 0, result: { output: responseText }, identity });
865
+ return { success: true, agentId, duration: 0, result: { output: responseText }, identity };
820
866
  }
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
867
  }
826
868
  console.warn(`[${agentId}] ⚠️ Streaming request failed — falling back to subprocess`);
827
869
  } catch (err) {
@@ -956,18 +998,40 @@ export class OpenClawCLI extends EventEmitter {
956
998
  const existingAgent = this.activeAgents.get(agentId);
957
999
  if (existingAgent && existingAgent.proc && !existingAgent.proc.killed) {
958
1000
  console.log(`[${agentId}] 🔪 Killing lingering process (pid ${existingAgent.proc.pid}) before spawning new one`);
1001
+ const lingerPgid = existingAgent.pgid || existingAgent.proc.pid;
1002
+ try { process.kill(-lingerPgid, 'SIGKILL'); } catch (e) { /* already dead */ }
959
1003
  try { treeKill(existingAgent.proc.pid, 'SIGKILL'); } catch (e) { /* already dead */ }
960
1004
  this.activeAgents.delete(agentId);
961
1005
  // Wait for process to fully exit and release file locks before spawning
962
1006
  await new Promise(r => setTimeout(r, 800));
963
1007
  }
964
1008
 
1009
+ // Nuke any stale lock files before spawning — the gateway (a persistent process)
1010
+ // can hold session locks indefinitely after a killed task and never release them,
1011
+ // causing the next task to fail with "session file locked".
1012
+ const sessionDir = path.join(homedir(), '.openclaw', 'agents', agentId, 'sessions');
1013
+ if (existsSync(sessionDir)) {
1014
+ try {
1015
+ for (const f of readdirSync(sessionDir)) {
1016
+ if (f.endsWith('.lock')) {
1017
+ unlinkSync(path.join(sessionDir, f));
1018
+ console.log(`[${agentId}] 🔓 Cleared stale lock before spawn: ${f}`);
1019
+ }
1020
+ }
1021
+ } catch (e) { /* ignore */ }
1022
+ }
1023
+
965
1024
  // Change to working directory and run agent
966
1025
  // Use process.execPath (node) directly to avoid shell metacharacter issues
967
1026
  // with user message content (quotes, apostrophes, etc.)
1027
+ // detached: true makes the child a process group leader (pgid = proc.pid).
1028
+ // Background subprocesses spawned by openclaw's exec tool inherit this pgid
1029
+ // (bash disables job control when non-interactive, so & doesn't create new groups).
1030
+ // This lets cancelAgent kill the entire group — including orphaned bg processes.
968
1031
  const proc = spawn(process.execPath, [this.bin, ...args], {
969
1032
  cwd: workDir,
970
- env: agentEnv
1033
+ env: agentEnv,
1034
+ detached: true
971
1035
  });
972
1036
 
973
1037
  let output = '';
@@ -980,7 +1044,7 @@ export class OpenClawCLI extends EventEmitter {
980
1044
  const firstOutputTimer = setTimeout(() => {
981
1045
  if (!firstOutputSeen && !promiseSettled) {
982
1046
  console.warn(`[${agentId}] ⚠️ No output in 90s — openclaw hung, killing`);
983
- try { proc.kill('SIGKILL'); } catch (e) { /* already dead */ }
1047
+ treeKill(proc.pid, 'SIGKILL'); // kill entire process tree sub-agents included
984
1048
  if (!promiseSettled) {
985
1049
  promiseSettled = true;
986
1050
  this.activeAgents.delete(agentId);
@@ -1014,8 +1078,7 @@ export class OpenClawCLI extends EventEmitter {
1014
1078
  if (runCompleted || promiseSettled) return; // already handled
1015
1079
  console.log(`[${agentId}] ⚠️ Process still running 30s after agent end — force killing (compaction hung?)`);
1016
1080
  runCompleted = true;
1017
- try { proc.kill('SIGTERM'); } catch (e) { /* already dead */ }
1018
- setTimeout(() => { try { proc.kill('SIGKILL'); } catch (e) {} }, 1000);
1081
+ treeKill(proc.pid, 'SIGKILL'); // kill entire process tree sub-agents included
1019
1082
  if (!promiseSettled) {
1020
1083
  promiseSettled = true;
1021
1084
  const duration = Date.now() - startTime;
@@ -1050,16 +1113,7 @@ export class OpenClawCLI extends EventEmitter {
1050
1113
  completionTimer = setTimeout(async () => {
1051
1114
  if (proc && !proc.killed) {
1052
1115
  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
- }
1116
+ treeKill(proc.pid, 'SIGKILL'); // kill entire process tree — sub-agents included
1063
1117
  }
1064
1118
  // Task completed successfully — resolve now instead of waiting for close event.
1065
1119
  // The close event can hang indefinitely if openclaw's child subprocesses keep
@@ -1199,6 +1253,8 @@ export class OpenClawCLI extends EventEmitter {
1199
1253
  proc.on('close', async (code) => {
1200
1254
  const duration = Date.now() - startTime;
1201
1255
  clearTimeout(firstOutputTimer);
1256
+ // Sweep up any surviving child processes (e.g. sessions_spawn sub-agents that outlived the parent)
1257
+ try { treeKill(proc.pid, 'SIGKILL'); } catch (e) { /* already dead */ }
1202
1258
 
1203
1259
  // Clear the completion timer if it's still running
1204
1260
  if (completionTimer) {
@@ -1278,12 +1334,13 @@ export class OpenClawCLI extends EventEmitter {
1278
1334
  }
1279
1335
  });
1280
1336
 
1281
- // Track active agent
1337
+ // Track active agent (pgid = proc.pid because detached:true makes it a group leader)
1282
1338
  this.activeAgents.set(agentId, {
1283
1339
  proc,
1284
1340
  startTime,
1285
1341
  task,
1286
- workDir
1342
+ workDir,
1343
+ pgid: proc.pid
1287
1344
  });
1288
1345
  });
1289
1346
  }
@@ -1320,6 +1377,73 @@ export class OpenClawCLI extends EventEmitter {
1320
1377
  return results;
1321
1378
  }
1322
1379
 
1380
+ /**
1381
+ * Set per-agent model override in ~/.openclaw/openclaw.json
1382
+ * Adds the model to the catalog allowlist and sets the agent's primary model.
1383
+ * Called before running a task so the spawned openclaw process (or gateway) picks it up.
1384
+ */
1385
+ async setAgentModel(agentId, modelString) {
1386
+ if (!modelString) return;
1387
+ const cfgPath = path.join(homedir(), '.openclaw', 'openclaw.json');
1388
+ try {
1389
+ let cfg = {};
1390
+ if (existsSync(cfgPath)) {
1391
+ cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
1392
+ }
1393
+ // Ensure structure
1394
+ if (!cfg.agents) cfg.agents = {};
1395
+ if (!cfg.agents.defaults) cfg.agents.defaults = {};
1396
+ if (!cfg.agents.defaults.models) cfg.agents.defaults.models = {};
1397
+ if (!cfg.agents.list) cfg.agents.list = [];
1398
+
1399
+ // Add to catalog/allowlist so OpenClaw permits this model
1400
+ if (!cfg.agents.defaults.models[modelString]) {
1401
+ cfg.agents.defaults.models[modelString] = {};
1402
+ }
1403
+
1404
+ // Find or create per-agent entry
1405
+ let agentEntry = cfg.agents.list.find(a => a.id === agentId);
1406
+ if (!agentEntry) {
1407
+ agentEntry = { id: agentId };
1408
+ cfg.agents.list.push(agentEntry);
1409
+ }
1410
+
1411
+ // Set per-agent model override
1412
+ agentEntry.model = { primary: modelString };
1413
+
1414
+ writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
1415
+ console.log(`[${agentId}] 🔧 Model override → ${modelString}`);
1416
+
1417
+ // Signal gateway to reload config so streaming path picks up the new model
1418
+ if (this.gatewayPort && this.gatewayToken) {
1419
+ try {
1420
+ // Try PATCH /api/config (OpenClaw gateway REST endpoint)
1421
+ const res = await fetch(`http://127.0.0.1:${this.gatewayPort}/api/config`, {
1422
+ method: 'PATCH',
1423
+ headers: {
1424
+ 'Authorization': `Bearer ${this.gatewayToken}`,
1425
+ 'Content-Type': 'application/json',
1426
+ },
1427
+ body: JSON.stringify({
1428
+ agents: {
1429
+ defaults: { models: cfg.agents.defaults.models },
1430
+ list: cfg.agents.list,
1431
+ },
1432
+ }),
1433
+ signal: AbortSignal.timeout(2000),
1434
+ });
1435
+ if (res.ok) {
1436
+ console.log(`[${agentId}] ✅ Gateway config patched`);
1437
+ }
1438
+ } catch {
1439
+ // Not fatal — subprocess path reads config fresh each time
1440
+ }
1441
+ }
1442
+ } catch (err) {
1443
+ console.warn(`[${agentId}] ⚠️ setAgentModel failed: ${err.message}`);
1444
+ }
1445
+ }
1446
+
1323
1447
  /**
1324
1448
  * List all agents (with timeout to prevent hanging)
1325
1449
  */
@@ -1490,27 +1614,61 @@ export class OpenClawCLI extends EventEmitter {
1490
1614
  * Cancel a running agent task by killing the process tree immediately
1491
1615
  */
1492
1616
  cancelAgent(agentId) {
1617
+ // Kill gateway fetch if running in streaming mode
1618
+ const abortCtrl = this.gatewayAbortControllers.get(agentId);
1619
+ if (abortCtrl) {
1620
+ console.log(`🛑 Aborting gateway stream for agent ${agentId}`);
1621
+ abortCtrl.abort();
1622
+ this.gatewayAbortControllers.delete(agentId);
1623
+ }
1624
+
1493
1625
  const agentInfo = this.activeAgents.get(agentId);
1494
1626
  if (!agentInfo || !agentInfo.proc) {
1627
+ if (abortCtrl) return true; // gateway abort counts as success
1495
1628
  console.log(`⚠️ No running process found for agent ${agentId}`);
1496
1629
  return false;
1497
1630
  }
1498
1631
 
1499
- const { proc } = agentInfo;
1632
+ const { proc, pgid } = agentInfo;
1500
1633
  const pid = proc.pid;
1501
-
1502
- console.log(`🛑 Killing process tree for agent ${agentId} (PID: ${pid})`);
1503
-
1634
+
1635
+ console.log(`🛑 Killing process group for agent ${agentId} (PID: ${pid}, PGID: ${pgid || pid})`);
1636
+
1504
1637
  // Clean up tracking immediately
1505
1638
  this.activeAgents.delete(agentId);
1506
-
1507
- // Use tree-kill to kill the entire process tree with SIGKILL (immediate, no grace period)
1639
+
1640
+ // Kill the entire process GROUP first (catches background/orphaned subprocesses that
1641
+ // escaped the process tree, e.g. `python3 -m http.server &` spawned by openclaw exec).
1642
+ // Since we spawn with detached:true, pgid == proc.pid and all non-interactive bash
1643
+ // background processes inherit this pgid.
1644
+ const groupId = pgid || pid;
1645
+ try {
1646
+ process.kill(-groupId, 'SIGKILL');
1647
+ console.log(`✅ Process group -${groupId} killed`);
1648
+ } catch (e) {
1649
+ // Group may already be dead or process detached before we set pgid
1650
+ console.log(`⚠️ process group kill (-${groupId}): ${e.message}`);
1651
+ }
1652
+
1653
+ // Also use tree-kill as belt-and-suspenders for any sub-processes that changed pgid
1508
1654
  treeKill(pid, 'SIGKILL', (err) => {
1509
1655
  if (err) {
1510
1656
  console.log(`⚠️ tree-kill error (process may already be dead): ${err.message}`);
1511
1657
  } else {
1512
1658
  console.log(`✅ Process tree ${pid} killed successfully`);
1513
1659
  }
1660
+ // Clean up any stale lock files left by the killed process
1661
+ const sessionDir = path.join(homedir(), '.openclaw', 'agents', agentId, 'sessions');
1662
+ if (existsSync(sessionDir)) {
1663
+ try {
1664
+ for (const f of readdirSync(sessionDir)) {
1665
+ if (f.endsWith('.lock')) {
1666
+ unlinkSync(path.join(sessionDir, f));
1667
+ console.log(`🔓 Removed stale lock: ${f}`);
1668
+ }
1669
+ }
1670
+ } catch (e) { /* ignore */ }
1671
+ }
1514
1672
  });
1515
1673
 
1516
1674
  this.emit('agent_cancelled', { agentId });
@@ -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
+ }