Merge remote-tracking branch 'origin/main' into brennanb2025/global-worktree-visibility-defaults

This commit is contained in:
Brennan Benson
2026-08-13 22:30:36 -07:00
6 changed files with 272 additions and 31 deletions
@@ -26,7 +26,8 @@ export class ClaudeAgentTeamsService {
leaderHandle: string
baseEnv: Record<string, string | undefined>
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
@@ -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<string, string> => {
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')
})
})
+51 -16
View File
@@ -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<string, string | undefined> = 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')
}
+4
View File
@@ -14280,6 +14280,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'
@@ -14290,6 +14292,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'
@@ -14304,6 +14307,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-/)
+17 -9
View File
@@ -29496,7 +29496,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)
@@ -29577,11 +29576,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 }
@@ -36103,13 +36104,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<Repo, 'path' | 'connectionId'>): number {
return !repo.connectionId && isAgentScratchRepoRootPath(repo.path)
@@ -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<string, unknown> = {}) {
return {
@@ -116,11 +121,14 @@ function makeRuntime(
): {
runtime: OrcaRuntimeService
list: () => Promise<unknown>
store: ReturnType<typeof makeStore>
} {
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<void> {
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()