@openagents-org/agent-launcher 0.2.172 → 0.2.175
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/icons/commandcode.svg +3 -0
- package/package.json +1 -1
- package/registry.json +259 -35
- package/src/adapters/base.js +32 -0
- package/src/adapters/claude.js +10 -2
- package/src/adapters/codex.js +41 -1
- package/src/adapters/commandcode-stream.js +405 -0
- package/src/adapters/commandcode.js +853 -0
- package/src/adapters/hermes.js +56 -0
- package/src/adapters/index.js +4 -1
- package/src/adapters/kimi-stream.js +360 -0
- package/src/adapters/kimi.js +724 -13
- package/src/adapters/openclaw.js +28 -2
- package/src/adapters/opencode.js +86 -0
- package/src/adapters/pi-stream.js +28 -0
- package/src/adapters/pi.js +21 -2
- package/src/adapters/workspace-prompt.js +42 -8
- package/src/cli.js +182 -6
- package/src/config.js +13 -2
- package/src/daemon.js +192 -59
- package/src/env.js +22 -2
- package/src/index.js +13 -0
- package/src/installer.js +69 -1
- package/src/paths.js +315 -11
- package/src/probe.js +29 -8
- package/src/registry.js +4 -1
- package/src/workspace-client.js +1 -0
package/src/paths.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const fs = require('fs');
|
|
13
13
|
const os = require('os');
|
|
14
|
-
const { execSync } = require('child_process');
|
|
14
|
+
const { execSync, execFileSync } = require('child_process');
|
|
15
15
|
|
|
16
16
|
const IS_WINDOWS = process.platform === 'win32';
|
|
17
17
|
const IS_MACOS = process.platform === 'darwin';
|
|
@@ -50,6 +50,14 @@ function getExtraBinDirs() {
|
|
|
50
50
|
// Common: ~/.local/bin (pipx, user installs)
|
|
51
51
|
_push(dirs, path.join(HOME, '.local', 'bin'));
|
|
52
52
|
|
|
53
|
+
// Every JS package manager's global bin, plus the npm prefix the user
|
|
54
|
+
// actually configured. Shared by both platforms — an agent installed with
|
|
55
|
+
// pnpm/bun/yarn is no less installed than one npm dropped.
|
|
56
|
+
_addPackageManagerPaths(dirs);
|
|
57
|
+
|
|
58
|
+
// CLIs whose own installer script picks a directory of its own.
|
|
59
|
+
_addAgentInstallerPaths(dirs);
|
|
60
|
+
|
|
53
61
|
// Aider (uv tool install) — its executable honors XDG_BIN_HOME /
|
|
54
62
|
// XDG_DATA_HOME/../bin / ~/.local/bin and also always lands in the uv tools
|
|
55
63
|
// venv. A GUI/daemon process won't see the installer's PATH edit, so add the
|
|
@@ -80,6 +88,11 @@ function getExtraBinDirs() {
|
|
|
80
88
|
// Legacy: shared node_modules/.bin (for backward compat with pre-isolation installs)
|
|
81
89
|
_push(dirs, path.join(portableNode, 'node_modules', '.bin'));
|
|
82
90
|
|
|
91
|
+
// Last, and the widest net: whatever the user's login shell puts on PATH. See
|
|
92
|
+
// loginShellDirs() for why a GUI launch needs this at all. Ranked below every
|
|
93
|
+
// dir above so a copy this launcher installed still wins over a global one.
|
|
94
|
+
for (const d of loginShellDirs()) _push(dirs, d);
|
|
95
|
+
|
|
83
96
|
// Filter to existing directories only, deduplicate
|
|
84
97
|
const seen = new Set();
|
|
85
98
|
const value = dirs.filter(d => {
|
|
@@ -165,10 +178,29 @@ function whichBinary(name) {
|
|
|
165
178
|
timeout: 5000,
|
|
166
179
|
windowsHide: true,
|
|
167
180
|
});
|
|
168
|
-
|
|
181
|
+
const hits = [];
|
|
169
182
|
for (const line of result.split(/\r?\n/)) {
|
|
170
183
|
const hit = line.trim();
|
|
171
|
-
if (hit && fs.existsSync(hit))
|
|
184
|
+
if (hit && fs.existsSync(hit)) hits.push(hit);
|
|
185
|
+
}
|
|
186
|
+
let value = hits[0] || null;
|
|
187
|
+
if (IS_WINDOWS && hits.length) {
|
|
188
|
+
// `where` can list npm's extensionless POSIX shim (node_modules/.bin/
|
|
189
|
+
// claude, a sh script) ahead of claude.cmd. Windows cannot execute that
|
|
190
|
+
// file — spawning it fails and the probe misreports an installed CLI as
|
|
191
|
+
// "not installed" while the chat adapters (which prefer .cmd) work fine.
|
|
192
|
+
// Prefer a hit Windows can run; failing that, a runnable sibling of the
|
|
193
|
+
// shim; only then fall back to the raw first hit.
|
|
194
|
+
const RUNNABLE = /\.(cmd|exe|bat|com)$/i;
|
|
195
|
+
const runnable = hits.find((h) => RUNNABLE.test(h));
|
|
196
|
+
if (runnable) {
|
|
197
|
+
value = runnable;
|
|
198
|
+
} else {
|
|
199
|
+
for (const h of hits) {
|
|
200
|
+
const sibling = ['.cmd', '.exe', '.bat'].map((e) => h + e).find((c) => fs.existsSync(c));
|
|
201
|
+
if (sibling) { value = sibling; break; }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
172
204
|
}
|
|
173
205
|
whichBinaryCache.set(cacheKey, { value, at: Date.now() });
|
|
174
206
|
return value;
|
|
@@ -236,6 +268,238 @@ function whereBinary(names, env) {
|
|
|
236
268
|
return null;
|
|
237
269
|
}
|
|
238
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Global bin directories for every JS package manager we might find an agent
|
|
273
|
+
* CLI in — plus the npm prefix the user has actually configured.
|
|
274
|
+
*
|
|
275
|
+
* `npm install -g` only lands in the default prefix when nobody moved it, and
|
|
276
|
+
* moving it is the standard advice for avoiding `sudo` (`npm config set prefix
|
|
277
|
+
* ~/.npm-global`). pnpm/bun/yarn each own a different directory again. Missing
|
|
278
|
+
* any of them makes a perfectly working CLI read as "not installed" — the
|
|
279
|
+
* launcher then offers to install a second copy of something already there.
|
|
280
|
+
* Windows already consulted `npm config get prefix`; Unix never did, which is
|
|
281
|
+
* the half of that check this function restores.
|
|
282
|
+
*/
|
|
283
|
+
function _addPackageManagerPaths(dirs) {
|
|
284
|
+
// npm's configured prefix. On Windows the shims sit in the prefix itself; on
|
|
285
|
+
// Unix they sit in <prefix>/bin. Cached for the process — it costs a spawn
|
|
286
|
+
// and the value does not change while the app is open.
|
|
287
|
+
const prefix = _npmPrefix();
|
|
288
|
+
if (prefix) {
|
|
289
|
+
_push(dirs, IS_WINDOWS ? prefix : path.join(prefix, 'bin'));
|
|
290
|
+
// A Windows prefix can still carry a bin/ (Git-bash-flavoured setups).
|
|
291
|
+
if (IS_WINDOWS) _push(dirs, path.join(prefix, 'bin'));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// pnpm — PNPM_HOME when exported, else the per-platform default.
|
|
295
|
+
if (process.env.PNPM_HOME) _push(dirs, process.env.PNPM_HOME);
|
|
296
|
+
if (IS_WINDOWS) {
|
|
297
|
+
if (process.env.LOCALAPPDATA) _push(dirs, path.join(process.env.LOCALAPPDATA, 'pnpm'));
|
|
298
|
+
} else {
|
|
299
|
+
if (IS_MACOS) _push(dirs, path.join(HOME, 'Library', 'pnpm'));
|
|
300
|
+
_push(dirs, path.join(HOME, '.local', 'share', 'pnpm'));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// bun — the install root is relocatable via BUN_INSTALL.
|
|
304
|
+
const bunRoot = process.env.BUN_INSTALL || path.join(HOME, '.bun');
|
|
305
|
+
_push(dirs, path.join(bunRoot, 'bin'));
|
|
306
|
+
|
|
307
|
+
// yarn (both the v1 global bin and the classic global node_modules/.bin)
|
|
308
|
+
_push(dirs, path.join(HOME, '.yarn', 'bin'));
|
|
309
|
+
_push(dirs, path.join(HOME, '.config', 'yarn', 'global', 'node_modules', '.bin'));
|
|
310
|
+
|
|
311
|
+
// deno
|
|
312
|
+
const denoRoot = process.env.DENO_INSTALL || path.join(HOME, '.deno');
|
|
313
|
+
_push(dirs, path.join(denoRoot, 'bin'));
|
|
314
|
+
|
|
315
|
+
// Version-manager shim dirs — asdf and mise put every tool's shim here, so a
|
|
316
|
+
// CLI installed under a non-default runtime still resolves.
|
|
317
|
+
_push(dirs, path.join(process.env.ASDF_DATA_DIR || path.join(HOME, '.asdf'), 'shims'));
|
|
318
|
+
_push(dirs, path.join(HOME, '.local', 'share', 'mise', 'shims'));
|
|
319
|
+
|
|
320
|
+
// Windows package managers that install CLIs outside any of the above.
|
|
321
|
+
if (IS_WINDOWS) {
|
|
322
|
+
_push(dirs, path.join(process.env.SCOOP || path.join(HOME, 'scoop'), 'shims'));
|
|
323
|
+
if (process.env.ChocolateyInstall) _push(dirs, path.join(process.env.ChocolateyInstall, 'bin'));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Install dirs chosen by an agent CLI's own installer script.
|
|
329
|
+
*
|
|
330
|
+
* These never appear on a GUI process's PATH: the script edits a shell rc file
|
|
331
|
+
* (Unix) or the registry (Windows), neither of which reaches an already-running
|
|
332
|
+
* app. Cursor/Amp/Hermes are handled in the per-platform blocks for historical
|
|
333
|
+
* reasons; anything added here is picked up on both.
|
|
334
|
+
*/
|
|
335
|
+
function _addAgentInstallerPaths(dirs) {
|
|
336
|
+
// opencode — `curl -fsSL https://opencode.ai/install | bash` defaults to
|
|
337
|
+
// ~/.opencode/bin and honours OPENCODE_INSTALL_DIR. Reported as
|
|
338
|
+
// "launcher can't see my opencode" (#648).
|
|
339
|
+
_push(dirs, process.env.OPENCODE_INSTALL_DIR || path.join(HOME, '.opencode', 'bin'));
|
|
340
|
+
|
|
341
|
+
// kimi — the npm package (@moonshot-ai/kimi-code) declares a real `kimi` bin,
|
|
342
|
+
// but its postinstall ALSO drops a native build in ~/.kimi-code/bin and puts
|
|
343
|
+
// that on PATH by editing a shell rc file. A running app never sees that
|
|
344
|
+
// edit, and for a user who took the native route it is the only copy there is.
|
|
345
|
+
_push(dirs, path.join(HOME, '.kimi-code', 'bin'));
|
|
346
|
+
|
|
347
|
+
// Legacy npm prefixes the opencode/copilot adapters have always searched.
|
|
348
|
+
// They were known to the adapter that SPAWNS the CLI but not to the
|
|
349
|
+
// installer that decides whether it EXISTS — so an agent could run fine and
|
|
350
|
+
// still be offered for install. See the coverage test in
|
|
351
|
+
// test/agent-detection-matrix.test.js.
|
|
352
|
+
_push(dirs, path.join(HOME, '.npm-global', 'bin'));
|
|
353
|
+
_push(dirs, path.join(HOME, '.openagents', 'npm-global', 'bin'));
|
|
354
|
+
|
|
355
|
+
// The plain ~/bin some installers fall back to when ~/.local/bin is absent.
|
|
356
|
+
_push(dirs, path.join(HOME, 'bin'));
|
|
357
|
+
|
|
358
|
+
if (IS_WINDOWS && process.env.LOCALAPPDATA) {
|
|
359
|
+
const lad = process.env.LOCALAPPDATA;
|
|
360
|
+
// antigravity (agy) — install.sh targets ~/.local/bin, the Windows
|
|
361
|
+
// installer %LOCALAPPDATA%\agy\bin. Only the adapter knew the latter.
|
|
362
|
+
_push(dirs, path.join(lad, 'agy', 'bin'));
|
|
363
|
+
// cursor-agent also ships under Programs\ on some Windows installs — the
|
|
364
|
+
// cursor adapter checks both, the installer only knew one.
|
|
365
|
+
_push(dirs, path.join(lad, 'Programs', 'cursor-agent'));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
let npmPrefixCache;
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Where the user's `npm install -g` puts things, or null. Probed once.
|
|
373
|
+
*
|
|
374
|
+
* Read from npm's own configuration rather than by asking an npm binary,
|
|
375
|
+
* because WHICH npm answers is not a question we can settle: the launcher
|
|
376
|
+
* prepends its bundled runtime to PATH, Homebrew may have another, and each
|
|
377
|
+
* reports its OWN builtin default. Only a prefix the user deliberately set is
|
|
378
|
+
* interesting here — the builtin defaults are already covered by the
|
|
379
|
+
* well-known dirs — and that setting lives in exactly two places:
|
|
380
|
+
*
|
|
381
|
+
* 1. $NPM_CONFIG_PREFIX (npm's env override, highest precedence)
|
|
382
|
+
* 2. `prefix=` in ~/.npmrc (what `npm config set prefix …` writes)
|
|
383
|
+
*
|
|
384
|
+
* Falling back to spawning npm only when neither is present keeps the common
|
|
385
|
+
* case free of a subprocess entirely.
|
|
386
|
+
*/
|
|
387
|
+
function _npmPrefix() {
|
|
388
|
+
if (npmPrefixCache !== undefined) return npmPrefixCache;
|
|
389
|
+
npmPrefixCache = null;
|
|
390
|
+
const accept = (value) => {
|
|
391
|
+
const v = String(value || '').trim().replace(/^["']|["']$/g, '');
|
|
392
|
+
if (!v || v === 'undefined') return false;
|
|
393
|
+
// ~ is not expanded by npm's config reader for us.
|
|
394
|
+
const abs = v.startsWith('~') ? path.join(HOME, v.slice(1)) : v;
|
|
395
|
+
try {
|
|
396
|
+
if (!fs.existsSync(abs)) return false;
|
|
397
|
+
} catch {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
npmPrefixCache = abs;
|
|
401
|
+
return true;
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
if (accept(process.env.NPM_CONFIG_PREFIX)) return npmPrefixCache;
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
const npmrc = fs.readFileSync(path.join(HOME, '.npmrc'), 'utf-8');
|
|
408
|
+
for (const line of npmrc.split(/\r?\n/)) {
|
|
409
|
+
const m = /^\s*prefix\s*=\s*(.+?)\s*$/.exec(line);
|
|
410
|
+
if (m && accept(m[1])) return npmPrefixCache;
|
|
411
|
+
}
|
|
412
|
+
} catch {}
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
const out = execSync('npm config get prefix', {
|
|
416
|
+
encoding: 'utf-8',
|
|
417
|
+
timeout: 8000,
|
|
418
|
+
windowsHide: true,
|
|
419
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
420
|
+
// The user's PATH first: it is their npm we are asking about.
|
|
421
|
+
env: { ...process.env, PATH: _basePATH() },
|
|
422
|
+
}).trim();
|
|
423
|
+
accept(out);
|
|
424
|
+
} catch {}
|
|
425
|
+
return npmPrefixCache;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* PATH for the internal probes above — the process PATH plus the well-known
|
|
430
|
+
* dirs, WITHOUT recursing back into getExtraBinDirs() (which is what calls us).
|
|
431
|
+
*/
|
|
432
|
+
function _basePATH() {
|
|
433
|
+
const seed = [];
|
|
434
|
+
if (IS_WINDOWS) {
|
|
435
|
+
if (process.env.APPDATA) seed.push(path.join(process.env.APPDATA, 'npm'));
|
|
436
|
+
seed.push(path.join(process.env.ProgramFiles || 'C:\\Program Files', 'nodejs'));
|
|
437
|
+
} else {
|
|
438
|
+
seed.push('/usr/local/bin', '/opt/homebrew/bin', path.join(HOME, '.local', 'bin'));
|
|
439
|
+
}
|
|
440
|
+
try { seed.push(path.dirname(process.execPath)); } catch {}
|
|
441
|
+
// Process PATH FIRST — these seeds are a fallback for a GUI launch with no
|
|
442
|
+
// PATH at all, not a preference. Putting them ahead would answer questions
|
|
443
|
+
// about Homebrew's tooling on a machine whose real toolchain is elsewhere.
|
|
444
|
+
return (process.env.PATH ? process.env.PATH + SEP : '') + seed.join(SEP);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const SHELL_PROBE_TIMEOUT_MS = 6000;
|
|
448
|
+
const SHELL_ENV_DELIM = '__OPENAGENTS_ENV__';
|
|
449
|
+
let loginShellDirsCache;
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* The PATH entries a login shell would hand the user, harvested once.
|
|
453
|
+
*
|
|
454
|
+
* This is the general form of every "installed but not detected" report. A
|
|
455
|
+
* launcher started from Finder / the Dock / a desktop entry does NOT inherit
|
|
456
|
+
* the shell's environment — macOS hands a GUI app `/usr/bin:/bin:/usr/sbin:
|
|
457
|
+
* /sbin` and nothing else. So every CLI the user installed through their own
|
|
458
|
+
* shell (nvm on a non-default version, a relocated npm prefix, bun, pnpm, a
|
|
459
|
+
* hand-rolled ~/bin) is invisible, and no hardcoded list of well-known dirs can
|
|
460
|
+
* ever be complete enough to cover it. Asking the shell is the only answer that
|
|
461
|
+
* generalises: whatever `which opencode` finds in a terminal, we find too.
|
|
462
|
+
*
|
|
463
|
+
* Runs `$SHELL -ilc env` and reads the PATH line out of a delimited block, so a
|
|
464
|
+
* chatty rc file (motd, version-manager banners, prompt setup) can't corrupt the
|
|
465
|
+
* result. Best-effort throughout: no shell, an unusual shell that rejects the
|
|
466
|
+
* flags, or a hang all fall back to the hardcoded dirs. Set
|
|
467
|
+
* OPENAGENTS_SKIP_SHELL_PATH=1 to opt out.
|
|
468
|
+
*/
|
|
469
|
+
function loginShellDirs() {
|
|
470
|
+
if (loginShellDirsCache !== undefined) return loginShellDirsCache;
|
|
471
|
+
loginShellDirsCache = [];
|
|
472
|
+
if (IS_WINDOWS) return loginShellDirsCache;
|
|
473
|
+
if (process.env.OPENAGENTS_SKIP_SHELL_PATH === '1') return loginShellDirsCache;
|
|
474
|
+
const shell = process.env.SHELL || '/bin/zsh';
|
|
475
|
+
try {
|
|
476
|
+
if (!fs.existsSync(shell)) return loginShellDirsCache;
|
|
477
|
+
const out = execFileSync(
|
|
478
|
+
shell,
|
|
479
|
+
['-ilc', `echo ${SHELL_ENV_DELIM}; command env; echo ${SHELL_ENV_DELIM}`],
|
|
480
|
+
{
|
|
481
|
+
encoding: 'utf-8',
|
|
482
|
+
timeout: SHELL_PROBE_TIMEOUT_MS,
|
|
483
|
+
killSignal: 'SIGKILL',
|
|
484
|
+
// stdin closed, stderr dropped: an interactive shell with no tty writes
|
|
485
|
+
// job-control warnings there and we do not want them in the output.
|
|
486
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
487
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
488
|
+
},
|
|
489
|
+
);
|
|
490
|
+
const parts = String(out).split(SHELL_ENV_DELIM);
|
|
491
|
+
if (parts.length < 3) return loginShellDirsCache;
|
|
492
|
+
const line = parts[1].split(/\r?\n/).find((l) => l.startsWith('PATH='));
|
|
493
|
+
if (!line) return loginShellDirsCache;
|
|
494
|
+
const seen = new Set();
|
|
495
|
+
for (const d of line.slice('PATH='.length).split(SEP)) {
|
|
496
|
+
const dir = d.trim();
|
|
497
|
+
if (dir && !seen.has(dir)) { seen.add(dir); loginShellDirsCache.push(dir); }
|
|
498
|
+
}
|
|
499
|
+
} catch {}
|
|
500
|
+
return loginShellDirsCache;
|
|
501
|
+
}
|
|
502
|
+
|
|
239
503
|
// ---- Windows paths ----
|
|
240
504
|
|
|
241
505
|
function _addWindowsPaths(dirs) {
|
|
@@ -250,14 +514,8 @@ function _addWindowsPaths(dirs) {
|
|
|
250
514
|
// npm global bin (default location)
|
|
251
515
|
if (appData) _push(dirs, path.join(appData, 'npm'));
|
|
252
516
|
|
|
253
|
-
// npm
|
|
254
|
-
|
|
255
|
-
const npmPrefix = execSync('npm config get prefix', {
|
|
256
|
-
encoding: 'utf-8', timeout: 5000, windowsHide: true,
|
|
257
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
258
|
-
}).trim();
|
|
259
|
-
if (npmPrefix) _push(dirs, npmPrefix);
|
|
260
|
-
} catch {}
|
|
517
|
+
// npm's custom prefix (e.g. D:\node\node_global) is added for BOTH platforms
|
|
518
|
+
// by _addPackageManagerPaths — see the note there.
|
|
261
519
|
|
|
262
520
|
// Portable Node.js installed by OpenAgents Launcher
|
|
263
521
|
_push(dirs, path.join(HOME, '.openagents', 'nodejs'));
|
|
@@ -339,6 +597,8 @@ function _addUnixPaths(dirs) {
|
|
|
339
597
|
// nvm
|
|
340
598
|
const nvmDir = process.env.NVM_DIR || path.join(HOME, '.nvm');
|
|
341
599
|
try {
|
|
600
|
+
// The version the shell that spawned us is on, when it exported it.
|
|
601
|
+
if (process.env.NVM_BIN) _push(dirs, process.env.NVM_BIN);
|
|
342
602
|
// Find current nvm version
|
|
343
603
|
const defaultPath = path.join(nvmDir, 'alias', 'default');
|
|
344
604
|
if (fs.existsSync(defaultPath)) {
|
|
@@ -349,6 +609,14 @@ function _addUnixPaths(dirs) {
|
|
|
349
609
|
}
|
|
350
610
|
// Also try current symlink
|
|
351
611
|
_push(dirs, path.join(nvmDir, 'current', 'bin'));
|
|
612
|
+
// Then EVERY installed version, newest first. `npm install -g` writes into
|
|
613
|
+
// whichever version was active in that terminal, which is routinely not the
|
|
614
|
+
// default alias — a user who ran `nvm use 20 && npm i -g opencode` has a
|
|
615
|
+
// real, working CLI that the default-alias-only lookup above cannot see.
|
|
616
|
+
// Newest-first so the most likely copy still wins.
|
|
617
|
+
for (const v of _installedNvmVersions(nvmDir)) {
|
|
618
|
+
_push(dirs, path.join(nvmDir, 'versions', 'node', v, 'bin'));
|
|
619
|
+
}
|
|
352
620
|
} catch {}
|
|
353
621
|
|
|
354
622
|
// fnm
|
|
@@ -403,6 +671,23 @@ function _push(arr, dir) {
|
|
|
403
671
|
if (dir) arr.push(dir);
|
|
404
672
|
}
|
|
405
673
|
|
|
674
|
+
/** Every node version nvm has installed, newest first. */
|
|
675
|
+
function _installedNvmVersions(nvmDir) {
|
|
676
|
+
try {
|
|
677
|
+
return fs
|
|
678
|
+
.readdirSync(path.join(nvmDir, 'versions', 'node'))
|
|
679
|
+
.filter((v) => /^v\d+\./.test(v))
|
|
680
|
+
.sort((a, b) => {
|
|
681
|
+
const pa = a.slice(1).split('.').map(Number);
|
|
682
|
+
const pb = b.slice(1).split('.').map(Number);
|
|
683
|
+
for (let i = 0; i < 3; i++) if ((pb[i] || 0) !== (pa[i] || 0)) return (pb[i] || 0) - (pa[i] || 0);
|
|
684
|
+
return 0;
|
|
685
|
+
});
|
|
686
|
+
} catch {
|
|
687
|
+
return [];
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
406
691
|
function _resolveNvmVersion(nvmDir, alias) {
|
|
407
692
|
// Handle direct version like 'v22.14.0'
|
|
408
693
|
if (alias.startsWith('v')) {
|
|
@@ -482,6 +767,23 @@ function defaultAgentWorkdir(agentName) {
|
|
|
482
767
|
function clearBinaryLookupCache() {
|
|
483
768
|
extraBinDirsCache = { value: null, at: 0, path: '' };
|
|
484
769
|
whichBinaryCache.clear();
|
|
770
|
+
// The login-shell / npm-prefix probes are deliberately NOT cleared: they each
|
|
771
|
+
// cost a process spawn, they answer a question about the machine rather than
|
|
772
|
+
// about any install, and this is called after every install/uninstall.
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Pay the one-time probe cost (login shell + npm prefix) up front.
|
|
777
|
+
*
|
|
778
|
+
* getExtraBinDirs() is synchronous and every caller is on a path where a ~1s
|
|
779
|
+
* stall is felt — the marketplace listing, a readiness check, spawning an
|
|
780
|
+
* agent. Calling this once while a splash screen is already up moves that cost
|
|
781
|
+
* somewhere nobody is waiting on. Safe to call more than once; safe to skip.
|
|
782
|
+
*/
|
|
783
|
+
function primeBinaryLookup() {
|
|
784
|
+
try { loginShellDirs(); } catch {}
|
|
785
|
+
try { _npmPrefix(); } catch {}
|
|
786
|
+
try { getExtraBinDirs(); } catch {}
|
|
485
787
|
}
|
|
486
788
|
|
|
487
789
|
/**
|
|
@@ -581,6 +883,8 @@ module.exports = {
|
|
|
581
883
|
whereBinary,
|
|
582
884
|
resolveBinaryInKnownDirs,
|
|
583
885
|
clearBinaryLookupCache,
|
|
886
|
+
primeBinaryLookup,
|
|
887
|
+
loginShellDirs,
|
|
584
888
|
getRuntimePrefix,
|
|
585
889
|
getCorePrefix,
|
|
586
890
|
defaultAgentWorkdir,
|
package/src/probe.js
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
const os = require('os');
|
|
29
29
|
const { spawn } = require('child_process');
|
|
30
30
|
const { getEnhancedEnv } = require('./paths');
|
|
31
|
+
const { shouldUseShellForBinary } = require('./adapters/health-status');
|
|
31
32
|
const { formatAuthGuidance } = require('./auth-guidance');
|
|
32
33
|
const { testLLMConnection } = require('./utils');
|
|
33
34
|
|
|
@@ -51,6 +52,7 @@ const CODE = {
|
|
|
51
52
|
NETWORK: 'network',
|
|
52
53
|
TIMEOUT: 'timeout',
|
|
53
54
|
EMPTY_RESPONSE: 'empty_response',
|
|
55
|
+
BAD_MODEL: 'bad_model',
|
|
54
56
|
CLI_ERROR: 'cli_error',
|
|
55
57
|
};
|
|
56
58
|
|
|
@@ -86,19 +88,30 @@ function authFlavor(entry, agentEnv) {
|
|
|
86
88
|
* Classify a failed live check into a code. Pattern-matches the combined
|
|
87
89
|
* output; callers turn the code into guidance via buildGuidance().
|
|
88
90
|
*/
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (timedOut) return CODE.TIMEOUT;
|
|
92
|
-
const text = String(output || '').toLowerCase();
|
|
93
|
-
if (!text && spawnError) return CODE.CLI_ERROR;
|
|
94
|
-
|
|
91
|
+
/** Classify an error by its OUTPUT TEXT alone; null when nothing matches. */
|
|
92
|
+
function classifyOutputText(text) {
|
|
95
93
|
if (/credit balance|insufficient[_ ]?(quota|credits|funds)|payment required|\b402\b|billing/.test(text)) return CODE.OUT_OF_CREDIT;
|
|
96
94
|
if (/rate[- ]?limit|too many requests|\b429\b|overloaded|\b529\b/.test(text)) return CODE.RATE_LIMITED;
|
|
97
95
|
if (/enotfound|econnrefused|econnreset|etimedout|eai_again|fetch failed|socket hang up|network error|self[- ]signed certificate|unable to get local issuer/.test(text)) return CODE.NETWORK;
|
|
98
96
|
if (/api key.{0,24}(not set|not found|missing)|no api key|missing api key|environment variable.{0,40}(is )?(not set|required)/.test(text)) return CODE.MISSING_API_KEY;
|
|
99
97
|
if (/invalid.{0,12}(api.?key|x-api-key|token)|incorrect api key|authentication[_ ]?error|invalid credentials|\b401\b|unauthorized|forbidden|\b403\b/.test(text)) return CODE.INVALID_API_KEY;
|
|
100
98
|
if (/not logged in|not authenticated|login required|please (log ?in|sign ?in|authenticate)|run .{0,40}login|no credentials/.test(text)) return CODE.NOT_LOGGED_IN;
|
|
101
|
-
return CODE.
|
|
99
|
+
if (/unrecognized_model|issue with the selected model|unknown model|model.{0,40}(does not exist|not found|not exist|is not supported)/.test(text)) return CODE.BAD_MODEL;
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function classifyFailure(output, { timedOut = false, spawnError = null } = {}) {
|
|
104
|
+
if (spawnError && /ENOENT/.test(String(spawnError))) return CODE.NOT_INSTALLED;
|
|
105
|
+
const text = String(output || '').toLowerCase();
|
|
106
|
+
if (timedOut) {
|
|
107
|
+
// A CLI that retries a hard API error (bad key, unknown model, quota)
|
|
108
|
+
// until the probe clock runs out is NOT "waiting for interactive input" —
|
|
109
|
+
// the collected output already names the real problem. Only an output
|
|
110
|
+
// with no recognizable error is a genuine timeout.
|
|
111
|
+
return (text && classifyOutputText(text)) || CODE.TIMEOUT;
|
|
112
|
+
}
|
|
113
|
+
if (!text && spawnError) return CODE.CLI_ERROR;
|
|
114
|
+
return classifyOutputText(text) || CODE.CLI_ERROR;
|
|
102
115
|
}
|
|
103
116
|
|
|
104
117
|
/** Human next-steps for a code. Plain strings, no markup, no secrets. */
|
|
@@ -145,6 +158,9 @@ function buildGuidance(code, entry, agentEnv, extra = {}) {
|
|
|
145
158
|
case CODE.EMPTY_RESPONSE:
|
|
146
159
|
lines.push(`${label} exited without producing a response. Run its CLI manually on the device to see what it prints.`);
|
|
147
160
|
break;
|
|
161
|
+
case CODE.BAD_MODEL:
|
|
162
|
+
lines.push(`The configured model isn't recognized or isn't served by ${label}'s endpoint. Pick a different model in the launcher (Agents → ${label} → Configure) or edit the agent in the workspace.`);
|
|
163
|
+
break;
|
|
148
164
|
case CODE.NOT_READY:
|
|
149
165
|
// Caller supplies the static-auth guidance lines instead.
|
|
150
166
|
break;
|
|
@@ -255,7 +271,12 @@ async function probeAgentType(connector, type, opts = {}) {
|
|
|
255
271
|
const altCheck = entry.check_ready && entry.check_ready.alt_check;
|
|
256
272
|
if ((probeArgs && health.binary) || altCheck) {
|
|
257
273
|
const res = probeArgs && health.binary
|
|
258
|
-
|
|
274
|
+
// Node refuses to spawn .cmd/.bat without a shell (EINVAL since the
|
|
275
|
+
// CVE-2024-27980 hardening), which made every Windows CLI probe of an
|
|
276
|
+
// npm-shim binary fail as "Exited with code 1" with no output.
|
|
277
|
+
? await runCommand(health.binary, probeArgs, {
|
|
278
|
+
env, timeoutMs, shell: shouldUseShellForBinary(health.binary),
|
|
279
|
+
})
|
|
259
280
|
: await runCommand(altCheck, [], { env, timeoutMs, shell: true });
|
|
260
281
|
const output = `${res.stdout}\n${res.stderr}`;
|
|
261
282
|
if (!res.timedOut && !res.spawnError && res.code === 0 && res.stdout.trim()) {
|
package/src/registry.js
CHANGED
|
@@ -96,7 +96,10 @@ class Registry {
|
|
|
96
96
|
const b = bundled.find(x => x.name === entry.name);
|
|
97
97
|
if (b) {
|
|
98
98
|
if ((!entry.env_config || entry.env_config.length === 0) && b.env_config && b.env_config.length > 0) entry.env_config = b.env_config;
|
|
99
|
-
|
|
99
|
+
// resolve_env is behavior-critical env mapping tied to the adapter
|
|
100
|
+
// version shipping WITH this launcher — bundled wins over a (possibly
|
|
101
|
+
// day-old) cached remote copy, same as install.
|
|
102
|
+
if (b.resolve_env) entry.resolve_env = b.resolve_env;
|
|
100
103
|
if (b.install) entry.install = { ...b.install };
|
|
101
104
|
if (!entry.check_ready && b.check_ready) entry.check_ready = b.check_ready;
|
|
102
105
|
if (!entry.launch && b.launch) entry.launch = b.launch;
|