From 9d56108e8917beb4f6126ef68ee67c4d28030dcf Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:36:30 -0700 Subject: [PATCH] fix(agents): detect all user-installed CLIs by hydrating PATH from login shell (#843) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agents): detect opencode/pi CLIs installed under ~/.opencode/bin and ~/.vite-plus/bin Packaged Electron inherits a minimal PATH without shell rc files, so agent install-script fallback dirs stay invisible to `which` probes — the Agents settings page then shows OpenCode/Pi as "Not installed" even when the user can run them from Terminal. Add ~/bin, ~/.opencode/bin, and ~/.vite-plus/bin to the packaged PATH augmentation so preflight detection matches shell behavior. Fixes #829 * feat(agents): hydrate PATH from user's login shell + Agents Refresh button Packaged Electron inherits a minimal launchd PATH that misses whatever the user's shell rc files append — ~/.opencode/bin, ~/.cargo/bin, nvm shims, pyenv, custom tool dirs. The preceding commit hardcoded two known install locations; this replaces that whack-a-mole pattern with a generic approach. On packaged startup (non-Windows), spawn `${SHELL} -ilc 'echo $PATH'` with a 5s timeout, parse the delimited PATH, and prepend any new segments to process.env.PATH. The result is cached for the app session so we pay the shell-init cost at most once. Surface a Refresh button in Settings > Agents that forces a re-probe and re-detects installed agents — handy right after installing a new CLI, no restart needed. Live-verified that a `zsh -ilc` spawn with a minimal launchd-style env still resolves the user's full PATH (32+ segments including the rc-appended dirs). * refactor(hydrate-shell-path): simplify dedup with Set + Set.difference Set preserves insertion order, so PATH first-match-wins semantics are preserved without manual tracking. Set.prototype.difference (Node 22+) expresses the new-segments calculation in mergePathSegments as the set-difference operation it always was. --- src/main/index.ts | 16 ++ src/main/ipc/preflight.test.ts | 86 +++++++- src/main/ipc/preflight.ts | 31 +++ src/main/startup/configure-process.test.ts | 65 ++++++ src/main/startup/configure-process.ts | 14 +- src/main/startup/hydrate-shell-path.test.ts | 117 +++++++++++ src/main/startup/hydrate-shell-path.ts | 186 ++++++++++++++++++ src/preload/api-types.d.ts | 7 + src/preload/index.ts | 7 +- .../src/components/settings/AgentsPane.tsx | 36 +++- 10 files changed, 555 insertions(+), 10 deletions(-) create mode 100644 src/main/startup/hydrate-shell-path.test.ts create mode 100644 src/main/startup/hydrate-shell-path.ts diff --git a/src/main/index.ts b/src/main/index.ts index be42bfc6..e1ad1834 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -27,6 +27,7 @@ import { installUncaughtPipeErrorGuard, patchPackagedProcessPath } from './startup/configure-process' +import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path' import { RateLimitService } from './rate-limits/service' import { attachMainWindowServices } from './window/attach-main-window-services' import { createMainWindow } from './window/createMainWindow' @@ -53,6 +54,21 @@ let starNag: StarNagService | null = null installUncaughtPipeErrorGuard() patchPackagedProcessPath() +// Why: patchPackagedProcessPath seeds a minimal list of well-known system +// dirs synchronously so early IPC (e.g. preflight before the shell spawn +// completes) doesn't miss homebrew/nix. Kick off the login-shell probe in +// parallel for packaged runs — when it resolves, its PATH is prepended and +// detectInstalledAgents picks up whatever the user's rc files put on PATH +// (cargo/pyenv/volta/custom tool install dirs) without hardcoding each one. +// Dev runs already inherit a complete PATH from the launching terminal, so +// the spawn cost is only paid where it's needed. +if (app.isPackaged && process.platform !== 'win32') { + void hydrateShellPath().then((result) => { + if (result.ok) { + mergePathSegments(result.segments) + } + }) +} configureDevUserDataPath(is.dev) installDevParentDisconnectQuit(is.dev) installDevParentWatchdog(is.dev) diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index 707eb112..3dd92551 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -1,10 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { handleMock, execFileMock, execFileAsyncMock } = vi.hoisted(() => ({ - handleMock: vi.fn(), - execFileMock: vi.fn(), - execFileAsyncMock: vi.fn() -})) +const { handleMock, execFileMock, execFileAsyncMock, hydrateShellPathMock, mergePathSegmentsMock } = + vi.hoisted(() => ({ + handleMock: vi.fn(), + execFileMock: vi.fn(), + execFileAsyncMock: vi.fn(), + hydrateShellPathMock: vi.fn(), + mergePathSegmentsMock: vi.fn() + })) vi.mock('electron', () => ({ ipcMain: { @@ -17,10 +20,16 @@ vi.mock('child_process', () => { [Symbol.for('nodejs.util.promisify.custom')]: execFileAsyncMock }) return { - execFile: execFileWithPromisify + execFile: execFileWithPromisify, + spawn: vi.fn() } }) +vi.mock('../startup/hydrate-shell-path', () => ({ + hydrateShellPath: hydrateShellPathMock, + mergePathSegments: mergePathSegmentsMock +})) + import { _resetPreflightCache, detectInstalledAgents, @@ -36,6 +45,8 @@ describe('preflight', () => { beforeEach(() => { handleMock.mockReset() execFileAsyncMock.mockReset() + hydrateShellPathMock.mockReset() + mergePathSegmentsMock.mockReset() _resetPreflightCache() for (const key of Object.keys(handlers)) { @@ -180,4 +191,67 @@ describe('preflight', () => { await expect(handlers['preflight:detectAgents']()).resolves.toEqual(['cursor']) }) + + it('refreshes via preflight:refreshAgents by re-hydrating PATH before re-detecting', async () => { + // Why: the Agents settings Refresh button calls this path. It must (1) ask + // the shell hydrator for a fresh PATH, (2) merge any new segments, then + // (3) re-run `which` so newly-installed CLIs appear without a restart. + hydrateShellPathMock.mockResolvedValueOnce({ + segments: ['/Users/test/.opencode/bin'], + ok: true + }) + mergePathSegmentsMock.mockReturnValueOnce(['/Users/test/.opencode/bin']) + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command !== 'which') { + throw new Error(`unexpected command ${String(command)}`) + } + if (String(args[0]) === 'opencode') { + return { stdout: '/Users/test/.opencode/bin/opencode\n' } + } + throw new Error('not found') + }) + + registerPreflightHandlers() + + const result = (await handlers['preflight:refreshAgents']()) as { + agents: string[] + addedPathSegments: string[] + shellHydrationOk: boolean + } + + expect(result).toEqual({ + agents: ['opencode'], + addedPathSegments: ['/Users/test/.opencode/bin'], + shellHydrationOk: true + }) + expect(hydrateShellPathMock).toHaveBeenCalledWith({ force: true }) + }) + + it('still re-detects when the shell spawn fails — relies on the existing PATH', async () => { + hydrateShellPathMock.mockResolvedValueOnce({ segments: [], ok: false }) + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command !== 'which') { + throw new Error(`unexpected command ${String(command)}`) + } + if (String(args[0]) === 'claude') { + return { stdout: '/Users/test/.local/bin/claude\n' } + } + throw new Error('not found') + }) + + registerPreflightHandlers() + + const result = (await handlers['preflight:refreshAgents']()) as { + agents: string[] + addedPathSegments: string[] + shellHydrationOk: boolean + } + + expect(result.shellHydrationOk).toBe(false) + expect(result.addedPathSegments).toEqual([]) + expect(result.agents).toEqual(['claude']) + // Why: when hydration fails, we must not call merge — nothing to merge — + // otherwise we'd log a no-op "added 0 segments" event on every refresh. + expect(mergePathSegmentsMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index 984930ac..fbd19049 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -3,6 +3,7 @@ import { execFile } from 'child_process' import { promisify } from 'util' import path from 'path' import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' const execFileAsync = promisify(execFile) @@ -60,6 +61,32 @@ export async function detectInstalledAgents(): Promise { return checks.filter((c) => c.installed).map((c) => c.id) } +export type RefreshAgentsResult = { + /** Agents detected after hydrating PATH from the user's login shell. */ + agents: string[] + /** PATH segments that were added this refresh (empty if nothing new). */ + addedPathSegments: string[] + /** True when the shell spawn succeeded. False = relied on existing PATH. */ + shellHydrationOk: boolean +} + +/** + * Re-spawn the user's login shell to refresh process.env.PATH, then re-run + * agent detection. Called by the Agents settings pane when the user clicks + * Refresh — handles the "installed a new CLI, Orca doesn't see it yet" case + * without requiring an app restart. + */ +export async function refreshShellPathAndDetectAgents(): Promise { + const hydration = await hydrateShellPath({ force: true }) + const added = hydration.ok ? mergePathSegments(hydration.segments) : [] + const agents = await detectInstalledAgents() + return { + agents, + addedPathSegments: added, + shellHydrationOk: hydration.ok + } +} + async function isGhAuthenticated(): Promise { try { await execFileAsync('gh', ['auth', 'status'], { @@ -110,4 +137,8 @@ export function registerPreflightHandlers(): void { ipcMain.handle('preflight:detectAgents', async (): Promise => { return detectInstalledAgents() }) + + ipcMain.handle('preflight:refreshAgents', async (): Promise => { + return refreshShellPathAndDetectAgents() + }) } diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 6403e999..a20dd7c6 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -11,6 +11,7 @@ vi.mock('electron', () => { }), quit: vi.fn(), exit: vi.fn(), + isPackaged: false, commandLine: { appendSwitch: vi.fn() } @@ -23,6 +24,70 @@ afterEach(() => { vi.restoreAllMocks() }) +describe('patchPackagedProcessPath', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + const originalHome = process.env.HOME + const originalPath = process.env.PATH + + function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform + }) + } + + afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + if (originalPath === undefined) { + delete process.env.PATH + } else { + process.env.PATH = originalPath + } + }) + + it('prepends agent-CLI install dirs (~/.opencode/bin, ~/.vite-plus/bin) for packaged darwin runs', async () => { + const { app } = await import('electron') + const { patchPackagedProcessPath } = await import('./configure-process') + + setPlatform('darwin') + Object.defineProperty(app, 'isPackaged', { configurable: true, value: true }) + process.env.HOME = '/Users/tester' + process.env.PATH = '/usr/bin:/bin' + + patchPackagedProcessPath() + + const segments = (process.env.PATH ?? '').split(':') + // Why: issue #829 — ~/.opencode/bin and ~/.vite-plus/bin are the documented + // fallback install locations for the opencode and Pi CLI install scripts. + // Without them on PATH, GUI-launched Orca reports both as "Not installed" + // even when `which` resolves them in the user's shell. + expect(segments).toContain('/Users/tester/.opencode/bin') + expect(segments).toContain('/Users/tester/.vite-plus/bin') + expect(segments).toContain('/Users/tester/bin') + }) + + it('leaves PATH untouched when the app is not packaged', async () => { + const { app } = await import('electron') + const { patchPackagedProcessPath } = await import('./configure-process') + + setPlatform('darwin') + Object.defineProperty(app, 'isPackaged', { configurable: true, value: false }) + process.env.HOME = '/Users/tester' + process.env.PATH = '/usr/bin:/bin' + + patchPackagedProcessPath() + + expect(process.env.PATH).toBe('/usr/bin:/bin') + }) +}) + describe('configureDevUserDataPath', () => { it('uses an explicit dev userData override when provided', async () => { const { app } = await import('electron') diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index af362221..fe1c309e 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -52,7 +52,19 @@ export function patchPackagedProcessPath(): void { ] if (home) { - extraPaths.push(join(home, '.local/bin'), join(home, '.nix-profile/bin')) + extraPaths.push( + join(home, 'bin'), + join(home, '.local/bin'), + join(home, '.nix-profile/bin'), + // Why: several agent CLIs ship install scripts that drop binaries into + // tool-specific ~/./bin directories (opencode's documented fallback, + // Pi's vite-plus installer). GUI-launched Electron inherits a minimal PATH + // without shell rc files, so these stay invisible to `which` probes — and + // the Agents settings page reports them as "Not installed" even when the + // user can run them from Terminal. See stablyai/orca#829. + join(home, '.opencode/bin'), + join(home, '.vite-plus/bin') + ) } // Why: CLI tools installed via Node version managers (nvm, volta, asdf, fnm, diff --git a/src/main/startup/hydrate-shell-path.test.ts b/src/main/startup/hydrate-shell-path.test.ts new file mode 100644 index 00000000..9006b93f --- /dev/null +++ b/src/main/startup/hydrate-shell-path.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + _resetHydrateShellPathCache, + hydrateShellPath, + mergePathSegments +} from './hydrate-shell-path' + +describe('hydrateShellPath', () => { + const originalPath = process.env.PATH + + beforeEach(() => { + _resetHydrateShellPathCache() + }) + + afterEach(() => { + if (originalPath === undefined) { + delete process.env.PATH + } else { + process.env.PATH = originalPath + } + }) + + it('invokes the provided shell with a custom spawner and returns its segments', async () => { + let capturedShell = '' + const result = await hydrateShellPath({ + shellOverride: '/bin/zsh', + spawner: async (shell) => { + capturedShell = shell + return { + segments: ['/Users/tester/.opencode/bin', '/Users/tester/.cargo/bin'], + ok: true + } + } + }) + + expect(capturedShell).toBe('/bin/zsh') + expect(result.ok).toBe(true) + expect(result.segments).toEqual(['/Users/tester/.opencode/bin', '/Users/tester/.cargo/bin']) + }) + + it('caches the hydration result so repeated calls do not re-spawn', async () => { + let spawnCount = 0 + const spawner = async (): Promise<{ segments: string[]; ok: boolean }> => { + spawnCount += 1 + return { segments: ['/a'], ok: true } + } + + await hydrateShellPath({ shellOverride: '/bin/zsh', spawner }) + await hydrateShellPath({ shellOverride: '/bin/zsh', spawner }) + await hydrateShellPath({ shellOverride: '/bin/zsh', spawner }) + + expect(spawnCount).toBe(1) + }) + + it('re-spawns when force:true is passed — matches the Refresh button contract', async () => { + let spawnCount = 0 + const spawner = async (): Promise<{ segments: string[]; ok: boolean }> => { + spawnCount += 1 + return { segments: ['/a'], ok: true } + } + + await hydrateShellPath({ shellOverride: '/bin/zsh', spawner }) + await hydrateShellPath({ shellOverride: '/bin/zsh', spawner, force: true }) + + expect(spawnCount).toBe(2) + }) + + it('returns ok:false when no shell is available (Windows path)', async () => { + const result = await hydrateShellPath({ + shellOverride: null, + spawner: async () => { + throw new Error('spawner must not run when shell is null') + } + }) + + expect(result).toEqual({ segments: [], ok: false }) + }) +}) + +describe('mergePathSegments', () => { + const originalPath = process.env.PATH + + afterEach(() => { + if (originalPath === undefined) { + delete process.env.PATH + } else { + process.env.PATH = originalPath + } + }) + + it('prepends new segments ahead of existing PATH entries', () => { + process.env.PATH = '/usr/bin:/bin' + + const added = mergePathSegments(['/Users/tester/.opencode/bin', '/Users/tester/.cargo/bin']) + + expect(added).toEqual(['/Users/tester/.opencode/bin', '/Users/tester/.cargo/bin']) + expect(process.env.PATH).toBe( + '/Users/tester/.opencode/bin:/Users/tester/.cargo/bin:/usr/bin:/bin' + ) + }) + + it('skips segments already on PATH so re-hydration is a no-op', () => { + process.env.PATH = '/Users/tester/.cargo/bin:/usr/bin' + + const added = mergePathSegments(['/Users/tester/.cargo/bin', '/Users/tester/.opencode/bin']) + + expect(added).toEqual(['/Users/tester/.opencode/bin']) + expect(process.env.PATH).toBe('/Users/tester/.opencode/bin:/Users/tester/.cargo/bin:/usr/bin') + }) + + it('returns [] and leaves PATH untouched when given nothing', () => { + process.env.PATH = '/usr/bin:/bin' + + expect(mergePathSegments([])).toEqual([]) + expect(process.env.PATH).toBe('/usr/bin:/bin') + }) +}) diff --git a/src/main/startup/hydrate-shell-path.ts b/src/main/startup/hydrate-shell-path.ts new file mode 100644 index 00000000..75a7f014 --- /dev/null +++ b/src/main/startup/hydrate-shell-path.ts @@ -0,0 +1,186 @@ +import { spawn } from 'child_process' +import { delimiter } from 'path' + +// Why: GUI-launched Electron on macOS/Linux inherits a minimal PATH from launchd +// that does not include dirs appended by the user's shell rc files (~/.zshrc, +// ~/.bashrc). Tools installed into ~/.opencode/bin, ~/.cargo/bin, pyenv/volta +// shims, and countless other user-local locations end up invisible to our +// `which` probe even though they work fine from Terminal (see stablyai/orca#829). +// +// Rather than play whack-a-mole adding every agent's install dir to a hardcoded +// list, we spawn the user's login shell once per app session and read the PATH +// it would export. This matches the behavior of every popular Electron app that +// handles this problem (Hyper, VS Code, Cursor, etc. via shell-env/fix-path) — +// we implement it inline to avoid adding a dependency. + +const DELIMITER = '__ORCA_SHELL_PATH__' +const SPAWN_TIMEOUT_MS = 5000 + +// ANSI escape sequences can leak into the captured output when the user's rc +// files print banners or set colored prompts. Strip them before parsing. +const ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/g // eslint-disable-line no-control-regex + +type HydrationResult = { + /** PATH segments extracted from the login shell, in order, de-duplicated. */ + segments: string[] + /** True when the shell spawn succeeded and returned a non-empty PATH. */ + ok: boolean +} + +let cached: Promise | null = null + +/** @internal - tests need a clean hydration cache between cases. */ +export function _resetHydrateShellPathCache(): void { + cached = null +} + +function pickShell(): string | null { + if (process.platform === 'win32') { + return null + } + const shell = process.env.SHELL + if (shell && shell.length > 0) { + return shell + } + return process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash' +} + +function parseCapturedPath(stdout: string): string[] { + const cleaned = stdout.replace(ANSI_RE, '') + const first = cleaned.indexOf(DELIMITER) + if (first < 0) { + return [] + } + const second = cleaned.indexOf(DELIMITER, first + DELIMITER.length) + if (second < 0) { + return [] + } + const value = cleaned.slice(first + DELIMITER.length, second).trim() + if (!value) { + return [] + } + // Why: Set preserves insertion order, and PATH resolution is first-match-wins, + // so de-duping this way keeps the user's rc-file ordering intact. + return [ + ...new Set( + value + .split(delimiter) + .map((s) => s.trim()) + .filter(Boolean) + ) + ] +} + +function spawnShellAndReadPath(shell: string): Promise { + return new Promise((resolve) => { + // Why: printing $PATH between delimiters is resilient to rc-file banners, + // MOTDs, and `echo` invocations that shells like fish print unprompted. + // `-ilc` runs the shell as a login+interactive so both .profile/.zprofile + // and .bashrc/.zshrc are sourced — matches what `which` in Terminal sees. + const command = `printf '%s' '${DELIMITER}'; printf '%s' "$PATH"; printf '%s' '${DELIMITER}'` + let finished = false + let stdout = '' + + const child = spawn(shell, ['-ilc', command], { + // Why: inherit current env so the shell sees the same baseline, then let + // it layer its own rc files on top. Do NOT forward stdio — some shells + // (oh-my-zsh setups, powerlevel10k) print a lot to stderr on startup, + // and we don't want that in Orca's console. + env: process.env, + stdio: ['ignore', 'pipe', 'ignore'], + detached: false + }) + + const timer = setTimeout(() => { + if (finished) { + return + } + finished = true + // Why: slow rc files (corporate env setup, nvm eager init) can exceed + // our budget. Kill the shell and fall back to process.env rather than + // blocking the Agents pane indefinitely. + try { + child.kill('SIGKILL') + } catch { + // ignore + } + resolve({ segments: [], ok: false }) + }, SPAWN_TIMEOUT_MS) + + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf8') + }) + + child.on('error', () => { + if (finished) { + return + } + finished = true + clearTimeout(timer) + resolve({ segments: [], ok: false }) + }) + + child.on('close', () => { + if (finished) { + return + } + finished = true + clearTimeout(timer) + const segments = parseCapturedPath(stdout) + resolve({ segments, ok: segments.length > 0 }) + }) + }) +} + +type HydrateOptions = { + force?: boolean + /** Override for tests — defaults to running `spawn` against the real shell. */ + spawner?: (shell: string) => Promise + /** Override for tests — defaults to `pickShell()`. */ + shellOverride?: string | null +} + +/** + * Spawn the user's login shell once and return the PATH it would export. + * Caches the promise for the lifetime of the process — call + * `_resetHydrateShellPathCache()` in tests or `hydrateShellPath({ force: true })` + * when the user asks to re-probe (e.g. after installing a new CLI). + */ +export function hydrateShellPath(options: HydrateOptions = {}): Promise { + if (cached && !options.force) { + return cached + } + const shell = options.shellOverride !== undefined ? options.shellOverride : pickShell() + if (!shell) { + // Windows uses cmd/PowerShell rather than a POSIX login shell — the + // `patchPackagedProcessPath` static list is sufficient there. + cached = Promise.resolve({ segments: [], ok: false }) + return cached + } + cached = (options.spawner ?? spawnShellAndReadPath)(shell) + return cached +} + +/** + * Prepend newly-discovered PATH segments to process.env.PATH, preserving + * existing ordering and avoiding duplicates. Returns the segments that were + * actually added so callers can log/telemetry on nontrivial hydrations. + */ +export function mergePathSegments(segments: string[]): string[] { + if (segments.length === 0) { + return [] + } + const current = process.env.PATH ?? '' + const existing = new Set(current.split(delimiter).filter(Boolean)) + // Why: Node 22+ Set.prototype.difference preserves insertion order of the + // receiver, so [...incoming.difference(existing)] gives us the new entries + // in the order the shell provided them (first-match-wins on PATH). + const added = [...new Set(segments).difference(existing)] + if (added.length === 0) { + return [] + } + // Why: prepend so shell-provided entries win over the hardcoded fallbacks. + // The user's rc files are the source of truth for `which`-style resolution. + process.env.PATH = [...added, ...current.split(delimiter).filter(Boolean)].join(delimiter) + return added +} diff --git a/src/preload/api-types.d.ts b/src/preload/api-types.d.ts index 333802cf..615b563a 100644 --- a/src/preload/api-types.d.ts +++ b/src/preload/api-types.d.ts @@ -159,9 +159,16 @@ export type PreflightStatus = { gh: { installed: boolean; authenticated: boolean } } +export type RefreshAgentsResult = { + agents: string[] + addedPathSegments: string[] + shellHydrationOk: boolean +} + export type PreflightApi = { check: (args?: { force?: boolean }) => Promise detectAgents: () => Promise + refreshAgents: () => Promise } export type StatsApi = { diff --git a/src/preload/index.ts b/src/preload/index.ts index fb5e31a5..a33ab0f1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -460,7 +460,12 @@ const api = { git: { installed: boolean } gh: { installed: boolean; authenticated: boolean } }> => ipcRenderer.invoke('preflight:check', args), - detectAgents: (): Promise => ipcRenderer.invoke('preflight:detectAgents') + detectAgents: (): Promise => ipcRenderer.invoke('preflight:detectAgents'), + refreshAgents: (): Promise<{ + agents: string[] + addedPathSegments: string[] + shellHydrationOk: boolean + }> => ipcRenderer.invoke('preflight:refreshAgents') }, notifications: { diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index 9ae07126..162ecfa3 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from 'react' -import { Check, ChevronDown, ExternalLink, Terminal } from 'lucide-react' +import { useCallback, useEffect, useState } from 'react' +import { Check, ChevronDown, ExternalLink, RefreshCw, Terminal } from 'lucide-react' import type { GlobalSettings, TuiAgent } from '../../../../shared/types' import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog' import { Button } from '../ui/button' @@ -205,6 +205,7 @@ function AgentRow({ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React.JSX.Element { const [detectedIds, setDetectedIds] = useState | null>(null) + const [isRefreshing, setIsRefreshing] = useState(false) useEffect(() => { void window.api.preflight.detectAgents().then((ids) => { @@ -212,6 +213,22 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React }) }, []) + const handleRefresh = useCallback(async (): Promise => { + // Why: refresh re-spawns the user's login shell to re-capture PATH + // (preflight:refreshAgents on the main side). This handles the + // "installed a new CLI, Orca doesn't see it yet" case without a restart. + if (isRefreshing) { + return + } + setIsRefreshing(true) + try { + const result = await window.api.preflight.refreshAgents() + setDetectedIds(new Set(result.agents)) + } finally { + setIsRefreshing(false) + } + }, [isRefreshing]) + const defaultAgent = settings.defaultTuiAgent const cmdOverrides = settings.agentCmdOverrides ?? {} @@ -296,6 +313,21 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React {detectedAgents.length} detected +