diff --git a/apps/daemon/src/agents.ts b/apps/daemon/src/agents.ts index 6eb7bc2cbd..f2e0972084 100644 --- a/apps/daemon/src/agents.ts +++ b/apps/daemon/src/agents.ts @@ -1,9 +1,10 @@ // @ts-nocheck import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; -import { existsSync } from 'node:fs'; +import { existsSync, readdirSync } from 'node:fs'; import { delimiter } from 'node:path'; import path from 'node:path'; +import { homedir } from 'node:os'; import { detectAcpModels } from './acp.js'; import { parsePiModels } from './pi-rpc.js'; @@ -577,12 +578,79 @@ export const AGENT_DEFS = [ }, ]; +function existingDirsUnder(root, segments = []) { + const dirs = []; + let entries = []; + try { + entries = readdirSync(root, { withFileTypes: true }); + } catch { + return dirs; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const full = path.join(root, entry.name, ...segments); + if (existsSync(full)) dirs.push(full); + } + return dirs; +} + +const TOOLCHAIN_DIR_CACHE_TTL_MS = 5000; +let cachedToolchainHome = null; +let cachedToolchainDirs = null; +let cachedToolchainDirsAt = 0; + +function userToolchainDirs() { + const homeOverride = process.env.OD_AGENT_HOME; + const home = homeOverride || homedir(); + const now = Date.now(); + if ( + cachedToolchainHome === home && + cachedToolchainDirs && + now - cachedToolchainDirsAt < TOOLCHAIN_DIR_CACHE_TTL_MS + ) { + return cachedToolchainDirs; + } + cachedToolchainHome = home; + cachedToolchainDirsAt = now; + cachedToolchainDirs = [ + path.join(home, '.local', 'bin'), + path.join(home, '.opencode', 'bin'), + path.join(home, '.bun', 'bin'), + path.join(home, '.volta', 'bin'), + path.join(home, '.asdf', 'shims'), + path.join(home, 'Library', 'pnpm'), + path.join(home, '.cargo', 'bin'), + ...(process.platform !== 'win32' && !homeOverride ? ['/opt/homebrew/bin', '/usr/local/bin'] : []), + ...existingDirsUnder(path.join(home, '.local', 'share', 'mise', 'installs', 'node'), ['bin']), + ...existingDirsUnder(path.join(home, '.nvm', 'versions', 'node'), ['bin']), + ...existingDirsUnder(path.join(home, '.local', 'share', 'fnm', 'node-versions'), ['installation', 'bin']), + ]; + return cachedToolchainDirs; +} + +function resolvePathDirs() { + const seen = new Set(); + const dirs = [ + ...(process.env.PATH || '').split(delimiter), + // GUI launchers (macOS .app bundles, Linux .desktop files) often start + // with a minimal PATH. Include common user-level CLI install locations + // so agent detection matches the user's shell-installed tools, + // especially Node version managers. + ...userToolchainDirs(), + ]; + return dirs.filter((dir) => { + if (!dir || seen.has(dir)) return false; + seen.add(dir); + return true; + }); +} + export function resolveOnPath(bin) { const exts = process.platform === 'win32' ? (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';') : ['']; - const dirs = (process.env.PATH || '').split(delimiter); + const dirs = resolvePathDirs(); for (const dir of dirs) { for (const ext of exts) { const full = path.join(dir, bin + ext); diff --git a/apps/daemon/tests/agents.test.ts b/apps/daemon/tests/agents.test.ts index 22f7fa1b4b..8e3b30fe8a 100644 --- a/apps/daemon/tests/agents.test.ts +++ b/apps/daemon/tests/agents.test.ts @@ -1,7 +1,7 @@ // @ts-nocheck import { afterEach, test } from 'vitest'; import assert from 'node:assert/strict'; -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { AGENT_DEFS, resolveAgentExecutable } from '../src/agents.js'; @@ -14,6 +14,8 @@ const claude = AGENT_DEFS.find((agent) => agent.id === 'claude'); const devin = AGENT_DEFS.find((agent) => agent.id === 'devin'); const originalDisablePlugins = process.env.OD_CODEX_DISABLE_PLUGINS; const originalPath = process.env.PATH; +const originalHome = process.env.HOME; +const originalAgentHome = process.env.OD_AGENT_HOME; afterEach(() => { if (originalDisablePlugins == null) { @@ -22,6 +24,16 @@ afterEach(() => { process.env.OD_CODEX_DISABLE_PLUGINS = originalDisablePlugins; } process.env.PATH = originalPath; + if (originalHome == null) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalAgentHome == null) { + delete process.env.OD_AGENT_HOME; + } else { + process.env.OD_AGENT_HOME = originalAgentHome; + } }); test('codex args disable plugins when OD_CODEX_DISABLE_PLUGINS is 1', () => { @@ -288,6 +300,7 @@ fsTest('resolveAgentExecutable prefers def.bin over fallbackBins when bin is on writeFileSync(join(dir, 'openclaude'), ''); chmodSync(join(dir, 'claude'), 0o755); chmodSync(join(dir, 'openclaude'), 0o755); + process.env.OD_AGENT_HOME = dir; process.env.PATH = dir; const resolved = resolveAgentExecutable({ @@ -306,6 +319,7 @@ fsTest('resolveAgentExecutable falls back through fallbackBins when def.bin is m // Only `openclaude` is installed (Claude Code fork-only setup). writeFileSync(join(dir, 'openclaude'), ''); chmodSync(join(dir, 'openclaude'), 0o755); + process.env.OD_AGENT_HOME = dir; process.env.PATH = dir; const resolved = resolveAgentExecutable({ @@ -321,6 +335,7 @@ fsTest('resolveAgentExecutable falls back through fallbackBins when def.bin is m fsTest('resolveAgentExecutable returns null when neither def.bin nor any fallback is on PATH', () => { const dir = mkdtempSync(join(tmpdir(), 'od-agents-resolve-')); try { + process.env.OD_AGENT_HOME = dir; process.env.PATH = dir; const resolved = resolveAgentExecutable({ @@ -333,6 +348,25 @@ fsTest('resolveAgentExecutable returns null when neither def.bin nor any fallbac } }); +fsTest('resolveAgentExecutable searches mise node bins when PATH is minimal', () => { + const home = mkdtempSync(join(tmpdir(), 'od-agents-home-')); + try { + const dir = join(home, '.local', 'share', 'mise', 'installs', 'node', '24.14.1', 'bin'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'codex'), ''); + chmodSync(join(dir, 'codex'), 0o755); + process.env.OD_AGENT_HOME = home; + process.env.PATH = '/usr/bin:/bin'; + + const resolved = resolveAgentExecutable({ + bin: 'codex', + }); + assert.equal(resolved, join(dir, 'codex')); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + fsTest('resolveAgentExecutable still resolves agents without a fallbackBins field', () => { // Guard against a regression that would require every AGENT_DEF to // declare fallbackBins. Most agents (codex / gemini / opencode / ...)