@openagents-org/agent-launcher 0.2.173 → 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/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
- let value = null;
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)) { value = hit; break; }
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 global bin (custom prefix — e.g. user configured npm prefix to D:\node\node_global)
254
- try {
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
- function classifyFailure(output, { timedOut = false, spawnError = null } = {}) {
90
- if (spawnError && /ENOENT/.test(String(spawnError))) return CODE.NOT_INSTALLED;
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.CLI_ERROR;
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
- ? await runCommand(health.binary, probeArgs, { env, timeoutMs })
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
- if (!entry.resolve_env && b.resolve_env) entry.resolve_env = b.resolve_env;
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;