Fix Claude Agent Teams detection without Claude CLI (#7834)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-07-08 14:34:16 -07:00 committed by GitHub
parent 0784a7ea37
commit 5a390cd60e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 359 additions and 26 deletions

View File

@ -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') {

View File

@ -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>): 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)
}

View File

@ -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([])
})
})

View File

@ -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<string>,
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
}

View File

@ -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<string, (params: Record<string, unknown>) => Promise<unknown>>()
const dispatcher = {
onRequest: vi.fn(
(method: string, handler: (params: Record<string, unknown>) => Promise<unknown>) => {
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<string, (params: Record<string, unknown>) => Promise<unknown>>()
const dispatcher = {
onRequest: vi.fn(
(method: string, handler: (params: Record<string, unknown>) => Promise<unknown>) => {
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', {

View File

@ -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<string, unknown>): 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,

View File

@ -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<Record<NodeJS.Platform, string>>
@ -71,10 +77,15 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
},
'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`,