@openagents-org/agent-launcher 0.2.179 → 0.2.180

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openagents-org/agent-launcher",
3
- "version": "0.2.179",
3
+ "version": "0.2.180",
4
4
  "description": "OpenAgents Launcher — install, configure, and run AI coding agents from your terminal",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -20,6 +20,9 @@
20
20
  const { WorkspaceClient, SessionRevokedError } = require('../workspace-client');
21
21
  const { generateSessionTitle, SESSION_DEFAULT_RE } = require('./utils');
22
22
  const { defaultAgentWorkdir } = require('../paths');
23
+ const fs = require('fs');
24
+ const os = require('os');
25
+ const path = require('path');
23
26
  const {
24
27
  decisionLogTitle,
25
28
  glossaryTitle,
@@ -35,6 +38,21 @@ const {
35
38
 
36
39
  const DEFAULT_ENDPOINT = 'https://workspace-endpoint.openagents.org';
37
40
 
41
+ // ── Poll-cursor persistence ──
42
+ // An adapter that starts by jumping to the head of the event stream silently
43
+ // drops every message that arrived while it was down (crash, update, user
44
+ // bounce). Users see that as "I said 继续 and the agent never answered". The
45
+ // cursor is therefore persisted after every poll and resumed on start.
46
+ //
47
+ // Replay is bounded: on the first poll after a resume, messages older than
48
+ // STALE_MESSAGE_MAX_AGE_MS are skipped (and the channel told), so a launcher
49
+ // that was off for a day does not act on hours-old instructions unannounced.
50
+ // This also covers a backend that ignores an unknown/deleted cursor and
51
+ // returns history from the start. A persisted cursor older than
52
+ // CURSOR_RESUME_MAX_AGE_MS is ignored altogether (jump to head as before).
53
+ const STALE_MESSAGE_MAX_AGE_MS = 60 * 60 * 1000;
54
+ const CURSOR_RESUME_MAX_AGE_MS = 24 * 60 * 60 * 1000;
55
+
38
56
  // Heartbeat runs every 30s. A SINGLE failure is usually a transient blip (brief
39
57
  // network hiccup, server redeploy) that the next tick recovers from — surfacing
40
58
  // it as a hard 'error' would make the agent flap red for no real reason. Only
@@ -77,6 +95,9 @@ class BaseAdapter {
77
95
  this._stopRequested = false;
78
96
  this.client = new WorkspaceClient(this.endpoint);
79
97
  this._lastEventId = null;
98
+ this._cursorFile = this._cursorFilePath();
99
+ this._lastPersistedCursor = null;
100
+ this._resumedFromCursor = false;
80
101
  this._running = false;
81
102
  this._sessionId = null; // issued by server on /v1/join; used to prove liveness
82
103
  this._processedIds = new Set();
@@ -263,7 +284,64 @@ class BaseAdapter {
263
284
  // Event cursor / skip existing
264
285
  // ------------------------------------------------------------------
265
286
 
287
+ /** Where this agent's poll cursor lives between runs (per workspace+agent). */
288
+ _cursorFilePath() {
289
+ const safe = (v) => String(v || 'unknown').replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
290
+ return path.join(os.homedir(), '.openagents', 'cursors', `${safe(this.workspaceId)}__${safe(this.agentName)}.json`);
291
+ }
292
+
293
+ /** The cursor saved by the previous run, or null when absent/stale/foreign. */
294
+ _loadPersistedCursor() {
295
+ try {
296
+ const raw = JSON.parse(fs.readFileSync(this._cursorFile, 'utf-8'));
297
+ if (!raw || typeof raw.cursor !== 'string' || !raw.cursor) return null;
298
+ if (raw.workspaceId !== this.workspaceId || raw.agentName !== this.agentName) return null;
299
+ const age = Date.now() - (Number(raw.savedAt) || 0);
300
+ if (!(age >= 0 && age <= CURSOR_RESUME_MAX_AGE_MS)) return null;
301
+ return raw.cursor;
302
+ } catch {
303
+ return null;
304
+ }
305
+ }
306
+
307
+ /** Persist the poll cursor (no-op when unchanged; never throws). */
308
+ _persistCursor(cursor) {
309
+ if (!cursor || cursor === this._lastPersistedCursor) return;
310
+ this._lastPersistedCursor = cursor;
311
+ try {
312
+ fs.mkdirSync(path.dirname(this._cursorFile), { recursive: true });
313
+ const tmp = `${this._cursorFile}.tmp`;
314
+ fs.writeFileSync(tmp, JSON.stringify({
315
+ cursor, savedAt: Date.now(), workspaceId: this.workspaceId, agentName: this.agentName,
316
+ }));
317
+ fs.renameSync(tmp, this._cursorFile);
318
+ } catch (e) {
319
+ if (!this._warnedCursorPersist) {
320
+ this._warnedCursorPersist = true;
321
+ this._log(`Could not persist poll cursor (${e.message}) — a restart will skip messages that arrive while offline`);
322
+ }
323
+ }
324
+ }
325
+
326
+ /** True when a replayed message is too old to act on (see STALE_MESSAGE_MAX_AGE_MS). */
327
+ _isStaleMessage(msg) {
328
+ if (!msg || !msg.createdAt) return false;
329
+ const t = Date.parse(msg.createdAt);
330
+ if (!Number.isFinite(t)) return false;
331
+ return Date.now() - t > STALE_MESSAGE_MAX_AGE_MS;
332
+ }
333
+
266
334
  async _skipExistingEvents() {
335
+ // Prefer the cursor the previous run persisted: a restart must not drop the
336
+ // messages that arrived in between (bounded by the stale filter in the
337
+ // poll loop).
338
+ const persisted = this._loadPersistedCursor();
339
+ if (persisted) {
340
+ this._lastEventId = persisted;
341
+ this._resumedFromCursor = true;
342
+ this._log(`Resuming from persisted cursor ${persisted}`);
343
+ return;
344
+ }
267
345
  // Jump straight to the head with one server call. Pagination from the
268
346
  // start was slow and brittle: on a busy workspace it could take many
269
347
  // minutes to chew through historical events 200 at a time, leaving the
@@ -272,6 +350,7 @@ class BaseAdapter {
272
350
  const head = await this.client.getHeadEventId(this.workspaceId, this.token);
273
351
  if (head) {
274
352
  this._lastEventId = head;
353
+ this._persistCursor(head);
275
354
  this._log(`Skipped existing events, cursor at ${head}`);
276
355
  }
277
356
  }
@@ -689,7 +768,16 @@ class BaseAdapter {
689
768
  continue;
690
769
  }
691
770
 
692
- if (rawCursor) this._lastEventId = rawCursor;
771
+ if (rawCursor) {
772
+ this._lastEventId = rawCursor;
773
+ this._persistCursor(rawCursor);
774
+ }
775
+ // Only the first poll after resuming from a persisted cursor is a
776
+ // replay; later polls carry live traffic and are never age-filtered
777
+ // (a skewed local clock must not make a live agent go deaf).
778
+ const isReplay = this._resumedFromCursor;
779
+ this._resumedFromCursor = false;
780
+ const staleByChannel = {};
693
781
 
694
782
  // Deduplicate
695
783
  const incoming = [];
@@ -697,6 +785,12 @@ class BaseAdapter {
697
785
  const msgId = msg.id || msg.messageId;
698
786
  if (msgId && this._processedIds.has(msgId)) continue;
699
787
  if (msg.messageType === 'status') continue;
788
+ if (isReplay && this._isStaleMessage(msg)) {
789
+ if (msgId) this._processedIds.add(msgId);
790
+ const ch = msg.sessionId || this.channelName || 'general';
791
+ staleByChannel[ch] = (staleByChannel[ch] || 0) + 1;
792
+ continue;
793
+ }
700
794
  // Handle queue cancellation signals from frontend
701
795
  if (msg.messageType === 'queue_cancel') {
702
796
  if (msgId) this._processedIds.add(msgId);
@@ -708,6 +802,17 @@ class BaseAdapter {
708
802
  incoming.push(msg);
709
803
  }
710
804
 
805
+ for (const [ch, n] of Object.entries(staleByChannel)) {
806
+ const mins = Math.round(STALE_MESSAGE_MAX_AGE_MS / 60000);
807
+ this._log(`Skipped ${n} stale message(s) in ${ch} (older than ${mins} min, arrived while this agent was offline)`);
808
+ try {
809
+ await this.sendStatus(ch, `Skipped ${n} message(s) sent more than ${mins} minutes ago while this agent was offline — resend if still needed.`);
810
+ } catch {}
811
+ }
812
+ if (isReplay && incoming.length > 0) {
813
+ this._log(`Replaying ${incoming.length} message(s) that arrived while this agent was offline`);
814
+ }
815
+
711
816
  if (incoming.length > 0) {
712
817
  idleCount = 0;
713
818
  for (const msg of incoming) {
@@ -1231,3 +1336,5 @@ class BaseAdapter {
1231
1336
  }
1232
1337
 
1233
1338
  module.exports = BaseAdapter;
1339
+ module.exports.STALE_MESSAGE_MAX_AGE_MS = STALE_MESSAGE_MAX_AGE_MS;
1340
+ module.exports.CURSOR_RESUME_MAX_AGE_MS = CURSOR_RESUME_MAX_AGE_MS;
@@ -74,6 +74,7 @@ const FAILURE_MESSAGES = {
74
74
  timeout: 'OpenCode timed out before producing a reply. Retry; if it persists, check the model and provider configuration.',
75
75
  stream_parse_error: 'OpenCode produced output this version could not parse. Update to a supported OpenCode version, or open diagnostics.',
76
76
  empty_response: 'OpenCode finished without producing a final reply. Retry; if it persists, open diagnostics.',
77
+ incomplete_run: 'OpenCode stopped mid-task right after a tool call and never produced a final reply — its progress notes are shown above. Send "continue" to pick up where it left off. If this keeps happening, that tool call itself is failing (on Windows, prefer single-line commands over multi-line PowerShell).',
77
78
  process_crashed: 'OpenCode exited unexpectedly. Open diagnostics or retry.',
78
79
  cwd_unavailable: "OpenCode's working directory is not accessible. Check the agent's configured path (or agent home) permissions.",
79
80
  unknown_error: 'OpenCode failed for an undetermined reason. Open diagnostics or retry.',
@@ -449,6 +450,13 @@ class OpenCodeAdapter extends BaseAdapter {
449
450
  return text || null;
450
451
  }
451
452
 
453
+ /** Tool name from a tool event (null for non-tool events). */
454
+ static _toolNameFromEvent(event) {
455
+ if (!OpenCodeAdapter._isToolEvent(event)) return null;
456
+ const item = event.item || event.part || event.tool || event;
457
+ return OpenCodeAdapter._safeToolName(item.name || item.tool || item.toolName || item.id || 'tool');
458
+ }
459
+
452
460
  static _toolStatusFromEvent(event) {
453
461
  if (!OpenCodeAdapter._isToolEvent(event)) return null;
454
462
 
@@ -502,11 +510,13 @@ class OpenCodeAdapter extends BaseAdapter {
502
510
  const status = OpenCodeAdapter._toolStatusFromEvent(event);
503
511
  if (status) {
504
512
  // A tool call resets `finalText` (the "text since the last tool"), so the
505
- // final answer is the text emitted AFTER the last tool. But we keep
506
- // `allText` intact: if the run ends on a tool with no closing text, the
507
- // earlier assistant text is still recoverable instead of being reported
508
- // as an empty response (see _finalTextFromStdout).
509
- if (responseState) responseState.finalText = '';
513
+ // final answer is the text emitted AFTER the last tool. `allText` stays
514
+ // intact for diagnostics, and `lastTool` lets the exit handler tell a
515
+ // finished answer from a run that died on its last tool call.
516
+ if (responseState) {
517
+ responseState.finalText = '';
518
+ responseState.lastTool = OpenCodeAdapter._toolNameFromEvent(event);
519
+ }
510
520
  await this.sendStatus(msgChannel, status);
511
521
  return;
512
522
  }
@@ -906,24 +916,75 @@ class OpenCodeAdapter extends BaseAdapter {
906
916
 
907
917
  // Exit 0.
908
918
  if (stdout) this._persistSessionId(msgChannel, stdout);
909
- const text = stdout ? OpenCodeAdapter._finalTextFromStdout(stdout, responseState) : '';
910
- if (text) return finish(resolve, text);
911
-
912
- // Exit 0 with no final assistant text. This is NOT proof of a missing
913
- // provider — it can be a structured error on stdout, an incomplete
914
- // stream, or a genuinely empty completion. Classify rather than guess.
915
- if (stdoutErr) {
916
- const cls = OpenCodeAdapter._classifyFailure({ code, signal, stdout, stderr, stdoutErr });
917
- this._log(`opencode exit 0 with error event → [${cls.category}]: ${OpenCodeAdapter._redact(cls.diagnostic).slice(0, 300)}`);
918
- return finish(reject, this._failure(cls.category, cls.diagnostic, cls.detail));
919
- }
920
- const emptyCat = OpenCodeAdapter._emptyExitCategory(stdout);
921
- this._log(`opencode exit 0, no final text → [${emptyCat}]. stdout head: ${OpenCodeAdapter._redact(stdout).slice(0, 200)}`);
922
- return finish(reject, this._failure(emptyCat, `exit 0, no final assistant text; stdout head: ${stdout.slice(0, 200)}`));
919
+ const outcome = OpenCodeAdapter._outcomeForCleanExit({ stdout, stderr, stdoutErr, responseState });
920
+ if (outcome.text) return finish(resolve, outcome.text);
921
+ const f = outcome.failure;
922
+ this._log(`opencode exit 0 → [${f.category}]: ${OpenCodeAdapter._redact(f.diagnostic).slice(0, 300)}`);
923
+ return finish(reject, this._failure(f.category, f.diagnostic, f.detail));
923
924
  });
924
925
  });
925
926
  }
926
927
 
928
+ /**
929
+ * Decide what an exit-0 run means. Returns `{ text }` for a real reply or
930
+ * `{ failure: { category, diagnostic, detail } }`.
931
+ *
932
+ * A run that ends on a tool call with no text after it is NOT a finished
933
+ * answer: opencode stopped right as (or because) that tool ran. Field
934
+ * reports (Windows, multi-line PowerShell through the `bash` tool) showed
935
+ * the streamed reasoning being posted as the reply, which reads as the agent
936
+ * "going silent mid-task". Report it as a failure instead — preferring the
937
+ * structured error event when opencode emitted one — so the user gets a
938
+ * clear "stopped after `bash`" plus a way forward. Exit 0 with no text at
939
+ * all is likewise classified rather than guessed at.
940
+ */
941
+ static _outcomeForCleanExit({ stdout = '', stderr = '', stdoutErr = null, responseState = null } = {}) {
942
+ stdout = String(stdout || '').trim();
943
+ const state = responseState || {};
944
+ const lastTool = state.lastTool || null;
945
+ const endedOnTool = !!lastTool && !(state.finalText || '').trim();
946
+
947
+ if (endedOnTool) {
948
+ if (stdoutErr) {
949
+ const cls = OpenCodeAdapter._classifyFailure({ code: 0, signal: null, stdout, stderr, stdoutErr });
950
+ const vague = ['unknown_error', 'process_crashed', 'empty_response'].includes(cls.category);
951
+ return {
952
+ failure: {
953
+ category: vague ? 'incomplete_run' : cls.category,
954
+ diagnostic: `exit 0 after tool ${lastTool} with error event: ${cls.diagnostic || ''}`,
955
+ detail: cls.detail || `Last tool call: \`${lastTool}\``,
956
+ },
957
+ };
958
+ }
959
+ return {
960
+ failure: {
961
+ category: 'incomplete_run',
962
+ diagnostic: `exit 0 right after tool ${lastTool}, no final assistant text`,
963
+ detail: `Last tool call: \`${lastTool}\``,
964
+ },
965
+ };
966
+ }
967
+
968
+ const text = stdout ? OpenCodeAdapter._finalTextFromStdout(stdout, responseState) : '';
969
+ if (text) return { text };
970
+
971
+ // Exit 0 with no final assistant text. This is NOT proof of a missing
972
+ // provider — it can be a structured error on stdout, an incomplete
973
+ // stream, or a genuinely empty completion. Classify rather than guess.
974
+ if (stdoutErr) {
975
+ const cls = OpenCodeAdapter._classifyFailure({ code: 0, signal: null, stdout, stderr, stdoutErr });
976
+ return { failure: { category: cls.category, diagnostic: `exit 0 with error event: ${cls.diagnostic || ''}`, detail: cls.detail } };
977
+ }
978
+ const emptyCat = OpenCodeAdapter._emptyExitCategory(stdout);
979
+ return {
980
+ failure: {
981
+ category: emptyCat,
982
+ diagnostic: `exit 0, no final assistant text; stdout head: ${stdout.slice(0, 200)}`,
983
+ detail: '',
984
+ },
985
+ };
986
+ }
987
+
927
988
  // ------------------------------------------------------------------
928
989
  // Preflight (deterministic, no model call) + failure classification
929
990
  // ------------------------------------------------------------------
@@ -1069,7 +1130,8 @@ class OpenCodeAdapter extends BaseAdapter {
1069
1130
  const base = FAILURE_MESSAGES[category] || FAILURE_MESSAGES.unknown_error;
1070
1131
  const safe = detail ? OpenCodeAdapter._redact(detail).trim() : '';
1071
1132
  const body = safe ? `${base}\n\n> ${safe}` : base;
1072
- const content = `⚠️ **OpenCode couldn't run** — ${body}`;
1133
+ const headline = category === 'incomplete_run' ? 'OpenCode stopped mid-task' : "OpenCode couldn't run";
1134
+ const content = `⚠️ **${headline}** — ${body}`;
1073
1135
  try {
1074
1136
  await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
1075
1137
  senderType: 'agent',
@@ -178,6 +178,25 @@ function buildCollaborationPrompt(toolMode = 'mcp', skillName = 'openagents-work
178
178
  );
179
179
  }
180
180
 
181
+ /**
182
+ * Windows-only shell guidance for CLIs whose shell tool is named `bash`.
183
+ * Field reports (2026-09) show OpenCode runs on Windows ending the moment the
184
+ * model sends multi-line PowerShell (here-strings, hash-table literals,
185
+ * `$var = ...` blocks) through that tool: the turn stops with no reply and no
186
+ * error. Steering the model to single-line commands and the file tools avoids
187
+ * the failure at the source.
188
+ */
189
+ function buildWindowsShellHint(isWindows = process.platform === 'win32') {
190
+ if (!isWindows) return '';
191
+ return (
192
+ '\n## Shell on Windows\n' +
193
+ 'This machine runs Windows. When you run shell commands:\n' +
194
+ '- Keep every command on ONE line. Never send multi-line scripts, PowerShell here-strings (@"..."@), hash-table literals (@{...}) or `$var = ...` blocks through the shell tool — the run will abort.\n' +
195
+ '- To create or change files with multi-line content, use the file write/edit tools, not shell redirection or here-strings.\n' +
196
+ '- Prefer simple one-line commands (`dir`, `type`, `findstr`, `curl.exe`, version-control commands), or one short `powershell -Command "..."` per call.\n'
197
+ );
198
+ }
199
+
181
200
  /**
182
201
  * Build mode-specific instructions.
183
202
  */
@@ -990,11 +1009,11 @@ function buildOpenclawSkillMd({ endpoint, workspaceId, token, agentName, channel
990
1009
  * `decisionLog` / `glossary` opt in to knowledge pinning exactly as in
991
1010
  * buildOpenclawSystemPrompt.
992
1011
  */
993
- function buildOpenCodeSystemPrompt({ agentName, workspaceId, channelName, endpoint, token, mode = 'execute', disabledModules, browserEnabled = false, decisionLog = null, glossary = null }) {
1012
+ function buildOpenCodeSystemPrompt({ agentName, workspaceId, channelName, endpoint, token, mode = 'execute', disabledModules, browserEnabled = false, decisionLog = null, glossary = null, isWindows = process.platform === 'win32' }) {
994
1013
  const identity = buildWorkspaceIdentity(agentName, workspaceId, channelName, mode, 'skills');
995
1014
  const directive = buildBrowserDirective(browserEnabled);
996
1015
  const collab = buildCollaborationPrompt('skills', workspaceSkillName(agentName));
997
- const modePrompt = buildModePrompt(mode);
1016
+ const modePrompt = buildModePrompt(mode) + buildWindowsShellHint(isWindows);
998
1017
  const pinned = buildPinnedSections({ toolMode: 'skills', channelName, mode, decisionLog, glossary })
999
1018
  .map((s) => '\n' + s).join('');
1000
1019
  const api = buildApiSkillsPrompt({ endpoint, workspaceId, token, agentName, channelName, disabledModules, mode });
@@ -1241,6 +1260,7 @@ module.exports = {
1241
1260
  buildBrowserDirective,
1242
1261
  buildCollaborationPrompt,
1243
1262
  buildModePrompt,
1263
+ buildWindowsShellHint,
1244
1264
  buildGuardrails,
1245
1265
  buildApiSkillsPrompt,
1246
1266
  buildClaudeMcpToolBlock,
package/src/paths.js CHANGED
@@ -371,10 +371,11 @@ function _addPackageManagerPaths(dirs) {
371
371
  * reasons; anything added here is picked up on both.
372
372
  */
373
373
  function _addAgentInstallerPaths(dirs) {
374
- // opencode — `curl -fsSL https://opencode.ai/install | bash` defaults to
375
- // ~/.opencode/bin and honours OPENCODE_INSTALL_DIR. Reported as
376
- // "launcher can't see my opencode" (#648).
377
- _push(dirs, process.env.OPENCODE_INSTALL_DIR || path.join(HOME, '.opencode', 'bin'));
374
+ // opencode — `curl -fsSL https://opencode.ai/install | bash` is the route
375
+ // opencode.ai leads with, and its INSTALL_DIR is ~/.opencode/bin, reached
376
+ // through a shell rc edit. Reported as "launcher can't see my opencode"
377
+ // (#648, and again on launcher 0.9.27).
378
+ _push(dirs, path.join(HOME, '.opencode', 'bin'));
378
379
 
379
380
  // kimi — the npm package (@moonshot-ai/kimi-code) declares a real `kimi` bin,
380
381
  // but its postinstall ALSO drops a native build in ~/.kimi-code/bin and puts
@@ -405,6 +406,32 @@ function _addAgentInstallerPaths(dirs) {
405
406
  // _addWindowsPaths; this is the half nothing covered.
406
407
  _push(dirs, path.join(HOME, '.hermes', 'bin'));
407
408
 
409
+ // Installers that let the user choose the install dir. Each of these CLIs
410
+ // reads an env var for its target, and a user who set one has their ONLY copy
411
+ // there — every hardcoded default above then finds nothing. Added rather than
412
+ // substituted: the default dir usually still holds an older copy, and which
413
+ // of the two is on the user's PATH is not ours to guess.
414
+ if (process.env.OPENCODE_INSTALL_DIR) _push(dirs, process.env.OPENCODE_INSTALL_DIR);
415
+ if (process.env.AMP_HOME) _push(dirs, path.join(process.env.AMP_HOME, 'bin'));
416
+ if (process.env.GOOSE_BIN_DIR) _push(dirs, process.env.GOOSE_BIN_DIR);
417
+ if (process.env.HERMES_INSTALL_DIR) _push(dirs, process.env.HERMES_INSTALL_DIR);
418
+ if (process.env.HERMES_HOME) _push(dirs, path.join(process.env.HERMES_HOME, 'bin'));
419
+
420
+ // uv — `uv tool install X` builds a venv at <uv tool dir>/X and only COPIES
421
+ // the executable into uv's bin dir, which reaches PATH through a shell rc
422
+ // edit a GUI launch never sees. Enumerated rather than named, for the same
423
+ // reason as pipx below: aider, mini-swe-agent and openworker all arrive this
424
+ // way today — `uv tool install mini-swe-agent` is the route mini's own docs
425
+ // give, and the mini adapter already searched here while the detection that
426
+ // decides whether mini EXISTS did not — and so will the next Python agent.
427
+ for (const root of _uvToolRoots()) {
428
+ try {
429
+ for (const d of fs.readdirSync(root, { withFileTypes: true })) {
430
+ if (d.isDirectory()) _push(dirs, path.join(root, d.name, IS_WINDOWS ? 'Scripts' : 'bin'));
431
+ }
432
+ } catch {}
433
+ }
434
+
408
435
  // pipx — `pipx install X` puts the executable in <PIPX_HOME>/venvs/X/bin and
409
436
  // only SYMLINKS it into PIPX_BIN_DIR. When that link step is skipped, or its
410
437
  // dir is one a GUI launch can't see, the venv copy is the only one there is.
@@ -438,6 +465,26 @@ function _addAgentInstallerPaths(dirs) {
438
465
  }
439
466
  }
440
467
 
468
+ /**
469
+ * Roots uv keeps its tool venvs under. UV_TOOL_DIR wins when exported;
470
+ * otherwise it is the XDG data dir on Unix — macOS included, uv does NOT use
471
+ * ~/Library/Application Support for this — and %APPDATA%\uv\tools on Windows.
472
+ */
473
+ function _uvToolRoots() {
474
+ if (process.env.UV_TOOL_DIR) return [process.env.UV_TOOL_DIR];
475
+ // os.homedir(), not the module-level HOME: uvToolBinDirs() is also called by
476
+ // the adapters and the post-install verifier at arbitrary times, and that
477
+ // constant is frozen at require time — which resolved to the developer's real
478
+ // home in a test that had moved $HOME.
479
+ const home = os.homedir();
480
+ if (IS_WINDOWS) {
481
+ const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
482
+ return [path.join(appData, 'uv', 'tools')];
483
+ }
484
+ const dataHome = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share');
485
+ return [path.join(dataHome, 'uv', 'tools')];
486
+ }
487
+
441
488
  /**
442
489
  * Roots pipx may keep its venvs under. PIPX_HOME wins when exported; otherwise
443
490
  * pipx has moved its default once (~/.local/pipx → the platform user-data dir),
@@ -682,6 +729,17 @@ function _addUnixPaths(dirs) {
682
729
  _push(dirs, '/usr/local/bin');
683
730
  _push(dirs, '/usr/bin');
684
731
 
732
+ // Homebrew on Linux. macOS gets /opt/homebrew and /usr/local from
733
+ // _addMacPaths; Linuxbrew's prefix is in neither list, and `brew install` is
734
+ // a real route for goose, opencode, gemini and codex. Its PATH entry comes
735
+ // from `brew shellenv` in a shell rc file — invisible to a GUI launch, the
736
+ // same reason every other dir here is listed explicitly.
737
+ if (!IS_MACOS) {
738
+ if (process.env.HOMEBREW_PREFIX) _push(dirs, path.join(process.env.HOMEBREW_PREFIX, 'bin'));
739
+ _push(dirs, '/home/linuxbrew/.linuxbrew/bin');
740
+ _push(dirs, path.join(HOME, '.linuxbrew', 'bin'));
741
+ }
742
+
685
743
  // npm agents install to isolated prefixes: ~/.openagents/runtimes/<type>/
686
744
 
687
745
  // nvm
@@ -900,15 +958,12 @@ function uvToolBinDirs(pkg) {
900
958
  if (process.env.XDG_BIN_HOME) dirs.push(process.env.XDG_BIN_HOME);
901
959
  if (process.env.XDG_DATA_HOME) dirs.push(path.join(process.env.XDG_DATA_HOME, '..', 'bin'));
902
960
  dirs.push(path.join(home, '.local', 'bin'));
961
+ for (const root of _uvToolRoots()) {
962
+ dirs.push(path.join(root, pkg, IS_WINDOWS ? 'Scripts' : 'bin'));
963
+ }
903
964
  if (IS_WINDOWS) {
904
- const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
905
- const uvTools = process.env.UV_TOOL_DIR || path.join(appData, 'uv', 'tools');
906
- dirs.push(path.join(uvTools, pkg, 'Scripts'));
907
965
  dirs.push(path.join(home, 'bin'));
908
966
  } else {
909
- const uvTools = process.env.UV_TOOL_DIR
910
- || path.join(home, '.local', 'share', 'uv', 'tools');
911
- dirs.push(path.join(uvTools, pkg, 'bin'));
912
967
  dirs.push(path.join(home, 'bin'), '/usr/local/bin', '/opt/homebrew/bin');
913
968
  }
914
969
  return dirs;