From bac21ee64def57cddcc20998d88264b49b891887 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:20:59 -0700 Subject: [PATCH 1/2] fix(worktree-scan): keep the admin-fingerprint probe inside the caller's per-repo budget (#14454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(worktree-scan): keep the admin-fingerprint wait inside the caller's per-repo budget The awaited probe was capped at 10s while `computeResolvedWorktrees` gives each repo 5s, so a slow mount always blew the budget: the caller gave up and republished persisted rows. The resolved snapshot was then stamped from the *start* of the compute, so a compute longer than its 1s TTL published an already-expired entry and the next poll repeated the whole 5s wait — deterministically, on every TTL expiry. Cap the probe at 2s so the remaining budget still covers the fallback `git worktree list`, and stamp the snapshot on completion. * fix(worktree-scan): derive the probe deadline from the caller budget A flat 2s cut reuse for hosts whose probe lands between 2s and 5s, which used to fit the caller's budget — trading the stall for a repeating `git worktree list`. Subtract a fallback-scan allowance from RESOLVED_WORKTREE_REPO_TIMEOUT_MS instead, so the invariant holds by construction and only probes that could not have fitted are cut. Tests now pin both ends: too large fails the budget invariant, too small fails reuse for a slow-but-healthy probe. --- src/main/runtime/orca-runtime.ts | 26 ++++-- ...rktree-scan-admin-fingerprint-gate.test.ts | 91 ++++++++++++++++++- 2 files changed, 106 insertions(+), 11 deletions(-) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 807eef35c05..be28a589c70 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -29448,7 +29448,6 @@ export class OrcaRuntimeService { if (!this.store) { return { worktrees: [], platformByRepoId: new Map() } } - const now = Date.now() const metaById = this.store.getAllWorktreeMeta() ?? {} const repos = this.store.getRepos() const projectRuntimeByRepoId = resolveLocalProjectRuntimesForRepos(this.requireStore(), repos) @@ -29529,11 +29528,13 @@ export class OrcaRuntimeService { this.store?.getAllWorktreeLineage?.() ?? {} ) // Why: short TTL avoids shelling out on every frequent poll while still catching worktree changes made outside Orca. + // Why stamped on completion, not entry: a compute that spent longer than the TTL would otherwise publish an + // already-expired entry, so the very next poll recomputes and every caller repeats the same slow path. if (generation === this.resolvedWorktreeGeneration) { this.resolvedWorktreeCache = { worktrees, platformByRepoId, - expiresAt: now + RESOLVED_WORKTREE_CACHE_TTL_MS + expiresAt: Date.now() + RESOLVED_WORKTREE_CACHE_TTL_MS } } return { worktrees, platformByRepoId } @@ -36055,13 +36056,20 @@ const WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS = 5 * 60_000 // edits are invisible to it and a tip living in packed-refs or reftable only gets an mtime + size // stamp, so a real scan still runs on this interval even while the probe reports "unchanged". export const WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS = 5 * 60_000 -// Why so generous: a healthy but slow host (100+ linked worktrees, Windows Defender, cold dentry -// cache, cloud placeholders) must still get to reuse its scan. Well under WORKTREE_LIST_TIMEOUT_MS, -// and expiring yields `null` — the existing "cannot prove unchanged" sentinel, so a real scan runs. -// Exceeding RESOLVED_WORKTREE_REPO_TIMEOUT_MS is deliberate, not a bug: the payoff is skipping a -// `git worktree list` subprocess, not cutting this caller's latency — that caller is already capped. -export const WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS = 10_000 -const RESOLVED_WORKTREE_REPO_TIMEOUT_MS = 5000 +export const RESOLVED_WORKTREE_REPO_TIMEOUT_MS = 5000 +// Why reserved rather than spent on the probe: when the probe expires the caller still has to run +// `git worktree list` and answer inside the same budget, so the fallback needs its own room. Sized +// for a healthy Git on a busy host, well above the tens of milliseconds a warm list costs. +const WORKTREE_SCAN_FALLBACK_ALLOWANCE_MS = 1500 +// Why derived from the caller's budget instead of a generous absolute: this wait runs *inside* +// RESOLVED_WORKTREE_REPO_TIMEOUT_MS, so outlasting it buys nothing — the caller has already given up +// and restored persisted rows — while turning a reusable scan into a full-budget stall that repeats +// on every TTL expiry. Subtracting keeps that invariant true by construction if either side moves. +// Why not smaller: the probe reads a subset of what the fallback scan reads, so a probe too slow to +// fit is a scan that will not fit either — waiting is strictly better right up to the budget. +// Expiring yields `null`, the existing "cannot prove unchanged" sentinel, so a real scan runs. +export const WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS = + RESOLVED_WORKTREE_REPO_TIMEOUT_MS - WORKTREE_SCAN_FALLBACK_ALLOWANCE_MS export function resolveWorktreeScanCacheTtlMs(repo: Pick): number { return !repo.connectionId && isAgentScratchRepoRootPath(repo.path) diff --git a/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts b/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts index e1bf370de1d..ae845e42e67 100644 --- a/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts +++ b/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts @@ -39,6 +39,7 @@ vi.mock('./repo-worktree-admin-fingerprint', () => ({ import { OrcaRuntimeService, + RESOLVED_WORKTREE_REPO_TIMEOUT_MS, WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS, WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS } from './orca-runtime' @@ -49,6 +50,10 @@ const WORKTREE_PATH = '/Users/me/dev/app-feature' const WORKTREE_ID = `${REPO_ID}::${WORKTREE_PATH}` const MAIN_WORKTREE_ID = `${REPO_ID}::${REPO_PATH}` const SCAN_TTL_MS = 30_000 +// A busy host (100+ linked worktrees, Defender, cold dentry cache, cloud placeholders) can spend +// seconds in pure stat time. Reuse has to survive that, or trimming the probe's deadline to fit the +// caller budget just trades a repeating stall for a repeating `git worktree list`. +const SLOW_BUT_HEALTHY_PROBE_MS = 3_000 function makeMeta(overrides: Record = {}) { return { @@ -116,11 +121,14 @@ function makeRuntime( ): { runtime: OrcaRuntimeService list: () => Promise + store: ReturnType } { - const runtime = new OrcaRuntimeService(makeStore(options) as never) + const store = makeStore(options) + const runtime = new OrcaRuntimeService(store as never) return { runtime, - list: () => (runtime as unknown as RuntimeInternals).listResolvedWorktrees() + list: () => (runtime as unknown as RuntimeInternals).listResolvedWorktrees(), + store } } @@ -128,6 +136,11 @@ function scanCount(): number { return listWorktreesStrictMock.mock.calls.length } +/** Scanned rows carry the mocked tips; the persisted-row fallback publishes empty ones. */ +function heads(worktrees: unknown): string[] { + return (worktrees as { git: { head: string } }[]).map((worktree) => worktree.git.head).sort() +} + /** Let every already-settled promise chain run without advancing the clock, so a stall stays a stall. */ async function drainMicrotasks(): Promise { for (let tick = 0; tick < 200; tick += 1) { @@ -455,6 +468,80 @@ describe('worktree scan admin-fingerprint gate', () => { } }) + it('bounds the awaited probe by the caller per-repo budget', () => { + // The wait runs inside that budget, so outlasting it can only strand the caller on persisted rows. + expect(WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS).toBeLessThan( + RESOLVED_WORKTREE_REPO_TIMEOUT_MS + ) + }) + + it('still reuses the scan when a slow probe answers inside its deadline', async () => { + vi.useFakeTimers() + try { + const { list } = makeRuntime() + await list() + expect(scanCount()).toBe(1) + + const releaseSlowProbe = stallProbeOnce() + vi.advanceTimersByTime(SCAN_TTL_MS + 1_000) + const second = list() + + // Deliberately an absolute figure, not one derived from the deadline: this is the lower end of + // the band, and a self-referential advance would still pass however far the deadline is cut. + await vi.advanceTimersByTimeAsync(SLOW_BUT_HEALTHY_PROBE_MS) + releaseSlowProbe('fp-1') + await second + expect(scanCount()).toBe(1) + } finally { + vi.useRealTimers() + } + }) + + it('still returns scanned rows within the per-repo budget when the probe stalls', async () => { + vi.useFakeTimers() + try { + const { list } = makeRuntime() + await list() + + stallProbeOnce() + vi.advanceTimersByTime(SCAN_TTL_MS + 1_000) + const second = list() + const secondSettled = trackSettled(second) + + // The probe's own deadline has to end the wait; if the caller's fallback gets there first the + // repo answers with persisted rows on every TTL expiry. + await vi.advanceTimersByTimeAsync(WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS) + await drainMicrotasks() + expect(secondSettled()).toBe(true) + expect(scanCount()).toBe(2) + expect(heads(await second)).toEqual(['abc', 'def']) + } finally { + vi.useRealTimers() + } + }) + + it('does not publish an already-expired snapshot after a slow compute', async () => { + vi.useFakeTimers() + try { + const { list, store } = makeRuntime() + await list() + + stallProbeOnce() + vi.advanceTimersByTime(SCAN_TTL_MS + 1_000) + const second = list() + await vi.advanceTimersByTimeAsync(WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS) + await second + + // The probe deadline outlasts the snapshot TTL, so a start-stamped entry is born expired and + // the next poll repeats the whole wait instead of reading the answer that just landed. + const getRepos = vi.spyOn(store, 'getRepos') + await list() + expect(getRepos).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('shares one probe and one scan across concurrent callers', async () => { const { list } = makeRuntime() From b04da03ffcd06bc85303b288a8e9423d8ce263cf Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:23:52 -0700 Subject: [PATCH 2/2] Require an absolute Orca CLI for the agent-teams tmux shim (#14438) The generated tmux shim fell back to a bare orca / orca.cmd / orca-ide, and cmd.exe resolves an unqualified command against the current directory before PATH (sh does the same via ./empty PATH entries), so a stray orca.cmd in an agent's checkout could run with the agent-teams team id and token in its environment. Resolve only absolute paths, honor the Windows Path env spelling, degrade to in-process teammates when no CLI can be qualified, and make both shims exit 127 instead of guessing. Verified on macOS, Linux (dash + bash), and Windows (cmd.exe + Git Bash). Fixes STA-4215. --- .../runtime/claude-agent-teams-service.ts | 9 +- .../claude-agent-teams-shim-env.test.ts | 106 +++++++++++++++++- .../runtime/claude-agent-teams-shim-env.ts | 67 ++++++++--- src/main/runtime/orca-runtime.test.ts | 4 + 4 files changed, 166 insertions(+), 20 deletions(-) diff --git a/src/main/runtime/claude-agent-teams-service.ts b/src/main/runtime/claude-agent-teams-service.ts index 1b0199c6bd0..34ef35ecb36 100644 --- a/src/main/runtime/claude-agent-teams-service.ts +++ b/src/main/runtime/claude-agent-teams-service.ts @@ -26,7 +26,8 @@ export class ClaudeAgentTeamsService { leaderHandle: string baseEnv: Record shimDir: string - shimBin: string + /** Absolute path only; null leaves the var unset so the shim refuses to guess a cwd-relative CLI. */ + shimBin: string | null }): AgentTeamsLaunchEnv { const teamId = `team-${randomUUID()}` const token = randomBytes(32).toString('base64url') @@ -47,8 +48,10 @@ export class ClaudeAgentTeamsService { ORCA_AGENT_TEAMS_TEAM_ID: teamId, ORCA_AGENT_TEAMS_TOKEN: token, ORCA_AGENT_TEAMS_LEADER_PANE: leaderPane, - ORCA_AGENT_TEAMS_SHIM_DIR: args.shimDir, - ORCA_AGENT_TEAMS_SHIM_BIN: args.shimBin + ORCA_AGENT_TEAMS_SHIM_DIR: args.shimDir + } + if (args.shimBin) { + env.ORCA_AGENT_TEAMS_SHIM_BIN = args.shimBin } if (args.baseEnv.ORCA_PAIRING_CODE) { env.ORCA_PAIRING_CODE = args.baseEnv.ORCA_PAIRING_CODE 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 362291e3c26..7c62d2ae01a 100644 --- a/src/main/runtime/claude-agent-teams-shim-env.test.ts +++ b/src/main/runtime/claude-agent-teams-shim-env.test.ts @@ -1,3 +1,5 @@ +import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -5,7 +7,8 @@ import { afterEach, describe, expect, it } from 'vitest' import { buildClaudeAgentTeamsLaunchPlan, ensureClaudeAgentTeamsShimDir, - resolveClaudeAgentTeamsShimBin + resolveClaudeAgentTeamsShimBin, + windowsClaudeAgentTeamsShimScript } from './claude-agent-teams-shim-env' const roots: string[] = [] @@ -88,4 +91,105 @@ describe('claude agent teams shim env', () => { expect(resolveClaudeAgentTeamsShimBin({ PATH: root })).toBe(cliPath) }) + + 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) + for (const name of ['orca', 'orca-ide', 'orca.cmd']) { + const path = join(root, name) + await writeFile(path, '#!/usr/bin/env sh\n', 'utf8') + if (process.platform !== 'win32') { + await chmod(path, 0o755) + } + } + + expect(resolveClaudeAgentTeamsShimBin({ PATH: '.' })).toBeNull() + expect(resolveClaudeAgentTeamsShimBin({ PATH: '' })).toBeNull() + expect( + resolveClaudeAgentTeamsShimBin({ PATH: '.', ORCA_AGENT_TEAMS_SHIM_BIN: 'orca' }) + ).toBeNull() + // Why: a bare override is still honored when it maps to a real absolute PATH entry. + expect(resolveClaudeAgentTeamsShimBin({ PATH: root, ORCA_AGENT_TEAMS_SHIM_BIN: 'orca' })).toBe( + join(root, 'orca') + ) + }) + + it.skipIf(process.platform !== 'win32')( + 'resolves through the Windows `Path` env spelling', + async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-cli-')) + roots.push(root) + const cliPath = join(root, 'orca.cmd') + await writeFile(cliPath, '@echo off\r\n', 'utf8') + + expect(resolveClaudeAgentTeamsShimBin({ Path: root })).toBe(cliPath) + } + ) + + it('falls back to in-process teammates when no absolute CLI can be qualified', async () => { + const createTeamEnv = (): Record => { + throw new Error('native shim env must not be built without a qualified CLI') + } + + await expect( + buildClaudeAgentTeamsLaunchPlan({ + command: 'claude', + mode: 'native-panes-shim', + baseEnv: { PATH: '.' }, + createTeamEnv + }) + ).resolves.toEqual({ + command: 'claude --teammate-mode in-process', + env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' } + }) + }) + + it.skipIf(process.platform === 'win32')( + 'never runs a cwd-resolved orca when the shim bin is unqualified', + async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-shim-')) + roots.push(root) + await ensureClaudeAgentTeamsShimDir(root) + const cwd = await mkdtemp(join(tmpdir(), 'orca-agent-teams-cwd-')) + roots.push(cwd) + const marker = join(cwd, 'hijacked') + for (const name of ['orca', 'orca-ide']) { + const decoy = join(cwd, name) + await writeFile(decoy, `#!/usr/bin/env sh\ntouch ${JSON.stringify(marker)}\n`, 'utf8') + await chmod(decoy, 0o755) + } + + const hijack = spawnSync(join(root, 'tmux'), ['display-message', '-p', '#{pane_id}'], { + cwd, + env: { PATH: `.:${process.env.PATH ?? ''}` }, + encoding: 'utf8' + }) + + expect(hijack.status).toBe(127) + expect(hijack.stderr).toContain('absolute path') + expect(existsSync(marker)).toBe(false) + + const cli = join(cwd, 'fake-orca') + await writeFile(cli, '#!/usr/bin/env sh\necho "ran $*"\n', 'utf8') + await chmod(cli, 0o755) + const qualified = spawnSync(join(root, 'tmux'), ['list-panes'], { + cwd, + env: { PATH: `.:${process.env.PATH ?? ''}`, ORCA_AGENT_TEAMS_SHIM_BIN: cli }, + encoding: 'utf8' + }) + + expect(qualified.status).toBe(0) + expect(qualified.stdout.trim()).toBe('ran agent-teams-tmux list-panes') + } + ) + + it('writes a Windows shim that rejects an unqualified shim bin', () => { + const script = windowsClaudeAgentTeamsShimScript() + + expect(script).not.toMatch(/^set "ORCA_AGENT_TEAMS_SHIM_BIN=orca/m) + expect(script).toContain('if "%ORCA_SHIM_BIN:~1,1%"==":" goto :run') + // Why: `call` would re-expand `%2`-style tmux pane args as batch parameters. + expect(script).toContain('\r\n"%ORCA_SHIM_BIN%" agent-teams-tmux %*\r\n') + expect(script).toContain('exit /b 127') + }) }) diff --git a/src/main/runtime/claude-agent-teams-shim-env.ts b/src/main/runtime/claude-agent-teams-shim-env.ts index 7f6c3a1922e..b8ff1b5a5c0 100644 --- a/src/main/runtime/claude-agent-teams-shim-env.ts +++ b/src/main/runtime/claude-agent-teams-shim-env.ts @@ -1,7 +1,7 @@ import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import { accessSync, constants, existsSync } from 'node:fs' import { homedir } from 'node:os' -import { delimiter, dirname, join } from 'node:path' +import { delimiter, dirname, isAbsolute, join } from 'node:path' import { addClaudeTeammateModeAuto, addClaudeTeammateModeInProcess, @@ -9,6 +9,7 @@ import { type ClaudeAgentTeamsMode } from '../../shared/claude-agent-teams-tmux-compat' import { getOrcaCliCommandNameForPlatform } from '../../shared/orca-cli-command-name' +import { resolvePathEnvKey } from '../pty/windows-path-segment-merge' export type ClaudeAgentTeamsLaunchPlan = { command: string @@ -20,7 +21,7 @@ export async function ensureClaudeAgentTeamsShimDir(root = defaultShimRoot()): P await mkdir(root, { recursive: true }) await writeIfChanged(join(root, 'tmux'), unixShimScript()) if (process.platform === 'win32') { - await writeIfChanged(join(root, 'tmux.cmd'), windowsShimScript()) + await writeIfChanged(join(root, 'tmux.cmd'), windowsClaudeAgentTeamsShimScript()) } return root } @@ -41,8 +42,15 @@ export async function buildClaudeAgentTeamsLaunchPlan(args: { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' } } } - const shimDir = await ensureClaudeAgentTeamsShimDir() const shimBin = resolveClaudeAgentTeamsShimBin(args.baseEnv) + if (!shimBin) { + // Why: without an absolute CLI path the shim would resolve a bare `orca` against the pane cwd, so degrade instead. + return { + command: addClaudeTeammateModeInProcess(args.command), + env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' } + } + } + const shimDir = await ensureClaudeAgentTeamsShimDir() const env = args.createTeamEnv(shimDir, shimBin) return { command: addClaudeTeammateModeAuto(args.command), @@ -51,20 +59,27 @@ export async function buildClaudeAgentTeamsLaunchPlan(args: { } } +/** Absolute path to the Orca CLI that backs the tmux shim, or null when none can be qualified. */ export function resolveClaudeAgentTeamsShimBin( env: Record = process.env -): string { - if (env.ORCA_AGENT_TEAMS_SHIM_BIN) { - return env.ORCA_AGENT_TEAMS_SHIM_BIN +): 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 bundled = bundledLauncherPath() if (bundled && isExecutableFile(bundled)) { return bundled } return ( - findExecutableOnPath(process.platform === 'win32' ? 'orca-dev.cmd' : 'orca-dev', env.PATH) ?? - findExecutableOnPath(getOrcaCliCommandNameForPlatform(process.platform), env.PATH) ?? - getOrcaCliCommandNameForPlatform(process.platform) + findExecutableOnPath(process.platform === 'win32' ? 'orca-dev.cmd' : 'orca-dev', pathValue) ?? + findExecutableOnPath(getOrcaCliCommandNameForPlatform(process.platform), pathValue) ) } @@ -90,7 +105,8 @@ function bundledLauncherPath(): string | null { function findExecutableOnPath(command: string, pathValue: string | undefined): string | null { for (const directory of pathValue?.split(delimiter) ?? []) { - if (!directory) { + // Why: empty and relative PATH entries resolve against a cwd we do not control, which is the hijack we are avoiding. + if (!directory || !isAbsolute(directory)) { continue } const candidate = join(directory, command) @@ -113,23 +129,42 @@ function isExecutableFile(candidate: string): boolean { } } +// Why: an unqualified command name is resolved against the invoking pane's cwd (always on cmd.exe, and via `.`/empty +// PATH entries on POSIX), so a stray `orca` next to the agent's files would run with the team token. Demand a +// fully-qualified binary instead of guessing one. function unixShimScript(): string { return [ '#!/usr/bin/env sh', 'set -eu', - `exec "\${ORCA_AGENT_TEAMS_SHIM_BIN:-${getOrcaCliCommandNameForPlatform(process.platform)}}" agent-teams-tmux "$@"`, + 'orca_bin=${ORCA_AGENT_TEAMS_SHIM_BIN:-}', + 'case $orca_bin in', + ' /*|[A-Za-z]:[\\\\/]*) ;;', + ' *)', + ' echo "orca agent-teams tmux shim: ORCA_AGENT_TEAMS_SHIM_BIN must be an absolute path" >&2', + ' exit 127', + ' ;;', + 'esac', + 'exec "$orca_bin" agent-teams-tmux "$@"', '' ].join('\n') } -function windowsShimScript(): string { +export function windowsClaudeAgentTeamsShimScript(): string { return [ '@echo off', 'setlocal', - 'if "%ORCA_AGENT_TEAMS_SHIM_BIN%"=="" (', - ` set "ORCA_AGENT_TEAMS_SHIM_BIN=${getOrcaCliCommandNameForPlatform(process.platform)}"`, - ')', - '"%ORCA_AGENT_TEAMS_SHIM_BIN%" agent-teams-tmux %*', + 'set "ORCA_SHIM_BIN=%ORCA_AGENT_TEAMS_SHIM_BIN%"', + 'if not defined ORCA_SHIM_BIN goto :unqualified', + 'if "%ORCA_SHIM_BIN:~1,1%"==":" goto :run', + 'if "%ORCA_SHIM_BIN:~0,2%"=="\\\\" goto :run', + 'goto :unqualified', + ':run', + // Why: no `call` — its extra percent-expansion pass would rewrite tmux pane args such as `%2` into batch parameters. + '"%ORCA_SHIM_BIN%" agent-teams-tmux %*', + 'exit /b %ERRORLEVEL%', + ':unqualified', + 'echo orca agent-teams tmux shim: ORCA_AGENT_TEAMS_SHIM_BIN must be an absolute path 1>&2', + 'exit /b 127', '' ].join('\r\n') } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 9beb782cebc..18880defc77 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -14263,6 +14263,8 @@ describe('OrcaRuntimeService', () => { command: 'claude --resume claude-session', env: { CLAUDE_PROFILE: 'captured', + // Why: native panes need an absolute CLI; without one the plan degrades to in-process teammates. + ORCA_AGENT_TEAMS_SHIM_BIN: '/opt/orca/bin/orca-ide', ORCA_AGENT_TEAMS_TEAM_ID: 'stale-team', ORCA_AGENT_TEAMS_TOKEN: 'stale-token', TMUX: '/tmp/orca-claude-agent-teams/stale-team,0,1' @@ -14273,6 +14275,7 @@ describe('OrcaRuntimeService', () => { agentArgs: '--teammate-mode auto', agentEnv: { CLAUDE_PROFILE: 'captured', + ORCA_AGENT_TEAMS_SHIM_BIN: '/opt/orca/bin/orca-ide', ORCA_AGENT_TEAMS_TEAM_ID: 'stale-team', ORCA_AGENT_TEAMS_TOKEN: 'stale-token', TMUX: '/tmp/orca-claude-agent-teams/stale-team,0,1' @@ -14287,6 +14290,7 @@ describe('OrcaRuntimeService', () => { expect(spawnCall?.env).toMatchObject({ CLAUDE_PROFILE: 'captured', CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', + ORCA_AGENT_TEAMS_SHIM_BIN: '/opt/orca/bin/orca-ide', TMUX_PANE: '%1' }) expect(spawnCall?.env?.ORCA_AGENT_TEAMS_TEAM_ID).toMatch(/^team-/)