@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.
- package/bin/agentforge.js +390 -44
- package/package.json +1 -1
- package/src/OpenClawCLI.js +204 -46
- package/src/resolveOpenclaw.js +105 -0
- package/src/selfUpdate.js +66 -0
- package/src/supervisor.js +128 -0
- package/src/worker.js +265 -227
- package/templates/agent/AGENTFORGE.md +148 -56
- package/templates/agent/AGENTS.md +0 -212
- package/templates/agent/SOUL.md +0 -36
- package/templates/agent/TOOLS.md +0 -40
package/src/OpenClawCLI.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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), '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
+
}
|