From 5a390cd60e81e815d4aaa8b9b8d10c45cb37ee86 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:34:16 -0700 Subject: [PATCH] Fix Claude Agent Teams detection without Claude CLI (#7834) Co-authored-by: Orca --- src/main/ipc/preflight.test.ts | 74 +++++++++++++++++ src/main/ipc/preflight.ts | 43 +++++----- .../ipc/tui-agent-detection-commands.test.ts | 49 ++++++++++++ src/main/ipc/tui-agent-detection-commands.ts | 77 ++++++++++++++++++ src/relay/preflight-handler.test.ts | 80 +++++++++++++++++++ src/relay/preflight-handler.ts | 47 ++++++++++- src/shared/tui-agent-config.ts | 15 +++- 7 files changed, 359 insertions(+), 26 deletions(-) create mode 100644 src/main/ipc/tui-agent-detection-commands.test.ts create mode 100644 src/main/ipc/tui-agent-detection-commands.ts diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index 944ee1b4418..e1f391af79d 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -492,6 +492,58 @@ describe('preflight', () => { await expect(detectInstalledAgents()).resolves.toEqual(['claude', 'cursor']) }) + it('does not report Claude Agent Teams when only the Orca shim is present', async () => { + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command !== 'which') { + throw new Error(`unexpected command ${String(command)}`) + } + if (String(args[0]) === 'orca') { + return { stdout: '/Applications/Orca.app/Contents/MacOS/orca\n' } + } + throw new Error('not found') + }) + + await expect(detectInstalledAgents()).resolves.toEqual([]) + }) + + it('reports Claude Agent Teams when both Orca and Claude are present', async () => { + 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' } + } + if (String(args[0]) === 'orca') { + return { stdout: '/Applications/Orca.app/Contents/MacOS/orca\n' } + } + throw new Error('not found') + }) + + await expect(detectInstalledAgents()).resolves.toEqual(['claude', 'claude-agent-teams']) + }) + + it('does not report Claude Agent Teams on native Windows', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command !== 'where') { + throw new Error(`unexpected command ${String(command)}`) + } + if (String(args[0]) === 'claude') { + return { stdout: '/mock/windows/npm/claude.cmd\n' } + } + if (String(args[0]) === 'orca') { + return { stdout: '/mock/windows/programs/orca.cmd\n' } + } + throw new Error('not found') + }) + + await expect(detectInstalledAgents()).resolves.toEqual(['claude']) + }) + it('detects agents via the install-dir resolver when which fails (stripped GUI PATH)', async () => { // Why: cold GUI launches can run detection before shell-PATH hydration // adds user install dirs, so `which` can miss runnable CLIs. @@ -634,6 +686,28 @@ describe('preflight', () => { expect(hydrateShellPathMock).not.toHaveBeenCalled() }) + it('does not report Claude Agent Teams from WSL agent detection', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command !== 'wsl.exe') { + throw new Error(`unexpected command ${String(command)}`) + } + const script = String(args[5]) + expect(script).not.toContain("'orca'") + expect(script).not.toContain("'orca-dev'") + expect(script).not.toContain("'orca-ide'") + if (script.includes("'claude'")) { + return { stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n' } + } + throw new Error('not found') + }) + + await expect(detectInstalledAgents({ wslDistro: 'Ubuntu' })).resolves.toEqual(['claude']) + }) + it('detects Mistral Vibe from the installed vibe executable', async () => { execFileAsyncMock.mockImplementation(async (command, args) => { if (command !== 'which') { diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index cc14ca490b6..6a33e112fa2 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -1,5 +1,4 @@ import { ipcMain } from 'electron' -import { getTuiAgentDetectCommands, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import type { PathSource, ShellHydrationFailureReason } from '../../shared/types' import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' import { getAzureDevOpsAuthStatus } from '../azure-devops/client' @@ -22,6 +21,11 @@ import { detectRemoteWindowsTerminalCapabilities, type RemoteWindowsTerminalCapabilities } from './preflight-remote-windows-terminal-capabilities' +import { + getTuiAgentDetectionProbeCommands, + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + resolveDetectedTuiAgentIds +} from './tui-agent-detection-commands' export type PreflightStatus = { git: { installed: boolean } @@ -60,13 +64,6 @@ export function _resetPreflightCache(): void { cached = null } -const KNOWN_AGENT_COMMANDS = Object.entries(TUI_AGENT_CONFIG).flatMap(([id, config]) => - getTuiAgentDetectCommands(config).map((cmd) => ({ - id, - cmd - })) -) - function uniqueAgentIds(ids: Iterable): string[] { return [...new Set(ids)] } @@ -92,16 +89,17 @@ export async function detectInstalledAgents(context?: PreflightRuntimeContext): if (wslTarget) { const foundCommands = await detectWslCommandsOnPath( wslTarget, - KNOWN_AGENT_COMMANDS.map(({ cmd }) => cmd) - ) - return uniqueAgentIds( - KNOWN_AGENT_COMMANDS.filter(({ cmd }) => foundCommands.has(cmd)).map(({ id }) => id) + getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, 'wsl') ) + return resolveDetectedTuiAgentIds(KNOWN_TUI_AGENT_DETECTION_COMMANDS, foundCommands, 'wsl') } + const probeCommands = getTuiAgentDetectionProbeCommands( + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + process.platform + ) const pathChecks = await Promise.all( - KNOWN_AGENT_COMMANDS.map(async ({ id, cmd }) => ({ - id, + probeCommands.map(async (cmd) => ({ cmd, installedOnPath: await isCommandOnPath(cmd) })) @@ -110,11 +108,16 @@ export async function detectInstalledAgents(context?: PreflightRuntimeContext): // Why: PATH may still be unhydrated on a cold GUI launch; bulk resolution // computes user install dirs once instead of blocking once per missed CLI. const installDirCommands = detectCommandsInInstallDirs(missedCommands) - const checks = pathChecks.map(({ id, cmd, installedOnPath }) => ({ - id, - installed: installedOnPath || installDirCommands.has(cmd) - })) - return uniqueAgentIds(checks.filter((c) => c.installed).map((c) => c.id)) + const foundCommands = new Set( + pathChecks + .filter(({ cmd, installedOnPath }) => installedOnPath || installDirCommands.has(cmd)) + .map(({ cmd }) => cmd) + ) + return resolveDetectedTuiAgentIds( + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + foundCommands, + process.platform + ) } export async function detectInstalledAgentsWithShellPathHydration( @@ -181,7 +184,7 @@ export async function detectRemoteAgents(args: { connectionId: string }): Promis return [] } const result = (await mux.request('preflight.detectAgents', { - commands: KNOWN_AGENT_COMMANDS + commands: KNOWN_TUI_AGENT_DETECTION_COMMANDS })) as { agents: string[] } return uniqueAgentIds(result.agents) } diff --git a/src/main/ipc/tui-agent-detection-commands.test.ts b/src/main/ipc/tui-agent-detection-commands.test.ts new file mode 100644 index 00000000000..b6d536f732b --- /dev/null +++ b/src/main/ipc/tui-agent-detection-commands.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { + getTuiAgentDetectionProbeCommands, + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + resolveDetectedTuiAgentIds +} from './tui-agent-detection-commands' + +describe('tui agent detection commands', () => { + it('requires Claude before reporting Claude Agent Teams', () => { + const commands = KNOWN_TUI_AGENT_DETECTION_COMMANDS.filter( + (command) => command.id === 'claude-agent-teams' + ) + + expect(commands).toEqual([ + { + id: 'claude-agent-teams', + cmd: 'orca', + requiredCommands: ['claude'], + unsupportedRuntimes: ['win32', 'wsl'] + }, + { + id: 'claude-agent-teams', + cmd: 'orca-dev', + requiredCommands: ['claude'], + unsupportedRuntimes: ['win32', 'wsl'] + }, + { + id: 'claude-agent-teams', + cmd: 'orca-ide', + requiredCommands: ['claude'], + unsupportedRuntimes: ['win32', 'wsl'] + } + ]) + expect(getTuiAgentDetectionProbeCommands(commands, 'linux')).toEqual([ + 'orca', + 'claude', + 'orca-dev', + 'orca-ide' + ]) + expect(resolveDetectedTuiAgentIds(commands, new Set(['orca']), 'linux')).toEqual([]) + expect(resolveDetectedTuiAgentIds(commands, new Set(['orca', 'claude']), 'linux')).toEqual([ + 'claude-agent-teams' + ]) + expect(getTuiAgentDetectionProbeCommands(commands, 'win32')).toEqual([]) + expect(resolveDetectedTuiAgentIds(commands, new Set(['orca', 'claude']), 'win32')).toEqual([]) + expect(getTuiAgentDetectionProbeCommands(commands, 'wsl')).toEqual([]) + expect(resolveDetectedTuiAgentIds(commands, new Set(['orca-ide', 'claude']), 'wsl')).toEqual([]) + }) +}) diff --git a/src/main/ipc/tui-agent-detection-commands.ts b/src/main/ipc/tui-agent-detection-commands.ts new file mode 100644 index 00000000000..dc5d5909543 --- /dev/null +++ b/src/main/ipc/tui-agent-detection-commands.ts @@ -0,0 +1,77 @@ +import type { TuiAgent } from '../../shared/types' +import { + getTuiAgentDetectCommands, + TUI_AGENT_CONFIG, + type TuiAgentConfig, + type TuiAgentDetectionRuntime +} from '../../shared/tui-agent-config' + +export type TuiAgentDetectionCommand = { + id: TuiAgent + cmd: string + requiredCommands?: readonly string[] + unsupportedRuntimes?: readonly TuiAgentDetectionRuntime[] +} + +export const KNOWN_TUI_AGENT_DETECTION_COMMANDS = buildTuiAgentDetectionCommands() + +function buildTuiAgentDetectionCommands(): TuiAgentDetectionCommand[] { + return Object.entries(TUI_AGENT_CONFIG).flatMap(([id, config]) => + getTuiAgentDetectCommands(config).map((cmd) => + buildTuiAgentDetectionCommand(id as TuiAgent, cmd, config) + ) + ) +} + +function buildTuiAgentDetectionCommand( + id: TuiAgent, + cmd: string, + config: TuiAgentConfig +): TuiAgentDetectionCommand { + return { + id, + cmd, + ...(config.detectRequiredCommands?.length + ? { requiredCommands: config.detectRequiredCommands } + : {}), + ...(config.detectUnsupportedRuntimes?.length + ? { unsupportedRuntimes: config.detectUnsupportedRuntimes } + : {}) + } +} + +export function getTuiAgentDetectionProbeCommands( + commands: readonly TuiAgentDetectionCommand[], + runtime: TuiAgentDetectionRuntime +): string[] { + return [ + ...new Set( + commands + .filter((command) => !isDetectionUnsupportedInRuntime(command, runtime)) + .flatMap((command) => [command.cmd, ...(command.requiredCommands ?? [])]) + ) + ] +} + +export function resolveDetectedTuiAgentIds( + commands: readonly TuiAgentDetectionCommand[], + foundCommands: ReadonlySet, + runtime: TuiAgentDetectionRuntime +): TuiAgent[] { + const detected = commands + .filter( + (command) => + !isDetectionUnsupportedInRuntime(command, runtime) && + foundCommands.has(command.cmd) && + (command.requiredCommands ?? []).every((required) => foundCommands.has(required)) + ) + .map(({ id }) => id) + return [...new Set(detected)] +} + +export function isDetectionUnsupportedInRuntime( + command: TuiAgentDetectionCommand, + runtime: TuiAgentDetectionRuntime +): boolean { + return command.unsupportedRuntimes?.includes(runtime) === true +} diff --git a/src/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts index 668354b03db..8833ddcb97f 100644 --- a/src/relay/preflight-handler.test.ts +++ b/src/relay/preflight-handler.test.ts @@ -236,6 +236,86 @@ describe('hasAbsoluteCommandPath', () => { }) describe('PreflightHandler', () => { + it('honors required commands when reporting detected agents', async () => { + execFileAsyncMock.mockImplementation(async (_file, args) => { + const script = String(args[1]) + if (script.includes("'orca'")) { + return { stdout: '__ORCA_AGENT_PATH__/relay/path/orca\n' } + } + throw new Error('not found') + }) + const requestHandlers = new Map) => Promise>() + const dispatcher = { + onRequest: vi.fn( + (method: string, handler: (params: Record) => Promise) => { + requestHandlers.set(method, handler) + } + ) + } + + new PreflightHandler(dispatcher as never) + + const handler = requestHandlers.get('preflight.detectAgents') + expect(handler).toBeDefined() + await expect( + handler!({ + commands: [ + { id: 'claude-agent-teams', cmd: 'orca', requiredCommands: ['claude'] }, + { id: 'claude', cmd: 'claude' } + ] + }) + ).resolves.toEqual({ agents: [] }) + }) + + it('does not report platform-unsupported agents on native Windows SSH hosts', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + execFileAsyncMock.mockImplementation(async (_file, args) => { + if (String(args[0]) === 'claude') { + return { stdout: 'C:\\Users\\test\\AppData\\Roaming\\npm\\claude.cmd\r\n' } + } + if (String(args[0]) === 'orca') { + return { stdout: 'C:\\Program Files\\Orca\\orca.cmd\r\n' } + } + throw new Error('not found') + }) + const requestHandlers = new Map) => Promise>() + const dispatcher = { + onRequest: vi.fn( + (method: string, handler: (params: Record) => Promise) => { + requestHandlers.set(method, handler) + } + ) + } + + try { + new PreflightHandler(dispatcher as never) + const handler = requestHandlers.get('preflight.detectAgents') + expect(handler).toBeDefined() + await expect( + handler!({ + commands: [ + { + id: 'claude-agent-teams', + cmd: 'orca', + requiredCommands: ['claude'], + unsupportedRuntimes: ['win32'] + }, + { id: 'claude', cmd: 'claude' } + ] + }) + ).resolves.toEqual({ agents: ['claude'] }) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + it('reports remote Windows shell capabilities through the SSH preflight path', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index 3eff8121368..27e7713343c 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -22,6 +22,15 @@ type RelayCommandLookupOptions = { accountLoginShell?: string | null } +type AgentDetectionRuntime = NodeJS.Platform | 'wsl' + +type AgentDetectionCommand = { + id: string + cmd: string + requiredCommands?: readonly string[] + unsupportedRuntimes?: readonly AgentDetectionRuntime[] +} + const SUPPORTED_POSIX_SHELLS = new Set(['sh', 'dash', 'bash', 'zsh', 'fish']) const CONSERVATIVE_SYSTEM_SHELL_DIRS = new Set(['/bin', '/usr/bin']) const AGENT_PATH_PREFIX = '__ORCA_AGENT_PATH__' @@ -45,19 +54,42 @@ export class PreflightHandler { // on the relay side. This keeps the relay bundle minimal and makes the protocol // self-describing — the relay doesn't need to know the agent catalog. private async detectAgents(params: Record): Promise<{ agents: string[] }> { - const commands = params.commands as { id: string; cmd: string }[] + const commands = params.commands as AgentDetectionCommand[] if (!Array.isArray(commands)) { return { agents: [] } } + const probeCommands = [ + ...new Set( + commands + .filter((command) => !isDetectionUnsupportedInRuntime(command, process.platform)) + .flatMap((command) => [command.cmd, ...(command.requiredCommands ?? [])]) + ) + ] const results = await Promise.all( - commands.map(async ({ id, cmd }) => ({ - id, + probeCommands.map(async (cmd) => ({ + cmd, installed: await this.isCommandOnPath(cmd) })) ) + const foundCommands = new Set( + results.filter((result) => result.installed).map(({ cmd }) => cmd) + ) - return { agents: [...new Set(results.filter((r) => r.installed).map((r) => r.id))] } + return { + agents: [ + ...new Set( + commands + .filter( + (command) => + !isDetectionUnsupportedInRuntime(command, process.platform) && + foundCommands.has(command.cmd) && + (command.requiredCommands ?? []).every((required) => foundCommands.has(required)) + ) + .map(({ id }) => id) + ) + ] + } } private async detectWindowsTerminalCapabilities(): Promise<{ @@ -91,6 +123,13 @@ export class PreflightHandler { } } +function isDetectionUnsupportedInRuntime( + command: AgentDetectionCommand, + runtime: AgentDetectionRuntime +): boolean { + return command.unsupportedRuntimes?.includes(runtime) === true +} + export function buildCommandLookupSpec( command: string, platform: NodeJS.Platform, diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index d15bc8bdb13..fc06ac4912b 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -13,10 +13,16 @@ export type DraftPasteReadySignal = | 'codex-composer-prompt' | 'render-cursor-after-bracketed-paste' +export type TuiAgentDetectionRuntime = NodeJS.Platform | 'wsl' + export type TuiAgentConfig = { detectCmd: string /** Additional executable names that identify the same agent on PATH. */ detectCmdAliases?: readonly string[] + /** Other commands that must also be present before this agent counts as installed. */ + detectRequiredCommands?: readonly string[] + /** Detection runtimes where this launch mode is not available as a detected agent. */ + detectUnsupportedRuntimes?: readonly TuiAgentDetectionRuntime[] launchCmd: string /** Platform-specific launch command when the public binary name differs. */ launchCmdByPlatform?: Partial> @@ -71,10 +77,15 @@ export const TUI_AGENT_CONFIG: Record = { }, 'claude-agent-teams': { // Why: this is an Orca-provided launch mode, not a separate upstream - // binary. Detection follows the Orca CLI, while the wrapper validates the - // real Claude binary when it starts. + // binary. Detection follows the Orca CLI and requires Claude below. detectCmd: 'orca', detectCmdAliases: ['orca-dev', 'orca-ide'], + // Why: the Orca shim alone exists on fresh installs. Require Claude too so + // onboarding does not report Agent Teams when no agent CLI is installed. + detectRequiredCommands: ['claude'], + // Why: native Windows and WSL use Claude's in-process Agent Teams fallback, + // not the Orca native-pane/tmux-shim wrapper exposed by this agent entry. + detectUnsupportedRuntimes: ['win32', 'wsl'], launchCmd: 'orca claude-teams', launchCmdByPlatform: { linux: `${getOrcaCliCommandNameForPlatform('linux')} claude-teams`,