@openagents-org/agent-launcher 0.2.183 → 0.2.184

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.
@@ -114,12 +114,54 @@ class OpenCodeAdapter extends BaseAdapter {
114
114
 
115
115
  this._opencodeBinary = this._findOpencodeBinary();
116
116
  if (this._opencodeBinary) {
117
- this._log(`Using OpenCode subprocess mode: ${this._opencodeBinary}`);
117
+ this._log(`Using ${this._cliLabel()} subprocess mode: ${this._opencodeBinary}`);
118
118
  } else {
119
- this._log(`OpenCode binary not found. Install with: npm install -g opencode-ai@${OPENCODE_PINNED_VERSION}`);
119
+ this._log(`${this._cliLabel()} binary not found. ${this._installHint()}`);
120
120
  }
121
121
  }
122
122
 
123
+ // ------------------------------------------------------------------
124
+ // What differs between OpenCode and the CLIs built on it
125
+ // ------------------------------------------------------------------
126
+ //
127
+ // Vendors ship their own agents on top of OpenCode (Huawei's CodeArts Agent
128
+ // is one — see codearts.js). They keep its `run --format json` stream, its
129
+ // sessions and its failure shapes, and change how the CLI is found, launched
130
+ // and authenticated. These hooks are exactly that difference.
131
+
132
+ /** Product name used in logs and user-facing errors. */
133
+ _cliLabel() {
134
+ return 'OpenCode';
135
+ }
136
+
137
+ _installHint() {
138
+ return `Install with: npm install -g opencode-ai@${OPENCODE_PINNED_VERSION}`;
139
+ }
140
+
141
+ /** User-facing message per failure category. */
142
+ _failureMessages() {
143
+ return FAILURE_MESSAGES;
144
+ }
145
+
146
+ /** Whether a run may not start without a model (OpenCode hangs without one). */
147
+ _modelRequired() {
148
+ return true;
149
+ }
150
+
151
+ /** Arguments after the binary for one `run`. */
152
+ _buildRunArgs({ runCwd, model, sessionId }) {
153
+ // Preflight guarantees a resolvable model; pin it explicitly — without it
154
+ // opencode hangs waiting for interactive provider/model selection.
155
+ const args = ['run', '--format', 'json', '--dir', runCwd, '--model', model];
156
+ if (sessionId) args.push('--session', sessionId);
157
+ return args;
158
+ }
159
+
160
+ /** Environment the CLI is spawned with. */
161
+ _spawnEnv() {
162
+ return { ...(this.agentEnv || process.env) };
163
+ }
164
+
123
165
  /**
124
166
  * Migrate sessions file from old location to agent home.
125
167
  */
@@ -793,30 +835,25 @@ class OpenCodeAdapter extends BaseAdapter {
793
835
  try { this._ensureCustomProviderConfig(runCwd); } catch (e) {
794
836
  this._log(`Gateway provider config failed (continuing): ${e.message}`);
795
837
  }
796
- const cmd = [binary, 'run', '--format', 'json', '--dir', runCwd];
797
-
798
- // Preflight guarantees a resolvable model; pin it explicitly — without it
799
- // opencode hangs waiting for interactive provider/model selection.
800
838
  const model = this._resolveModel();
801
- cmd.push('--model', model);
802
-
803
839
  const sessionId = this._channelSessions[msgChannel];
840
+ const args = this._buildRunArgs({ runCwd, model, sessionId });
841
+
804
842
  let fullPrompt;
805
843
  if (sessionId) {
806
844
  fullPrompt = content;
807
- cmd.push('--session', sessionId);
808
845
  } else {
809
846
  this._ensureWorkspaceSkill(msgChannel);
810
847
  const context = this._buildSystemContext(msgChannel);
811
848
  fullPrompt = `${context}\n\n---\n\n${content}`;
812
849
  }
813
850
 
814
- this._log(`CLI: ${binary} run --format json --dir ${runCwd} --model ${model || '(none)'}`);
851
+ this._log(`CLI: ${binary} ${args.join(' ')}`);
815
852
 
816
- const spawnEnv = { ...(this.agentEnv || process.env) };
853
+ const spawnEnv = this._spawnEnv();
817
854
 
818
- let spawnBinary = cmd[0];
819
- let spawnArgs = cmd.slice(1);
855
+ let spawnBinary = binary;
856
+ let spawnArgs = args;
820
857
  // Only the npm `.cmd` shim needs a cmd.exe host. The native opencode.exe
821
858
  // (preferred by _findOpencodeBinary) is spawned directly — no console host.
822
859
  if (IS_WINDOWS && spawnBinary.toLowerCase().endsWith('.cmd')) {
@@ -905,7 +942,7 @@ class OpenCodeAdapter extends BaseAdapter {
905
942
 
906
943
  if (signal) {
907
944
  this._log(`opencode killed by signal ${signal} after ${TIMEOUT_MS / 1000}s (output: ${!!stdout})`);
908
- const cls = OpenCodeAdapter._classifyFailure({ code, signal, stdout, stderr, stdoutErr });
945
+ const cls = this.constructor._classifyFailure({ code, signal, stdout, stderr, stdoutErr });
909
946
  // A signal almost always means our own timeout fired; only override to
910
947
  // a more specific provider error if stdout/stderr clearly show one.
911
948
  const category = (cls.category === 'unknown_error' || cls.category === 'process_crashed')
@@ -914,14 +951,14 @@ class OpenCodeAdapter extends BaseAdapter {
914
951
  }
915
952
 
916
953
  if (code !== 0) {
917
- const cls = OpenCodeAdapter._classifyFailure({ code, signal, stdout, stderr, stdoutErr });
954
+ const cls = this.constructor._classifyFailure({ code, signal, stdout, stderr, stdoutErr });
918
955
  this._log(`opencode exited code ${code} → [${cls.category}]: ${OpenCodeAdapter._redact(cls.diagnostic).slice(0, 300)}`);
919
956
  return finish(reject, this._failure(cls.category, cls.diagnostic, cls.detail));
920
957
  }
921
958
 
922
959
  // Exit 0.
923
960
  if (stdout) this._persistSessionId(msgChannel, stdout);
924
- const outcome = OpenCodeAdapter._outcomeForCleanExit({ stdout, stderr, stdoutErr, responseState });
961
+ const outcome = this.constructor._outcomeForCleanExit({ stdout, stderr, stdoutErr, responseState });
925
962
  if (outcome.text) return finish(resolve, outcome.text);
926
963
  const f = outcome.failure;
927
964
  this._log(`opencode exit 0 → [${f.category}]: ${OpenCodeAdapter._redact(f.diagnostic).slice(0, 300)}`);
@@ -951,7 +988,7 @@ class OpenCodeAdapter extends BaseAdapter {
951
988
 
952
989
  if (endedOnTool) {
953
990
  if (stdoutErr) {
954
- const cls = OpenCodeAdapter._classifyFailure({ code: 0, signal: null, stdout, stderr, stdoutErr });
991
+ const cls = this._classifyFailure({ code: 0, signal: null, stdout, stderr, stdoutErr });
955
992
  const vague = ['unknown_error', 'process_crashed', 'empty_response'].includes(cls.category);
956
993
  return {
957
994
  failure: {
@@ -977,7 +1014,7 @@ class OpenCodeAdapter extends BaseAdapter {
977
1014
  // provider — it can be a structured error on stdout, an incomplete
978
1015
  // stream, or a genuinely empty completion. Classify rather than guess.
979
1016
  if (stdoutErr) {
980
- const cls = OpenCodeAdapter._classifyFailure({ code: 0, signal: null, stdout, stderr, stdoutErr });
1017
+ const cls = this._classifyFailure({ code: 0, signal: null, stdout, stderr, stdoutErr });
981
1018
  return { failure: { category: cls.category, diagnostic: `exit 0 with error event: ${cls.diagnostic || ''}`, detail: cls.detail } };
982
1019
  }
983
1020
  const emptyCat = OpenCodeAdapter._emptyExitCategory(stdout);
@@ -1010,7 +1047,7 @@ class OpenCodeAdapter extends BaseAdapter {
1010
1047
  if (!probe.executable) {
1011
1048
  return { ok: false, category: 'cli_not_executable', diagnostic: 'opencode --version did not run' };
1012
1049
  }
1013
- const vclass = OpenCodeAdapter._classifyVersion(probe.version);
1050
+ const vclass = this.constructor._classifyVersion(probe.version);
1014
1051
  if (vclass === 'unsupported') {
1015
1052
  return {
1016
1053
  ok: false,
@@ -1022,7 +1059,7 @@ class OpenCodeAdapter extends BaseAdapter {
1022
1059
  // 'unknown' (unparseable) and 'degraded' (newer than tested) still run.
1023
1060
 
1024
1061
  const model = this._resolveModel();
1025
- if (!model) return { ok: false, category: 'model_missing' };
1062
+ if (!model && this._modelRequired()) return { ok: false, category: 'model_missing' };
1026
1063
 
1027
1064
  const cred = this._credentialState();
1028
1065
  if (cred === 'missing') return { ok: false, category: 'credential_missing' };
@@ -1140,10 +1177,12 @@ class OpenCodeAdapter extends BaseAdapter {
1140
1177
  * normal reply. Carries `error_category` in metadata so the UI can route it.
1141
1178
  */
1142
1179
  async _sendClassifiedError(channel, category, detail) {
1143
- const base = FAILURE_MESSAGES[category] || FAILURE_MESSAGES.unknown_error;
1180
+ const messages = this._failureMessages();
1181
+ const base = messages[category] || messages.unknown_error;
1144
1182
  const safe = detail ? OpenCodeAdapter._redact(detail).trim() : '';
1145
1183
  const body = safe ? `${base}\n\n> ${safe}` : base;
1146
- const headline = category === 'incomplete_run' ? 'OpenCode stopped mid-task' : "OpenCode couldn't run";
1184
+ const label = this._cliLabel();
1185
+ const headline = category === 'incomplete_run' ? `${label} stopped mid-task` : `${label} couldn't run`;
1147
1186
  const content = `⚠️ **${headline}** — ${body}`;
1148
1187
  try {
1149
1188
  await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
package/src/daemon.js CHANGED
@@ -369,11 +369,32 @@ class Daemon {
369
369
  */
370
370
  async _setModelEnv(type, model) {
371
371
  const val = String(model || '');
372
- const nativeVar = { claude: 'ANTHROPIC_MODEL', gemini: 'GEMINI_MODEL' }[type];
373
372
  await this._runAgn(['env', type, '--set', `LLM_MODEL=${val}`]);
373
+ const nativeVar = { claude: 'ANTHROPIC_MODEL', gemini: 'GEMINI_MODEL' }[type] || this._nativeModelVar(type);
374
374
  if (nativeVar) await this._runAgn(['env', type, '--set', `${nativeVar}=${val}`]);
375
375
  }
376
376
 
377
+ /**
378
+ * The agent's own model setting, for a type whose registry declares one and
379
+ * does not map LLM_MODEL onto anything itself (CodeArts reads CODEARTS_MODEL
380
+ * and nothing else). A type with a resolve_env rule for LLM_MODEL already
381
+ * gets there through env resolution, so it is left alone.
382
+ */
383
+ _nativeModelVar(type) {
384
+ try {
385
+ const entry = this.registry.getEntry(type);
386
+ if (!entry) return null;
387
+ const rules = (entry.resolve_env && entry.resolve_env.rules) || [];
388
+ if (rules.some((r) => r && r.from === 'LLM_MODEL')) return null;
389
+ const field = (entry.env_config || []).find(
390
+ (f) => f && /_MODEL$/.test(f.name || '') && f.name !== 'LLM_MODEL',
391
+ );
392
+ return field ? field.name : null;
393
+ } catch {
394
+ return null;
395
+ }
396
+ }
397
+
377
398
  async _refreshRuntimes() {
378
399
  try {
379
400
  const r = await this._runAgn(['runtimes', '--json']);
package/src/installer.js CHANGED
@@ -6,6 +6,7 @@ const path = require('path');
6
6
  const { execSync, exec } = require('child_process');
7
7
  const { whichBinary, getEnhancedEnv, getRuntimePrefix, clearBinaryLookupCache, aiderBinDirs, resolveBinaryInKnownDirs } = require('./paths');
8
8
  const { isWslBinary, bridgedCommandString, wslHomeUnc } = require('./wsl');
9
+ const { canBlock } = require('./probe-mode');
9
10
  const { EnvManager } = require('./env');
10
11
  const { nodeDistUrls, installRegistry } = require('./mirrors');
11
12
  const { readinessReason, REASON } = require('./adapters/health-status');
@@ -39,6 +40,19 @@ const STALL_POLL_MS = Math.max(50, Math.min(15000, Math.floor(INSTALL_STALL_MS /
39
40
  // results are cached briefly and invalidated on install/uninstall.
40
41
  const VERSION_CACHE_TTL_MS = 60000;
41
42
  const versionCache = new Map();
43
+ /** Version probes running in the background, by cache key. See probe-mode.js. */
44
+ const versionRefresh = new Set();
45
+
46
+ /** The version token in a `--version` output, or its first line. */
47
+ function parseVersionOutput(raw) {
48
+ // The optional `-prerelease` tail is NOT decoration. An agent pinned to a
49
+ // preview release (`@deepseek-ai/dsh@0.1.0-rc.6`) is gated on the exact
50
+ // version, and dropping the suffix reported a correctly installed rc.6 as
51
+ // plain `0.1.0` — which then failed its own gate. Agents without a
52
+ // prerelease segment match exactly as before.
53
+ const match = raw.match(/(\d+[\d.]+\d+(?:-[0-9A-Za-z.-]+)?)/);
54
+ return match ? match[1] : (raw.split('\n')[0] || null);
55
+ }
42
56
 
43
57
  /**
44
58
  * Compare two dotted version strings numerically, ignoring any pre-release /
@@ -616,15 +630,8 @@ class Installer {
616
630
  ]) {
617
631
  if (fs.existsSync(candidate)) return candidate;
618
632
  }
619
- try {
620
- const found = execSync(isWin ? 'where node.exe' : 'which node', {
621
- encoding: 'utf-8',
622
- timeout: 5000,
623
- stdio: ['pipe', 'pipe', 'pipe'],
624
- env: getEnhancedEnv(),
625
- }).split(/\r?\n/)[0].trim();
626
- if (found) return found;
627
- } catch {}
633
+ const found = whichBinary(nodeName, { allowWsl: false });
634
+ if (found) return found;
628
635
  return 'node';
629
636
  }
630
637
 
@@ -636,6 +643,14 @@ class Installer {
636
643
  const cached = versionCache.get(key);
637
644
  if (cached && (Date.now() - cached.ts) < VERSION_CACHE_TTL_MS) return cached.version;
638
645
 
646
+ if (!canBlock()) {
647
+ // A node CLI's `--version` takes 0.5s+ (seconds for one inside WSL).
648
+ // Report the last known version — or unknown, which healthCheck already
649
+ // handles — and let the next check pick up the answer.
650
+ this._refreshVersion(key, versionCmd);
651
+ return cached ? cached.version : null;
652
+ }
653
+
639
654
  let version = null;
640
655
  try {
641
656
  const raw = execSync(versionCmd, {
@@ -644,19 +659,36 @@ class Installer {
644
659
  env: getEnhancedEnv(),
645
660
  timeout: 10000,
646
661
  }).trim();
647
- // The optional `-prerelease` tail is NOT decoration. An agent pinned to a
648
- // preview release (`@deepseek-ai/dsh@0.1.0-rc.6`) is gated on the exact
649
- // version, and dropping the suffix reported a correctly installed rc.6 as
650
- // plain `0.1.0` — which then failed its own gate. Agents without a
651
- // prerelease segment match exactly as before.
652
- const match = raw.match(/(\d+[\d.]+\d+(?:-[0-9A-Za-z.-]+)?)/);
653
- version = match ? match[1] : (raw.split('\n')[0] || null);
662
+ version = parseVersionOutput(raw);
654
663
  } catch {}
655
664
 
656
665
  versionCache.set(key, { version, ts: Date.now() });
657
666
  return version;
658
667
  }
659
668
 
669
+ /** _detectVersion's probe, off the calling thread. */
670
+ _refreshVersion(key, versionCmd) {
671
+ if (versionRefresh.has(key)) return;
672
+ versionRefresh.add(key);
673
+ try {
674
+ const child = exec(versionCmd, {
675
+ encoding: 'utf-8',
676
+ env: getEnhancedEnv(),
677
+ timeout: 10000,
678
+ windowsHide: true,
679
+ }, (err, stdout) => {
680
+ versionRefresh.delete(key);
681
+ versionCache.set(key, {
682
+ version: err ? null : parseVersionOutput(String(stdout).trim()),
683
+ ts: Date.now(),
684
+ });
685
+ });
686
+ if (child.stdin) child.stdin.end();
687
+ } catch {
688
+ versionRefresh.delete(key);
689
+ }
690
+ }
691
+
660
692
  /**
661
693
  * Evaluate a registry version gate against a detected version. Generic for any
662
694
  * agent. Returns { compatible: true|false|null, minVersion, message }.
package/src/paths.js CHANGED
@@ -11,13 +11,14 @@
11
11
  const path = require('path');
12
12
  const fs = require('fs');
13
13
  const os = require('os');
14
- const { execSync, execFileSync } = require('child_process');
14
+ const { exec, execSync, execFile, execFileSync } = require('child_process');
15
15
  const {
16
16
  isRunningInWsl,
17
17
  resolveWslBinary,
18
18
  clearWslBinaryCache,
19
19
  wslBinDirs,
20
20
  } = require('./wsl');
21
+ const { canBlock } = require('./probe-mode');
21
22
 
22
23
  const IS_WINDOWS = process.platform === 'win32';
23
24
  const IS_MACOS = process.platform === 'darwin';
@@ -254,43 +255,107 @@ function _whichNative(name) {
254
255
  return value;
255
256
  }
256
257
 
258
+ /** The value of an env var, matching the key case-insensitively on Windows. */
259
+ function _envValue(env, name) {
260
+ if (!IS_WINDOWS) return env[name];
261
+ const key = Object.keys(env).find((k) => k.toUpperCase() === name);
262
+ return key ? env[key] : undefined;
263
+ }
264
+
257
265
  /**
258
- * Existing paths `where`/`which` reports for a name, in its own order.
259
- *
260
- * On a non-English Windows the console OUTPUT codepage is OEM (e.g. 936/GBK on
261
- * zh-CN), so `where` prints a path whose non-ASCII bytes don't match the utf-8
262
- * decoding execSync does — a Chinese username comes back mangled (e.g.
263
- * `C:\Users\??.?[\…`) and yields ENOENT downstream. We can't reliably re-encode
264
- * it (and forcing `chcp` is unsafe under windowsHide's console-less cmd), so we
265
- * existence-check every hit and only return ones that are real; a mangled path
266
- * fails and the caller falls through to its Node-derived tiers (built from
267
- * process.env / os.homedir, which the OS hands us as correct UTF-16).
266
+ * Directory listings for _searchPath, keyed by dir. One sweep over every agent
267
+ * type asks for ~20 names × every PATHEXT spelling × every PATH dir — thousands
268
+ * of stats — where reading each dir once answers all of them.
268
269
  */
269
- function _runWhich(name) {
270
- const cmd = IS_WINDOWS ? `where ${name}` : `which ${name}`;
270
+ const DIR_INDEX_TTL_MS = 5000;
271
+ const dirIndexCache = new Map();
272
+
273
+ /** name (lower-cased on Windows) → name on disk; null when unlistable. */
274
+ function _dirIndex(dir) {
275
+ const hit = dirIndexCache.get(dir);
276
+ if (hit && Date.now() - hit.at < DIR_INDEX_TTL_MS) return hit.names;
277
+ let names = new Map();
271
278
  try {
272
- const result = execSync(cmd, {
273
- encoding: 'utf-8',
274
- stdio: ['pipe', 'pipe', 'pipe'],
275
- // getEnhancedEnv(), never `{...process.env, PATH}`: spreading process.env
276
- // on Windows yields a "Path" key, so assigning PATH creates a SECOND path
277
- // variable and which of the two the child reads is undefined. It also
278
- // supplies ComSpec, without which execSync's shell cannot even start in an
279
- // Electron process that has no cmd.exe on PATH — and a `where` that never
280
- // ran looks exactly like a CLI that isn't installed.
281
- env: getEnhancedEnv(),
282
- timeout: 5000,
283
- windowsHide: true,
284
- });
285
- const hits = [];
286
- for (const line of result.split(/\r?\n/)) {
287
- const hit = line.trim();
288
- if (hit && fs.existsSync(hit)) hits.push(hit);
289
- }
290
- return hits;
279
+ for (const n of fs.readdirSync(dir)) names.set(IS_WINDOWS ? n.toLowerCase() : n, n);
280
+ } catch (e) {
281
+ // A dir that isn't there holds nothing. One we may not list can still hold
282
+ // files we may run (an execute-only dir), so that one is stat'ed instead.
283
+ if (e && e.code !== 'ENOENT' && e.code !== 'ENOTDIR') names = null;
284
+ }
285
+ dirIndexCache.set(dir, { names, at: Date.now() });
286
+ return names;
287
+ }
288
+
289
+ /** Whether `p` is a file `where`/`which` would report. */
290
+ function _isCommandFile(p) {
291
+ try {
292
+ if (!fs.statSync(p).isFile()) return false;
291
293
  } catch {
292
- return [];
294
+ // App Execution Aliases (…\WindowsApps\*.exe) are reparse points stat
295
+ // cannot follow, yet Windows runs them and `where` lists them.
296
+ if (!IS_WINDOWS) return false;
297
+ try { fs.lstatSync(p); return true; } catch { return false; }
298
+ }
299
+ if (IS_WINDOWS) return true;
300
+ try { fs.accessSync(p, fs.constants.X_OK); return true; } catch { return false; }
301
+ }
302
+
303
+ /**
304
+ * What `where name` (Windows: every match, PATH order, PATHEXT applied to a
305
+ * bare name) or `which name` (Unix: the first executable) would report.
306
+ *
307
+ * Walked in-process rather than by spawning either command. Detection asks
308
+ * this for every agent type every time a lookup cache expires, and each spawn
309
+ * is a cmd.exe start — ~60-300ms apiece on Windows, far more under an
310
+ * antivirus scan — which in the desktop app froze the window for seconds on
311
+ * every refresh. It also removes the codepage hazard the spawn had: `where`
312
+ * printed paths in the OEM codepage (936 on zh-CN), mangling any non-ASCII
313
+ * user name, whereas these paths never leave UTF-16.
314
+ */
315
+ function _searchPath(name, env) {
316
+ const hits = [];
317
+ if (!name) return hits;
318
+ const source = env || process.env;
319
+ let names = [name];
320
+ if (IS_WINDOWS) {
321
+ const exts = String(_envValue(source, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD')
322
+ .split(';')
323
+ .map((e) => e.trim().toLowerCase())
324
+ .filter((e) => e.startsWith('.'));
325
+ const ext = path.extname(name).toLowerCase();
326
+ if (!ext || !exts.includes(ext)) names = [name, ...exts.map((e) => name + e)];
327
+ }
328
+ const seen = new Set();
329
+ for (const raw of String(_envValue(source, 'PATH') || '').split(SEP)) {
330
+ const dir = raw.trim().replace(/^"(.*)"$/, '$1');
331
+ if (!dir) continue;
332
+ const index = _dirIndex(dir);
333
+ for (const n of names) {
334
+ let full;
335
+ if (index) {
336
+ const onDisk = index.get(IS_WINDOWS ? n.toLowerCase() : n);
337
+ if (!onDisk) continue;
338
+ full = path.join(dir, onDisk);
339
+ } else {
340
+ full = path.join(dir, n);
341
+ }
342
+ const key = IS_WINDOWS ? full.toLowerCase() : full;
343
+ if (seen.has(key)) continue;
344
+ seen.add(key);
345
+ if (!_isCommandFile(full)) continue;
346
+ hits.push(full);
347
+ if (!IS_WINDOWS) return hits;
348
+ }
293
349
  }
350
+ return hits;
351
+ }
352
+
353
+ /** Existing paths `where`/`which` reports for a name, in its own order. */
354
+ function _runWhich(name) {
355
+ // getEnhancedEnv(), never `{...process.env, PATH}`: spreading process.env on
356
+ // Windows yields a "Path" key, and a second PATH key makes which one counts
357
+ // undefined.
358
+ return _searchPath(name, getEnhancedEnv());
294
359
  }
295
360
 
296
361
  /**
@@ -303,18 +368,12 @@ function _isWindowsMount(p) {
303
368
  }
304
369
 
305
370
  /**
306
- * Codepage-safe `where`/`which` lookup, shared by every CLI adapter.
371
+ * `where`/`which` lookup, shared by every CLI adapter.
307
372
  *
308
- * Hazard: on a non-English Windows the console output codepage is OEM (e.g.
309
- * 936/GBK on zh-CN), so execSync decodes `where` stdout with the wrong codepage
310
- * and mangles a non-ASCII (e.g. Chinese) username in the path — yielding ENOENT
311
- * downstream (the `C:\Users\??.?[\…\claude.cmd` failure). Two safe defenses,
312
- * no `chcp` (which is unreliable under windowsHide's console-less cmd):
313
- * 1. (Windows) check the npm-global default `%APPDATA%\npm\<name>.cmd` FIRST.
314
- * APPDATA comes from the OS as UTF-16 via Node, so this path is always
315
- * correctly encoded — and it's where the npm-installed CLIs actually live.
316
- * 2. Fall back to `where`/`which`, but existence-check every hit so a mangled
317
- * path is skipped and the caller drops to its own Node-derived tiers.
373
+ * 1. (Windows) the npm-global default `%APPDATA%\npm\<name>.cmd` FIRST —
374
+ * it's where the npm-installed CLIs actually live.
375
+ * 2. Then PATH, as `where <name>.cmd || where <name>.exe || where <name>`
376
+ * (Unix: `which <name>`) would search it — see _searchPath.
318
377
  *
319
378
  * @param {string|string[]} names base name(s), e.g. 'claude' or ['cursor-agent','agent'].
320
379
  * On Windows each is tried as <name>.cmd, <name>.exe, <name>.
@@ -333,33 +392,27 @@ function whereBinary(names, env) {
333
392
  }
334
393
  }
335
394
 
336
- // 2. PATH lookup, existence-guarded.
337
- let cmd;
338
- if (IS_WINDOWS) {
339
- const parts = [];
340
- for (const b of bases) {
341
- parts.push(`where ${b}.cmd 2>nul`, `where ${b}.exe 2>nul`, `where ${b} 2>nul`);
395
+ // 2. PATH lookup.
396
+ const lookupEnv = env || getEnhancedEnv();
397
+ for (const b of bases) {
398
+ for (const form of IS_WINDOWS ? [`${b}.cmd`, `${b}.exe`, b] : [b]) {
399
+ const hit = _searchPath(form, lookupEnv)[0];
400
+ if (hit) return hit;
342
401
  }
343
- cmd = parts.join(' || ');
344
- } else {
345
- cmd = bases.map((b) => `which ${b}`).join(' || ');
346
402
  }
347
- try {
348
- const out = execSync(cmd, {
349
- encoding: 'utf-8',
350
- stdio: ['pipe', 'pipe', 'pipe'],
351
- timeout: 5000,
352
- windowsHide: true,
353
- env: env || getEnhancedEnv(),
354
- });
355
- for (const line of out.split(/\r?\n/)) {
356
- const hit = line.trim();
357
- if (hit && fs.existsSync(hit)) return hit;
358
- }
359
- } catch {}
360
403
  return null;
361
404
  }
362
405
 
406
+ /**
407
+ * Drop the bin-dir snapshots and every lookup answered from them — for when a
408
+ * background probe has just learned of directories they did not include.
409
+ */
410
+ function _invalidateBinDirCaches() {
411
+ knownBinDirsCache = { value: null, at: 0 };
412
+ extraBinDirsCache = { value: null, at: 0, path: '' };
413
+ whichBinaryCache.clear();
414
+ }
415
+
363
416
  /**
364
417
  * Global bin directories for every JS package manager we might find an agent
365
418
  * CLI in — plus the npm prefix the user has actually configured.
@@ -451,6 +504,13 @@ function _addAgentInstallerPaths(dirs) {
451
504
  // these; the installer that decides whether it EXISTS had not.
452
505
  _push(dirs, path.join(HOME, '.codebuddy', 'bin'));
453
506
 
507
+ // codearts — Huawei's install.sh / install.ps1 unpack into
508
+ // ~/.codeartsdoer/installers and put that dir on PATH through a shell rc
509
+ // edit or the user `Path` registry value. The `codearts` there is a wrapper
510
+ // script; the CLI itself sits in its bin/, which the installer deliberately
511
+ // keeps off PATH.
512
+ _push(dirs, path.join(HOME, '.codeartsdoer', 'installers'));
513
+
454
514
  // claude — `claude install` (the native, non-npm build) relocates the CLI to
455
515
  // ~/.claude/local and reaches it through a shell alias, which a GUI process
456
516
  // inherits no more than it inherits an rc-file PATH edit.
@@ -602,14 +662,28 @@ function _npmPrefix() {
602
662
  }
603
663
  } catch {}
604
664
 
665
+ const opts = {
666
+ encoding: 'utf-8',
667
+ timeout: 8000,
668
+ windowsHide: true,
669
+ // The user's PATH first: it is their npm we are asking about.
670
+ env: { ...process.env, PATH: _basePATH() },
671
+ };
672
+ if (!canBlock()) {
673
+ // npm takes a second or more to start on Windows. Answer "no custom
674
+ // prefix" for now; the bin dirs are rebuilt once npm has said otherwise.
675
+ try {
676
+ const child = exec('npm config get prefix', opts, (err, stdout) => {
677
+ if (!err && accept(String(stdout).trim())) _invalidateBinDirCaches();
678
+ });
679
+ if (child.stdin) child.stdin.end();
680
+ } catch {}
681
+ return npmPrefixCache;
682
+ }
605
683
  try {
606
684
  const out = execSync('npm config get prefix', {
607
- encoding: 'utf-8',
608
- timeout: 8000,
609
- windowsHide: true,
685
+ ...opts,
610
686
  stdio: ['ignore', 'pipe', 'ignore'],
611
- // The user's PATH first: it is their npm we are asking about.
612
- env: { ...process.env, PATH: _basePATH() },
613
687
  }).trim();
614
688
  accept(out);
615
689
  } catch {}
@@ -663,34 +737,54 @@ function loginShellDirs() {
663
737
  if (IS_WINDOWS) return loginShellDirsCache;
664
738
  if (process.env.OPENAGENTS_SKIP_SHELL_PATH === '1') return loginShellDirsCache;
665
739
  const shell = process.env.SHELL || '/bin/zsh';
740
+ const argv = ['-ilc', `echo ${SHELL_ENV_DELIM}; command env; echo ${SHELL_ENV_DELIM}`];
741
+ const opts = {
742
+ encoding: 'utf-8',
743
+ timeout: SHELL_PROBE_TIMEOUT_MS,
744
+ killSignal: 'SIGKILL',
745
+ maxBuffer: 4 * 1024 * 1024,
746
+ };
666
747
  try {
667
748
  if (!fs.existsSync(shell)) return loginShellDirsCache;
668
- const out = execFileSync(
669
- shell,
670
- ['-ilc', `echo ${SHELL_ENV_DELIM}; command env; echo ${SHELL_ENV_DELIM}`],
671
- {
672
- encoding: 'utf-8',
673
- timeout: SHELL_PROBE_TIMEOUT_MS,
674
- killSignal: 'SIGKILL',
675
- // stdin closed, stderr dropped: an interactive shell with no tty writes
676
- // job-control warnings there and we do not want them in the output.
677
- stdio: ['ignore', 'pipe', 'ignore'],
678
- maxBuffer: 4 * 1024 * 1024,
679
- },
680
- );
681
- const parts = String(out).split(SHELL_ENV_DELIM);
682
- if (parts.length < 3) return loginShellDirsCache;
683
- const line = parts[1].split(/\r?\n/).find((l) => l.startsWith('PATH='));
684
- if (!line) return loginShellDirsCache;
685
- const seen = new Set();
686
- for (const d of line.slice('PATH='.length).split(SEP)) {
687
- const dir = d.trim();
688
- if (dir && !seen.has(dir)) { seen.add(dir); loginShellDirsCache.push(dir); }
749
+ if (!canBlock()) {
750
+ // A login shell with nvm/oh-my-zsh in its rc files takes seconds. Search
751
+ // the curated dirs meanwhile and add the shell's once it has answered.
752
+ const child = execFile(shell, argv, opts, (err, stdout) => {
753
+ const dirs = err ? [] : _parseLoginShellPath(stdout);
754
+ if (dirs.length) {
755
+ loginShellDirsCache = dirs;
756
+ _invalidateBinDirCaches();
757
+ }
758
+ });
759
+ if (child.stdin) child.stdin.end();
760
+ return loginShellDirsCache;
689
761
  }
762
+ const out = execFileSync(shell, argv, {
763
+ ...opts,
764
+ // stdin closed, stderr dropped: an interactive shell with no tty writes
765
+ // job-control warnings there and we do not want them in the output.
766
+ stdio: ['ignore', 'pipe', 'ignore'],
767
+ });
768
+ loginShellDirsCache = _parseLoginShellPath(out);
690
769
  } catch {}
691
770
  return loginShellDirsCache;
692
771
  }
693
772
 
773
+ /** The PATH entries in a delimited `env` dump, in order and deduplicated. */
774
+ function _parseLoginShellPath(out) {
775
+ const parts = String(out).split(SHELL_ENV_DELIM);
776
+ if (parts.length < 3) return [];
777
+ const line = parts[1].split(/\r?\n/).find((l) => l.startsWith('PATH='));
778
+ if (!line) return [];
779
+ const dirs = [];
780
+ const seen = new Set();
781
+ for (const d of line.slice('PATH='.length).split(SEP)) {
782
+ const dir = d.trim();
783
+ if (dir && !seen.has(dir)) { seen.add(dir); dirs.push(dir); }
784
+ }
785
+ return dirs;
786
+ }
787
+
694
788
  // ---- Windows paths ----
695
789
 
696
790
  function _addWindowsPaths(dirs) {
@@ -970,6 +1064,7 @@ function clearBinaryLookupCache() {
970
1064
  knownBinDirsCache = { value: null, at: 0 };
971
1065
  extraBinDirsCache = { value: null, at: 0, path: '' };
972
1066
  whichBinaryCache.clear();
1067
+ dirIndexCache.clear();
973
1068
  clearWslBinaryCache();
974
1069
  // The login-shell / npm-prefix probes are deliberately NOT cleared: they each
975
1070
  // cost a process spawn, they answer a question about the machine rather than