@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/installer.js
CHANGED
|
@@ -4,7 +4,18 @@ const fs = require('fs');
|
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { execSync, exec } = require('child_process');
|
|
7
|
-
const {
|
|
7
|
+
const {
|
|
8
|
+
whichBinary,
|
|
9
|
+
getEnhancedEnv,
|
|
10
|
+
getRuntimePrefix,
|
|
11
|
+
clearBinaryLookupCache,
|
|
12
|
+
aiderBinDirs,
|
|
13
|
+
resolveBinaryInKnownDirs,
|
|
14
|
+
resolveManagedNpmBinary,
|
|
15
|
+
resolveManagedNpmPackageBin,
|
|
16
|
+
isNodeShebangScript,
|
|
17
|
+
whereAll,
|
|
18
|
+
} = require('./paths');
|
|
8
19
|
const { isWslBinary, bridgedCommandString, wslHomeUnc } = require('./wsl');
|
|
9
20
|
const { canBlock } = require('./probe-mode');
|
|
10
21
|
const { EnvManager } = require('./env');
|
|
@@ -610,6 +621,14 @@ class Installer {
|
|
|
610
621
|
if (/\.(mjs|cjs|js)$/i.test(binary)) {
|
|
611
622
|
return `"${this._nodeBinary()}" "${binary}" --version`;
|
|
612
623
|
}
|
|
624
|
+
// Same problem, no extension to spot it by: npm's package bin is often an
|
|
625
|
+
// extensionless `#!/usr/bin/env node` script (node_modules/cline/bin/cline),
|
|
626
|
+
// which is what we resolve whenever npm wrote no .cmd shim. cmd.exe cannot
|
|
627
|
+
// run it — the probe dies with "is not recognized as an internal or external
|
|
628
|
+
// command" — and the agent then shows up in the app with no version at all.
|
|
629
|
+
if (isNodeShebangScript(binary)) {
|
|
630
|
+
return `"${this._nodeBinary()}" "${binary}" --version`;
|
|
631
|
+
}
|
|
613
632
|
return `"${binary}" --version`;
|
|
614
633
|
}
|
|
615
634
|
|
|
@@ -1083,6 +1102,19 @@ class Installer {
|
|
|
1083
1102
|
throw new Error(`No install command for ${agentType} on ${Installer.platform()}`);
|
|
1084
1103
|
}
|
|
1085
1104
|
|
|
1105
|
+
let bootstrapEnv = this._buildShellEnv();
|
|
1106
|
+
let installEnv;
|
|
1107
|
+
if (agentType === 'hermes' && process.platform === 'win32') {
|
|
1108
|
+
const prepared = this._prepareWindowsPowerShellInstall(cmd, bootstrapEnv);
|
|
1109
|
+
cmd = prepared.command;
|
|
1110
|
+
bootstrapEnv = prepared.env;
|
|
1111
|
+
installEnv = prepared.env;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// Same uv bootstrap the streaming path does — `agn install hermes` and the
|
|
1115
|
+
// workspace's remote create must not behave differently from the button.
|
|
1116
|
+
await this._bootstrapManagedUv(agentType, bootstrapEnv, null);
|
|
1117
|
+
|
|
1086
1118
|
// Use bundled node/npm if system npm not available
|
|
1087
1119
|
if (cmd.startsWith('npm install')) {
|
|
1088
1120
|
const prefixDir = getRuntimePrefix(agentType);
|
|
@@ -1091,7 +1123,7 @@ class Installer {
|
|
|
1091
1123
|
cmd = this._resolveNpmCommand(args);
|
|
1092
1124
|
}
|
|
1093
1125
|
|
|
1094
|
-
const output = await this._execShell(cmd);
|
|
1126
|
+
const output = await this._execShell(cmd, 300000, installEnv);
|
|
1095
1127
|
|
|
1096
1128
|
// Aider-only: the curl/uv/pipx installer can exit 0 without landing a
|
|
1097
1129
|
// runnable (or genuine) binary, so verify the real CLI exists BEFORE
|
|
@@ -1159,9 +1191,23 @@ class Installer {
|
|
|
1159
1191
|
await this.installNodejs(onData);
|
|
1160
1192
|
}
|
|
1161
1193
|
|
|
1162
|
-
|
|
1194
|
+
let env = this._buildShellEnv();
|
|
1163
1195
|
const isWin = process.platform === 'win32';
|
|
1164
1196
|
|
|
1197
|
+
// Hermes's registry command names the inbox Windows PowerShell host for
|
|
1198
|
+
// compatibility, but some Windows environments expose only PowerShell 7.
|
|
1199
|
+
// Resolve the host that actually exists and keep module paths from the
|
|
1200
|
+
// launcher's own PowerShell runtime out of both installer processes.
|
|
1201
|
+
if (agentType === 'hermes' && isWin) {
|
|
1202
|
+
const prepared = this._prepareWindowsPowerShellInstall(rawCmd, env);
|
|
1203
|
+
rawCmd = prepared.command;
|
|
1204
|
+
env = prepared.env;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// Hand the installer a uv it can use, so its own fragile bootstrap is
|
|
1208
|
+
// never reached. No-op for every agent but hermes on Windows.
|
|
1209
|
+
await this._bootstrapManagedUv(agentType, env, onData);
|
|
1210
|
+
|
|
1165
1211
|
// Build the spawn invocation. Three shapes:
|
|
1166
1212
|
// 1. npm install via bundled `node npm-cli.js` — argv array, no shell.
|
|
1167
1213
|
// Path-safe under non-ASCII home dirs on Windows (preferred).
|
|
@@ -1297,15 +1343,27 @@ class Installer {
|
|
|
1297
1343
|
// The watchdog already rejected; this close is the kill it performed.
|
|
1298
1344
|
if (stalled) return;
|
|
1299
1345
|
if (code === 0) {
|
|
1346
|
+
// An installer that exits 0 without leaving a runnable binary is
|
|
1347
|
+
// the class below (aider/amp/hermes/cursor). The "…could not be
|
|
1348
|
+
// found" sentence alone says nothing about WHY, and the reason is
|
|
1349
|
+
// sitting in the output we just captured — hermes's install.ps1, for
|
|
1350
|
+
// one, prints "[X] Installation failed: uv installation failed" and
|
|
1351
|
+
// exits 0, and without the tail the launcher could only shrug
|
|
1352
|
+
// ("The installer stopped before it could finish"). Carry the tail
|
|
1353
|
+
// into the rejection so the message the user sees, and the copy that
|
|
1354
|
+
// classifies it, both have the real error in them.
|
|
1355
|
+
const failVerify = (message) => {
|
|
1356
|
+
const tail = outputTail.trim();
|
|
1357
|
+
if (onData) onData(`\n${message}\n`);
|
|
1358
|
+
reject(new Error(tail ? `${message}\n\nInstaller output:\n${tail}` : message));
|
|
1359
|
+
};
|
|
1300
1360
|
// Aider-only: confirm a real, genuine binary exists before recording
|
|
1301
1361
|
// the install (verify-before-mark; never writes a marker on
|
|
1302
1362
|
// failure). Other agent types are unaffected.
|
|
1303
1363
|
if (agentType === 'aider') {
|
|
1304
1364
|
const aider = this._verifyAiderBinary();
|
|
1305
1365
|
if (!aider) {
|
|
1306
|
-
|
|
1307
|
-
if (onData) onData(`\n${msg}\n`);
|
|
1308
|
-
reject(new Error(msg));
|
|
1366
|
+
failVerify(this._aiderBinaryNotFoundMessage());
|
|
1309
1367
|
return;
|
|
1310
1368
|
}
|
|
1311
1369
|
if (onData) onData(`\nAider CLI resolved: ${aider.path}${aider.version ? ` (${aider.version})` : ''}\n`);
|
|
@@ -1315,9 +1373,7 @@ class Installer {
|
|
|
1315
1373
|
if (agentType === 'amp') {
|
|
1316
1374
|
const amp = this._verifyAmpBinary();
|
|
1317
1375
|
if (!amp) {
|
|
1318
|
-
|
|
1319
|
-
if (onData) onData(`\n${msg}\n`);
|
|
1320
|
-
reject(new Error(msg));
|
|
1376
|
+
failVerify(this._ampBinaryNotFoundMessage());
|
|
1321
1377
|
return;
|
|
1322
1378
|
}
|
|
1323
1379
|
if (onData) onData(`\nAmp CLI resolved: ${amp.path}${amp.version ? ` (${amp.version})` : ''}\n`);
|
|
@@ -1327,9 +1383,7 @@ class Installer {
|
|
|
1327
1383
|
if (agentType === 'hermes') {
|
|
1328
1384
|
const hermes = this._verifyHermesBinary();
|
|
1329
1385
|
if (!hermes) {
|
|
1330
|
-
|
|
1331
|
-
if (onData) onData(`\n${msg}\n`);
|
|
1332
|
-
reject(new Error(msg));
|
|
1386
|
+
failVerify(this._hermesBinaryNotFoundMessage());
|
|
1333
1387
|
return;
|
|
1334
1388
|
}
|
|
1335
1389
|
if (onData) onData(`\nHermes CLI resolved: ${hermes.path}\n`);
|
|
@@ -1346,9 +1400,7 @@ class Installer {
|
|
|
1346
1400
|
if (agentType === 'cursor') {
|
|
1347
1401
|
const cursor = this._verifyCursorBinary();
|
|
1348
1402
|
if (!cursor) {
|
|
1349
|
-
|
|
1350
|
-
if (onData) onData(`\n${msg}\n`);
|
|
1351
|
-
reject(new Error(msg));
|
|
1403
|
+
failVerify(this._cursorBinaryNotFoundMessage());
|
|
1352
1404
|
return;
|
|
1353
1405
|
}
|
|
1354
1406
|
if (onData) onData(`\nCursor CLI resolved: ${cursor.path}\n`);
|
|
@@ -1683,29 +1735,7 @@ class Installer {
|
|
|
1683
1735
|
const pkgName = npmPkg || npmPkgFromCmd;
|
|
1684
1736
|
if (!pkgName) return null; // script-installed agent: nothing of ours to prefer
|
|
1685
1737
|
|
|
1686
|
-
|
|
1687
|
-
path.join(getRuntimePrefix(agentType), 'node_modules'),
|
|
1688
|
-
path.join(os.homedir(), '.openagents', 'nodejs', 'node_modules'),
|
|
1689
|
-
];
|
|
1690
|
-
const exts = process.platform === 'win32' ? ['.cmd', '.exe', '.bat', ''] : [''];
|
|
1691
|
-
for (const modules of prefixes) {
|
|
1692
|
-
try {
|
|
1693
|
-
if (!fs.existsSync(path.join(modules, pkgName, 'package.json'))) continue;
|
|
1694
|
-
} catch { continue; }
|
|
1695
|
-
for (const name of [binary, ...aliases]) {
|
|
1696
|
-
for (const ext of exts) {
|
|
1697
|
-
const candidate = path.join(modules, '.bin', `${name}${ext}`);
|
|
1698
|
-
try {
|
|
1699
|
-
if (fs.existsSync(candidate)) return candidate;
|
|
1700
|
-
} catch {}
|
|
1701
|
-
}
|
|
1702
|
-
}
|
|
1703
|
-
// npm does not always link the root package's own bin — same case
|
|
1704
|
-
// _resolvePackageBin exists for.
|
|
1705
|
-
const own = this._resolvePackageBin(agentType, entry, binary);
|
|
1706
|
-
if (own) return own;
|
|
1707
|
-
}
|
|
1708
|
-
return null;
|
|
1738
|
+
return resolveManagedNpmBinary(agentType, pkgName, [binary, ...aliases]);
|
|
1709
1739
|
}
|
|
1710
1740
|
|
|
1711
1741
|
/**
|
|
@@ -1723,25 +1753,7 @@ class Installer {
|
|
|
1723
1753
|
if (m) npmPkgFromCmd = m[1];
|
|
1724
1754
|
}
|
|
1725
1755
|
const pkgName = npmPkg || npmPkgFromCmd || binary;
|
|
1726
|
-
|
|
1727
|
-
path.join(getRuntimePrefix(agentType), 'node_modules'),
|
|
1728
|
-
path.join(os.homedir(), '.openagents', 'nodejs', 'node_modules'),
|
|
1729
|
-
];
|
|
1730
|
-
for (const modules of prefixes) {
|
|
1731
|
-
const pkgDir = path.join(modules, pkgName);
|
|
1732
|
-
const pkgJsonPath = path.join(pkgDir, 'package.json');
|
|
1733
|
-
try {
|
|
1734
|
-
if (!fs.existsSync(pkgJsonPath)) continue;
|
|
1735
|
-
const bin = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).bin;
|
|
1736
|
-
let rel = null;
|
|
1737
|
-
if (typeof bin === 'string') rel = bin;
|
|
1738
|
-
else if (bin && typeof bin === 'object') rel = bin[binary] || bin[pkgName] || Object.values(bin)[0];
|
|
1739
|
-
if (!rel) continue;
|
|
1740
|
-
const abs = path.join(pkgDir, rel);
|
|
1741
|
-
if (fs.existsSync(abs)) return abs;
|
|
1742
|
-
} catch {}
|
|
1743
|
-
}
|
|
1744
|
-
return null;
|
|
1756
|
+
return resolveManagedNpmPackageBin(agentType, pkgName, binary);
|
|
1745
1757
|
}
|
|
1746
1758
|
|
|
1747
1759
|
// -- Markers --
|
|
@@ -1907,6 +1919,168 @@ class Installer {
|
|
|
1907
1919
|
return env;
|
|
1908
1920
|
}
|
|
1909
1921
|
|
|
1922
|
+
/**
|
|
1923
|
+
* Build an isolated environment for a PowerShell child process.
|
|
1924
|
+
*
|
|
1925
|
+
* PowerShell constructs PSModulePath for the host that is starting. Passing
|
|
1926
|
+
* through a value created by another host (for example pwsh -> powershell)
|
|
1927
|
+
* can make it load binary modules from the wrong runtime. Remove every
|
|
1928
|
+
* casing of the key and let the child rebuild its own defaults.
|
|
1929
|
+
*/
|
|
1930
|
+
_powerShellChildEnv(env) {
|
|
1931
|
+
const childEnv = { ...env };
|
|
1932
|
+
for (const key of Object.keys(childEnv)) {
|
|
1933
|
+
if (key.toLowerCase() === 'psmodulepath') delete childEnv[key];
|
|
1934
|
+
}
|
|
1935
|
+
return childEnv;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
/**
|
|
1939
|
+
* Resolve a PowerShell host without assuming Windows PowerShell 5.1 exists.
|
|
1940
|
+
* Prefer the Windows inbox host for compatibility with existing installer
|
|
1941
|
+
* scripts, then fall back to pwsh from PATH or its standard install folder.
|
|
1942
|
+
*
|
|
1943
|
+
* `platform` and `exists` are seams so the Windows rules run on every CI
|
|
1944
|
+
* platform without touching the real machine.
|
|
1945
|
+
*/
|
|
1946
|
+
_resolveWindowsPowerShellHost(
|
|
1947
|
+
env,
|
|
1948
|
+
platform = process.platform,
|
|
1949
|
+
exists = fs.existsSync,
|
|
1950
|
+
) {
|
|
1951
|
+
if (platform !== 'win32') return null;
|
|
1952
|
+
const value = (name) => {
|
|
1953
|
+
const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
|
|
1954
|
+
return key ? env[key] : '';
|
|
1955
|
+
};
|
|
1956
|
+
const systemRoot = value('SystemRoot') || 'C:\\Windows';
|
|
1957
|
+
const windowsPowerShell = path.win32.join(
|
|
1958
|
+
systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe',
|
|
1959
|
+
);
|
|
1960
|
+
if (exists(windowsPowerShell)) return windowsPowerShell;
|
|
1961
|
+
|
|
1962
|
+
for (const rawDir of (value('Path') || '').split(';')) {
|
|
1963
|
+
const dir = rawDir.trim().replace(/^"|"$/g, '');
|
|
1964
|
+
if (!dir) continue;
|
|
1965
|
+
const candidate = path.win32.join(dir, 'pwsh.exe');
|
|
1966
|
+
if (exists(candidate)) return candidate;
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
const programFiles = value('ProgramFiles') || 'C:\\Program Files';
|
|
1970
|
+
const standardPwsh = path.win32.join(programFiles, 'PowerShell', '7', 'pwsh.exe');
|
|
1971
|
+
return exists(standardPwsh) ? standardPwsh : null;
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
/** Replace only the leading PowerShell executable in an install command. */
|
|
1975
|
+
_retargetPowerShellCommand(command, executable) {
|
|
1976
|
+
const hostPrefix = /^(\s*)(?:"[^"]*\\(?:powershell|pwsh)(?:\.exe)?"|(?:powershell|pwsh)(?:\.exe)?)\s+/i;
|
|
1977
|
+
return command.replace(hostPrefix, `$1"${executable}" `);
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
/** Prepare a Windows PowerShell install without leaking another host's modules. */
|
|
1981
|
+
_prepareWindowsPowerShellInstall(
|
|
1982
|
+
command,
|
|
1983
|
+
env,
|
|
1984
|
+
platform = process.platform,
|
|
1985
|
+
exists = fs.existsSync,
|
|
1986
|
+
) {
|
|
1987
|
+
const host = this._resolveWindowsPowerShellHost(env, platform, exists);
|
|
1988
|
+
if (!host) {
|
|
1989
|
+
throw new Error('Windows PowerShell or PowerShell 7 is required for this installer.');
|
|
1990
|
+
}
|
|
1991
|
+
return {
|
|
1992
|
+
command: this._retargetPowerShellCommand(command, host),
|
|
1993
|
+
env: this._powerShellChildEnv(env),
|
|
1994
|
+
host,
|
|
1995
|
+
};
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
/**
|
|
1999
|
+
* Put uv where hermes's installer looks for it, before that installer runs.
|
|
2000
|
+
*
|
|
2001
|
+
* hermes's Install-Uv bootstraps its OWN copy at <HERMES_HOME>\bin\uv.exe
|
|
2002
|
+
* ("no PATH probing, no conda guards" — a uv already on the machine is not
|
|
2003
|
+
* used), and it does that by spawning a child PowerShell for the astral
|
|
2004
|
+
* installer WITHOUT -NoProfile. When that child cannot run — a profile that
|
|
2005
|
+
* throws, a PSModulePath that cannot load built-in modules — uv never lands,
|
|
2006
|
+
* and hermes prints "Installation failed: uv installation failed" and exits
|
|
2007
|
+
* 0 anyway, leaving a half-installed agent.
|
|
2008
|
+
*
|
|
2009
|
+
* Doing the same download ourselves, from an environment we control, takes
|
|
2010
|
+
* that fragile hop out of the install. Its early
|
|
2011
|
+
* `if (Test-Path $managedUv) { return $true }` then short-circuits the whole
|
|
2012
|
+
* step. Best-effort by design: any failure here is logged and the install
|
|
2013
|
+
* proceeds exactly as it does today, so this can only add successes.
|
|
2014
|
+
*
|
|
2015
|
+
* Windows-only. The Unix install.sh runs the uv installer through `sh`,
|
|
2016
|
+
* which reads no profile and has never shown this failure.
|
|
2017
|
+
*
|
|
2018
|
+
* `platform` and `exists` are seams for tests; production passes neither.
|
|
2019
|
+
* They must be threaded into the host lookup below — without them it falls
|
|
2020
|
+
* back to the real filesystem and can never resolve a Windows path on a
|
|
2021
|
+
* Linux or macOS CI runner, which silently turned every assertion about the
|
|
2022
|
+
* spawn into a no-op.
|
|
2023
|
+
*/
|
|
2024
|
+
async _bootstrapManagedUv(
|
|
2025
|
+
agentType,
|
|
2026
|
+
env,
|
|
2027
|
+
onData,
|
|
2028
|
+
platform = process.platform,
|
|
2029
|
+
exists = fs.existsSync,
|
|
2030
|
+
) {
|
|
2031
|
+
if (agentType !== 'hermes' || platform !== 'win32') return;
|
|
2032
|
+
const home = os.homedir();
|
|
2033
|
+
const hermesHome = env.HERMES_HOME
|
|
2034
|
+
|| path.join(env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'hermes');
|
|
2035
|
+
const binDir = path.join(hermesHome, 'bin');
|
|
2036
|
+
const managedUv = path.join(binDir, 'uv.exe');
|
|
2037
|
+
try { if (exists(managedUv)) return; } catch { return; }
|
|
2038
|
+
|
|
2039
|
+
if (onData) onData(`\nProvisioning uv for Hermes into ${binDir} ...\n`);
|
|
2040
|
+
try {
|
|
2041
|
+
const psExe = this._resolveWindowsPowerShellHost(env, platform, exists);
|
|
2042
|
+
if (!psExe) throw new Error('no Windows PowerShell or PowerShell 7 host found');
|
|
2043
|
+
fs.mkdirSync(binDir, { recursive: true });
|
|
2044
|
+
await new Promise((resolve, reject) => {
|
|
2045
|
+
// `_spawnForTest` is a seam so a test can inspect the environment this
|
|
2046
|
+
// child is given; production never sets it.
|
|
2047
|
+
const spawnFn = this._spawnForTest || require('child_process').spawn;
|
|
2048
|
+
const child = spawnFn(
|
|
2049
|
+
psExe,
|
|
2050
|
+
[
|
|
2051
|
+
'-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
|
|
2052
|
+
// Force UTF-8 out of the child before it prints anything. A
|
|
2053
|
+
// Chinese-locale Windows emits GBK, which reaches the install log
|
|
2054
|
+
// as mojibake — the user saw a wall of "??" where the error was.
|
|
2055
|
+
'-Command',
|
|
2056
|
+
'[Console]::OutputEncoding=[Text.Encoding]::UTF8; irm https://astral.sh/uv/install.ps1 | iex',
|
|
2057
|
+
],
|
|
2058
|
+
{
|
|
2059
|
+
// UV_INSTALL_DIR is what the astral script honours; it is the same
|
|
2060
|
+
// variable hermes sets for its own attempt.
|
|
2061
|
+
env: this._powerShellChildEnv({ ...env, UV_INSTALL_DIR: binDir }),
|
|
2062
|
+
cwd: home,
|
|
2063
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
2064
|
+
windowsHide: true,
|
|
2065
|
+
},
|
|
2066
|
+
);
|
|
2067
|
+
const relay = (d) => { if (onData) onData(String(d)); };
|
|
2068
|
+
if (child.stdout) child.stdout.on('data', relay);
|
|
2069
|
+
if (child.stderr) child.stderr.on('data', relay);
|
|
2070
|
+
child.on('error', reject);
|
|
2071
|
+
child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`exit code ${code}`))));
|
|
2072
|
+
});
|
|
2073
|
+
} catch (e) {
|
|
2074
|
+
if (onData) onData(`\nCould not provision uv ahead of the installer (${e.message}); letting Hermes try its own.\n`);
|
|
2075
|
+
return;
|
|
2076
|
+
}
|
|
2077
|
+
if (onData) {
|
|
2078
|
+
onData(fs.existsSync(managedUv)
|
|
2079
|
+
? `uv ready at ${managedUv}\n`
|
|
2080
|
+
: `uv installer finished but ${managedUv} is absent; letting Hermes try its own.\n`);
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
|
|
1910
2084
|
/**
|
|
1911
2085
|
* True if `node` exists outside the bundled launcher directory. Uses raw
|
|
1912
2086
|
* process.env.PATH (no enhancement) so we don't accidentally detect the
|
|
@@ -1914,18 +2088,10 @@ class Installer {
|
|
|
1914
2088
|
*/
|
|
1915
2089
|
_hasSystemNode(bundledDir) {
|
|
1916
2090
|
try {
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
const args = process.platform === 'win32' ? ['node'] : ['node'];
|
|
1920
|
-
const out = execFileSync(cmd, args, {
|
|
1921
|
-
encoding: 'utf-8',
|
|
1922
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1923
|
-
timeout: 5000,
|
|
1924
|
-
windowsHide: true,
|
|
1925
|
-
env: { ...process.env },
|
|
1926
|
-
});
|
|
2091
|
+
// In-process PATH walk: `where` prints in the OEM codepage, so on a
|
|
2092
|
+
// non-ASCII profile its UTF-8-decoded lines never matched bundledDir.
|
|
1927
2093
|
const prefix = (bundledDir || '').toLowerCase();
|
|
1928
|
-
for (const line of
|
|
2094
|
+
for (const line of whereAll('node')) {
|
|
1929
2095
|
if (!prefix || !line.toLowerCase().startsWith(prefix)) return true;
|
|
1930
2096
|
}
|
|
1931
2097
|
return false;
|
|
@@ -2273,12 +2439,15 @@ class Installer {
|
|
|
2273
2439
|
const psTokens = /\b(irm|iwr|iex|Invoke-RestMethod|Invoke-WebRequest|Invoke-Expression|Expand-Archive|Get-[A-Z]\w*|Set-[A-Z]\w*)\b/;
|
|
2274
2440
|
if (!psTokens.test(cmd)) return cmd;
|
|
2275
2441
|
const escaped = cmd.replace(/"/g, '\\"');
|
|
2276
|
-
|
|
2442
|
+
// PowerShell writes to a pipe in the OEM codepage (936 on zh-CN) and this
|
|
2443
|
+
// output is read as UTF-8, so a non-ASCII path in an installer's log or
|
|
2444
|
+
// error came back garbled. Switch the console encoding first.
|
|
2445
|
+
return `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "[Console]::OutputEncoding=[Text.Encoding]::UTF8; ${escaped}"`;
|
|
2277
2446
|
}
|
|
2278
2447
|
|
|
2279
|
-
_execShell(cmd, timeoutMs = 300000) {
|
|
2448
|
+
_execShell(cmd, timeoutMs = 300000, envOverride = null) {
|
|
2280
2449
|
return new Promise((resolve, reject) => {
|
|
2281
|
-
const env = this._buildShellEnv();
|
|
2450
|
+
const env = envOverride || this._buildShellEnv();
|
|
2282
2451
|
|
|
2283
2452
|
let shell = true;
|
|
2284
2453
|
if (process.platform === 'win32') {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ask an OpenAI- or Anthropic-compatible endpoint which models it serves.
|
|
5
|
+
*
|
|
6
|
+
* The daemon answers the workspace's `list_models` command with this, so the
|
|
7
|
+
* model picker offers what the agent's relay actually has, and the key the
|
|
8
|
+
* request needs never leaves the device. Mirrors the launcher's
|
|
9
|
+
* model-catalog listOpenAiModels / listAnthropicModels.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const MAX_MODELS = 300;
|
|
13
|
+
|
|
14
|
+
/** The one line worth showing from an error body (relays nest it in JSON). */
|
|
15
|
+
function briefError(text) {
|
|
16
|
+
try {
|
|
17
|
+
const j = JSON.parse(text);
|
|
18
|
+
const msg = typeof j.error === 'string' ? j.error : j.error && j.error.message;
|
|
19
|
+
const line = String(msg || j.message || '').trim();
|
|
20
|
+
if (line) return line.slice(0, 160);
|
|
21
|
+
} catch {}
|
|
22
|
+
return String(text || '').trim().slice(0, 160);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isOfficialAnthropic(url) {
|
|
26
|
+
return url.hostname.toLowerCase() === 'api.anthropic.com';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `{data: [...]}`, a bare array, or a relay's `{models: [...]}`; ids deduped and sorted. */
|
|
30
|
+
function parseModels(text) {
|
|
31
|
+
let parsed;
|
|
32
|
+
try { parsed = JSON.parse(text); } catch { return []; }
|
|
33
|
+
const list = Array.isArray(parsed)
|
|
34
|
+
? parsed
|
|
35
|
+
: (parsed && (parsed.data || parsed.models)) || [];
|
|
36
|
+
if (!Array.isArray(list)) return [];
|
|
37
|
+
const seen = new Map();
|
|
38
|
+
for (const entry of list) {
|
|
39
|
+
const id = String(typeof entry === 'string' ? entry : (entry && entry.id) || '').trim();
|
|
40
|
+
if (!id || seen.has(id)) continue;
|
|
41
|
+
const label = entry && typeof entry === 'object' && typeof entry.display_name === 'string'
|
|
42
|
+
? entry.display_name.trim()
|
|
43
|
+
: '';
|
|
44
|
+
seen.set(id, { id, label: label || id });
|
|
45
|
+
}
|
|
46
|
+
return [...seen.values()].sort((a, b) => a.id.localeCompare(b.id)).slice(0, MAX_MODELS);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {object} opts
|
|
51
|
+
* @param {string} opts.baseUrl The base URL the agent's CLI is pointed at.
|
|
52
|
+
* @param {string} opts.apiKey
|
|
53
|
+
* @param {string} [opts.protocol] 'anthropic', or OpenAI-compatible (default).
|
|
54
|
+
* @returns {Promise<{models: {id: string, label: string}[], error?: string}>}
|
|
55
|
+
*/
|
|
56
|
+
async function listEndpointModels({ baseUrl, apiKey, protocol = 'openai', fetchImpl = globalThis.fetch, timeoutMs = 15000 }) {
|
|
57
|
+
let url;
|
|
58
|
+
try {
|
|
59
|
+
url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(baseUrl) ? baseUrl : `http://${baseUrl}`);
|
|
60
|
+
} catch {
|
|
61
|
+
return { models: [], error: 'The configured base URL is not a valid URL.' };
|
|
62
|
+
}
|
|
63
|
+
if (!apiKey) return { models: [], error: 'No API key is configured for this agent.' };
|
|
64
|
+
|
|
65
|
+
const headers = { Accept: 'application/json' };
|
|
66
|
+
let path = url.pathname.replace(/\/+$/, '');
|
|
67
|
+
if (protocol === 'anthropic') {
|
|
68
|
+
// Official takes x-api-key; relays only ever honor a bearer token.
|
|
69
|
+
if (isOfficialAnthropic(url)) headers['x-api-key'] = apiKey;
|
|
70
|
+
else headers.Authorization = `Bearer ${apiKey}`;
|
|
71
|
+
headers['anthropic-version'] = '2023-06-01';
|
|
72
|
+
path = `${path.replace(/\/v1$/, '')}/v1/models`;
|
|
73
|
+
url.search = '?limit=100';
|
|
74
|
+
} else {
|
|
75
|
+
headers.Authorization = `Bearer ${apiKey}`;
|
|
76
|
+
// A bare host means the /v1 API; a path (…/v1, …/v1beta/openai) is used as given.
|
|
77
|
+
path = `${path || '/v1'}/models`;
|
|
78
|
+
url.search = '';
|
|
79
|
+
}
|
|
80
|
+
url.pathname = path;
|
|
81
|
+
url.username = '';
|
|
82
|
+
url.password = '';
|
|
83
|
+
|
|
84
|
+
let res;
|
|
85
|
+
let text;
|
|
86
|
+
try {
|
|
87
|
+
res = await fetchImpl(url.toString(), { headers, signal: AbortSignal.timeout(timeoutMs) });
|
|
88
|
+
text = await res.text();
|
|
89
|
+
} catch (e) {
|
|
90
|
+
const reason = e && e.name === 'TimeoutError' ? 'the endpoint did not answer in time' : (e && e.message) || String(e);
|
|
91
|
+
return { models: [], error: `Could not reach ${url.host}: ${reason}` };
|
|
92
|
+
}
|
|
93
|
+
if (res.status >= 400) {
|
|
94
|
+
return { models: [], error: `HTTP ${res.status}: ${briefError(text)}` };
|
|
95
|
+
}
|
|
96
|
+
const models = parseModels(text);
|
|
97
|
+
return models.length ? { models } : { models: [], error: 'The endpoint listed no models.' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { listEndpointModels, parseModels };
|
package/src/paths.js
CHANGED
|
@@ -403,6 +403,20 @@ function whereBinary(names, env) {
|
|
|
403
403
|
return null;
|
|
404
404
|
}
|
|
405
405
|
|
|
406
|
+
/**
|
|
407
|
+
* Every PATH hit for a name, in PATH order — for callers that filter the list
|
|
408
|
+
* (skip the bundled node, find the first one that runs). Like whereBinary it
|
|
409
|
+
* walks PATH in-process: `where` prints in the OEM codepage, and decoding that
|
|
410
|
+
* as UTF-8 mangles any non-ASCII profile path.
|
|
411
|
+
*
|
|
412
|
+
* @param {string} name
|
|
413
|
+
* @param {object} [env] env whose PATH to search (defaults to process.env)
|
|
414
|
+
* @returns {string[]}
|
|
415
|
+
*/
|
|
416
|
+
function whereAll(name, env) {
|
|
417
|
+
return _searchPath(name, env || process.env);
|
|
418
|
+
}
|
|
419
|
+
|
|
406
420
|
/**
|
|
407
421
|
* Drop the bin-dir snapshots and every lookup answered from them — for when a
|
|
408
422
|
* background probe has just learned of directories they did not include.
|
|
@@ -1132,6 +1146,105 @@ function aiderBinDirs() {
|
|
|
1132
1146
|
/** Executable suffixes to try for a bare binary name, per platform. */
|
|
1133
1147
|
const BIN_EXTS = IS_WINDOWS ? ['.cmd', '.exe', '.bat', ''] : [''];
|
|
1134
1148
|
|
|
1149
|
+
/**
|
|
1150
|
+
* A `#!...node` shebang line, as the Windows launcher matches it (win-exec.ts).
|
|
1151
|
+
* `env -S`/flag forms are allowed; `#!/bin/sh -c node` is NOT a node script.
|
|
1152
|
+
*/
|
|
1153
|
+
const NODE_SHEBANG_RE =
|
|
1154
|
+
/^#!\s*(?:(?:\S*[\\/])?env(?:\.exe)?\s+(?:-\S+\s+)*)?(?:\S*[\\/])?node(?:\.exe)?(?:\s|$)/i;
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* True when a file is an extensionless Node script — npm's package bin shape
|
|
1158
|
+
* (e.g. node_modules/cline/bin/cline). Windows cannot execute one: handed to
|
|
1159
|
+
* cmd.exe it fails with "is not recognized as an internal or external command",
|
|
1160
|
+
* so every caller that shells out to a resolved binary must prefix `node`.
|
|
1161
|
+
*/
|
|
1162
|
+
function isNodeShebangScript(filePath) {
|
|
1163
|
+
if (!filePath) return false;
|
|
1164
|
+
try {
|
|
1165
|
+
const fd = fs.openSync(filePath, 'r');
|
|
1166
|
+
try {
|
|
1167
|
+
const buf = Buffer.alloc(64);
|
|
1168
|
+
const n = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
1169
|
+
const firstLine = buf.subarray(0, n).toString('utf-8').split('\n', 1)[0];
|
|
1170
|
+
return NODE_SHEBANG_RE.test(firstLine);
|
|
1171
|
+
} finally {
|
|
1172
|
+
fs.closeSync(fd);
|
|
1173
|
+
}
|
|
1174
|
+
} catch {
|
|
1175
|
+
return false;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/** Managed npm module roots, in the same priority order used by Installer. */
|
|
1180
|
+
function managedNpmModuleDirs(agentType) {
|
|
1181
|
+
const dirs = [
|
|
1182
|
+
path.join(getRuntimePrefix(agentType), 'node_modules'),
|
|
1183
|
+
path.join(os.homedir(), '.openagents', 'nodejs', 'node_modules'),
|
|
1184
|
+
];
|
|
1185
|
+
return [...new Set(dirs)];
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* Resolve a package's declared `bin` target from an OpenAgents-managed npm
|
|
1190
|
+
* prefix. This covers packages such as Cline whose root package is installed
|
|
1191
|
+
* successfully but npm does not create a node_modules/.bin shim for it.
|
|
1192
|
+
*/
|
|
1193
|
+
function resolveManagedNpmPackageBin(agentType, packageName, binaryName) {
|
|
1194
|
+
if (!agentType || !packageName || !binaryName) return null;
|
|
1195
|
+
|
|
1196
|
+
for (const modules of managedNpmModuleDirs(agentType)) {
|
|
1197
|
+
const pkgDir = path.join(modules, packageName);
|
|
1198
|
+
const pkgJsonPath = path.join(pkgDir, 'package.json');
|
|
1199
|
+
try {
|
|
1200
|
+
if (!fs.existsSync(pkgJsonPath)) continue;
|
|
1201
|
+
const bin = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).bin;
|
|
1202
|
+
let rel = null;
|
|
1203
|
+
if (typeof bin === 'string') rel = bin;
|
|
1204
|
+
else if (bin && typeof bin === 'object') {
|
|
1205
|
+
rel = bin[binaryName] || bin[packageName] || Object.values(bin)[0];
|
|
1206
|
+
}
|
|
1207
|
+
if (!rel) continue;
|
|
1208
|
+
const abs = path.join(pkgDir, rel);
|
|
1209
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) return abs;
|
|
1210
|
+
} catch {
|
|
1211
|
+
/* malformed or unreadable package — keep looking */
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
return null;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* Resolve the copy of an npm CLI installed in an OpenAgents-managed prefix.
|
|
1219
|
+
* Package presence gates the lookup so an orphaned shim cannot shadow a
|
|
1220
|
+
* working global install. The package's own bin is the final managed fallback.
|
|
1221
|
+
*/
|
|
1222
|
+
function resolveManagedNpmBinary(agentType, packageName, names) {
|
|
1223
|
+
const list = (Array.isArray(names) ? names : [names]).filter(Boolean);
|
|
1224
|
+
if (!agentType || !packageName || !list.length) return null;
|
|
1225
|
+
|
|
1226
|
+
for (const modules of managedNpmModuleDirs(agentType)) {
|
|
1227
|
+
try {
|
|
1228
|
+
if (!fs.existsSync(path.join(modules, packageName, 'package.json'))) continue;
|
|
1229
|
+
} catch {
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
for (const name of list) {
|
|
1233
|
+
for (const ext of BIN_EXTS) {
|
|
1234
|
+
const candidate = path.join(modules, '.bin', `${name}${ext}`);
|
|
1235
|
+
try {
|
|
1236
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
|
|
1237
|
+
} catch {
|
|
1238
|
+
/* unreadable shim — keep looking */
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
const own = resolveManagedNpmPackageBin(agentType, packageName, list[0]);
|
|
1243
|
+
if (own) return own;
|
|
1244
|
+
}
|
|
1245
|
+
return null;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1135
1248
|
/**
|
|
1136
1249
|
* Last-resort binary lookup: walk the directories we already know agent CLIs
|
|
1137
1250
|
* land in and check the filesystem directly, instead of asking the shell.
|
|
@@ -1205,6 +1318,10 @@ module.exports = {
|
|
|
1205
1318
|
getEnhancedEnv,
|
|
1206
1319
|
whichBinary,
|
|
1207
1320
|
whereBinary,
|
|
1321
|
+
whereAll,
|
|
1322
|
+
resolveManagedNpmBinary,
|
|
1323
|
+
resolveManagedNpmPackageBin,
|
|
1324
|
+
isNodeShebangScript,
|
|
1208
1325
|
resolveBinaryInKnownDirs,
|
|
1209
1326
|
clearBinaryLookupCache,
|
|
1210
1327
|
primeBinaryLookup,
|