diff --git a/src/cli/specs/cli-command-names-parity.test.ts b/src/cli/specs/cli-command-names-parity.test.ts index 2f9a520d566..b7633db656d 100644 --- a/src/cli/specs/cli-command-names-parity.test.ts +++ b/src/cli/specs/cli-command-names-parity.test.ts @@ -7,7 +7,8 @@ vi.mock('electron', () => ({ app: { commandLine: { getSwitchValue: () => '' } } describe('CLI launch redirect command names', () => { it('routes every CLI root through command redirect or direct serve', () => { - const roots = new Set(COMMAND_SPECS.map((spec) => spec.path[0])) + // Why agent-teams-tmux: the CLI dispatches it before spec parsing, and the tmux shim runs it. + const roots = new Set([...COMMAND_SPECS.map((spec) => spec.path[0]), 'agent-teams-tmux']) const options = { platform: 'linux', isPackaged: true, diff --git a/src/main/runtime/claude-agent-teams-shim-env.test.ts b/src/main/runtime/claude-agent-teams-shim-env.test.ts index 12271e67ae9..e9859778f59 100644 --- a/src/main/runtime/claude-agent-teams-shim-env.test.ts +++ b/src/main/runtime/claude-agent-teams-shim-env.test.ts @@ -1,9 +1,11 @@ import { spawnSync } from 'node:child_process' import { existsSync } from 'node:fs' -import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join, relative } from 'node:path' +import { dirname, join, relative } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' +import { getOrcaCliCommandNameForPlatform } from '../../shared/orca-cli-command-name' import { buildClaudeAgentTeamsLaunchPlan, ensureClaudeAgentTeamsShimDir, @@ -96,6 +98,33 @@ describe('claude agent teams shim env', () => { expect(resolveClaudeAgentTeamsShimBin({ PATH: root })).toBe(cliPath) }) + it.skipIf(process.platform === 'win32')( + "prefers a headless host's CLI launcher over a GUI binary earlier on PATH", + async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-cli-')) + roots.push(root) + // Why: an AppImage puts its Electron binary, named like the CLI, first on PATH. + const electron = join(root, getOrcaCliCommandNameForPlatform(process.platform)) + const launcher = join(root, 'cli', 'bin', 'orca') + await mkdir(dirname(launcher), { recursive: true }) + for (const path of [electron, launcher]) { + await writeFile(path, '#!/usr/bin/env sh\n', 'utf8') + await chmod(path, 0o755) + } + installFakeAppEnvironment({ getCliLauncherPath: () => launcher }) + + expect(resolveClaudeAgentTeamsShimBin({ PATH: root })).toBe(launcher) + // An older leader pane re-running `orca claude-teams` carries its stale team env. + expect( + resolveClaudeAgentTeamsShimBin({ + PATH: root, + ORCA_AGENT_TEAMS_TEAM_ID: 'team-old', + ORCA_AGENT_TEAMS_SHIM_BIN: electron + }) + ).toBe(launcher) + } + ) + it('refuses to resolve a CLI through relative PATH entries or a bare override', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-cli-')) roots.push(root) diff --git a/src/main/runtime/claude-agent-teams-shim-env.ts b/src/main/runtime/claude-agent-teams-shim-env.ts index b8ff1b5a5c0..ae4d3bc86d9 100644 --- a/src/main/runtime/claude-agent-teams-shim-env.ts +++ b/src/main/runtime/claude-agent-teams-shim-env.ts @@ -8,7 +8,9 @@ import { isDirectClaudeCommand, type ClaudeAgentTeamsMode } from '../../shared/claude-agent-teams-tmux-compat' +import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' import { getOrcaCliCommandNameForPlatform } from '../../shared/orca-cli-command-name' +import { getBundledLauncherPath } from '../cli/bundled-cli-launcher-path' import { resolvePathEnvKey } from '../pty/windows-path-segment-merge' export type ClaudeAgentTeamsLaunchPlan = { @@ -65,18 +67,33 @@ export function resolveClaudeAgentTeamsShimBin( ): string | null { // Why: Windows callers pass an env spelt `Path`; reading only `PATH` there would find no CLI at all. const pathValue = env[resolvePathEnvKey(env, process.platform)] - const override = env.ORCA_AGENT_TEAMS_SHIM_BIN - if (override) { - // Why: a bare override name would be resolved by the shim's shell against its cwd, so qualify it or ignore it. - const qualified = isAbsolute(override) ? override : findExecutableOnPath(override, pathValue) - if (qualified) { - return qualified - } + const rawOverride = env.ORCA_AGENT_TEAMS_SHIM_BIN + // Why: a bare override name would be resolved by the shim's shell against its cwd, so qualify it or ignore it. + const override = rawOverride + ? isAbsolute(rawOverride) + ? rawOverride + : findExecutableOnPath(rawOverride, pathValue) + : null + // Why: with a team id the value was inherited from an older leader pane and may name a stale GUI binary. + const inheritedFromTeam = Boolean(env.ORCA_AGENT_TEAMS_TEAM_ID) + if (override && !inheritedFromTeam) { + return override } - const bundled = bundledLauncherPath() + // Why first: a headless host has no resources dir, and the PATH fallback below can then find + // the AppImage's Electron binary, which boots the GUI instead of the CLI. + const hostLauncher = hasAppEnvironment() ? getAppEnvironment().getCliLauncherPath?.() : null + if (hostLauncher && isAbsolute(hostLauncher) && isExecutableFile(hostLauncher)) { + return hostLauncher + } + const bundled = process.resourcesPath + ? getBundledLauncherPath(process.platform, process.resourcesPath) + : null if (bundled && isExecutableFile(bundled)) { return bundled } + if (override) { + return override + } return ( findExecutableOnPath(process.platform === 'win32' ? 'orca-dev.cmd' : 'orca-dev', pathValue) ?? findExecutableOnPath(getOrcaCliCommandNameForPlatform(process.platform), pathValue) @@ -87,22 +104,6 @@ function defaultShimRoot(): string { return join(homedir(), '.orca', 'claude-agent-teams-bin') } -function bundledLauncherPath(): string | null { - if (!process.resourcesPath) { - return null - } - if (process.platform === 'darwin') { - return join(process.resourcesPath, 'bin', 'orca') - } - if (process.platform === 'linux') { - return join(process.resourcesPath, 'bin', 'orca-ide') - } - if (process.platform === 'win32') { - return join(process.resourcesPath, 'bin', 'orca.exe') - } - return null -} - function findExecutableOnPath(command: string, pathValue: string | undefined): string | null { for (const directory of pathValue?.split(delimiter) ?? []) { // Why: empty and relative PATH entries resolve against a cwd we do not control, which is the hijack we are avoiding. diff --git a/src/main/startup/cli-command-names.ts b/src/main/startup/cli-command-names.ts index 9499d3c0766..1d311552dfc 100644 --- a/src/main/startup/cli-command-names.ts +++ b/src/main/startup/cli-command-names.ts @@ -3,6 +3,7 @@ export const CLI_COMMAND_NAMES = [ 'account', 'agent', 'agent-context', + 'agent-teams-tmux', 'artifacts', 'automations', 'back',