@openagents-org/agent-launcher 0.2.186 → 0.2.188
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/package.json +2 -2
- package/registry.json +13 -8
- package/src/adapters/cline.js +34 -38
- package/src/adapters/codex.js +246 -66
- package/src/adapters/goose.js +4 -5
- package/src/adapters/hermes.js +40 -0
- package/src/adapters/llm-direct.js +2 -1
- package/src/adapters/nanoclaw-control.js +5 -17
- package/src/adapters/openclaw.js +13 -33
- package/src/adapters/utils.js +7 -4
- package/src/daemon.js +81 -1
- package/src/installer.js +240 -71
- package/src/model-list.js +100 -0
- package/src/paths.js +117 -0
- package/src/probe.js +23 -2
- package/src/update-check.js +5 -5
package/src/adapters/hermes.js
CHANGED
|
@@ -25,6 +25,7 @@ const { spawn, bridgeSpawn } = require('../wsl');
|
|
|
25
25
|
|
|
26
26
|
const BaseAdapter = require('./base');
|
|
27
27
|
const { buildOpenclawSystemPrompt } = require('./workspace-prompt');
|
|
28
|
+
const { REASON } = require('./health-status');
|
|
28
29
|
const { whichBinary, whereBinary } = require('../paths');
|
|
29
30
|
|
|
30
31
|
const IS_WINDOWS = process.platform === 'win32';
|
|
@@ -123,6 +124,34 @@ class HermesAdapter extends BaseAdapter {
|
|
|
123
124
|
return null;
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Preflight gate (run by the daemon before join). Hermes can do nothing
|
|
129
|
+
* without its CLI, so when none can be resolved we surface a precise
|
|
130
|
+
* 'runtime_missing' reason and skip the workspace join — instead of joining,
|
|
131
|
+
* reporting "online" and then failing every message. That contradiction is
|
|
132
|
+
* exactly what users hit: the workspace showed a green hermes and "all set"
|
|
133
|
+
* while the smoke test on the same screen said "Not installed", because
|
|
134
|
+
* `create_agent --install` treats a failed third-party install script as a
|
|
135
|
+
* warning and connects the agent anyway.
|
|
136
|
+
*
|
|
137
|
+
* Re-resolves rather than trusting the constructor's lookup: an install that
|
|
138
|
+
* landed after the daemon started must not need a restart to be seen. NOTE:
|
|
139
|
+
* 'runtime_missing' (binary gone/never landed at run time), NOT
|
|
140
|
+
* 'not_installed' — install detection lives in the installer.
|
|
141
|
+
*/
|
|
142
|
+
preflight() {
|
|
143
|
+
if (!this._hermesBin) this._hermesBin = this._findHermesBinary();
|
|
144
|
+
if (!this._hermesBin) {
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
reason: REASON.RUNTIME_MISSING,
|
|
148
|
+
message: `Hermes CLI not found — install with: ${HERMES_INSTALL_HINT}`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
this._log(`Hermes CLI resolved: ${this._hermesBin}`);
|
|
152
|
+
return { ok: true };
|
|
153
|
+
}
|
|
154
|
+
|
|
126
155
|
_resolveProfile(explicit, agentName) {
|
|
127
156
|
if (explicit && explicit !== '' && explicit !== 'auto') return explicit;
|
|
128
157
|
// Match agent name to an existing ~/.hermes/profiles/<name> if present
|
|
@@ -371,6 +400,17 @@ class HermesAdapter extends BaseAdapter {
|
|
|
371
400
|
const sender = msg.senderName || msg.senderType || 'user';
|
|
372
401
|
this._log(`Processing workspace message from ${sender} in ${msgChannel}`);
|
|
373
402
|
|
|
403
|
+
// Re-resolve per message: the constructor's lookup is a snapshot, and an
|
|
404
|
+
// agent that was created before its CLI finished installing would
|
|
405
|
+
// otherwise stay broken until the daemon restarts.
|
|
406
|
+
if (!this._hermesBin) this._hermesBin = this._findHermesBinary();
|
|
407
|
+
if (!this._hermesBin) {
|
|
408
|
+
const message = `Hermes CLI not found — install with: ${HERMES_INSTALL_HINT}`;
|
|
409
|
+
this._reportStatus(REASON.RUNTIME_MISSING, message);
|
|
410
|
+
await this.sendError(msgChannel, message);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
374
414
|
await this._autoTitleChannel(msgChannel, content);
|
|
375
415
|
await this.sendStatus(msgChannel, 'thinking...');
|
|
376
416
|
|
|
@@ -158,7 +158,8 @@ class LlmDirectAdapter extends BaseAdapter {
|
|
|
158
158
|
'Authorization': `Bearer ${this._apiKey}`,
|
|
159
159
|
};
|
|
160
160
|
const payload = JSON.stringify({
|
|
161
|
-
model
|
|
161
|
+
// The model picked in the workspace, which modelLabel() reports, wins.
|
|
162
|
+
model: this.modelLabel() || 'gpt-4o',
|
|
162
163
|
messages,
|
|
163
164
|
stream: true,
|
|
164
165
|
});
|
|
@@ -21,6 +21,7 @@ const path = require('path');
|
|
|
21
21
|
const { execFile, execFileSync } = require('child_process');
|
|
22
22
|
|
|
23
23
|
const { redactSecrets } = require('./nanoclaw-protocol');
|
|
24
|
+
const { whereAll } = require('../paths');
|
|
24
25
|
|
|
25
26
|
const IS_WINDOWS = process.platform === 'win32';
|
|
26
27
|
|
|
@@ -176,14 +177,7 @@ function looksLikeNanoclaw(dir) {
|
|
|
176
177
|
/** Follow `ncl` on PATH back to the checkout root (bin/ncl → <root>). */
|
|
177
178
|
function homeFromNclBinary() {
|
|
178
179
|
try {
|
|
179
|
-
const
|
|
180
|
-
const out = execFileSync(which, ['ncl'], {
|
|
181
|
-
encoding: 'utf-8',
|
|
182
|
-
timeout: 5000,
|
|
183
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
184
|
-
})
|
|
185
|
-
.split(/\r?\n/)[0]
|
|
186
|
-
.trim();
|
|
180
|
+
const out = whichSafe('ncl');
|
|
187
181
|
if (!out) return null;
|
|
188
182
|
let real = out;
|
|
189
183
|
try {
|
|
@@ -233,15 +227,9 @@ function findNanoclawHome(env = process.env) {
|
|
|
233
227
|
|
|
234
228
|
function whichSafe(bin) {
|
|
235
229
|
try {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
timeout: 5000,
|
|
240
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
241
|
-
})
|
|
242
|
-
.split(/\r?\n/)[0]
|
|
243
|
-
.trim();
|
|
244
|
-
return out || null;
|
|
230
|
+
// Walks PATH in-process: `where` prints in the OEM codepage, which
|
|
231
|
+
// mangled any non-ASCII profile path when read back as UTF-8.
|
|
232
|
+
return whereAll(bin)[0] || null;
|
|
245
233
|
} catch {
|
|
246
234
|
return null;
|
|
247
235
|
}
|
package/src/adapters/openclaw.js
CHANGED
|
@@ -23,7 +23,7 @@ const { spawn } = require('../wsl');
|
|
|
23
23
|
const BaseAdapter = require('./base');
|
|
24
24
|
const { formatAttachmentsForPrompt } = require('./utils');
|
|
25
25
|
const { buildOpenclawSkillMd, buildOpenclawSystemPrompt, workspaceSkillName } = require('./workspace-prompt');
|
|
26
|
-
const { getRuntimePrefix } = require('../paths');
|
|
26
|
+
const { getRuntimePrefix, whereBinary } = require('../paths');
|
|
27
27
|
|
|
28
28
|
const IS_WINDOWS = process.platform === 'win32';
|
|
29
29
|
const OPENCLAW_STATE_DIR = path.join(
|
|
@@ -74,31 +74,17 @@ class OpenClawAdapter extends BaseAdapter {
|
|
|
74
74
|
const mjs = path.join(portableDir, 'node_modules', 'openclaw', 'openclaw.mjs');
|
|
75
75
|
if (fs.existsSync(mjs)) return mjs;
|
|
76
76
|
|
|
77
|
-
// Fallback:
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
} catch {}
|
|
90
|
-
// Windows: also try without .cmd extension (for system installs on PATH)
|
|
91
|
-
if (IS_WINDOWS) {
|
|
92
|
-
try {
|
|
93
|
-
const result = execSync('where openclaw', { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
94
|
-
.split(/\r?\n/)[0].trim();
|
|
95
|
-
if (result) {
|
|
96
|
-
// Try the .cmd variant of this path
|
|
97
|
-
const cmdPath = result.replace(/(?:\.cmd)?$/i, '.cmd');
|
|
98
|
-
const resolved = this._resolveShimToMjs(cmdPath);
|
|
99
|
-
if (resolved) return resolved;
|
|
100
|
-
}
|
|
101
|
-
} catch {}
|
|
77
|
+
// Fallback: a copy the user installed themselves (npm -g, nvm, a custom
|
|
78
|
+
// npm prefix). whereBinary searches the directories itself instead of
|
|
79
|
+
// shelling out to `where`, which prints in the OEM codepage and mangled
|
|
80
|
+
// any non-ASCII profile path (C:\Users\王…) past recognition.
|
|
81
|
+
const found = whereBinary('openclaw');
|
|
82
|
+
if (found) {
|
|
83
|
+
// Windows: resolve the .cmd shim to openclaw.mjs to avoid spawn issues.
|
|
84
|
+
// Unix: `which` hands back the binary or its symlink, either runs as is.
|
|
85
|
+
const resolved = this._resolveShimToMjs(found);
|
|
86
|
+
if (resolved) return resolved;
|
|
87
|
+
if (!IS_WINDOWS) return found;
|
|
102
88
|
}
|
|
103
89
|
return null;
|
|
104
90
|
}
|
|
@@ -359,13 +345,7 @@ class OpenClawAdapter extends BaseAdapter {
|
|
|
359
345
|
// Unified path first (symlink on Unix), then legacy bin/ fallback, then system node
|
|
360
346
|
const nodeUnified = path.join(portableDir, IS_WINDOWS ? 'node.exe' : 'node');
|
|
361
347
|
let nodeBin = fs.existsSync(nodeUnified) ? nodeUnified : path.join(portableDir, 'bin', 'node');
|
|
362
|
-
if (!fs.existsSync(nodeBin))
|
|
363
|
-
try {
|
|
364
|
-
const cmd = IS_WINDOWS ? 'where node.exe' : 'which node';
|
|
365
|
-
nodeBin = execSync(cmd, { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
366
|
-
.split(/\r?\n/)[0].trim();
|
|
367
|
-
} catch { nodeBin = 'node'; }
|
|
368
|
-
}
|
|
348
|
+
if (!fs.existsSync(nodeBin)) nodeBin = whereBinary('node') || 'node';
|
|
369
349
|
|
|
370
350
|
// binary from _findOpenclawBinary() is already resolved to .mjs when possible
|
|
371
351
|
let spawnBin, spawnArgs;
|
package/src/adapters/utils.js
CHANGED
|
@@ -88,19 +88,22 @@ function formatAttachmentsForPrompt(
|
|
|
88
88
|
const url = att.url || `${base}/v1/files/${fileId}`;
|
|
89
89
|
const curl = isWindows ? 'curl.exe' : 'curl';
|
|
90
90
|
const tmpDir = isWindows ? '$env:TEMP' : '/tmp';
|
|
91
|
+
// A web upload's filename is its workspace path (uploaded_files/<stamp>_<name>)
|
|
92
|
+
// and curl -o does not create folders, so that download failed with exit 23.
|
|
93
|
+
const localName = filename.split(/[\\/]/).pop() || fileId || 'attachment';
|
|
91
94
|
if (contentType.startsWith('image/')) {
|
|
92
95
|
lines.push(
|
|
93
96
|
`- Image: ${filename} (file_id: ${fileId}) — ` +
|
|
94
97
|
`download with curl, then use your Read tool on the local file to view it:\n` +
|
|
95
|
-
` Step 1: ${curl} -s -H "X-Workspace-Token: ${tokenExpr}" "${url}" -o ${tmpDir}/${
|
|
96
|
-
` Step 2: Use the Read tool on ${tmpDir}/${
|
|
98
|
+
` Step 1: ${curl} -s -H "X-Workspace-Token: ${tokenExpr}" "${url}" -o ${tmpDir}/${localName}\n` +
|
|
99
|
+
` Step 2: Use the Read tool on ${tmpDir}/${localName} to see the image`
|
|
97
100
|
);
|
|
98
101
|
} else {
|
|
99
102
|
lines.push(
|
|
100
103
|
`- File: ${filename} (file_id: ${fileId}, type: ${contentType}) — ` +
|
|
101
104
|
`download with curl, then use your Read tool on the local file:\n` +
|
|
102
|
-
` Step 1: ${curl} -s -H "X-Workspace-Token: ${tokenExpr}" "${url}" -o ${tmpDir}/${
|
|
103
|
-
` Step 2: Use the Read tool on ${tmpDir}/${
|
|
105
|
+
` Step 1: ${curl} -s -H "X-Workspace-Token: ${tokenExpr}" "${url}" -o ${tmpDir}/${localName}\n` +
|
|
106
|
+
` Step 2: Use the Read tool on ${tmpDir}/${localName} to read the file`
|
|
104
107
|
);
|
|
105
108
|
}
|
|
106
109
|
} else {
|
package/src/daemon.js
CHANGED
|
@@ -9,6 +9,7 @@ const { execSync, execFileSync } = require('child_process');
|
|
|
9
9
|
const { spawn } = require('./wsl');
|
|
10
10
|
const os = require('os');
|
|
11
11
|
const { WorkspaceClient } = require('./workspace-client');
|
|
12
|
+
const { listEndpointModels } = require('./model-list');
|
|
12
13
|
const { getEnhancedEnv, whichBinary, IS_WINDOWS, defaultAgentWorkdir } = require('./paths');
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -44,6 +45,23 @@ function filesystemRoots(platform = os.platform(), exists = fs.existsSync) {
|
|
|
44
45
|
return roots;
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Hostname of a configured base URL, or null when there is none. The roster
|
|
50
|
+
* carries only this: a relay URL can hold a credential in its userinfo or
|
|
51
|
+
* path. A value written without a scheme (`localhost:4000`) still names a host.
|
|
52
|
+
*/
|
|
53
|
+
function endpointHost(value) {
|
|
54
|
+
const raw = String(value || '').trim();
|
|
55
|
+
if (!raw) return null;
|
|
56
|
+
for (const candidate of [raw, `http://${raw}`]) {
|
|
57
|
+
try {
|
|
58
|
+
const host = new URL(candidate).hostname.toLowerCase();
|
|
59
|
+
if (host) return host;
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
47
65
|
/**
|
|
48
66
|
* Agent process lifecycle manager.
|
|
49
67
|
*
|
|
@@ -326,7 +344,8 @@ class Daemon {
|
|
|
326
344
|
// Model/key: per-agent env override, else the type-level saved env.
|
|
327
345
|
// Both power the workspace agent cards; the key goes out MASKED only,
|
|
328
346
|
// so the edit form can show a key is configured without the secret
|
|
329
|
-
// ever leaving the device.
|
|
347
|
+
// ever leaving the device. The endpoint goes out as a hostname, so
|
|
348
|
+
// the workspace can tell a relay from the vendor its model list is for.
|
|
330
349
|
let typeEnv = {};
|
|
331
350
|
try { typeEnv = this.envManager.load(a.type) || {}; } catch {}
|
|
332
351
|
const model = (a.env && a.env.LLM_MODEL) || typeEnv.LLM_MODEL || null;
|
|
@@ -338,6 +357,7 @@ class Daemon {
|
|
|
338
357
|
model: model || null,
|
|
339
358
|
workingDir: a.path || null,
|
|
340
359
|
apiKeyMasked: apiKey ? maskApiKey(apiKey) : null,
|
|
360
|
+
baseUrlHost: endpointHost(this._configuredBaseUrl(a, typeEnv)),
|
|
341
361
|
probe: this._probes[a.name] || null,
|
|
342
362
|
});
|
|
343
363
|
}
|
|
@@ -347,6 +367,52 @@ class Daemon {
|
|
|
347
367
|
return roster;
|
|
348
368
|
}
|
|
349
369
|
|
|
370
|
+
/**
|
|
371
|
+
* The base URL this agent's CLI is pointed at, found the way _buildAgentEnv
|
|
372
|
+
* builds its env: LLM_BASE_URL or a provider variable (OPENAI_BASE_URL,
|
|
373
|
+
* ANTHROPIC_BASE_URL, …) saved for it or mapped by resolve_env, and failing
|
|
374
|
+
* those, the daemon's own environment for the provider variable this type's
|
|
375
|
+
* LLM_BASE_URL maps to, which the spawned CLI inherits.
|
|
376
|
+
*/
|
|
377
|
+
_configuredBaseUrl(a, typeEnv) {
|
|
378
|
+
const saved = { ...typeEnv, ...(a.env || {}) };
|
|
379
|
+
let env = saved;
|
|
380
|
+
try { env = { ...saved, ...this.envManager.resolve(a.type, saved, this.registry) }; } catch {}
|
|
381
|
+
const set = (v) => String(v || '').trim();
|
|
382
|
+
if (set(env.LLM_BASE_URL)) return env.LLM_BASE_URL;
|
|
383
|
+
const key = Object.keys(env).find((k) => /_BASE_URL$/.test(k) && set(env[k]));
|
|
384
|
+
if (key) return env[key];
|
|
385
|
+
let rules = [];
|
|
386
|
+
try { rules = (this.registry && this.registry.getResolveRules(a.type)) || []; } catch {}
|
|
387
|
+
const inherited = rules.find((r) => r && r.from === 'LLM_BASE_URL' && r.to && set(process.env[r.to]));
|
|
388
|
+
return inherited ? process.env[inherited.to] : null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Where this agent's CLI sends requests and with which key, for listing that
|
|
393
|
+
* endpoint's models. The key is looked up the way the CLI would find it: the
|
|
394
|
+
* protocol's own variables for an Anthropic-protocol agent, else LLM_API_KEY
|
|
395
|
+
* or the variable resolve_env maps it to, inherited from the daemon or not.
|
|
396
|
+
*/
|
|
397
|
+
_agentEndpoint(a) {
|
|
398
|
+
let typeEnv = {};
|
|
399
|
+
try { typeEnv = this.envManager.load(a.type) || {}; } catch {}
|
|
400
|
+
const env = this._buildAgentEnv(a);
|
|
401
|
+
let entry = null;
|
|
402
|
+
try { entry = this.registry.getEntry(a.type); } catch {}
|
|
403
|
+
const protocol = (entry && entry.protocol) || 'openai';
|
|
404
|
+
const rules = (entry && entry.resolve_env && entry.resolve_env.rules) || [];
|
|
405
|
+
const keyVars = protocol === 'anthropic'
|
|
406
|
+
? ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', 'LLM_API_KEY']
|
|
407
|
+
: ['LLM_API_KEY', ...rules.filter((r) => r && r.from === 'LLM_API_KEY').map((r) => r.to), 'OPENAI_API_KEY'];
|
|
408
|
+
const keyVar = keyVars.find((k) => k && String(env[k] || '').trim());
|
|
409
|
+
return {
|
|
410
|
+
baseUrl: this._configuredBaseUrl(a, typeEnv),
|
|
411
|
+
apiKey: keyVar ? String(env[keyVar]).trim() : '',
|
|
412
|
+
protocol,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
350
416
|
/**
|
|
351
417
|
* Filesystem hint for the working-directory picker: home, its immediate
|
|
352
418
|
* (non-hidden) subfolders, and the device's filesystem roots. Roots let a
|
|
@@ -606,6 +672,20 @@ async _runNodeCommand(n, cmd) {
|
|
|
606
672
|
this._nodeHeartbeat();
|
|
607
673
|
ok = true;
|
|
608
674
|
message = `Detected ${this._runtimes.length} runtime(s)`;
|
|
675
|
+
} else if (action === 'list_models') {
|
|
676
|
+
// The model picker asks what the agent's own endpoint serves. The
|
|
677
|
+
// request needs the key, which is why it runs here.
|
|
678
|
+
if (!name) throw new Error('Missing agent name');
|
|
679
|
+
const agent = this.config.getAgent(name);
|
|
680
|
+
if (!agent || !this._agentOnNodeWorkspace(agent, n)) {
|
|
681
|
+
throw new Error(`Agent '${name}' is not managed by this workspace`);
|
|
682
|
+
}
|
|
683
|
+
const endpoint = this._agentEndpoint(agent);
|
|
684
|
+
if (!endpoint.baseUrl) throw new Error(`Agent '${name}' uses its CLI's default endpoint`);
|
|
685
|
+
const listed = await listEndpointModels(endpoint);
|
|
686
|
+
ok = listed.models.length > 0;
|
|
687
|
+
message = ok ? `Listed ${listed.models.length} model(s)` : listed.error;
|
|
688
|
+
data = { models: listed.models };
|
|
609
689
|
} else if (action === 'list_dir') {
|
|
610
690
|
data = this._listDir(args.path);
|
|
611
691
|
ok = true;
|