@openagents-org/agent-launcher 0.2.143 → 0.2.146

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
@@ -50,6 +50,12 @@ function getExtraBinDirs() {
50
50
  // Common: ~/.local/bin (pipx, user installs)
51
51
  _push(dirs, path.join(HOME, '.local', 'bin'));
52
52
 
53
+ // Aider (uv tool install) — its executable honors XDG_BIN_HOME /
54
+ // XDG_DATA_HOME/../bin / ~/.local/bin and also always lands in the uv tools
55
+ // venv. A GUI/daemon process won't see the installer's PATH edit, so add the
56
+ // real install dirs explicitly (filtered to existing dirs by the caller).
57
+ for (const d of aiderBinDirs()) _push(dirs, d);
58
+
53
59
  // Also add the directory containing the current node binary
54
60
  try {
55
61
  const nodeDir = path.dirname(process.execPath);
@@ -142,6 +148,14 @@ function whichBinary(name) {
142
148
  return cached.value;
143
149
  }
144
150
 
151
+ // On a non-English Windows the console OUTPUT codepage is OEM (e.g. 936/GBK on
152
+ // zh-CN), so `where` prints a path whose non-ASCII bytes don't match the utf-8
153
+ // decoding execSync does — a Chinese username comes back mangled (e.g.
154
+ // `C:\Users\??.?[\…`) and yields ENOENT downstream. We can't reliably re-encode
155
+ // it (and forcing `chcp` is unsafe under windowsHide's console-less cmd), so we
156
+ // existence-check every hit and only return one that's real; a mangled path
157
+ // fails and the caller falls through to its Node-derived tiers (built from
158
+ // process.env / os.homedir, which the OS hands us as correct UTF-16).
145
159
  const cmd = IS_WINDOWS ? `where ${name}` : `which ${name}`;
146
160
  try {
147
161
  const result = execSync(cmd, {
@@ -150,8 +164,12 @@ function whichBinary(name) {
150
164
  env: { ...process.env, PATH: getEnhancedPATH() },
151
165
  timeout: 5000,
152
166
  windowsHide: true,
153
- }).trim();
154
- const value = result.split(/\r?\n/)[0] || null;
167
+ });
168
+ let value = null;
169
+ for (const line of result.split(/\r?\n/)) {
170
+ const hit = line.trim();
171
+ if (hit && fs.existsSync(hit)) { value = hit; break; }
172
+ }
155
173
  whichBinaryCache.set(cacheKey, { value, at: Date.now() });
156
174
  return value;
157
175
  } catch {
@@ -160,6 +178,64 @@ function whichBinary(name) {
160
178
  }
161
179
  }
162
180
 
181
+ /**
182
+ * Codepage-safe `where`/`which` lookup, shared by every CLI adapter.
183
+ *
184
+ * Hazard: on a non-English Windows the console output codepage is OEM (e.g.
185
+ * 936/GBK on zh-CN), so execSync decodes `where` stdout with the wrong codepage
186
+ * and mangles a non-ASCII (e.g. Chinese) username in the path — yielding ENOENT
187
+ * downstream (the `C:\Users\??.?[\…\claude.cmd` failure). Two safe defenses,
188
+ * no `chcp` (which is unreliable under windowsHide's console-less cmd):
189
+ * 1. (Windows) check the npm-global default `%APPDATA%\npm\<name>.cmd` FIRST.
190
+ * APPDATA comes from the OS as UTF-16 via Node, so this path is always
191
+ * correctly encoded — and it's where the npm-installed CLIs actually live.
192
+ * 2. Fall back to `where`/`which`, but existence-check every hit so a mangled
193
+ * path is skipped and the caller drops to its own Node-derived tiers.
194
+ *
195
+ * @param {string|string[]} names base name(s), e.g. 'claude' or ['cursor-agent','agent'].
196
+ * On Windows each is tried as <name>.cmd, <name>.exe, <name>.
197
+ * @param {object} [env] env for the lookup (defaults to getEnhancedEnv()).
198
+ * @returns {string|null} an existing absolute path, or null.
199
+ */
200
+ function whereBinary(names, env) {
201
+ const bases = Array.isArray(names) ? names : [names];
202
+
203
+ // 1. npm-global default, derived from Node's env (correct encoding even when
204
+ // the username is non-ASCII). This is where `npm i -g <cli>` drops the shim.
205
+ if (IS_WINDOWS && process.env.APPDATA) {
206
+ for (const b of bases) {
207
+ const c = path.join(process.env.APPDATA, 'npm', `${b}.cmd`);
208
+ try { if (fs.existsSync(c)) return c; } catch {}
209
+ }
210
+ }
211
+
212
+ // 2. PATH lookup, existence-guarded.
213
+ let cmd;
214
+ if (IS_WINDOWS) {
215
+ const parts = [];
216
+ for (const b of bases) {
217
+ parts.push(`where ${b}.cmd 2>nul`, `where ${b}.exe 2>nul`, `where ${b} 2>nul`);
218
+ }
219
+ cmd = parts.join(' || ');
220
+ } else {
221
+ cmd = bases.map((b) => `which ${b}`).join(' || ');
222
+ }
223
+ try {
224
+ const out = execSync(cmd, {
225
+ encoding: 'utf-8',
226
+ stdio: ['pipe', 'pipe', 'pipe'],
227
+ timeout: 5000,
228
+ windowsHide: true,
229
+ env: env || getEnhancedEnv(),
230
+ });
231
+ for (const line of out.split(/\r?\n/)) {
232
+ const hit = line.trim();
233
+ if (hit && fs.existsSync(hit)) return hit;
234
+ }
235
+ } catch {}
236
+ return null;
237
+ }
238
+
163
239
  // ---- Windows paths ----
164
240
 
165
241
  function _addWindowsPaths(dirs) {
@@ -197,6 +273,12 @@ function _addWindowsPaths(dirs) {
197
273
  if (localAppData) _push(dirs, path.join(localAppData, 'cursor-agent'));
198
274
  _push(dirs, path.join(HOME, '.local', 'bin'));
199
275
 
276
+ // Amp CLI (irm https://ampcode.com/install.ps1 | iex) — same registry-PATH
277
+ // staleness as Cursor; add the canonical install dir(s) so an already-running
278
+ // daemon resolves amp without a reboot.
279
+ _push(dirs, path.join(HOME, '.amp', 'bin'));
280
+ if (localAppData) _push(dirs, path.join(localAppData, 'amp'));
281
+
200
282
  // Hermes CLI native (no-WSL) installer drops hermes.exe in the portable
201
283
  // venv's Scripts dir and the uv shim in %LOCALAPPDATA%\hermes\bin. Same
202
284
  // registry-PATH staleness as Cursor — add explicitly so an already-running
@@ -291,6 +373,12 @@ function _addUnixPaths(dirs) {
291
373
 
292
374
  // Cursor CLI native installer (curl https://cursor.com/install | bash)
293
375
  _push(dirs, path.join(HOME, '.cursor', 'bin'));
376
+
377
+ // Amp CLI native installer (curl https://ampcode.com/install.sh | bash)
378
+ // drops the binary in ~/.amp/bin and only symlinks into ~/.local/bin when
379
+ // that dir is already on PATH — so a GUI- or daemon-spawned process never
380
+ // sees it unless the canonical dir is listed here explicitly.
381
+ _push(dirs, path.join(HOME, '.amp', 'bin'));
294
382
  }
295
383
 
296
384
  // ---- macOS-specific ----
@@ -396,15 +484,50 @@ function clearBinaryLookupCache() {
396
484
  whichBinaryCache.clear();
397
485
  }
398
486
 
487
+ /**
488
+ * Directories where the Aider CLI executable can land, in the SAME priority the
489
+ * official installer (aider.chat/install.{sh,ps1} → `uv tool install`) uses, so
490
+ * detection matches reality on every platform:
491
+ * 1. $XDG_BIN_HOME
492
+ * 2. $XDG_DATA_HOME/../bin
493
+ * 3. ~/.local/bin (the default uv-tool / pipx / pip --user bin)
494
+ * 4. the uv tools venv for aider-chat (Scripts on Windows, bin on Unix) — the
495
+ * executable always lands here on a successful `uv tool install`, even if
496
+ * the bin-dir copy/PATH edit didn't happen.
497
+ * Uses os.homedir()/live env so it reflects the current process (test-friendly).
498
+ * Returns directories only; callers join the platform binary names.
499
+ */
500
+ function aiderBinDirs() {
501
+ const home = os.homedir();
502
+ const dirs = [];
503
+ if (process.env.XDG_BIN_HOME) dirs.push(process.env.XDG_BIN_HOME);
504
+ if (process.env.XDG_DATA_HOME) dirs.push(path.join(process.env.XDG_DATA_HOME, '..', 'bin'));
505
+ dirs.push(path.join(home, '.local', 'bin'));
506
+ if (IS_WINDOWS) {
507
+ const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
508
+ const uvTools = process.env.UV_TOOL_DIR || path.join(appData, 'uv', 'tools');
509
+ dirs.push(path.join(uvTools, 'aider-chat', 'Scripts'));
510
+ dirs.push(path.join(home, 'bin'));
511
+ } else {
512
+ const uvTools = process.env.UV_TOOL_DIR
513
+ || path.join(home, '.local', 'share', 'uv', 'tools');
514
+ dirs.push(path.join(uvTools, 'aider-chat', 'bin'));
515
+ dirs.push(path.join(home, 'bin'), '/usr/local/bin', '/opt/homebrew/bin');
516
+ }
517
+ return dirs;
518
+ }
519
+
399
520
  module.exports = {
400
521
  getExtraBinDirs,
401
522
  getEnhancedPATH,
402
523
  getEnhancedEnv,
403
524
  whichBinary,
525
+ whereBinary,
404
526
  clearBinaryLookupCache,
405
527
  getRuntimePrefix,
406
528
  getCorePrefix,
407
529
  defaultAgentWorkdir,
530
+ aiderBinDirs,
408
531
  IS_WINDOWS,
409
532
  IS_MACOS,
410
533
  SEP,
package/src/tui.js CHANGED
@@ -13,6 +13,7 @@ const path = require('path');
13
13
  const fs = require('fs');
14
14
  const { spawn } = require('child_process');
15
15
  const { getExtraBinDirs } = require('./paths');
16
+ const { hasCredentialMetadata, formatAuthGuidance } = require('./auth-guidance');
16
17
 
17
18
  const IS_WINDOWS = process.platform === 'win32';
18
19
 
@@ -67,7 +68,14 @@ function getConnector() {
67
68
 
68
69
  function describeHealth(health) {
69
70
  if (!health) return '';
70
- if (!health.ready) return health.message || 'Not configured';
71
+ if (!health.ready) {
72
+ // "Not installed" is reserved for a genuinely missing executable; an
73
+ // installed-but-signed-out agent shows its login/config message instead.
74
+ if (health.reason === 'not_installed' || health.installed === false) {
75
+ return 'Not installed';
76
+ }
77
+ return health.message || 'Not configured';
78
+ }
71
79
  const parts = ['Ready'];
72
80
  if (health.auth_mode === 'api_key') parts.push('API key');
73
81
  else if (health.auth_mode === 'cli_login') parts.push('CLI login');
@@ -283,11 +291,14 @@ function createTUI() {
283
291
  // Local-only agents (no workspace) show a dimmed "(local)" marker.
284
292
  const ws = r.workspace || `{gray-fg}${r.workspaceLabel}{/gray-fg}`;
285
293
  items.push(` ${r.name.padEnd(22)} ${r.type.padEnd(14)} ${state.padEnd(30)} ${ws}`);
286
- // Detail row: working dir + config warning
294
+ // Detail row: working dir + config warning + the REAL runtime error
295
+ // (spawn/join/heartbeat) when the daemon reported one, so a failure shows
296
+ // its actual cause instead of a swallowed/"Not installed" guess.
287
297
  const details = [];
288
298
  details.push(r.path || process.env.HOME || '~');
289
299
  if (r.health) details.push(`{cyan-fg}${describeHealth(r.health)}{/cyan-fg}`);
290
300
  if (r.notReadyMsg) details.push(`{yellow-fg}⚠ ${r.notReadyMsg}{/yellow-fg}`);
301
+ if (r.lastError) details.push(`{red-fg}✗ ${r.lastError}{/red-fg}`);
291
302
  items.push(` {gray-fg} ${details.join(' | ')}{/gray-fg}`);
292
303
  }
293
304
  agentList.setItems(items);
@@ -754,6 +765,22 @@ function createTUI() {
754
765
  currentView = 'configure';
755
766
  const envFields = connector.registry.getEnvFields(agent.type);
756
767
  if (!envFields || envFields.length === 0) {
768
+ // Some agents declare no env_config yet still REQUIRE a sign-in/credential
769
+ // (e.g. Gemini's OAuth login). Detect that via the registry's check_ready
770
+ // and show readiness + login guidance instead of a misleading "no
771
+ // configuration required". Agents without any credential metadata keep the
772
+ // original message — this is gated, so other agents are untouched.
773
+ const entry = connector.registry.getEntry(agent.type);
774
+ const checkReady = entry && entry.check_ready;
775
+ if (hasCredentialMetadata(checkReady)) {
776
+ let health = null;
777
+ try { health = connector.healthCheck(agent.type); } catch {}
778
+ const g = formatAuthGuidance(entry, health);
779
+ const tag = g.ready ? 'green-fg' : 'yellow-fg';
780
+ log(`{bold}{${tag}}${entry.label || agent.type} authentication: ${g.ready ? 'Ready' : 'Not ready'}{/}{/bold}`);
781
+ for (const line of g.lines) log(`{gray-fg}${line}{/gray-fg}`);
782
+ return;
783
+ }
757
784
  log('{gray-fg}No configuration required for this agent type.{/gray-fg}');
758
785
  return;
759
786
  }
@@ -88,20 +88,31 @@ async function checkForUpdate() {
88
88
  }
89
89
 
90
90
  /**
91
- * Walk up from __dirname until we find the enclosing node_modules; the
92
- * directory containing node_modules is the install prefix.
91
+ * Derive the npm `--prefix` for a global install from the npm binary location.
92
+ *
93
+ * npm resolves `-g` installs relative to a PREFIX, and the layout differs by
94
+ * platform:
95
+ * Unix: packages → {prefix}/lib/node_modules (npm bin at {prefix}/bin)
96
+ * Windows: packages → {prefix}/node_modules (npm bin at {prefix})
97
+ *
98
+ * So the prefix is the PARENT of npm's bin dir on Unix, and the bin dir itself
99
+ * on Windows. The previous implementation walked up to the enclosing
100
+ * node_modules and returned the directory containing it — which on Unix is
101
+ * `{prefix}/lib`. Passing THAT as `--prefix` made npm append `lib/node_modules`
102
+ * again, installing into `{prefix}/lib/lib/node_modules` while the running `agn`
103
+ * (loaded from `{prefix}/lib/node_modules`) never changed: `agn update` reported
104
+ * success but silently no-op'd on every Unix host.
93
105
  */
94
- function detectInstallPrefix() {
95
- let dir = __dirname;
96
- for (let i = 0; i < 10; i++) {
97
- const parent = path.dirname(dir);
98
- if (parent === dir) break;
99
- if (path.basename(parent) === 'node_modules') {
100
- return path.dirname(parent);
101
- }
102
- dir = parent;
103
- }
104
- return null;
106
+ function npmPrefixFromBin(npmBin, platform = process.platform) {
107
+ if (!npmBin) return null;
108
+ // Use the target platform's path semantics so a Windows path resolves
109
+ // correctly even when this runs on a POSIX host (and vice versa).
110
+ const p = platform === 'win32' ? path.win32 : path.posix;
111
+ const binDir = p.dirname(npmBin);
112
+ // A bare name (e.g. 'npm' from the PATH hard-fallback) has no usable
113
+ // directory — let npm resolve its own default global prefix instead.
114
+ if (binDir === '.' || binDir === '') return null;
115
+ return platform === 'win32' ? binDir : p.dirname(binDir);
105
116
  }
106
117
 
107
118
  /**
@@ -139,7 +150,7 @@ function runUpdate(opts = {}) {
139
150
  const platform = opts.platform || process.platform;
140
151
  const spawn = opts.spawn || spawnSync;
141
152
  const npmBin = opts.npmBin || findNpmBin({ platform });
142
- const prefix = opts.prefix !== undefined ? opts.prefix : detectInstallPrefix();
153
+ const prefix = opts.prefix !== undefined ? opts.prefix : npmPrefixFromBin(npmBin, platform);
143
154
  // Use a GLOBAL install (-g). A LOCAL install (`npm install <pkg> --prefix
144
155
  // <dir>`) treats <dir> as a project root and prunes every node_modules entry
145
156
  // not reachable from its package.json. When the prefix is the portable node
@@ -228,5 +239,6 @@ module.exports = {
228
239
  notifyAndMaybeUpdate,
229
240
  runUpdate,
230
241
  findNpmBin,
242
+ npmPrefixFromBin,
231
243
  currentVersion,
232
244
  };
@@ -156,15 +156,12 @@ class WorkspaceClient {
156
156
  */
157
157
  async sendMessage(workspaceId, channelName, token, content, {
158
158
  senderType = 'agent', senderName, messageType = 'chat', metadata, attachments, sessionId,
159
- spec, specToolCallId,
160
159
  } = {}) {
161
160
  const sourcePrefix = senderType === 'agent' ? 'openagents' : 'human';
162
161
  const source = senderName ? `${sourcePrefix}:${senderName}` : `${sourcePrefix}:unknown`;
163
162
 
164
163
  const payload = { content, message_type: messageType };
165
164
  if (attachments && attachments.length) payload.attachments = attachments;
166
- if (spec) payload.spec = spec;
167
- if (specToolCallId) payload.spec_tool_call_id = specToolCallId;
168
165
 
169
166
  return this.sendEvent(workspaceId, {
170
167
  type: 'workspace.message.posted',
@@ -175,30 +172,6 @@ class WorkspaceClient {
175
172
  }, token, sessionId);
176
173
  }
177
174
 
178
- /**
179
- * Poll workspace.tool_result events — the client's response to an agent's
180
- * render_ui invocation. Returns events directly (no message normalization)
181
- * because the caller treats them differently than chat messages.
182
- */
183
- async pollToolResults(workspaceId, token, { after, limit = 50 } = {}) {
184
- const params = new URLSearchParams({
185
- network: workspaceId,
186
- type: 'workspace.tool_result',
187
- limit: String(limit),
188
- });
189
- if (after) params.set('after', after);
190
-
191
- const data = await this._get(`/v1/events?${params}`, this._wsHeaders(token));
192
- const result = data.data || data;
193
- const events = (result && result.events) || [];
194
-
195
- let cursor = null;
196
- if (events.length > 0) {
197
- cursor = events[events.length - 1].id || null;
198
- }
199
- return { events, cursor };
200
- }
201
-
202
175
  /**
203
176
  * Poll messages in a channel via GET /v1/events.
204
177
  * @returns {Array} message-compatible objects
@@ -275,6 +248,11 @@ class WorkspaceClient {
275
248
  const params = new URLSearchParams({
276
249
  network: workspaceId,
277
250
  type: 'workspace.message.posted',
251
+ // Server-side pre-filter: only return events routed to this agent (plus
252
+ // untargeted ones) instead of the whole network's traffic. Backward
253
+ // compatible — older backends ignore the unknown param and return
254
+ // everything, so the client-side filter below stays as the safety net.
255
+ target_agents: agentName,
278
256
  limit: String(limit),
279
257
  });
280
258
  if (after) params.set('after', after);
@@ -283,12 +261,23 @@ class WorkspaceClient {
283
261
  const result = data.data || data;
284
262
  const events = (result && result.events) || [];
285
263
 
264
+ // Prefer the server's next_cursor: with server-side target filtering the
265
+ // last returned event lags the stream tip, so advancing by it alone would
266
+ // re-scan the same range every poll. next_cursor jumps past other agents'
267
+ // events once ours are drained. Older backends don't send it — fall back
268
+ // to the last returned event id (they return the full stream, so that IS
269
+ // the tip).
286
270
  let cursor = null;
287
- if (events.length > 0) {
271
+ if (result && result.next_cursor) {
272
+ cursor = result.next_cursor;
273
+ } else if (events.length > 0) {
288
274
  cursor = events[events.length - 1].id || null;
289
275
  }
290
276
 
291
- // Filter for messages targeted at this agent.
277
+ // Filter for messages targeted at this agent. With a targeting-aware
278
+ // backend this is mostly a no-op (server already narrowed the set); it
279
+ // remains authoritative for older backends and for the untargeted events
280
+ // the server includes as a safe superset.
292
281
  //
293
282
  // target_agents semantics:
294
283
  // • absent → legacy server with no routing decision
@@ -802,6 +791,14 @@ class WorkspaceClient {
802
791
  method: 'GET',
803
792
  headers,
804
793
  timeout,
794
+ // Hard end-to-end deadline. The `timeout` option above is only a SOCKET
795
+ // inactivity timeout — it is NOT armed until a socket exists, so it does
796
+ // not bound DNS resolution or TCP connect. During a network partition a
797
+ // request stuck in getaddrinfo/connect never fires 'timeout', so the
798
+ // await never settles and the caller's single poll loop wedges forever
799
+ // (the agent stays "online" via its separate heartbeat but stops
800
+ // consuming messages). AbortSignal.timeout fires in ANY phase.
801
+ signal: AbortSignal.timeout(timeout),
805
802
  }, (res) => {
806
803
  let data = '';
807
804
  res.on('data', (chunk) => { data += chunk; });
@@ -836,6 +833,7 @@ class WorkspaceClient {
836
833
  method: 'GET',
837
834
  headers,
838
835
  timeout,
836
+ signal: AbortSignal.timeout(timeout), // hard deadline (covers DNS/connect); see _get
839
837
  }, (res) => {
840
838
  const chunks = [];
841
839
  res.on('data', (chunk) => { chunks.push(chunk); });
@@ -868,6 +866,7 @@ class WorkspaceClient {
868
866
  method: 'POST',
869
867
  headers: { ...headers, 'Content-Length': Buffer.byteLength(jsonBody) },
870
868
  timeout,
869
+ signal: AbortSignal.timeout(timeout), // hard deadline (covers DNS/connect); see _get
871
870
  }, (res) => {
872
871
  let data = '';
873
872
  res.on('data', (chunk) => { data += chunk; });
@@ -910,6 +909,7 @@ class WorkspaceClient {
910
909
  method: 'PUT',
911
910
  headers: { ...headers, 'Content-Length': Buffer.byteLength(jsonBody) },
912
911
  timeout,
912
+ signal: AbortSignal.timeout(timeout), // hard deadline (covers DNS/connect); see _get
913
913
  }, (res) => {
914
914
  let data = '';
915
915
  res.on('data', (chunk) => { data += chunk; });
@@ -952,6 +952,7 @@ class WorkspaceClient {
952
952
  method: 'PATCH',
953
953
  headers: { ...headers, 'Content-Length': Buffer.byteLength(jsonBody) },
954
954
  timeout,
955
+ signal: AbortSignal.timeout(timeout), // hard deadline (covers DNS/connect); see _get
955
956
  }, (res) => {
956
957
  let data = '';
957
958
  res.on('data', (chunk) => { data += chunk; });
@@ -987,6 +988,7 @@ class WorkspaceClient {
987
988
  method: 'DELETE',
988
989
  headers,
989
990
  timeout: 15000,
991
+ signal: AbortSignal.timeout(15000), // hard deadline (covers DNS/connect); see _get
990
992
  }, (res) => {
991
993
  let data = '';
992
994
  res.on('data', (chunk) => { data += chunk; });