diff --git a/.github/workflows/golden-e2e-experiment.yml b/.github/workflows/golden-e2e-experiment.yml index 8359c58ff6c..f33b00fdfa0 100644 --- a/.github/workflows/golden-e2e-experiment.yml +++ b/.github/workflows/golden-e2e-experiment.yml @@ -6,8 +6,11 @@ on: - '.github/workflows/golden-e2e-experiment.yml' - 'package.json' - 'tests/e2e/golden-core-flows.spec.ts' + - 'tests/e2e/fixtures/terminal-emoji-table.md' - 'tests/e2e/helpers/**' + - 'tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts' - 'src/renderer/src/components/sidebar/SidebarHeader.tsx' + - 'src/renderer/src/lib/pane-manager/**' workflow_dispatch: inputs: ref: @@ -69,11 +72,15 @@ jobs: - name: Run golden E2E tests on Linux if: runner.os == 'Linux' - run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts + run: | + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden - name: Run golden E2E tests on macOS if: runner.os == 'macOS' - run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts + run: | + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden - name: Run golden E2E tests on Windows if: runner.os == 'Windows' @@ -82,6 +89,7 @@ jobs: $env:SKIP_BUILD = '1' $env:ORCA_E2E_FORWARD_APP_LOGS = '1' pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts + pnpm run test:e2e:terminal-rendering-golden - name: Upload Playwright traces if: failure() diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 9f8eb6779ab..e5da9490494 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -211,4 +211,32 @@ describe('Electron runtime package contract', () => { expect(uploadStep.uses).toBe('actions/upload-artifact@v7') expect(uploadStep.with.path).toBe('${{ env.ORCA_E2E_TERMINAL_PERF_REPORT_PATH }}') }) + + it('keeps terminal rendering regressions in the fast golden E2E gate', () => { + const packageScripts = packageJson.scripts + const goldenWorkflow = parse( + readFileSync(join(projectDir, '.github/workflows/golden-e2e-experiment.yml'), 'utf8') + ) + const steps = goldenWorkflow.jobs['golden-e2e'].steps + const linuxRunStep = steps.find((step) => step.name === 'Run golden E2E tests on Linux') + const macRunStep = steps.find((step) => step.name === 'Run golden E2E tests on macOS') + const windowsRunStep = steps.find((step) => step.name === 'Run golden E2E tests on Windows') + const pullRequestPaths = goldenWorkflow.on.pull_request.paths + + expect(packageScripts['test:e2e:terminal-rendering-golden']).toContain( + '@terminal-rendering-golden' + ) + expect(packageScripts['test:e2e:terminal-rendering-golden']).toContain( + 'terminal-raw-emoji-table-scroll-restore.spec.ts' + ) + expect(packageScripts['test:e2e:terminal-rendering-golden']).not.toContain( + 'terminal-long-table-scroll-restore.spec.ts' + ) + for (const runStep of [linuxRunStep, macRunStep, windowsRunStep]) { + expect(runStep.run).toContain('pnpm run test:e2e:terminal-rendering-golden') + } + expect(pullRequestPaths).toContain('tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts') + expect(pullRequestPaths).toContain('tests/e2e/fixtures/terminal-emoji-table.md') + expect(pullRequestPaths).toContain('src/renderer/src/lib/pane-manager/**') + }) }) diff --git a/config/scripts/run-electron-vite-dev.mjs b/config/scripts/run-electron-vite-dev.mjs index a818f3e64bf..063cc5770d7 100644 --- a/config/scripts/run-electron-vite-dev.mjs +++ b/config/scripts/run-electron-vite-dev.mjs @@ -279,6 +279,7 @@ function prepareDevCliWrapper() { const binDir = path.join(repoRoot, 'out', 'bin') mkdirSync(binDir, { recursive: true }) const userDataPath = getDevUserDataPath() + const userDataBinDir = path.join(userDataPath, 'cli', 'bin') const cliPath = path.join(repoRoot, 'out', 'cli', 'index.js') const electronBin = getElectronExecutable() @@ -289,13 +290,20 @@ function prepareDevCliWrapper() { 'utf8' ) } else { + const wrapperContent = `#!/usr/bin/env bash\nexport ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}\nexport ORCA_APP_EXECUTABLE=${JSON.stringify(electronBin)}\nexport ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1\nexec node ${JSON.stringify(cliPath)} "$@"\n` const wrapperPath = path.join(binDir, 'orca-dev') - writeFileSync( - wrapperPath, - `#!/usr/bin/env bash\nexport ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}\nexport ORCA_APP_EXECUTABLE=${JSON.stringify(electronBin)}\nexport ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1\nexec node ${JSON.stringify(cliPath)} "$@"\n`, - 'utf8' - ) + writeFileSync(wrapperPath, wrapperContent, 'utf8') chmodSync(wrapperPath, 0o755) + + mkdirSync(userDataBinDir, { recursive: true }) + for (const commandName of ['orca-dev', 'orca']) { + const userDataWrapperPath = path.join(userDataBinDir, commandName) + // Why: dev Orca terminals prepend this directory to PATH; refreshing the + // `orca` alias prevents stale global/userData wrappers from hijacking + // Orca-owned commands such as `orca claude-teams`. + writeFileSync(userDataWrapperPath, wrapperContent, 'utf8') + chmodSync(userDataWrapperPath, 0o755) + } } process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ''}` diff --git a/package.json b/package.json index 8bc94fee7f0..fe0b9297e0a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.51", + "version": "1.4.52-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", @@ -60,6 +60,7 @@ "build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac", "build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux", "test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headless", + "test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:terminal-perf": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-typing-latency.spec.ts tests/e2e/terminal-foreground-redraw-freeze.spec.ts tests/e2e/terminal-output-scheduler.spec.ts tests/e2e/terminal-hidden-tui-visual-restore.spec.ts tests/e2e/artificial-opencode-terminal-load.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2", "test:e2e:terminal-perf:scale": "pnpm run ensure:electron-runtime && node config/scripts/run-terminal-scale-perf-e2e.mjs", "test:e2e:terminal-perf:scale:report": "pnpm run ensure:electron-runtime && node config/scripts/run-terminal-scale-perf-report-gate.mjs", diff --git a/src/cli/handlers/core.ts b/src/cli/handlers/core.ts index 4ad992ac12a..0f862864564 100644 --- a/src/cli/handlers/core.ts +++ b/src/cli/handlers/core.ts @@ -1,7 +1,41 @@ +import { spawn } from 'child_process' import type { CommandHandler } from '../dispatch' import { formatCliStatus, formatStatus, printResult } from '../format' import { RuntimeClientError, serveOrcaApp } from '../runtime-client' +function envRecord(): Record { + return Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined) + ) +} + +function withTeammateModeAuto(args: string[]): string[] { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--teammate-mode' || arg.startsWith('--teammate-mode=')) { + return args + } + } + return ['--teammate-mode', 'auto', ...args] +} + +async function runClaudeAgentTeams(env: Record, args: string[]): Promise { + return await new Promise((resolve, reject) => { + const child = spawn('claude', withTeammateModeAuto(args), { + stdio: 'inherit', + env + }) + child.once('error', reject) + child.once('exit', (code, signal) => { + if (typeof code === 'number') { + resolve(code) + return + } + resolve(signal ? 1 : 0) + }) + }) +} + function getOptionalServePort(flags: Map): string | null { if (!flags.has('port')) { return null @@ -18,6 +52,35 @@ function getOptionalServePort(flags: Map): string | nu } export const CORE_HANDLERS: Record = { + 'claude-teams': async ({ client }) => { + if (process.platform === 'win32') { + throw new RuntimeClientError( + 'unsupported_platform', + 'Claude Agent Teams native panes are not supported on Windows.' + ) + } + const paneKey = process.env.ORCA_PANE_KEY + if (!paneKey) { + throw new RuntimeClientError( + 'invalid_environment', + 'orca claude-teams must be run inside an Orca terminal.' + ) + } + const response = await client.call<{ launch: { env: Record } }>( + 'agentTeams.prepareLaunch', + { + paneKey, + env: envRecord() + } + ) + process.exitCode = await runClaudeAgentTeams( + { + ...envRecord(), + ...response.result.launch.env + }, + [] + ) + }, open: async ({ client, json }) => { const result = await client.openOrca() printResult(result, json, formatCliStatus) diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index fd17fb4dc23..787556311c4 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -7,13 +7,15 @@ const { serveOrcaAppMock, getDefaultUserDataPathMock, addEnvironmentFromPairingCodeMock, - listEnvironmentsMock + listEnvironmentsMock, + spawnMock } = vi.hoisted(() => ({ callMock: vi.fn(), serveOrcaAppMock: vi.fn(), getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'), addEnvironmentFromPairingCodeMock: vi.fn(), - listEnvironmentsMock: vi.fn() + listEnvironmentsMock: vi.fn(), + spawnMock: vi.fn() })) vi.mock('./runtime-client', () => { @@ -79,6 +81,17 @@ vi.mock('./runtime/environments', () => ({ resolveEnvironment: vi.fn() })) +vi.mock('child_process', async () => { + const { EventEmitter } = await import('events') + return { + spawn: spawnMock.mockImplementation(() => { + const child = new EventEmitter() + process.nextTick(() => child.emit('exit', 0, null)) + return child + }) + } +}) + import { buildCurrentWorktreeSelector, COMMAND_SPECS, @@ -147,6 +160,7 @@ describe('orca cli worktree awareness', () => { getDefaultUserDataPathMock.mockClear() addEnvironmentFromPairingCodeMock.mockReset() listEnvironmentsMock.mockReset() + spawnMock.mockClear() addEnvironmentFromPairingCodeMock.mockReturnValue({ id: 'env-1', name: 'desk', @@ -240,6 +254,42 @@ describe('orca cli worktree awareness', () => { expect(logSpy).toHaveBeenCalledTimes(1) }) + it.skipIf(process.platform === 'win32')( + 'prepares and starts Claude Agent Teams in the current Orca terminal', + async () => { + process.env.ORCA_PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111' + queueFixtures( + callMock, + okFixture('req_agent_teams_prepare', { + launch: { + env: { + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', + TMUX: '/tmp/orca-claude-agent-teams/team-1,0,1', + TMUX_PANE: '%1', + PATH: '/tmp/orca-shim:/usr/bin' + } + } + }) + ) + + await main(['claude-teams'], '/tmp/repo') + + expect(callMock).toHaveBeenCalledWith('agentTeams.prepareLaunch', { + paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', + env: expect.objectContaining({ + ORCA_PANE_KEY: 'tab-1:11111111-1111-4111-8111-111111111111' + }) + }) + expect(spawnMock).toHaveBeenCalledWith('claude', ['--teammate-mode', 'auto'], { + stdio: 'inherit', + env: expect.objectContaining({ + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', + TMUX_PANE: '%1' + }) + }) + } + ) + it('rejects remote `worktree current` without listing worktrees from client cwd', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/src/cli/index.ts b/src/cli/index.ts index 7df357fef85..3e57eb97bcc 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -23,6 +23,10 @@ function shouldIgnoreRemoteSelection(commandPath: string[]): boolean { } export async function main(argv = process.argv.slice(2), cwd = process.cwd()): Promise { + if (argv[0] === 'agent-teams-tmux') { + await runAgentTeamsTmuxShim(argv.slice(1)) + return + } const parsed = normalizeCommandPositionals(COMMAND_SPECS, parseArgs(argv)) const helpPath = resolveHelpPath(parsed) if (helpPath !== null) { @@ -76,6 +80,31 @@ export async function main(argv = process.argv.slice(2), cwd = process.cwd()): P } } +async function runAgentTeamsTmuxShim(argv: string[]): Promise { + try { + const client = new RuntimeClient(undefined, 10_000) + const response = await client.call<{ + tmux: { stdout: string; stderr: string; exitCode: number } + }>( + 'agentTeams.tmuxCompat', + { + teamId: process.env.ORCA_AGENT_TEAMS_TEAM_ID, + token: process.env.ORCA_AGENT_TEAMS_TOKEN, + envPane: process.env.TMUX_PANE, + cwd: process.cwd(), + argv + }, + { timeoutMs: 10_000 } + ) + process.stdout.write(response.result.tmux.stdout) + process.stderr.write(response.result.tmux.stderr) + process.exitCode = response.result.tmux.exitCode + } catch (error) { + reportCliError(error, false, { commandPath: ['agent-teams-tmux'] }) + process.exitCode = 1 + } +} + if (require.main === module) { void main() } diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index 32fc229e99f..2459ee29399 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -35,6 +35,16 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ allowedFlags: [...GLOBAL_FLAGS], examples: ['orca status', 'orca status --json'] }, + { + path: ['claude-teams'], + summary: 'Start Claude Code Agent Teams in the current Orca terminal', + usage: 'orca claude-teams', + allowedFlags: [...GLOBAL_FLAGS], + notes: [ + 'Must be run from inside an Orca terminal. Starts Claude Code Agent Teams in the current pane and opens teammates as native Orca splits.' + ], + examples: ['orca claude-teams'] + }, { path: ['repo', 'list'], summary: 'List repos registered in Orca', diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 001f88164c3..f3c0cb8a8ed 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -154,6 +154,9 @@ describe('CliInstaller', () => { expect(installed.commandPath).toBe(join(commandDir, 'orca-dev')) expect(installed.launcherPath).toBe(join(fixture.userDataPath, 'cli', 'bin', 'orca-dev')) await expect(readlink(installed.commandPath as string)).resolves.toBe(installed.launcherPath) + await expect( + readFile(join(fixture.userDataPath, 'cli', 'bin', 'orca'), 'utf8') + ).resolves.toBe(await readFile(installed.launcherPath as string, 'utf8')) } ) diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index e7dae5f381c..7155f6e61a4 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -801,6 +801,15 @@ async function ensureDevLauncher(args: { encoding: 'utf8', mode: args.platform === 'win32' ? undefined : 0o755 }) + if (args.commandName === DEV_COMMAND_NAME && args.platform !== 'win32') { + // Why: dev PTYs prepend userData/cli/bin to PATH, and product-owned + // commands are documented as `orca ...`. Keep that local alias fresh + // without claiming the global production command. + await writeFile(join(dirname(launcherPath), 'orca'), content, { + encoding: 'utf8', + mode: 0o755 + }) + } return launcherPath } diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index a8aee0423b7..99bab08e6ba 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -45,6 +45,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings workspaceDir: testState.fakeHomeDir, nestWorkspaces: false, refreshLocalBaseRefOnWorktreeCreate: false, + localBaseRefSuggestionDismissed: false, autoRenameBranchFromWork: false, branchPrefix: 'git-username', branchPrefixCustom: '', @@ -130,7 +131,6 @@ function createSettings(overrides: Partial = {}): GlobalSettings experimentalTerminalAttention: false, experimentalCompactWorktreeCards: false, experimentalWorktreeSymlinks: false, - experimentalUnifiedNewTabLauncher: false, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 04cb908443b..7d17596d6c2 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -49,6 +49,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings workspaceDir: testState.fakeHomeDir, nestWorkspaces: false, refreshLocalBaseRefOnWorktreeCreate: false, + localBaseRefSuggestionDismissed: false, autoRenameBranchFromWork: false, branchPrefix: 'git-username', branchPrefixCustom: '', @@ -134,7 +135,6 @@ function createSettings(overrides: Partial = {}): GlobalSettings experimentalTerminalAttention: false, experimentalCompactWorktreeCards: false, experimentalWorktreeSymlinks: false, - experimentalUnifiedNewTabLauncher: false, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 0612251bd6a..a9a5fef3253 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -613,6 +613,36 @@ describe('createPtySubprocess', () => { expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0') }) + it('uses shell wrapper when Agent Teams shim path must survive shell startup', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'linux' }) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: { + SHELL: '/bin/zsh', + PATH: '/tmp/orca-agent-teams-bin:/usr/bin', + ORCA_AGENT_TEAMS_TEAM_ID: 'team-test', + ORCA_AGENT_TEAMS_SHIM_DIR: '/tmp/orca-agent-teams-bin' + } + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + const lastCall = spawnMock.mock.calls.at(-1)! + expect(lastCall[1]).toEqual(['-l']) + expect(lastCall[2].env.ZDOTDIR).toMatch(ZSH_SHELL_READY_DIR) + expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0') + }) + it('deletes requested env keys after merging daemon process env', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) @@ -639,6 +669,31 @@ describe('createPtySubprocess', () => { expect(lastCall[2].env.CODEX_HOME).toBeUndefined() }) + it('honors explicit terminal env overrides after deleting requested defaults', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: { + SHELL: '/bin/bash', + TERM: 'screen-256color', + PATH: '/tmp/orca-agent-teams-bin:/usr/bin', + ORCA_AGENT_TEAMS_TEAM_ID: 'team-test' + }, + envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'] + }) + + const lastCall = spawnMock.mock.calls.at(-1)! + expect(lastCall[2].name).toBe('screen-256color') + expect(lastCall[2].env.TERM).toBe('screen-256color') + expect(lastCall[2].env.PATH.split(':')[0]).toBe('/tmp/orca-agent-teams-bin') + expect(lastCall[2].env.TERM_PROGRAM).toBeUndefined() + expect(lastCall[2].env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() + }) + it('combines HOMEDRIVE and HOMEPATH for Windows default cwd', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 4278a83efac..8f6fb96a313 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -2,7 +2,7 @@ preflight validation, and lifecycle guards that must stay in one execution path. */ import * as pty from 'node-pty' import { statSync } from 'fs' -import { win32 as pathWin32 } from 'path' +import { delimiter, win32 as pathWin32 } from 'path' import type { SubprocessHandle } from './session' import { DaemonProtocolError } from './types' import { @@ -74,6 +74,21 @@ function removeUnspecifiedPaneIdentityEnv( } } +function promoteAgentTeamsShimPath( + env: Record, + requestedPath: string | undefined +): void { + if (!env.ORCA_AGENT_TEAMS_TEAM_ID || !requestedPath) { + return + } + const shimDir = requestedPath.split(delimiter)[0] + if (!shimDir) { + return + } + const currentParts = env.PATH?.split(delimiter).filter(Boolean) ?? [] + env.PATH = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(delimiter) +} + function removeInheritedDevAgentHookEndpoint( env: Record, explicitEnv: Record | undefined @@ -248,6 +263,9 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl for (const key of opts.envToDelete ?? []) { delete env[key] } + if (opts.env?.TERM) { + env.TERM = opts.env.TERM + } // Why: the daemon is forked from Electron and can inherit the pane identity // of the terminal that launched `pn dev`; each PTY must opt into its own. removeUnspecifiedPaneIdentityEnv(env, opts.env) @@ -377,6 +395,14 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl addOrcaWslInteropEnv(env) } } else { + // Why: relay-side launch modes can ask for host defaults to stay scrubbed + // even after environment normalization above. + for (const key of opts.envToDelete ?? []) { + delete env[key] + } + if (opts.env?.TERM) { + env.TERM = opts.env.TERM + } // Why: any Orca-injected overlay env that user rc files can clobber // needs the wrapper so the post-rc restore line runs. const shellLaunch = opts.command @@ -385,7 +411,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl env.ORCA_OPENCODE_CONFIG_DIR || env.ORCA_PI_CODING_AGENT_DIR || env.ORCA_OMP_CODING_AGENT_DIR || - env.ORCA_CODEX_HOME + env.ORCA_CODEX_HOME || + env.ORCA_AGENT_TEAMS_SHIM_DIR ? getAttributionShellLaunchConfig(shellPath) : null if (shellLaunch) { @@ -393,6 +420,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl } shellArgs = shellLaunch?.args ?? ['-l'] } + promoteAgentTeamsShimPath(env, opts.env?.PATH) // Why: asar packaging can strip the +x bit from node-pty's spawn-helper // binary. The main process fixes this via LocalPtyProvider, but the daemon @@ -407,7 +435,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl let proc: pty.IPty try { proc = pty.spawn(shellPath, shellArgs, { - name: 'xterm-256color', + name: env.TERM ?? 'xterm-256color', cols: size.cols, rows: size.rows, cwd: spawnCwd, diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index 47ad5adc5d9..280405b706c 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -252,6 +252,7 @@ describePosix('daemon shell-ready launch config', () => { '[[ -n "${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="${ORCA_PI_CODING_AGENT_DIR}"' const codexRestoreLine = '[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"' + const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0' const ompRestoreLine = 'if [[ -z "${ORCA_PI_CODING_AGENT_DIR:-}" && -n "${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then' const ompWrapperLine = 'command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"' @@ -263,6 +264,9 @@ describePosix('daemon shell-ready launch config', () => { expect(bashRc).toContain(piRestoreLine) expect(zshrc).toContain(codexRestoreLine) expect(zlogin).toContain(codexRestoreLine) + expect(zshrc).toContain(agentTeamsPathRestoreLine) + expect(zlogin).toContain(agentTeamsPathRestoreLine) + expect(bashRc).toContain(agentTeamsPathRestoreLine) expect(bashRc).toContain(codexRestoreLine) // OMP launches use ORCA_OMP_CODING_AGENT_DIR; both restore lines must be // present so a PTY of either kind has its overlay restored after rc files. diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index cf850c9cfde..6d213ba8321 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -102,6 +102,14 @@ __orca_restore_attribution_path() { export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH" } __orca_restore_attribution_path +__orca_restore_agent_teams_path() { + [[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0 + case "$PATH" in + "\${ORCA_AGENT_TEAMS_SHIM_DIR}"|"\${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;; + esac + export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" +} +__orca_restore_agent_teams_path # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the PTY-scoped overlay before the first prompt. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" @@ -212,6 +220,14 @@ __orca_restore_attribution_path() { export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH" } [[ ! -o login ]] && __orca_restore_attribution_path +__orca_restore_agent_teams_path() { + [[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0 + case "$PATH" in + "\${ORCA_AGENT_TEAMS_SHIM_DIR}"|"\${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;; + esac + export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" +} +[[ ! -o login ]] && __orca_restore_agent_teams_path if [[ ! -o login ]]; then # Why: ~/.zshrc can export the user's default OpenCode config after spawn. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" @@ -273,6 +289,14 @@ __orca_restore_attribution_path() { export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH" } __orca_restore_attribution_path +__orca_restore_agent_teams_path() { + [[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0 + case "$PATH" in + "\${ORCA_AGENT_TEAMS_SHIM_DIR}"|"\${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;; + esac + export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" +} +__orca_restore_agent_teams_path # Why: .zlogin is the final login startup file before the prompt is shown. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" diff --git a/src/main/emulator/emulator-errors.ts b/src/main/emulator/emulator-errors.ts index 5e6e8fdca0d..cda05f8e506 100644 --- a/src/main/emulator/emulator-errors.ts +++ b/src/main/emulator/emulator-errors.ts @@ -12,6 +12,7 @@ export type EmulatorErrorCode = | 'emulator_no_active' | 'emulator_device_not_found' | 'emulator_helper_failed' + | 'emulator_simctl_unavailable' | 'emulator_not_macos' | 'emulator_disabled' | 'emulator_error' diff --git a/src/main/emulator/simctl-simulator-devices.test.ts b/src/main/emulator/simctl-simulator-devices.test.ts new file mode 100644 index 00000000000..452b13d999a --- /dev/null +++ b/src/main/emulator/simctl-simulator-devices.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as Os from 'os' + +const { execFileMock, platformMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), + platformMock: vi.fn(() => 'darwin') +})) + +vi.mock('child_process', () => ({ + execFile: execFileMock +})) + +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + platform: platformMock + } +}) + +vi.mock('./serve-sim-execution', () => ({ + execServeSimCommand: vi.fn() +})) + +import { listSimulatorDevices } from './simctl-simulator-devices' + +describe('listSimulatorDevices', () => { + beforeEach(() => { + execFileMock.mockReset() + platformMock.mockReset() + platformMock.mockReturnValue('darwin') + }) + + it('parses simctl devices on macOS', async () => { + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callback( + null, + JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { + name: 'iPhone 17 Pro', + udid: 'AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE', + state: 'Shutdown', + isAvailable: true + } + ] + } + }), + '' + ) + }) + + await expect(listSimulatorDevices()).resolves.toEqual([ + { + name: 'iPhone 17 Pro', + udid: 'AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE', + state: 'Shutdown', + runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-18-0', + isAvailable: true + } + ]) + expect(execFileMock).toHaveBeenCalledWith( + 'xcrun', + ['simctl', 'list', 'devices', '-j'], + { timeout: 15_000 }, + expect.any(Function) + ) + }) + + it('returns no devices on non-macOS hosts', async () => { + platformMock.mockReturnValue('linux') + + await expect(listSimulatorDevices()).resolves.toEqual([]) + expect(execFileMock).not.toHaveBeenCalled() + }) + + it('normalizes missing simctl errors for UI and CLI callers', async () => { + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callback( + Object.assign(new Error('Command failed: xcrun simctl list devices -j'), { code: 72 }), + '', + 'xcrun: error: unable to find utility "simctl", not a developer tool or in PATH' + ) + }) + + const error = await listSimulatorDevices().catch((caught: unknown) => caught) + + expect(error).toMatchObject({ + code: 'emulator_simctl_unavailable', + message: expect.stringContaining('Xcode Simulator tools are unavailable') + }) + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).not.toContain('Command failed') + }) +}) diff --git a/src/main/emulator/simctl-simulator-devices.ts b/src/main/emulator/simctl-simulator-devices.ts index 88520d4cee1..437a35fccae 100644 --- a/src/main/emulator/simctl-simulator-devices.ts +++ b/src/main/emulator/simctl-simulator-devices.ts @@ -1,4 +1,5 @@ import { execFile } from 'child_process' +import type { ExecFileException } from 'child_process' import { platform } from 'os' import { EmulatorError } from './emulator-errors' import { execServeSimCommand, type ServeSimExecutable } from './serve-sim-execution' @@ -23,6 +24,8 @@ type SimctlDeviceList = { } const UDID_RE = /^[0-9A-F]{8}-([0-9A-F]{4}-){3}[0-9A-F]{12}$/i +const SIMCTL_UNAVAILABLE_MESSAGE = + 'Xcode Simulator tools are unavailable. Install full Xcode, open it once, then select it with `sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer`.' function parseSimctlDevices(stdout: string): SimulatorDevice[] { const data = JSON.parse(stdout || '{}') as SimctlDeviceList @@ -44,22 +47,42 @@ function parseSimctlDevices(stdout: string): SimulatorDevice[] { return devices } +function mapSimctlError(error: ExecFileException, stderr?: string | Buffer): EmulatorError { + const raw = `${error.message}\n${stderr?.toString() ?? ''}` + const lower = raw.toLowerCase() + if ( + error.code === 'ENOENT' || + (lower.includes('unable to find utility') && lower.includes('simctl')) || + lower.includes('not a developer tool') + ) { + // Why: xcrun exposes setup problems as raw execFile text; UI and CLI need + // the actionable Xcode fix instead of "Command failed: xcrun ...". + return new EmulatorError('emulator_simctl_unavailable', SIMCTL_UNAVAILABLE_MESSAGE) + } + return new EmulatorError('emulator_error', raw.trim() || 'xcrun simctl command failed.') +} + export async function listSimulatorDevices(): Promise { if (platform() !== 'darwin') { return [] } return new Promise((resolve, reject) => { - execFile('xcrun', ['simctl', 'list', 'devices', '-j'], { timeout: 15_000 }, (error, stdout) => { - if (error) { - reject(error) - return + execFile( + 'xcrun', + ['simctl', 'list', 'devices', '-j'], + { timeout: 15_000 }, + (error, stdout, stderr) => { + if (error) { + reject(mapSimctlError(error, stderr)) + return + } + try { + resolve(parseSimctlDevices(stdout)) + } catch (parseError) { + reject(parseError) + } } - try { - resolve(parseSimctlDevices(stdout)) - } catch (parseError) { - reject(parseError) - } - }) + ) }) } @@ -119,14 +142,14 @@ export async function ensureSimulatorBooted(udid: string): Promise { try { await new Promise((resolve, reject) => { - execFile('xcrun', ['simctl', 'boot', udid], { timeout: 45_000 }, (error) => { + execFile('xcrun', ['simctl', 'boot', udid], { timeout: 45_000 }, (error, _stdout, stderr) => { if (error) { const message = error.message.toLowerCase() if (message.includes('booted') || message.includes('current state')) { resolve() return } - reject(error) + reject(mapSimctlError(error, stderr)) return } resolve() @@ -159,17 +182,22 @@ export async function shutdownSimulatorDevice(udid: string): Promise { } await new Promise((resolve, reject) => { - execFile('xcrun', ['simctl', 'shutdown', udid], { timeout: 30_000 }, (error) => { - if (!error) { - resolve() - return + execFile( + 'xcrun', + ['simctl', 'shutdown', udid], + { timeout: 30_000 }, + (error, _stdout, stderr) => { + if (!error) { + resolve() + return + } + const message = error.message.toLowerCase() + if (message.includes('shutdown') || message.includes('current state')) { + resolve() + return + } + reject(mapSimctlError(error, stderr)) } - const message = error.message.toLowerCase() - if (message.includes('shutdown') || message.includes('current state')) { - resolve() - return - } - reject(error) - }) + ) }) } diff --git a/src/main/git/worktree.test.ts b/src/main/git/worktree.test.ts index f9f8e882b17..ba5b99c47c2 100644 --- a/src/main/git/worktree.test.ts +++ b/src/main/git/worktree.test.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: addWorktree has multiple code paths (no refresh, - reset --hard vs update-ref, dirty worktree, diverged branch, custom remote) that each + owner reset safety, dirty worktree, diverged branch, custom remote) that each need dedicated test coverage. Splitting into separate files would scatter related tests without a meaningful boundary. */ import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -512,9 +512,14 @@ describe('addWorktree', () => { 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /repo-other\nHEAD def456\nbranch refs/heads/feature\n' gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} - .mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor + .mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count + .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit} + .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse remote tracking ref^{commit} + .mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain .mockResolvedValueOnce({ stdout: '' }) // status --porcelain (in /repo) + .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list recheck + .mockResolvedValueOnce({ stdout: '' }) // status --porcelain recheck (in /repo) .mockResolvedValueOnce({ stdout: '' }) // reset --hard (in /repo) .mockResolvedValueOnce({ stdout: '' }) // worktree add .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base @@ -525,10 +530,18 @@ describe('addWorktree', () => { expect(gitExecFileAsyncMock.mock.calls).toEqual([ [['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }], - [['merge-base', '--is-ancestor', 'main', 'refs/remotes/origin/main'], { cwd: '/repo' }], + [ + ['rev-list', '--left-right', '--count', 'refs/heads/main...refs/remotes/origin/main'], + { cwd: '/repo' } + ], + [['rev-parse', '--verify', 'refs/heads/main^{commit}'], { cwd: '/repo' }], + [['rev-parse', '--verify', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }], + [['merge-base', '--is-ancestor', 'old-main', 'remote-main'], { cwd: '/repo' }], [['worktree', 'list', '--porcelain'], { cwd: '/repo' }], [['status', '--porcelain', '--untracked-files=no'], { cwd: '/repo' }], - [['reset', '--hard', 'refs/remotes/origin/main'], { cwd: '/repo' }], + [['worktree', 'list', '--porcelain'], { cwd: '/repo' }], + [['status', '--porcelain', '--untracked-files=no'], { cwd: '/repo' }], + [['reset', '--hard', 'remote-main'], { cwd: '/repo' }], [ [ 'worktree', @@ -561,9 +574,14 @@ describe('addWorktree', () => { 'worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n\nworktree /repo-main-wt\nHEAD def456\nbranch refs/heads/main\n' gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} - .mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor + .mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count + .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit} + .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse remote tracking ref^{commit} + .mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain .mockResolvedValueOnce({ stdout: '' }) // status --porcelain (in /repo-main-wt) + .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list recheck + .mockResolvedValueOnce({ stdout: '' }) // status --porcelain recheck (in /repo-main-wt) .mockResolvedValueOnce({ stdout: '' }) // reset --hard (in /repo-main-wt) .mockResolvedValueOnce({ stdout: '' }) // worktree add .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base @@ -572,33 +590,160 @@ describe('addWorktree', () => { await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) - expect(gitExecFileAsyncMock.mock.calls[3]).toEqual([ + expect(gitExecFileAsyncMock.mock.calls[6]).toEqual([ ['status', '--porcelain', '--untracked-files=no'], expect.objectContaining({ cwd: '/repo-main-wt' }) ]) - expect(gitExecFileAsyncMock.mock.calls[4]).toEqual([ - ['reset', '--hard', 'refs/remotes/origin/main'], + expect(gitExecFileAsyncMock.mock.calls[8]).toEqual([ + ['status', '--porcelain', '--untracked-files=no'], + expect.objectContaining({ cwd: '/repo-main-wt' }) + ]) + expect(gitExecFileAsyncMock.mock.calls[9]).toEqual([ + ['reset', '--hard', 'remote-main'], expect.objectContaining({ cwd: '/repo-main-wt' }) ]) }) - it('uses update-ref when localBranch is not checked out in any worktree', async () => { + it('fast-forwards local base via update-ref when localBranch is not checked out', async () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n' gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} - .mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor + .mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count + .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit} + .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse remote tracking ref^{commit} + .mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain - .mockResolvedValueOnce({ stdout: '' }) // update-ref + .mockResolvedValueOnce({ stdout: '' }) // update-ref refs/heads/main remote-main old-main .mockResolvedValueOnce({ stdout: '' }) // worktree add .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote - await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) + const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) - expect(gitExecFileAsyncMock.mock.calls[3]).toEqual([ - ['update-ref', 'refs/heads/main', 'refs/remotes/origin/main'], - expect.objectContaining({ cwd: '/repo' }) + expect(result.localBaseRefRefresh).toEqual({ + status: 'updated', + baseRef: 'origin/main', + localBranch: 'main' + }) + // Compare-and-swap form (expected old OID) so a concurrent ref move is a no-op. + expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([ + 'update-ref', + 'refs/heads/main', + 'remote-main', + 'old-main' + ]) + // No worktree owns the branch, so no working tree is reset. + expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).not.toContainEqual([ + 'reset', + '--hard', + 'remote-main' + ]) + }) + + it('skips local base refresh when the owner worktree becomes dirty before mutation', async () => { + const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} + .mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count + .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit} + .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse remote tracking ref^{commit} + .mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs + .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain + .mockResolvedValueOnce({ stdout: '' }) // status --porcelain during evaluation + .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list before mutation + .mockResolvedValueOnce({ stdout: ' M package.json\n' }) // status --porcelain before mutation + .mockResolvedValueOnce({ stdout: '' }) // worktree add + .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base + .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) + .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + + const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) + + expect(result.localBaseRefRefresh).toEqual({ + status: 'skipped_dirty_worktree', + baseRef: 'origin/main', + localBranch: 'main', + ownerWorktreePath: '/repo' + }) + expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).not.toContainEqual([ + 'update-ref', + 'refs/heads/main', + 'remote-main', + 'old-main' + ]) + expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).not.toContainEqual([ + 'reset', + '--hard', + 'refs/heads/main' + ]) + }) + + it('skips local base refresh when the owner worktree switches branches before mutation', async () => { + const firstWorktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' + const secondWorktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n' + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} + .mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count + .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit} + .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/origin/main^{commit} + .mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs + .mockResolvedValueOnce({ stdout: firstWorktreeListOutput }) // worktree list during evaluation + .mockResolvedValueOnce({ stdout: '' }) // status --porcelain during evaluation + .mockResolvedValueOnce({ stdout: secondWorktreeListOutput }) // worktree list before mutation + .mockResolvedValueOnce({ stdout: '' }) // worktree add + .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base + .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) + .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + + const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) + + expect(result.localBaseRefRefresh).toEqual({ + status: 'skipped_error', + baseRef: 'origin/main', + localBranch: 'main' + }) + expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).not.toContainEqual([ + 'update-ref', + 'refs/heads/main', + 'remote-main', + 'old-main' + ]) + expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).not.toContainEqual([ + 'reset', + '--hard', + 'refs/heads/main' + ]) + }) + + it('skips local base refresh when owner revalidation cannot list worktrees', async () => { + const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} + .mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count + .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit} + .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/origin/main^{commit} + .mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs + .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list during evaluation + .mockResolvedValueOnce({ stdout: '' }) // status --porcelain during evaluation + .mockRejectedValueOnce(new Error('worktree list failed')) // worktree list before mutation + .mockResolvedValueOnce({ stdout: '' }) // worktree add + .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base + .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) + .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + + const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) + + expect(result.localBaseRefRefresh).toEqual({ + status: 'skipped_error', + baseRef: 'origin/main', + localBranch: 'main' + }) + expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).not.toContainEqual([ + 'update-ref', + 'refs/heads/main', + 'remote-main', + 'old-main' ]) }) @@ -606,7 +751,10 @@ describe('addWorktree', () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} - .mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor + .mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count + .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit} + .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse remote tracking ref^{commit} + .mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain .mockResolvedValueOnce({ stdout: ' M package.json\n' }) // status --porcelain (dirty) .mockResolvedValueOnce({ stdout: '' }) // worktree add @@ -623,15 +771,16 @@ describe('addWorktree', () => { ownerWorktreePath: '/repo' }) - // No reset --hard or update-ref — just base resolution, merge-base, worktree list, status, worktree add, base config, config --get, config --local set - expect(gitExecFileAsyncMock.mock.calls).toHaveLength(8) + // No reset --hard or update-ref — just base resolution, drift check, local/remote + // OIDs, ancestry check, worktree list, status, worktree add, and config writes. + expect(gitExecFileAsyncMock.mock.calls).toHaveLength(11) expect(gitExecFileAsyncMock.mock.calls[0]?.[0]).toEqual([ 'rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}' ]) - expect(gitExecFileAsyncMock.mock.calls[4]?.[0]).toEqual([ + expect(gitExecFileAsyncMock.mock.calls[7]?.[0]).toEqual([ 'worktree', 'add', '--no-track', @@ -640,19 +789,19 @@ describe('addWorktree', () => { '/repo-feature', 'refs/remotes/origin/main' ]) - expect(gitExecFileAsyncMock.mock.calls[5]?.[0]).toEqual([ + expect(gitExecFileAsyncMock.mock.calls[8]?.[0]).toEqual([ 'config', '--local', '--replace-all', 'branch.feature/test.base', 'refs/remotes/origin/main' ]) - expect(gitExecFileAsyncMock.mock.calls[6]?.[0]).toEqual([ + expect(gitExecFileAsyncMock.mock.calls[9]?.[0]).toEqual([ 'config', '--get', 'push.autoSetupRemote' ]) - expect(gitExecFileAsyncMock.mock.calls[7]?.[0]).toEqual([ + expect(gitExecFileAsyncMock.mock.calls[10]?.[0]).toEqual([ 'config', '--local', 'push.autoSetupRemote', @@ -676,7 +825,7 @@ describe('addWorktree', () => { expect.objectContaining({ cwd: '/repo' }) ], [ - ['merge-base', '--is-ancestor', 'main', 'refs/remotes/origin/main'], + ['rev-list', '--left-right', '--count', 'refs/heads/main...refs/remotes/origin/main'], expect.objectContaining({ cwd: '/repo' }) ], [ @@ -712,6 +861,185 @@ describe('addWorktree', () => { ]) }) + it('skips local base refresh when captured OIDs are no longer ancestor-safe', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '0\t2\n' }) // stale rev-list result + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'new-local\n' }) // rev-parse refs/heads/main^{commit} + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/origin/main^{commit} + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not an ancestor')) // merge-base captured OIDs + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + resolveCreationBaseConfigWrite() + gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + + const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) + + expect(result.localBaseRefRefresh).toEqual({ + status: 'skipped_not_fast_forward', + baseRef: 'origin/main', + localBranch: 'main' + }) + expect(gitExecFileAsyncMock.mock.calls).toEqual([ + [ + ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], + expect.objectContaining({ cwd: '/repo' }) + ], + [ + ['rev-list', '--left-right', '--count', 'refs/heads/main...refs/remotes/origin/main'], + expect.objectContaining({ cwd: '/repo' }) + ], + [ + ['rev-parse', '--verify', 'refs/heads/main^{commit}'], + expect.objectContaining({ cwd: '/repo' }) + ], + [ + ['rev-parse', '--verify', 'refs/remotes/origin/main^{commit}'], + expect.objectContaining({ cwd: '/repo' }) + ], + [ + ['merge-base', '--is-ancestor', 'new-local', 'remote-main'], + expect.objectContaining({ cwd: '/repo' }) + ], + [ + [ + 'worktree', + 'add', + '--no-track', + '-b', + 'feature/test', + '/repo-feature', + 'refs/remotes/origin/main' + ], + expect.objectContaining({ cwd: '/repo' }) + ], + [ + [ + 'config', + '--local', + '--replace-all', + 'branch.feature/test.base', + 'refs/remotes/origin/main' + ], + expect.objectContaining({ cwd: '/repo-feature' }) + ], + [ + ['config', '--get', 'push.autoSetupRemote'], + expect.objectContaining({ cwd: '/repo-feature' }) + ], + [ + ['config', '--local', 'push.autoSetupRemote', 'true'], + expect.objectContaining({ cwd: '/repo-feature' }) + ] + ]) + }) + + it('suggests updating the local base ref when setting is off and the branch is safely behind', async () => { + const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} + .mockResolvedValueOnce({ stdout: '0\t2\n' }) // rev-list --left-right --count + .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit} + .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/origin/main^{commit} + .mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs + .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain + .mockResolvedValueOnce({ stdout: '' }) // status --porcelain + .mockResolvedValueOnce({ stdout: '' }) // worktree add + .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base + .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) + .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + + const result = await addWorktree( + '/repo', + '/repo-feature', + 'feature/test', + 'origin/main', + false, + false, + { suggestLocalBaseRefUpdate: true } + ) + + expect(result.localBaseRefUpdateSuggestion).toEqual({ + baseRef: 'origin/main', + localBranch: 'main', + behind: 2 + }) + expect(gitExecFileAsyncMock.mock.calls[1]).toEqual([ + ['rev-list', '--left-right', '--count', 'refs/heads/main...refs/remotes/origin/main'], + { cwd: '/repo' } + ]) + }) + + it('uses normalized branch metadata for slash-containing remotes', async () => { + const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/foo/bar/main^{commit} + .mockResolvedValueOnce({ stdout: '0\t2\n' }) // rev-list --left-right --count + .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit} + .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/foo/bar/main^{commit} + .mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs + .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain + .mockResolvedValueOnce({ stdout: '' }) // status --porcelain + .mockResolvedValueOnce({ stdout: '' }) // worktree add + .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base + .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) + .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + + const result = await addWorktree( + '/repo', + '/repo-feature', + 'feature/test', + 'foo/bar/main', + false, + false, + { + suggestLocalBaseRefUpdate: true, + remoteTrackingBase: { + base: 'foo/bar/main', + branch: 'main', + ref: 'refs/remotes/foo/bar/main' + } + } + ) + + expect(result.localBaseRefUpdateSuggestion).toEqual({ + baseRef: 'foo/bar/main', + localBranch: 'main', + behind: 2 + }) + expect(gitExecFileAsyncMock.mock.calls[1]).toEqual([ + ['rev-list', '--left-right', '--count', 'refs/heads/main...refs/remotes/foo/bar/main'], + { cwd: '/repo' } + ]) + }) + + it('does not suggest updating the local base ref when its owner worktree is dirty', async () => { + const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} + .mockResolvedValueOnce({ stdout: '0\t2\n' }) // rev-list --left-right --count + .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit} + .mockResolvedValueOnce({ stdout: 'remote-upstream-main\n' }) // rev-parse refs/remotes/upstream/main^{commit} + .mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs + .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain + .mockResolvedValueOnce({ stdout: ' M package.json\n' }) // status --porcelain + .mockResolvedValueOnce({ stdout: '' }) // worktree add + .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base + .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) + .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + + const result = await addWorktree( + '/repo', + '/repo-feature', + 'feature/test', + 'origin/main', + false, + false, + { suggestLocalBaseRefUpdate: true } + ) + + expect(result).toEqual({}) + }) + it('qualifies bare branch name as refs/heads/ when a same-named tag exists', async () => { // Why: repos that fetch with --tags can end up with a local tag named 'main', // making `git worktree add ... main` fail with "fatal: Ambiguous object name". @@ -821,9 +1149,14 @@ describe('addWorktree', () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/upstream/main^{commit} - .mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor + .mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count + .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit} + .mockResolvedValueOnce({ stdout: 'remote-upstream-main\n' }) // rev-parse refs/remotes/upstream/main^{commit} + .mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain .mockResolvedValueOnce({ stdout: '' }) // status --porcelain + .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list recheck + .mockResolvedValueOnce({ stdout: '' }) // status --porcelain recheck .mockResolvedValueOnce({ stdout: '' }) // reset --hard .mockResolvedValueOnce({ stdout: '' }) // worktree add .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base @@ -833,15 +1166,15 @@ describe('addWorktree', () => { await addWorktree('/repo', '/repo-feature', 'feature/test', 'upstream/main', true) expect(gitExecFileAsyncMock.mock.calls[1]?.[0]).toEqual([ - 'merge-base', - '--is-ancestor', - 'main', - 'refs/remotes/upstream/main' + 'rev-list', + '--left-right', + '--count', + 'refs/heads/main...refs/remotes/upstream/main' ]) - expect(gitExecFileAsyncMock.mock.calls[4]?.[0]).toEqual([ + expect(gitExecFileAsyncMock.mock.calls[9]?.[0]).toEqual([ 'reset', '--hard', - 'refs/remotes/upstream/main' + 'remote-upstream-main' ]) }) diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 41f50687246..939a7827a26 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -10,6 +10,7 @@ import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref' import type { GitWorktreeInfo, LocalBaseRefRefreshResult, + LocalBaseRefUpdateSuggestion, RemoveWorktreeResult } from '../../shared/types' import { gitExecFileAsync, translateWslOutputPaths } from './runner' @@ -18,12 +19,40 @@ import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe' export type AddWorktreeResult = { localBaseRefRefresh?: LocalBaseRefRefreshResult + localBaseRefUpdateSuggestion?: LocalBaseRefUpdateSuggestion } type SparseWorktreeCreateError = Error & { cleanupFailed?: boolean } +type AddWorktreeOptions = { + checkoutExistingBranch?: boolean + suggestLocalBaseRefUpdate?: boolean + remoteTrackingBase?: { + base: string + branch: string + ref: string + } +} + +type LocalBaseRefRefreshability = + | { + refreshable: true + baseRef: string + localBranch: string + fullRef: string + remoteTrackingRef: string + localOid: string + remoteOid: string + behind: number + ownerWorktreePath?: string + } + | { + refreshable: false + result: LocalBaseRefRefreshResult + } + const SPARSE_CHECKOUT_DETECTION_CONCURRENCY = 8 function getErrorCode(error: unknown): string | undefined { @@ -60,6 +89,177 @@ function normalizeLocalBranchRef(branch: string): string { return branch.replace(/^refs\/heads\//, '') } +function parseRemoteTrackingLocalBaseRef( + baseBranch: string, + remoteTrackingRef: string, + remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'] +): { baseRef: string; localBranch: string; fullRef: string } | undefined { + if (remoteTrackingBase?.ref === remoteTrackingRef) { + return { + baseRef: remoteTrackingBase.base, + localBranch: remoteTrackingBase.branch, + fullRef: `refs/heads/${remoteTrackingBase.branch}` + } + } + + const remoteRefPrefix = 'refs/remotes/' + if (!remoteTrackingRef.startsWith(remoteRefPrefix)) { + return undefined + } + + // Why: Only refs proven to be remote-tracking refs get refresh status. + // Local branches can contain slashes (e.g. release/2026) and must not + // produce a fake "Local 2026 was not refreshed" warning. + const shortRemoteRef = remoteTrackingRef.slice(remoteRefPrefix.length) + const slashIndex = shortRemoteRef.indexOf('/') + if (slashIndex <= 0) { + return undefined + } + + const localBranch = shortRemoteRef.slice(slashIndex + 1) + return { + baseRef: baseBranch, + localBranch, + fullRef: `refs/heads/${localBranch}` + } +} + +function parseRevListDrift(output: string): { ahead: number; behind: number } | null { + const [aheadStr, behindStr] = output.trim().split(/\s+/) + const ahead = Number(aheadStr) + const behind = Number(behindStr) + if (!Number.isFinite(ahead) || !Number.isFinite(behind) || ahead < 0 || behind < 0) { + return null + } + return { ahead, behind } +} + +async function evaluateLocalBaseRefRefreshability( + repoPath: string, + baseBranch: string, + remoteTrackingRef: string, + remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'] +): Promise { + const parsed = parseRemoteTrackingLocalBaseRef(baseBranch, remoteTrackingRef, remoteTrackingBase) + if (!parsed) { + return undefined + } + + const resultBase = { baseRef: parsed.baseRef, localBranch: parsed.localBranch } + + let drift: { ahead: number; behind: number } + let localOid = '' + let remoteOid = '' + try { + // Why: advisory and mutating paths must agree on "safe to fast-forward". + // `rev-list A...B` proves the local branch exists, has no local-only + // commits, and tells the toast whether it is actually behind. + const { stdout } = await gitExecFileAsync( + ['rev-list', '--left-right', '--count', `${parsed.fullRef}...${remoteTrackingRef}`], + { cwd: repoPath } + ) + const parsedDrift = parseRevListDrift(stdout) + if (!parsedDrift || parsedDrift.ahead !== 0) { + return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } } + } + const { stdout: localOidOutput } = await gitExecFileAsync( + ['rev-parse', '--verify', `${parsed.fullRef}^{commit}`], + { cwd: repoPath } + ) + localOid = localOidOutput.trim() + if (!localOid) { + return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } } + } + const { stdout: remoteOidOutput } = await gitExecFileAsync( + ['rev-parse', '--verify', `${remoteTrackingRef}^{commit}`], + { cwd: repoPath } + ) + remoteOid = remoteOidOutput.trim() + if (!remoteOid) { + return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } } + } + await gitExecFileAsync(['merge-base', '--is-ancestor', localOid, remoteOid], { cwd: repoPath }) + drift = parsedDrift + } catch { + return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } } + } + + try { + // Why: if the local base branch is checked out anywhere, turning on the + // setting would only update it when that owner worktree is clean. + const { stdout: worktreeListOutput } = await gitExecFileAsync( + ['worktree', 'list', '--porcelain'], + { cwd: repoPath } + ) + const worktrees = parseWorktreeList(translateWslOutputPaths(worktreeListOutput, repoPath)) + const ownerWorktree = worktrees.find((wt) => wt.branch === parsed.fullRef) + + if (ownerWorktree) { + const { stdout: status } = await gitExecFileAsync( + ['status', '--porcelain', '--untracked-files=no'], + { cwd: ownerWorktree.path } + ) + if (status.trim()) { + return { + refreshable: false, + result: { + ...resultBase, + status: 'skipped_dirty_worktree', + ownerWorktreePath: ownerWorktree.path + } + } + } + return { + refreshable: true, + ...resultBase, + fullRef: parsed.fullRef, + remoteTrackingRef, + localOid, + remoteOid, + behind: drift.behind, + ownerWorktreePath: ownerWorktree.path + } + } + + // Why: localBranch isn't checked out anywhere, so there is no working tree + // to desync — a bare ref fast-forward (update-ref) is safe. Omitting + // ownerWorktreePath signals the mutating path to take that branch. + return { + refreshable: true, + ...resultBase, + fullRef: parsed.fullRef, + remoteTrackingRef, + localOid, + remoteOid, + behind: drift.behind + } + } catch { + return { refreshable: false, result: { ...resultBase, status: 'skipped_error' } } + } +} + +async function getLocalBaseRefUpdateSuggestionForWorktreeCreate( + repoPath: string, + baseBranch: string, + remoteTrackingRef: string, + remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'] +): Promise { + const evaluation = await evaluateLocalBaseRefRefreshability( + repoPath, + baseBranch, + remoteTrackingRef, + remoteTrackingBase + ) + if (!evaluation?.refreshable || evaluation.behind <= 0) { + return undefined + } + return { + baseRef: evaluation.baseRef, + localBranch: evaluation.localBranch, + behind: evaluation.behind + } +} + async function persistWorktreeCreationBase( worktreePath: string, branch: string, @@ -283,73 +483,56 @@ async function annotateSparseCheckoutStatus( async function refreshLocalBaseRefForWorktreeCreate( repoPath: string, baseBranch: string, - remoteTrackingRef: string + remoteTrackingRef: string, + remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'] ): Promise { - const remoteRefPrefix = 'refs/remotes/' - if (!remoteTrackingRef.startsWith(remoteRefPrefix)) { + const evaluation = await evaluateLocalBaseRefRefreshability( + repoPath, + baseBranch, + remoteTrackingRef, + remoteTrackingBase + ) + if (!evaluation) { return undefined } - - // Why: Only refs proven to be remote-tracking refs get refresh status. - // Local branches can contain slashes (e.g. release/2026) and must not - // produce a fake "Local 2026 was not refreshed" warning. - const shortRemoteRef = remoteTrackingRef.slice(remoteRefPrefix.length) - const slashIndex = shortRemoteRef.indexOf('/') - if (slashIndex <= 0) { - return undefined + if (!evaluation.refreshable) { + return evaluation.result } - const localBranch = shortRemoteRef.slice(slashIndex + 1) - const fullRef = `refs/heads/${localBranch}` - const resultBase = { baseRef: baseBranch, localBranch } - + const resultBase = { baseRef: evaluation.baseRef, localBranch: evaluation.localBranch } try { - // Why: We only fast-forward the local branch pointer. A force-move (`branch -f`) - // would silently destroy unpushed local commits if the branch has diverged from - // remote. `merge-base --is-ancestor` returns exit 0 when localBranch is an - // ancestor of baseBranch — i.e. the update is a safe fast-forward. - await gitExecFileAsync(['merge-base', '--is-ancestor', localBranch, remoteTrackingRef], { - cwd: repoPath - }) - } catch { - // merge-base fails if the local branch doesn't exist or has diverged. - return { ...resultBase, status: 'skipped_not_fast_forward' } - } - - try { - // Why: We need to find which worktree (if any) has localBranch checked - // out, because moving the ref without updating that worktree's files would - // leave it looking massively dirty. A sibling worktree we don't control is - // just as vulnerable as the primary one. - const { stdout: worktreeListOutput } = await gitExecFileAsync( - ['worktree', 'list', '--porcelain'], - { cwd: repoPath } - ) - const worktrees = parseWorktreeList(translateWslOutputPaths(worktreeListOutput, repoPath)) - const ownerWorktree = worktrees.find((wt) => wt.branch === fullRef) - - if (ownerWorktree) { - // Why: localBranch is checked out in a worktree. We can only safely - // update if that worktree is clean, and we must use `reset --hard` - // (run inside that worktree) so the files move with the ref. + if (evaluation.ownerWorktreePath) { + const { stdout: worktreeListOutput } = await gitExecFileAsync( + ['worktree', 'list', '--porcelain'], + { cwd: repoPath } + ) + const worktrees = parseWorktreeList(translateWslOutputPaths(worktreeListOutput, repoPath)) + const currentOwner = worktrees.find((wt) => wt.branch === evaluation.fullRef) + if (!currentOwner || currentOwner.path !== evaluation.ownerWorktreePath) { + return { ...resultBase, status: 'skipped_error' } + } const { stdout: status } = await gitExecFileAsync( ['status', '--porcelain', '--untracked-files=no'], - { cwd: ownerWorktree.path } + { cwd: currentOwner.path } ) if (status.trim()) { return { ...resultBase, status: 'skipped_dirty_worktree', - ownerWorktreePath: ownerWorktree.path + ownerWorktreePath: currentOwner.path } } - await gitExecFileAsync(['reset', '--hard', remoteTrackingRef], { cwd: ownerWorktree.path }) - return { ...resultBase, status: 'updated', ownerWorktreePath: ownerWorktree.path } + await gitExecFileAsync(['reset', '--hard', evaluation.remoteOid], { cwd: currentOwner.path }) + return { ...resultBase, status: 'updated', ownerWorktreePath: currentOwner.path } } - // Why: localBranch is not checked out anywhere, so there is no working - // tree to desync. `update-ref` is safe here. - await gitExecFileAsync(['update-ref', fullRef, remoteTrackingRef], { cwd: repoPath }) + // Why: no owner worktree — fast-forward the bare ref. The expected-old-OID + // form makes this a no-op-safe compare-and-swap if the ref moved since the + // evaluation snapshot. + await gitExecFileAsync( + ['update-ref', evaluation.fullRef, evaluation.remoteOid, evaluation.localOid], + { cwd: repoPath } + ) return { ...resultBase, status: 'updated' } } catch { // update-ref/reset can fail on locked refs, filesystem errors, or unusual @@ -376,9 +559,10 @@ export async function addWorktree( baseBranch?: string, refreshLocalBaseRef = false, noCheckout = false, - options: { checkoutExistingBranch?: boolean } = {} + options: AddWorktreeOptions = {} ): Promise { let localBaseRefRefresh: LocalBaseRefRefreshResult | undefined + let localBaseRefUpdateSuggestion: LocalBaseRefUpdateSuggestion | undefined const args = ['worktree', 'add'] let effectiveBase: string | undefined if (noCheckout) { @@ -406,7 +590,15 @@ export async function addWorktree( localBaseRefRefresh = await refreshLocalBaseRefForWorktreeCreate( repoPath, baseBranch, - effectiveBase + effectiveBase, + options.remoteTrackingBase + ) + } else if (options.suggestLocalBaseRefUpdate) { + localBaseRefUpdateSuggestion = await getLocalBaseRefUpdateSuggestionForWorktreeCreate( + repoPath, + baseBranch, + effectiveBase, + options.remoteTrackingBase ) } args.push(effectiveBase) @@ -478,7 +670,10 @@ export async function addWorktree( } catch (error) { console.warn(`addWorktree: failed to set push.autoSetupRemote for ${worktreePath}`, error) } - return localBaseRefRefresh ? { localBaseRefRefresh } : {} + return { + ...(localBaseRefRefresh ? { localBaseRefRefresh } : {}), + ...(localBaseRefUpdateSuggestion ? { localBaseRefUpdateSuggestion } : {}) + } } export async function addSparseWorktree( @@ -488,7 +683,7 @@ export async function addSparseWorktree( directories: string[], baseBranch?: string, refreshLocalBaseRef = false, - options: { checkoutExistingBranch?: boolean } = {} + options: AddWorktreeOptions = {} ): Promise { let created = false let addResult: AddWorktreeResult = {} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 8789be0b343..37aabd52e5b 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -977,7 +977,12 @@ describe('registerPtyHandlers', () => { processEnvOverrides?: Record, // Why: daemon spawn tests need to exercise both WSL launch metadata // from main and PR #2662 command threading for OMP overlay selection. - spawnArgs?: { cwd?: string; shellOverride?: string; command?: string } + spawnArgs?: { + cwd?: string + shellOverride?: string + command?: string + envToDelete?: string[] + } ): Promise { const daemonSpawn = setupDaemonAdapter() const savedEnv: Record = {} @@ -1221,6 +1226,8 @@ describe('registerPtyHandlers', () => { rows: number worktreeId?: string env?: Record + envToDelete?: string[] + command?: string }): Promise<{ id: string }> } const daemonSpawn = setupDaemonAdapter() @@ -1248,6 +1255,57 @@ describe('registerPtyHandlers', () => { expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token') }) + it('keeps the Agent Teams tmux shim ahead of host PATH shims for runtime-created daemon PTYs', async () => { + type RuntimeSpawnController = { + spawn(args: { + cols: number + rows: number + worktreeId?: string + env?: Record + envToDelete?: string[] + command?: string + }): Promise<{ id: string }> + } + const daemonSpawn = setupDaemonAdapter() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never, undefined, (() => ({ + enableGitHubAttribution: true + })) as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController + + await controller.spawn({ + cols: 80, + rows: 24, + worktreeId: 'wt-runtime', + command: 'claude', + env: { + PATH: `/tmp/orca-agent-teams-bin${delimiter}/usr/bin`, + ORCA_AGENT_TEAMS_TEAM_ID: 'team-test', + TERM_PROGRAM: 'Orca', + ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/stale-attribution' + }, + envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'] + }) + + const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall + expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/orca-agent-teams-bin') + expect(spawnOptions.env.PATH).toContain( + '/tmp/orca-user-data/orca-terminal-attribution/posix' + ) + expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined() + expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() + expect(spawnOptions.envToDelete).toEqual( + expect.arrayContaining(['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']) + ) + }) + it('strips inherited agent-hook endpoint env from development daemon PTYs', async () => { const { app } = await import('electron') const mockedApp = app as unknown as { isPackaged: boolean } @@ -1273,6 +1331,34 @@ describe('registerPtyHandlers', () => { expect(env.PATH).toContain('/tmp/orca-user-data/orca-terminal-attribution/posix') }) + it('keeps the Agent Teams tmux shim ahead of host PATH shims on daemon pty:spawn', async () => { + const spawnOptions = await daemonSpawnAndGetOptions( + { + PATH: `/tmp/orca-agent-teams-bin${delimiter}/usr/bin`, + ORCA_AGENT_TEAMS_TEAM_ID: 'team-test', + TERM_PROGRAM: 'Orca', + ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/stale-attribution' + }, + undefined, + () => ({ enableGitHubAttribution: true }), + undefined, + { + command: 'claude', + envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'] + } + ) + + expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/orca-agent-teams-bin') + expect(spawnOptions.env.PATH).toContain( + '/tmp/orca-user-data/orca-terminal-attribution/posix' + ) + expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined() + expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() + expect(spawnOptions.envToDelete).toEqual( + expect.arrayContaining(['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']) + ) + }) + it('injects dev-mode ORCA_USER_DATA_PATH + dev CLI PATH on the daemon path', async () => { // Why: the mocked `app` (see vi.mock at the top of the file) is a // plain object, so we can flip isPackaged for the scope of the test. diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 60dad8f0584..15223709073 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -325,6 +325,44 @@ function readInheritedPath(baseEnv: Record): string { return baseEnv.PATH ?? baseEnv.Path ?? process.env.PATH ?? process.env.Path ?? '' } +function firstPathEntry(pathValue: string | undefined): string | null { + const first = pathValue?.split(delimiter).find((entry) => entry.trim().length > 0) + return first ?? null +} + +function promoteAgentTeamsShimPath( + env: Record | undefined, + requestedPath: string | undefined +): void { + if (!env?.ORCA_AGENT_TEAMS_TEAM_ID) { + return + } + const shimPath = firstPathEntry(requestedPath) + if (!shimPath) { + return + } + const currentPathKey = env.PATH !== undefined || env.Path === undefined ? 'PATH' : 'Path' + const currentPath = env[currentPathKey] ?? '' + const remaining = currentPath + .split(delimiter) + .filter((entry) => entry.length > 0 && entry !== shimPath) + // Why: host env injection can prepend Orca's attribution/dev shims. Claude + // Agent Teams must still resolve our fake tmux before any real tmux. + env[currentPathKey] = [shimPath, ...remaining].join(delimiter) +} + +function deleteRequestedEnvKeys( + env: Record | undefined, + keys: string[] | undefined +): void { + if (!env || !keys) { + return + } + for (const key of keys) { + delete env[key] + } +} + function isWslShellName(shellPath: string | undefined): boolean { const shellName = shellPath?.replaceAll('\\', '/').split('/').pop()?.toLowerCase() return shellName === 'wsl.exe' || shellName === 'wsl' @@ -1587,6 +1625,7 @@ export function registerPtyHandlers( let env: Record | undefined = claudeAuth ? { ...sshScopedEnv, ...claudeAuth.envPatch } : sshScopedEnv + const requestedAgentTeamsPath = env?.ORCA_AGENT_TEAMS_TEAM_ID ? env.PATH : undefined if (args.preAllocatedHandle) { env = { ...env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle } } @@ -1616,8 +1655,12 @@ export function registerPtyHandlers( agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()), networkProxySettings: getSettings?.() }) + promoteAgentTeamsShimPath(env, requestedAgentTeamsPath) } + const authEnvToDelete = claudeAuth?.stripAuthEnv + ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] + : undefined const spawnOptions: PtySpawnOptions = { cols: args.cols, rows: args.rows, @@ -1626,10 +1669,8 @@ export function registerPtyHandlers( ...(isMintedSessionId ? { isNewSession: true } : {}) } spawnOptions.envToDelete = mergePtyEnvDeletions( - claudeAuth?.stripAuthEnv - ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] - : undefined, - args.connectionId ? [] : getInheritedAgentHookEnvKeysToDelete(env) + mergePtyEnvDeletions(authEnvToDelete, args.envToDelete ?? []), + isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(env) : [] ) if (skipCodexHomeEnv) { spawnOptions.envToDelete = mergePtyEnvDeletions( @@ -1637,6 +1678,8 @@ export function registerPtyHandlers( CODEX_HOME_ENV_KEYS ) } + deleteRequestedEnvKeys(env, spawnOptions.envToDelete) + promoteAgentTeamsShimPath(env, requestedAgentTeamsPath) if (args.command !== undefined) { spawnOptions.command = args.command } @@ -1939,6 +1982,7 @@ export function registerPtyHandlers( rows: number cwd?: string env?: Record + envToDelete?: string[] command?: string connectionId?: string | null worktreeId?: string @@ -2064,6 +2108,7 @@ export function registerPtyHandlers( : null const stablePaneKey = verifiedPaneKey ?? migrationUnsupportedPaneKey const baseEnv = baseEnvWithAuth ? { ...baseEnvWithAuth } : undefined + const requestedAgentTeamsPath = baseEnv?.ORCA_AGENT_TEAMS_TEAM_ID ? baseEnv.PATH : undefined if (baseEnv && stablePaneKey) { baseEnv.ORCA_PANE_KEY = stablePaneKey if (typeof args.tabId === 'string') { @@ -2142,6 +2187,7 @@ export function registerPtyHandlers( agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()), networkProxySettings: getSettings?.() }) + promoteAgentTeamsShimPath(env, requestedAgentTeamsPath) } catch (err) { // Why: buildPtyHostEnv has filesystem side-effects (Pi overlay // materialization). If it throws before we reach provider.spawn, @@ -2165,11 +2211,13 @@ export function registerPtyHandlers( : undefined const combinedEnvToDelete = mergePtyEnvDeletions( mergePtyEnvDeletions( - envToDelete, - args.connectionId ? [] : getInheritedAgentHookEnvKeysToDelete(spawnEnv) + mergePtyEnvDeletions(envToDelete, args.envToDelete ?? []), + isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(spawnEnv) : [] ), skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [] ) + deleteRequestedEnvKeys(spawnEnv, combinedEnvToDelete) + promoteAgentTeamsShimPath(spawnEnv, requestedAgentTeamsPath) const spawnOptions: PtySpawnOptions = { cols: args.cols, rows: args.rows, diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 4237c1a9e95..e876168fc6f 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -18,12 +18,13 @@ import type { GitPushTarget, GlobalSettings, LocalBaseRefRefreshResult, + LocalBaseRefUpdateSuggestion, Repo, Worktree, WorktreeMeta } from '../../shared/types' import { getPRForBranch } from '../github/client' -import { listWorktrees, addWorktree, addSparseWorktree, parseWorktreeList } from '../git/worktree' +import { listWorktrees, addWorktree, addSparseWorktree } from '../git/worktree' import type { AddWorktreeResult } from '../git/worktree' import { getGitUsername, getDefaultBaseRef, getBranchConflictKind } from '../git/repo' import { validateGitPushTarget } from '../git/push-target-validation' @@ -110,10 +111,29 @@ type StagedStartupResult = { warning?: string } +type RemoteLocalBaseRefRefreshability = + | { + refreshable: true + baseRef: string + localBranch: string + fullRef: string + remoteTrackingRef: string + behind: number + ownerWorktreePath?: string + } + | { + refreshable: false + result: LocalBaseRefRefreshResult + } + function appendWorktreeCreateWarning(current: string | undefined, next: string): string { return current ? `${current} Also ${next[0]?.toLowerCase() ?? ''}${next.slice(1)}` : next } +function countNonEmptyGitOutputLines(output: string): number { + return output.split(/\r?\n/).filter((line) => line.trim().length > 0).length +} + async function spawnLocalStartupAndSetupTerminals(args: { runtime: OrcaRuntimeService | undefined worktree: Pick @@ -871,13 +891,17 @@ async function resolveRemoteTrackingBaseSsh( return null } + const remoteRefPrefix = 'refs/remotes/' + const shortBaseBranch = baseBranch.startsWith(remoteRefPrefix) + ? baseBranch.slice(remoteRefPrefix.length) + : baseBranch const remote = remotes - .filter((candidate) => baseBranch.startsWith(`${candidate}/`)) + .filter((candidate) => shortBaseBranch.startsWith(`${candidate}/`)) .sort((a, b) => b.length - a.length)[0] if (!remote) { return null } - const branch = baseBranch.slice(remote.length + 1) + const branch = shortBaseBranch.slice(remote.length + 1) if (!branch) { return null } @@ -885,7 +909,7 @@ async function resolveRemoteTrackingBaseSsh( remote, branch, ref: `refs/remotes/${remote}/${branch}`, - base: baseBranch + base: `${remote}/${branch}` } } @@ -973,49 +997,128 @@ async function refreshLocalBaseRefForRemoteWorktreeCreate( repoPath: string, remoteTrackingBase: RemoteTrackingBase ): Promise { + const evaluation = await evaluateRemoteLocalBaseRefRefreshability( + provider, + repoPath, + remoteTrackingBase + ) + if (!evaluation.refreshable) { + return evaluation.result + } + + const resultBase = { baseRef: evaluation.baseRef, localBranch: evaluation.localBranch } + try { + await provider.refreshLocalBaseRefForWorktreeCreate({ + repoPath, + fullRef: evaluation.fullRef, + remoteTrackingRef: evaluation.remoteTrackingRef, + ...(evaluation.ownerWorktreePath ? { ownerWorktreePath: evaluation.ownerWorktreePath } : {}) + }) + return { + ...resultBase, + status: 'updated', + ...(evaluation.ownerWorktreePath ? { ownerWorktreePath: evaluation.ownerWorktreePath } : {}) + } + } catch { + return { ...resultBase, status: 'skipped_error' } + } +} + +async function evaluateRemoteLocalBaseRefRefreshability( + provider: SshGitProvider, + repoPath: string, + remoteTrackingBase: RemoteTrackingBase +): Promise { const resultBase = { baseRef: remoteTrackingBase.base, localBranch: remoteTrackingBase.branch } const fullRef = `refs/heads/${remoteTrackingBase.branch}` + let behind = 0 try { - await provider.exec( - ['merge-base', '--is-ancestor', remoteTrackingBase.branch, remoteTrackingBase.ref], + // Why: SSH generic git.exec is allowlisted. `merge-base` and `log` are + // allowed read-only probes; `rev-list` is intentionally not exposed there. + await provider.exec(['merge-base', '--is-ancestor', fullRef, remoteTrackingBase.ref], repoPath) + const { stdout } = await provider.exec( + ['log', '--format=%H', `${fullRef}..${remoteTrackingBase.ref}`], repoPath ) + behind = countNonEmptyGitOutputLines(stdout) } catch { - return { ...resultBase, status: 'skipped_not_fast_forward' } + return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } } } try { - const { stdout: worktreeListOutput } = await provider.exec( - ['worktree', 'list', '--porcelain'], - repoPath - ) - const worktrees = parseWorktreeList(worktreeListOutput) + const worktrees = await provider.listWorktrees(repoPath) const ownerWorktree = worktrees.find((wt) => wt.branch === fullRef) if (ownerWorktree) { - const { stdout: status } = await provider.exec( - ['status', '--porcelain', '--untracked-files=no'], - ownerWorktree.path - ) - if (status.trim()) { + const status = await provider.worktreeIsClean(ownerWorktree.path, { + includeUntracked: false + }) + if (!status.clean) { return { - ...resultBase, - status: 'skipped_dirty_worktree', - ownerWorktreePath: ownerWorktree.path + refreshable: false, + result: { + ...resultBase, + status: 'skipped_dirty_worktree', + ownerWorktreePath: ownerWorktree.path + } } } - await provider.exec(['reset', '--hard', remoteTrackingBase.ref], ownerWorktree.path) - return { ...resultBase, status: 'updated', ownerWorktreePath: ownerWorktree.path } + return { + refreshable: true, + ...resultBase, + fullRef, + remoteTrackingRef: remoteTrackingBase.ref, + behind, + ownerWorktreePath: ownerWorktree.path + } } - await provider.exec(['update-ref', fullRef, remoteTrackingBase.ref], repoPath) - return { ...resultBase, status: 'updated' } + // Why: not checked out anywhere — a bare ref fast-forward is safe. Omitting + // ownerWorktreePath tells the relay to update-ref instead of reset --hard. + return { + refreshable: true, + ...resultBase, + fullRef, + remoteTrackingRef: remoteTrackingBase.ref, + behind + } } catch { - return { ...resultBase, status: 'skipped_error' } + return { refreshable: false, result: { ...resultBase, status: 'skipped_error' } } + } +} + +async function getRemoteLocalBaseRefUpdateSuggestionForWorktreeCreate( + provider: SshGitProvider, + repoPath: string, + remoteTrackingBase: RemoteTrackingBase +): Promise { + const evaluation = await evaluateRemoteLocalBaseRefRefreshability( + provider, + repoPath, + remoteTrackingBase + ) + if (!evaluation.refreshable || evaluation.behind <= 0) { + return undefined + } + try { + await provider.refreshLocalBaseRefForWorktreeCreate({ + repoPath, + fullRef: evaluation.fullRef, + remoteTrackingRef: evaluation.remoteTrackingRef, + ...(evaluation.ownerWorktreePath ? { ownerWorktreePath: evaluation.ownerWorktreePath } : {}), + checkOnly: true + }) + } catch { + return undefined + } + return { + baseRef: evaluation.baseRef, + localBranch: evaluation.localBranch, + behind: evaluation.behind } } @@ -1031,10 +1134,11 @@ export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string // "Creating worktree..." label if no event arrives. export function emitCreateWorktreeProgress( mainWindow: BrowserWindow, - phase: 'fetching' | 'creating' + phase: 'fetching' | 'creating', + creationId?: string ): void { if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('createWorktree:progress', { phase }) + mainWindow.webContents.send('createWorktree:progress', { creationId, phase }) } } @@ -1174,10 +1278,43 @@ export async function createRemoteWorktree( /* best-effort */ } } + + const mux = getActiveMultiplexer(repo.connectionId!) + if (!mux) { + throw new Error('SSH connection is not available. Please reconnect and try again.') + } + // Why: register before the local-base advisory probe as well as addWorktree. + // Fresh/older relays may gate generic git.exec calls on registered roots; if + // the probe runs first it degrades to "no suggestion" even though create works. + try { + await Promise.all([ + mux.request('session.registerRoot', { rootPath: repo.path }), + mux.request('session.registerRoot', { rootPath: remotePath }) + ]) + } catch (err) { + if (err instanceof Error && err.message.includes('Method not found')) { + mux.notify('session.registerRoot', { rootPath: repo.path }) + mux.notify('session.registerRoot', { rootPath: remotePath }) + } else { + throw err + } + } + const localBaseRefRefresh = settings.refreshLocalBaseRefOnWorktreeCreate && !checkoutExistingBranch && remoteTrackingBase ? await refreshLocalBaseRefForRemoteWorktreeCreate(provider, repo.path, remoteTrackingBase) : undefined + const localBaseRefUpdateSuggestion = + !settings.refreshLocalBaseRefOnWorktreeCreate && + !settings.localBaseRefSuggestionDismissed && + !checkoutExistingBranch && + remoteTrackingBase + ? await getRemoteLocalBaseRefUpdateSuggestionForWorktreeCreate( + provider, + repo.path, + remoteTrackingBase + ) + : undefined if (fsProvider) { const primaryHooks = await readRemoteEffectiveHooks(repo, fsProvider, repo.path) @@ -1199,31 +1336,6 @@ export async function createRemoteWorktree( ) } - const mux = getActiveMultiplexer(repo.connectionId!) - if (!mux) { - throw new Error('SSH connection is not available. Please reconnect and try again.') - } - // Why: kept for back-compat with old relay binaries during the upgrade - // window — those still gate git.addWorktree on registered roots, so we - // must prime them synchronously to close fresh-host / reconnect windows. - // New relays no-op these calls. Notify-fallback handles older relays that - // pre-date the request-form session.registerRoot handler. Tracked for - // removal once the relay-version floor moves past the cutover (see - // docs/relay-fs-allowlist-removal.md). - try { - await Promise.all([ - mux.request('session.registerRoot', { rootPath: repo.path }), - mux.request('session.registerRoot', { rootPath: remotePath }) - ]) - } catch (err) { - if (err instanceof Error && err.message.includes('Method not found')) { - mux.notify('session.registerRoot', { rootPath: repo.path }) - mux.notify('session.registerRoot', { rootPath: remotePath }) - } else { - throw err - } - } - // Create worktree via relay try { await timing.time('git_worktree_add', async () => @@ -1403,6 +1515,7 @@ export async function createRemoteWorktree( ...(setup ? { setup } : {}), ...(defaultTabs ? { defaultTabs } : {}), ...(localBaseRefRefresh ? { localBaseRefRefresh } : {}), + ...(localBaseRefUpdateSuggestion ? { localBaseRefUpdateSuggestion } : {}), timing: timing.finish() } } @@ -1452,7 +1565,7 @@ export async function createLocalWorktree( remoteTrackingBase = await runtime.resolveRemoteTrackingBase(repo.path, baseBranch) if (remoteTrackingBase) { const hasLocalBaseRef = await runtime.hasRemoteTrackingRef(repo.path, remoteTrackingBase) - emitCreateWorktreeProgress(mainWindow, 'fetching') + emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId) remoteTrackingRefresh = { base: remoteTrackingBase, hadLocalBaseRef: hasLocalBaseRef, @@ -1469,14 +1582,14 @@ export async function createLocalWorktree( .fetchRemoteWithCache(repo.path, fallbackRemote) .then(() => undefined) .catch(() => undefined) - emitCreateWorktreeProgress(mainWindow, 'fetching') + emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId) } } else { const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin' legacyFetchPromise = gitExecFileAsync(['fetch', remote], { cwd: repo.path }) .then(() => undefined) .catch(() => undefined) - emitCreateWorktreeProgress(mainWindow, 'fetching') + emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId) } const workspaceRoot = computeWorkspaceRoot(repo.path, worktreePathSettings) @@ -1662,7 +1775,7 @@ export async function createLocalWorktree( await legacyFetchPromise }) } - emitCreateWorktreeProgress(mainWindow, 'creating') + emitCreateWorktreeProgress(mainWindow, 'creating', args.creationId) let preparedPushTarget: GitPushTarget | undefined if (args.pushTarget) { @@ -1672,11 +1785,42 @@ export async function createLocalWorktree( preparedPushTarget = await prepareWorktreePushTarget(repo.path, args.pushTarget, store, repo.id) } - const existingBranchOption = { checkoutExistingBranch } + const suggestLocalBaseRefUpdate = + !settings.refreshLocalBaseRefOnWorktreeCreate && + !settings.localBaseRefSuggestionDismissed && + Boolean(remoteTrackingBase) + const remoteTrackingBaseOption = remoteTrackingBase ? { remoteTrackingBase } : undefined + const existingBranchOption = { + checkoutExistingBranch, + ...remoteTrackingBaseOption, + ...(suggestLocalBaseRefUpdate ? { suggestLocalBaseRefUpdate } : {}) + } const addResult: AddWorktreeResult = - (await timing.time('git_worktree_add', async () => - sparseDirectories.length > 0 - ? checkoutExistingBranch + (await timing.time('git_worktree_add', async () => { + if (sparseDirectories.length > 0) { + if (checkoutExistingBranch) { + return addSparseWorktree( + repo.path, + worktreePath, + branchName, + sparseDirectories, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate, + existingBranchOption + ) + } + if (suggestLocalBaseRefUpdate) { + return addSparseWorktree( + repo.path, + worktreePath, + branchName, + sparseDirectories, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate, + { ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate } + ) + } + return remoteTrackingBaseOption ? addSparseWorktree( repo.path, worktreePath, @@ -1684,7 +1828,7 @@ export async function createLocalWorktree( sparseDirectories, baseBranch, settings.refreshLocalBaseRefOnWorktreeCreate, - existingBranchOption + remoteTrackingBaseOption ) : addSparseWorktree( repo.path, @@ -1694,24 +1838,48 @@ export async function createLocalWorktree( baseBranch, settings.refreshLocalBaseRefOnWorktreeCreate ) - : checkoutExistingBranch - ? addWorktree( - repo.path, - worktreePath, - branchName, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate, - false, - existingBranchOption - ) - : addWorktree( - repo.path, - worktreePath, - branchName, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate - ) - )) ?? {} + } + + if (checkoutExistingBranch) { + return addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate, + false, + existingBranchOption + ) + } + if (suggestLocalBaseRefUpdate) { + return addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate, + false, + { ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate } + ) + } + return remoteTrackingBaseOption + ? addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate, + false, + remoteTrackingBaseOption + ) + : addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate + ) + })) ?? {} let configuredPushTarget: GitPushTarget | undefined if (preparedPushTarget) { @@ -1877,6 +2045,9 @@ export async function createLocalWorktree( ...(addResult.localBaseRefRefresh ? { localBaseRefRefresh: addResult.localBaseRefRefresh } : {}), + ...(addResult.localBaseRefUpdateSuggestion + ? { localBaseRefUpdateSuggestion: addResult.localBaseRefUpdateSuggestion } + : {}), ...(stagedStartup.startupTerminal ? { startupTerminal: stagedStartup.startupTerminal } : {}), ...(stagedStartup.warning ? { warning: stagedStartup.warning } : {}), timing: timing.finish() diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 66ea1295ad3..f58b343a3b4 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { lstat, mkdir, mkdtemp, rm, writeFile } from 'fs/promises' import { tmpdir } from 'os' import { join } from 'path' +import type { CreateWorktreeResult } from '../../shared/types' const { handleMock, @@ -1610,28 +1611,38 @@ describe('registerWorktreeHandlers', () => { if (args[0] === 'remote') { return { stdout: 'origin\n', stderr: '' } } - if (args[0] === 'worktree' && args[1] === 'list') { - return { - stdout: 'worktree /remote/repo\nHEAD abc123\nbranch refs/heads/main\n', - stderr: '' - } + if (args[0] === 'merge-base') { + return { stdout: '', stderr: '' } } - if (args[0] === 'status') { - return { stdout: ' M package.json\n', stderr: '' } + if (args[0] === 'log') { + return { stdout: 'commit-a\ncommit-b\ncommit-c\n', stderr: '' } } return { stdout: '', stderr: '' } }), fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/remote/improve-dashboard', - head: 'abc123', - branch: 'refs/heads/improve-dashboard', - isBare: false, - isMainWorktree: false - } - ]) + listWorktrees: vi + .fn() + .mockResolvedValueOnce([ + { + path: '/remote/repo', + head: 'base123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + .mockResolvedValueOnce([ + { + path: '/remote/improve-dashboard', + head: 'abc123', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: false, stdout: ' M package.json\n' }), + refreshLocalBaseRefForWorktreeCreate: vi.fn().mockResolvedValue(undefined) } const mux = { request: vi.fn().mockResolvedValue(undefined), @@ -1649,24 +1660,27 @@ describe('registerWorktreeHandlers', () => { getActiveMultiplexerMock.mockReturnValue(mux) store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) - const result = await handlers['worktrees:create'](null, { + const result = (await handlers['worktrees:create'](null, { repoId: 'repo-ssh', name: 'improve-dashboard' - }) + })) as CreateWorktreeResult expect(provider.exec).toHaveBeenCalledWith( - ['merge-base', '--is-ancestor', 'main', 'refs/remotes/origin/main'], + ['merge-base', '--is-ancestor', 'refs/heads/main', 'refs/remotes/origin/main'], '/remote/repo' ) - expect(provider.exec).toHaveBeenCalledWith(['worktree', 'list', '--porcelain'], '/remote/repo') expect(provider.exec).toHaveBeenCalledWith( - ['status', '--porcelain', '--untracked-files=no'], + ['log', '--format=%H', 'refs/heads/main..refs/remotes/origin/main'], '/remote/repo' ) + expect(provider.worktreeIsClean).toHaveBeenCalledWith('/remote/repo', { + includeUntracked: false + }) expect(provider.exec).not.toHaveBeenCalledWith( ['reset', '--hard', 'refs/remotes/origin/main'], expect.any(String) ) + expect(provider.refreshLocalBaseRefForWorktreeCreate).not.toHaveBeenCalled() expect(result).toEqual( expect.objectContaining({ localBaseRefRefresh: { @@ -1679,6 +1693,297 @@ describe('registerWorktreeHandlers', () => { ) }) + it('refreshes SSH local base through the narrow relay RPC when the setting is on', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'merge-base') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'log') { + return { stdout: 'commit-a\ncommit-b\n', stderr: '' } + } + throw new Error(`unexpected generic exec: ${args.join(' ')}`) + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi + .fn() + .mockResolvedValueOnce([ + { + path: '/remote/repo', + head: 'base123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + .mockResolvedValueOnce([ + { + path: '/remote/improve-dashboard', + head: 'abc123', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }), + refreshLocalBaseRefForWorktreeCreate: vi.fn().mockResolvedValue(undefined) + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getSettings.mockReturnValue({ + branchPrefix: 'none', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: true, + workspaceDir: '/workspace' + }) + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + const result = (await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'improve-dashboard' + })) as CreateWorktreeResult + + expect(provider.exec).toHaveBeenCalledWith( + ['merge-base', '--is-ancestor', 'refs/heads/main', 'refs/remotes/origin/main'], + '/remote/repo' + ) + expect(provider.exec).toHaveBeenCalledWith( + ['log', '--format=%H', 'refs/heads/main..refs/remotes/origin/main'], + '/remote/repo' + ) + expect(provider.refreshLocalBaseRefForWorktreeCreate).toHaveBeenCalledWith({ + repoPath: '/remote/repo', + fullRef: 'refs/heads/main', + remoteTrackingRef: 'refs/remotes/origin/main', + ownerWorktreePath: '/remote/repo' + }) + expect(provider.exec).not.toHaveBeenCalledWith( + ['reset', '--hard', 'refs/remotes/origin/main'], + expect.any(String) + ) + expect(provider.exec).not.toHaveBeenCalledWith( + ['update-ref', 'refs/heads/main', 'refs/remotes/origin/main'], + expect.any(String) + ) + expect(result).toEqual( + expect.objectContaining({ + localBaseRefRefresh: { + status: 'updated', + baseRef: 'origin/main', + localBranch: 'main', + ownerWorktreePath: '/remote/repo' + } + }) + ) + }) + + it('returns SSH local base update suggestion when a full local base ref is safely behind', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'refs/remotes/origin/main' + } + let registeredRoots = false + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'merge-base' || args[0] === 'log') { + if (!registeredRoots) { + throw new Error('Path outside authorized workspace') + } + return { + stdout: args[0] === 'log' ? 'commit-a\ncommit-b\ncommit-c\ncommit-d\n' : '', + stderr: '' + } + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockImplementation(async () => { + if (!registeredRoots) { + throw new Error('No workspace roots registered yet') + } + return [ + { + path: '/remote/repo', + head: 'base123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/remote/improve-dashboard', + head: 'abc123', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ] + }), + worktreeIsClean: vi.fn().mockImplementation(async () => { + if (!registeredRoots) { + throw new Error('Path outside authorized workspace') + } + return { clean: true } + }), + refreshLocalBaseRefForWorktreeCreate: vi.fn().mockResolvedValue(undefined) + } + const mux = { + request: vi.fn().mockImplementation(async (method: string) => { + if (method === 'session.registerRoot') { + registeredRoots = true + } + }), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'improve-dashboard' + }) + + expect(provider.exec).toHaveBeenCalledWith( + ['merge-base', '--is-ancestor', 'refs/heads/main', 'refs/remotes/origin/main'], + '/remote/repo' + ) + expect(provider.exec).toHaveBeenCalledWith( + ['log', '--format=%H', 'refs/heads/main..refs/remotes/origin/main'], + '/remote/repo' + ) + expect(provider.listWorktrees).toHaveBeenCalledWith('/remote/repo') + expect(provider.worktreeIsClean).toHaveBeenCalledWith('/remote/repo', { + includeUntracked: false + }) + expect(provider.refreshLocalBaseRefForWorktreeCreate).toHaveBeenCalledWith({ + repoPath: '/remote/repo', + fullRef: 'refs/heads/main', + remoteTrackingRef: 'refs/remotes/origin/main', + ownerWorktreePath: '/remote/repo', + checkOnly: true + }) + expect(provider.exec).not.toHaveBeenCalledWith( + ['reset', '--hard', 'refs/remotes/origin/main'], + expect.any(String) + ) + expect(result).toEqual( + expect.objectContaining({ + localBaseRefUpdateSuggestion: { + baseRef: 'origin/main', + localBranch: 'main', + behind: 4 + } + }) + ) + }) + + it('does not suggest SSH local base updates when the relay cannot refresh local refs', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'refs/remotes/origin/main' + } + const methodNotFound = Object.assign( + new Error('Method not found: git.refreshLocalBaseRefForWorktreeCreate'), + { code: -32601 } + ) + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'merge-base') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'log') { + return { stdout: 'commit-a\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi + .fn() + .mockResolvedValueOnce([ + { + path: '/remote/repo', + head: 'base123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + .mockResolvedValueOnce([ + { + path: '/remote/improve-dashboard', + head: 'abc123', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }), + refreshLocalBaseRefForWorktreeCreate: vi.fn().mockRejectedValue(methodNotFound) + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + const result = (await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'improve-dashboard' + })) as CreateWorktreeResult + + expect(provider.refreshLocalBaseRefForWorktreeCreate).toHaveBeenCalledWith({ + repoPath: '/remote/repo', + fullRef: 'refs/heads/main', + remoteTrackingRef: 'refs/remotes/origin/main', + ownerWorktreePath: '/remote/repo', + checkOnly: true + }) + expect(result.localBaseRefUpdateSuggestion).toBeUndefined() + }) + it('reads remote orca.yaml and returns a setup launch payload during SSH create', async () => { const repo = { id: 'repo-ssh', @@ -2185,6 +2490,7 @@ describe('registerWorktreeHandlers', () => { addWorktree: vi.fn().mockResolvedValue(undefined), listWorktrees: vi .fn() + .mockResolvedValueOnce([]) .mockResolvedValueOnce([ { path: '/remote/first-worktree', @@ -2194,6 +2500,7 @@ describe('registerWorktreeHandlers', () => { isMainWorktree: false } ]) + .mockResolvedValueOnce([]) .mockResolvedValueOnce([ { path: '/remote/second-worktree', @@ -2644,6 +2951,66 @@ describe('registerWorktreeHandlers', () => { ) }) + it('threads the local base update suggestion from local create results', async () => { + const remoteBase = { + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + } + runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase) + runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true) + addWorktreeMock.mockResolvedValue({ + localBaseRefUpdateSuggestion: { + baseRef: 'origin/main', + localBranch: 'main', + behind: 2 + } + }) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/improve-dashboard', + head: 'created-sha', + branch: 'improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'created-sha\n', stderr: '' }) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard' + }) + + expect(addWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/improve-dashboard', + 'improve-dashboard', + 'origin/main', + false, + false, + { + suggestLocalBaseRefUpdate: true, + remoteTrackingBase: { + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + } + } + ) + expect(result).toEqual( + expect.objectContaining({ + localBaseRefUpdateSuggestion: { + baseRef: 'origin/main', + localBranch: 'main', + behind: 2 + } + }) + ) + }) + it('throws a clear error when no default base ref can be resolved', async () => { // Why: guard against regressing to a silent 'origin/main' fallback. When // getDefaultBaseRef returns null (e.g. a fresh repo with no origin/HEAD, diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 24d37ca2406..0aa314fb19e 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -2501,7 +2501,7 @@ describe('Store', () => { ) const store = await createStore() - expect(store.getSettings().disabledTuiAgents).toEqual(['codex', 'claude']) + expect(store.getSettings().disabledTuiAgents).toEqual(['codex', 'claude', 'claude-agent-teams']) const updated = store.updateSettings({ disabledTuiAgents: ['gemini', 'not-real', 'gemini', 'opencode'] as never diff --git a/src/main/persistence.ts b/src/main/persistence.ts index f6053947e69..105ec88039c 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -1871,6 +1871,20 @@ export class Store { if (!visibleTaskProvidersDefaultedForJira) { this.loadNeedsSave = true } + const claudeAgentTeamsDefaultDisabledMigrated = + parsed.settings?.claudeAgentTeamsDefaultDisabledMigrated === true + if (!claudeAgentTeamsDefaultDisabledMigrated) { + this.loadNeedsSave = true + } + const migratedDisabledTuiAgents = normalizeDisabledTuiAgents( + parsed.settings?.disabledTuiAgents + ) + if ( + !claudeAgentTeamsDefaultDisabledMigrated && + !migratedDisabledTuiAgents.includes('claude-agent-teams') + ) { + migratedDisabledTuiAgents.push('claude-agent-teams') + } if (!autoRenameBranchFromWorkDefaultedOn) { this.loadNeedsSave = true } @@ -1926,7 +1940,8 @@ export class Store { terminalShortcutPolicy: normalizeTerminalShortcutPolicy( parsed.settings?.terminalShortcutPolicy ), - disabledTuiAgents: normalizeDisabledTuiAgents(parsed.settings?.disabledTuiAgents), + disabledTuiAgents: migratedDisabledTuiAgents, + claudeAgentTeamsDefaultDisabledMigrated: true, openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications, { seedDefaults: true }), diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index d88c6cbf4d9..381c4a214e6 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -181,6 +181,35 @@ describe('LocalPtyProvider', () => { expect(spawnCall[2].env.CUSTOM_VAR).toBe('custom-value') }) + it('honors explicit terminal env overrides after deleting requested defaults', async () => { + provider.configure({ + buildSpawnEnv: (_id, env) => { + env.TERM_PROGRAM = 'Orca' + env.ORCA_ATTRIBUTION_SHIM_DIR = '/tmp/orca-attribution' + env.PATH = `/tmp/orca-attribution:${env.PATH ?? ''}` + return env + } + }) + + await provider.spawn({ + cols: 80, + rows: 24, + env: { + TERM: 'screen-256color', + PATH: '/tmp/orca-agent-teams-bin:/usr/bin', + ORCA_AGENT_TEAMS_TEAM_ID: 'team-test' + }, + envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'] + }) + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[2].name).toBe('screen-256color') + expect(spawnCall[2].env.TERM).toBe('screen-256color') + expect(spawnCall[2].env.PATH.split(':')[0]).toBe('/tmp/orca-agent-teams-bin') + expect(spawnCall[2].env.TERM_PROGRAM).toBeUndefined() + expect(spawnCall[2].env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() + }) + it('does not pass a Windows Codex home into WSL terminals', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) provider.configure({ diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index e858af2658e..4e9735b0db3 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -2,7 +2,7 @@ ~70 lines of scanner/promise wiring to spawn(). Splitting the method would scatter tightly coupled PTY lifecycle logic (scan → ready → write → exit cleanup) across files without a cleaner ownership seam. */ -import { basename } from 'path' +import { basename, delimiter } from 'path' import { win32 as pathWin32 } from 'path' import { resolveWindowsShellLaunchArgs } from './windows-shell-args' import { resolveEffectiveWindowsPowerShell } from './windows-powershell' @@ -88,6 +88,21 @@ function removeUnspecifiedPaneIdentityEnv( } } +function promoteAgentTeamsShimPath( + env: Record, + requestedPath: string | undefined +): void { + if (!env.ORCA_AGENT_TEAMS_TEAM_ID || !requestedPath) { + return + } + const shimDir = requestedPath.split(delimiter)[0] + if (!shimDir) { + return + } + const currentParts = env.PATH?.split(delimiter).filter(Boolean) ?? [] + env.PATH = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(delimiter) +} + function disposePtyListeners(id: string): void { const disposables = ptyDisposables.get(id) if (disposables) { @@ -341,6 +356,9 @@ export class LocalPtyProvider implements IPtyProvider { for (const key of args.envToDelete ?? []) { delete spawnEnv[key] } + if (args.env?.TERM) { + spawnEnv.TERM = args.env.TERM + } spawnEnv.LANG ??= 'en_US.UTF-8' @@ -369,6 +387,15 @@ export class LocalPtyProvider implements IPtyProvider { wslDistro: launchWslDistro }) : spawnEnv + // Why: app-level env hooks can reintroduce vars that special launch modes + // explicitly scrubbed. Apply deletions last so shims like Claude Agent + // Teams keep their PATH and terminal-detection contract. + for (const key of args.envToDelete ?? []) { + delete finalEnv[key] + } + if (args.env?.TERM) { + finalEnv.TERM = args.env.TERM + } if (process.platform === 'win32') { const codexHomeWslInfo = finalEnv.CODEX_HOME ? parseWslPath(finalEnv.CODEX_HOME) : null if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') { @@ -419,7 +446,8 @@ export class LocalPtyProvider implements IPtyProvider { finalEnv.ORCA_OPENCODE_CONFIG_DIR || finalEnv.ORCA_PI_CODING_AGENT_DIR || finalEnv.ORCA_OMP_CODING_AGENT_DIR || - finalEnv.ORCA_CODEX_HOME + finalEnv.ORCA_CODEX_HOME || + finalEnv.ORCA_AGENT_TEAMS_SHIM_DIR getFallbackShellReadyConfig = args.command ? (shell) => getShellReadyLaunchConfig(shell) : needsNoMarkerWrapper @@ -436,6 +464,7 @@ export class LocalPtyProvider implements IPtyProvider { shellReadyLaunch = args.command ? shellLaunch : null } } + promoteAgentTeamsShimPath(finalEnv, args.env?.PATH) // ── Worktree-scoped shell history (§7–§10 of terminal-history-scope-design) ── // Why: without this, all worktree terminals share a single global HISTFILE @@ -458,6 +487,7 @@ export class LocalPtyProvider implements IPtyProvider { rows: args.rows, cwd: effectiveCwd, env: finalEnv, + termName: finalEnv.TERM, ptySpawn: pty.spawn, getShellReadyConfig: getFallbackShellReadyConfig, // Why: if zsh failed and bash took over, HISTFILE still points to diff --git a/src/main/providers/local-pty-shell-ready.test.ts b/src/main/providers/local-pty-shell-ready.test.ts index f42aff7b817..7b9770afd98 100644 --- a/src/main/providers/local-pty-shell-ready.test.ts +++ b/src/main/providers/local-pty-shell-ready.test.ts @@ -327,6 +327,7 @@ describePosix('local PTY shell-ready launch config', () => { '[[ -n "${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="${ORCA_PI_CODING_AGENT_DIR}"' const codexRestoreLine = '[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"' + const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0' const ompRestoreLine = 'if [[ -z "${ORCA_PI_CODING_AGENT_DIR:-}" && -n "${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then' const ompWrapperLine = 'command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"' @@ -338,6 +339,9 @@ describePosix('local PTY shell-ready launch config', () => { expect(bashRc).toContain(piRestoreLine) expect(zshrc).toContain(codexRestoreLine) expect(zlogin).toContain(codexRestoreLine) + expect(zshrc).toContain(agentTeamsPathRestoreLine) + expect(zlogin).toContain(agentTeamsPathRestoreLine) + expect(bashRc).toContain(agentTeamsPathRestoreLine) expect(bashRc).toContain(codexRestoreLine) expect(zshrc).toContain(ompRestoreLine) expect(zlogin).toContain(ompRestoreLine) diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index e6f50416910..720b2c89b01 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -163,6 +163,14 @@ __orca_restore_attribution_path() { export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH" } __orca_restore_attribution_path +__orca_restore_agent_teams_path() { + [[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0 + case "$PATH" in + "\${ORCA_AGENT_TEAMS_SHIM_DIR}"|"\${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;; + esac + export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" +} +__orca_restore_agent_teams_path # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the PTY-scoped overlay before the first prompt. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" @@ -276,6 +284,14 @@ __orca_restore_attribution_path() { export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH" } [[ ! -o login ]] && __orca_restore_attribution_path +__orca_restore_agent_teams_path() { + [[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0 + case "$PATH" in + "\${ORCA_AGENT_TEAMS_SHIM_DIR}"|"\${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;; + esac + export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" +} +[[ ! -o login ]] && __orca_restore_agent_teams_path if [[ ! -o login ]]; then # Why: ~/.zshrc can export the user's default OpenCode config after spawn. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" @@ -338,6 +354,14 @@ __orca_restore_attribution_path() { export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH" } __orca_restore_attribution_path +__orca_restore_agent_teams_path() { + [[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0 + case "$PATH" in + "\${ORCA_AGENT_TEAMS_SHIM_DIR}"|"\${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;; + esac + export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" +} +__orca_restore_agent_teams_path # Why: .zlogin is the final login startup file before the prompt is shown. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts index 0c5235b9264..4dc42c3c044 100644 --- a/src/main/providers/local-pty-utils.ts +++ b/src/main/providers/local-pty-utils.ts @@ -94,6 +94,7 @@ export function validateWorkingDirectory(cwd: string): void { export type ShellSpawnParams = { shellPath: string shellArgs: string[] + termName?: string cols: number rows: number cwd: string @@ -120,6 +121,7 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu const { shellPath, shellArgs, + termName = 'xterm-256color', cols, rows, cwd, @@ -137,7 +139,7 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu if (!primaryError) { try { return { - process: ptySpawn(shellPath, shellArgs, { name: 'xterm-256color', cols, rows, cwd, env }), + process: ptySpawn(shellPath, shellArgs, { name: termName, cols, rows, cwd, env }), shellPath } } catch (err) { @@ -158,7 +160,7 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu onBeforeFallbackSpawn?.(env, fallback) Object.assign(env, fallbackReady?.env ?? {}) const proc = ptySpawn(fallback, fallbackReady?.args ?? ['-l'], { - name: 'xterm-256color', + name: termName, cols, rows, cwd, diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 9de75d67096..d56a5509460 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -721,6 +721,51 @@ describe('SshGitProvider', () => { expect(result).toEqual(cleanResult) }) + it('worktreeIsClean can ignore untracked files', async () => { + const cleanResult = { clean: true } + mux.request.mockResolvedValue(cleanResult) + + const result = await provider.worktreeIsClean('/home/user/feat', { includeUntracked: false }) + + expect(mux.request).toHaveBeenCalledWith('git.worktreeIsClean', { + worktreePath: '/home/user/feat', + includeUntracked: false + }) + expect(result).toEqual(cleanResult) + }) + + it('worktreeIsClean filters untracked stdout when old relays ignore the option', async () => { + mux.request.mockResolvedValue({ clean: false, stdout: '?? scratch.txt\n' }) + + const result = await provider.worktreeIsClean('/home/user/feat', { includeUntracked: false }) + + expect(result).toEqual({ clean: true }) + }) + + it('worktreeIsClean keeps dirty results without stdout dirty for tracked-only checks', async () => { + mux.request.mockResolvedValue({ clean: false }) + + const result = await provider.worktreeIsClean('/home/user/feat', { includeUntracked: false }) + + expect(result).toEqual({ clean: false }) + }) + + it('refreshLocalBaseRefForWorktreeCreate sends the narrow refresh request', async () => { + await provider.refreshLocalBaseRefForWorktreeCreate({ + repoPath: '/home/user/repo', + fullRef: 'refs/heads/main', + remoteTrackingRef: 'refs/remotes/origin/main', + ownerWorktreePath: '/home/user/repo' + }) + + expect(mux.request).toHaveBeenCalledWith('git.refreshLocalBaseRefForWorktreeCreate', { + repoPath: '/home/user/repo', + fullRef: 'refs/heads/main', + remoteTrackingRef: 'refs/remotes/origin/main', + ownerWorktreePath: '/home/user/repo' + }) + }) + it('worktreeIsClean falls back to git.status for old relays', async () => { const methodNotFound = Object.assign(new Error('Method not found: git.worktreeIsClean'), { code: -32601 @@ -746,6 +791,28 @@ describe('SshGitProvider', () => { } }) + it('worktreeIsClean filters untracked entries in old-relay fallback', async () => { + const methodNotFound = Object.assign(new Error('Method not found: git.worktreeIsClean'), { + code: -32601 + }) + mux.request.mockRejectedValueOnce(methodNotFound).mockResolvedValueOnce({ + entries: [{ path: 'scratch.txt', status: 'untracked', area: 'untracked' }], + conflictOperation: 'unknown' + }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + await expect( + provider.worktreeIsClean('/home/user/feat', { includeUntracked: false }) + ).resolves.toEqual({ clean: true }) + expect(mux.request).toHaveBeenNthCalledWith(2, 'git.status', { + worktreePath: '/home/user/feat' + }) + } finally { + warnSpy.mockRestore() + } + }) + it('renameCurrentBranch sends the narrow branch-rename request', async () => { await provider.renameCurrentBranch('/home/user/feat', 'you/fix-auth') expect(mux.request).toHaveBeenCalledWith('git.renameCurrentBranch', { diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index effc3aa914d..1fc9c25c1fb 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -43,6 +43,13 @@ function formatStatusEntriesForCleanCheck(entries: GitStatusResult['entries']): return entries.map((entry) => `${entry.area} ${entry.status}: ${entry.path}`).join('\n') } +function filterUntrackedPorcelainStatus(stdout: string | undefined): string | undefined { + const trackedLines = (stdout ?? '') + .split(/\r?\n/) + .filter((line) => line.trim().length > 0 && !line.startsWith('?? ')) + return trackedLines.length > 0 ? trackedLines.join('\n') : undefined +} + export class SshGitProvider implements IGitProvider { private connectionId: string private mux: SshChannelMultiplexer @@ -458,12 +465,26 @@ export class SshGitProvider implements IGitProvider { })) ?? {}) as RemoveWorktreeResult } - async worktreeIsClean(worktreePath: string): Promise<{ clean: boolean; stdout?: string }> { + async worktreeIsClean( + worktreePath: string, + options: { includeUntracked?: boolean } = {} + ): Promise<{ clean: boolean; stdout?: string }> { try { - return (await this.mux.request('git.worktreeIsClean', { worktreePath })) as { + const result = (await this.mux.request('git.worktreeIsClean', { + worktreePath, + ...(options.includeUntracked === false ? { includeUntracked: false } : {}) + })) as { clean: boolean stdout?: string } + if (options.includeUntracked === false) { + if (!result.clean && result.stdout === undefined) { + return result + } + const trackedStdout = filterUntrackedPorcelainStatus(result.stdout) + return { clean: !trackedStdout, ...(trackedStdout ? { stdout: trackedStdout } : {}) } + } + return result } catch (error) { if (!isJsonRpcMethodNotFoundError(error)) { throw error @@ -477,11 +498,25 @@ export class SshGitProvider implements IGitProvider { // Why: existing SSH relays may predate git.worktreeIsClean, but git.status // is a narrow relay RPC and avoids the generic git.exec allowlist. const status = await this.getStatus(worktreePath) - const clean = status.entries.length === 0 - return { clean, stdout: formatStatusEntriesForCleanCheck(status.entries) } + const entries = + options.includeUntracked === false + ? status.entries.filter((entry) => entry.area !== 'untracked') + : status.entries + const clean = entries.length === 0 + return { clean, stdout: formatStatusEntriesForCleanCheck(entries) } } } + async refreshLocalBaseRefForWorktreeCreate(args: { + repoPath: string + fullRef: string + remoteTrackingRef: string + ownerWorktreePath?: string + checkOnly?: boolean + }): Promise { + await this.mux.request('git.refreshLocalBaseRefForWorktreeCreate', args) + } + async renameCurrentBranch(worktreePath: string, newBranch: string): Promise { await this.mux.request('git.renameCurrentBranch', { worktreePath, newBranch }) } diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 07316ce08e8..2a674aca15f 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -218,7 +218,10 @@ export type IGitProvider = { isGitRepoAsync(dirPath: string): Promise<{ isRepo: boolean; rootPath: string | null }> exec(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> getRemoteFileUrl(worktreePath: string, relativePath: string, line: number): Promise - worktreeIsClean(worktreePath: string): Promise<{ clean: boolean; stdout?: string }> + worktreeIsClean( + worktreePath: string, + options?: { includeUntracked?: boolean } + ): Promise<{ clean: boolean; stdout?: string }> } // ─── Provider Registry ────────────────────────────────────────────── diff --git a/src/main/runtime/claude-agent-teams-service.test.ts b/src/main/runtime/claude-agent-teams-service.test.ts new file mode 100644 index 00000000000..56ba8567ad2 --- /dev/null +++ b/src/main/runtime/claude-agent-teams-service.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from 'vitest' +import { ClaudeAgentTeamsService, type AgentTeamsTerminalApi } from './claude-agent-teams-service' + +function createServiceWithLeader(): { + service: ClaudeAgentTeamsService + teamId: string + token: string + leaderPane: string + api: AgentTeamsTerminalApi + splitCalls: { handle: string; direction?: string; command?: string; envPane?: string }[] +} { + const service = new ClaudeAgentTeamsService() + const launch = service.createLaunchEnv({ + leaderHandle: 'leader-handle', + baseEnv: { PATH: '/usr/bin' }, + shimDir: '/tmp/orca-shim', + shimBin: '/usr/bin/orca' + }) + expect(launch.env.ORCA_AGENT_TEAMS_SHIM_DIR).toBe('/tmp/orca-shim') + const splitCalls: { handle: string; direction?: string; command?: string; envPane?: string }[] = + [] + let splitCount = 0 + const api: AgentTeamsTerminalApi = { + splitTerminal: vi.fn(async (handle, opts) => { + splitCount += 1 + splitCalls.push({ + handle, + direction: opts.direction, + command: opts.command, + envPane: opts.env?.TMUX_PANE + }) + return { handle: `teammate-${splitCount}`, tabId: 'tab-1', paneRuntimeId: -1 } + }), + readTerminal: vi.fn(async (handle) => ({ + handle, + status: 'running' as const, + tail: ['line one', 'line two'], + truncated: false, + nextCursor: null + })), + sendTerminal: vi.fn(async (handle, action) => ({ + handle, + accepted: Boolean(action.text), + bytesWritten: action.text?.length ?? 0 + })), + focusTerminal: vi.fn(async (handle) => ({ handle, tabId: 'tab-1', worktreeId: 'wt-1' })), + closeTerminal: vi.fn(async (handle) => ({ handle, tabId: 'tab-1', ptyKilled: true })), + showTerminal: vi.fn(async (handle) => ({ + handle, + worktreeId: 'wt-1', + worktreePath: '/tmp/wt', + branch: 'main', + tabId: 'tab-1', + leafId: 'leaf-1', + title: null, + connected: true, + writable: true, + lastOutputAt: null, + preview: '', + paneRuntimeId: -1, + ptyId: 'pty-1', + rendererGraphEpoch: 1 + })) + } + return { + service, + teamId: launch.teamId, + token: launch.token, + leaderPane: launch.leaderPane, + api, + splitCalls + } +} + +describe('ClaudeAgentTeamsService', () => { + it('supports Claude core tmux teammate sequence with native splits', async () => { + const { service, teamId, token, leaderPane, api, splitCalls } = createServiceWithLeader() + const request = (argv: string[]) => + service.handleTmuxCompat({ teamId, token, envPane: leaderPane, argv }, api) + + await expect( + request(['display-message', '-t', leaderPane, '-p', '#{session_name}:#{window_index}']) + ).resolves.toMatchObject({ stdout: 'orca:0\n', exitCode: 0 }) + + await expect( + request(['split-window', '-t', leaderPane, '-h', '-l', '70%', '-P', '-F', '#{pane_id}']) + ).resolves.toMatchObject({ stdout: '%2\n', exitCode: 0 }) + + await request(['select-layout', '-t', 'orca:0', 'main-vertical']) + await request(['resize-pane', '-t', leaderPane, '-x', '30%']) + + await expect( + request(['list-panes', '-t', 'orca:0', '-F', '#{pane_id}']) + ).resolves.toMatchObject({ + stdout: '%1\n%2\n' + }) + expect(splitCalls).toEqual([ + { handle: 'leader-handle', direction: 'vertical', command: undefined, envPane: '%2' } + ]) + }) + + it('puts the first teammate on the right, then stacks repeated main-vertical teammates downward', async () => { + const { service, teamId, token, leaderPane, api, splitCalls } = createServiceWithLeader() + const request = (argv: string[]) => + service.handleTmuxCompat({ teamId, token, envPane: leaderPane, argv }, api) + + await request(['split-window', '-t', leaderPane, '-h', '-l', '70%', '-P', '-F', '#{pane_id}']) + await request(['select-layout', '-t', 'orca:0', 'main-vertical']) + await request(['split-window', '-t', leaderPane, '-h', '-l', '70%', '-P', '-F', '#{pane_id}']) + await request(['split-window', '-t', leaderPane, '-h', '-l', '70%', '-P', '-F', '#{pane_id}']) + + expect(splitCalls.map((call) => [call.handle, call.direction, call.envPane])).toEqual([ + ['leader-handle', 'vertical', '%2'], + ['teammate-1', 'horizontal', '%3'], + ['teammate-2', 'horizontal', '%4'] + ]) + }) + + it('does not recycle fake pane ids after a teammate closes', async () => { + const { service, teamId, token, leaderPane, api, splitCalls } = createServiceWithLeader() + const request = (argv: string[], envPane = leaderPane) => + service.handleTmuxCompat({ teamId, token, envPane, argv }, api) + + await request(['split-window', '-t', leaderPane, '-h', '-P', '-F', '#{pane_id}']) + await request(['select-layout', '-t', 'orca:0', 'main-vertical']) + await request(['split-window', '-t', leaderPane, '-h', '-P', '-F', '#{pane_id}']) + await request(['kill-pane', '-t', '%3']) + + await expect( + request(['split-window', '-t', leaderPane, '-h', '-P', '-F', '#{pane_id}']) + ).resolves.toMatchObject({ stdout: '%4\n', exitCode: 0 }) + await expect( + request(['list-panes', '-t', 'orca:0', '-F', '#{pane_id}']) + ).resolves.toMatchObject({ + stdout: '%1\n%2\n%4\n' + }) + expect(splitCalls.map((call) => [call.handle, call.direction, call.envPane])).toEqual([ + ['leader-handle', 'vertical', '%2'], + ['teammate-1', 'horizontal', '%3'], + ['teammate-1', 'horizontal', '%4'] + ]) + }) + + it('rejects stale or unauthorized shim calls', async () => { + const { service, teamId, leaderPane, api } = createServiceWithLeader() + + await expect( + service.handleTmuxCompat( + { teamId, token: 'wrong', envPane: leaderPane, argv: ['list-panes'] }, + api + ) + ).resolves.toMatchObject({ ok: false, exitCode: 1 }) + }) +}) diff --git a/src/main/runtime/claude-agent-teams-service.ts b/src/main/runtime/claude-agent-teams-service.ts new file mode 100644 index 00000000000..acc71b5d3e7 --- /dev/null +++ b/src/main/runtime/claude-agent-teams-service.ts @@ -0,0 +1,109 @@ +import { randomBytes, randomUUID } from 'crypto' +import { splitTmuxCommand } from '../../shared/claude-agent-teams-tmux-compat' +import { ClaudeAgentTeamsTmuxDispatcher } from './claude-agent-teams-tmux-dispatcher' +import type { + AgentTeam, + AgentTeamsLaunchEnv, + AgentTeamsTerminalApi, + AgentTeamsTmuxCompatRequest, + AgentTeamsTmuxCompatResponse, + TeamPane +} from './claude-agent-teams-types' + +export type { + AgentTeamsLaunchEnv, + AgentTeamsTerminalApi, + AgentTeamsTmuxCompatRequest, + AgentTeamsTmuxCompatResponse +} from './claude-agent-teams-types' + +export class ClaudeAgentTeamsService { + private readonly teams = new Map() + private readonly dispatcher = new ClaudeAgentTeamsTmuxDispatcher() + + createLaunchEnv(args: { + leaderHandle: string + baseEnv: Record + shimDir: string + shimBin: string + }): AgentTeamsLaunchEnv { + const teamId = `team-${randomUUID()}` + const token = randomBytes(32).toString('base64url') + const leaderPane = '%1' + const pathValue = [args.shimDir, args.baseEnv.PATH] + .filter(Boolean) + .join(process.platform === 'win32' ? ';' : ':') + const tmuxValue = `/tmp/orca-claude-agent-teams/${teamId},0,1` + const env: Record = { + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', + PATH: pathValue, + TMUX: tmuxValue, + TMUX_PANE: leaderPane, + TERM: 'screen-256color', + COLORTERM: args.baseEnv.COLORTERM || 'truecolor', + 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 + } + if (args.baseEnv.ORCA_PAIRING_CODE) { + env.ORCA_PAIRING_CODE = args.baseEnv.ORCA_PAIRING_CODE + } + if (args.baseEnv.ORCA_ENVIRONMENT) { + env.ORCA_ENVIRONMENT = args.baseEnv.ORCA_ENVIRONMENT + } + + const leader: TeamPane = { fakePaneId: leaderPane, handle: args.leaderHandle, index: 0 } + this.teams.set(teamId, { + teamId, + token, + leaderPane, + leaderHandle: args.leaderHandle, + sessionName: 'orca', + windowIndex: '0', + tmuxValue, + baseEnv: env, + panes: new Map([[leaderPane, leader]]), + paneOrder: [leaderPane], + nextPaneNumber: 2, + mainVertical: null, + previouslyFocusedPane: null + }) + return { teamId, token, leaderPane, env } + } + + removeTeamForLeaderHandle(handle: string): void { + for (const [teamId, team] of this.teams) { + if (team.leaderHandle === handle) { + this.teams.delete(teamId) + } + } + } + + async handleTmuxCompat( + request: AgentTeamsTmuxCompatRequest, + api: AgentTeamsTerminalApi + ): Promise { + try { + const team = this.resolveTeam(request) + const { command, args } = splitTmuxCommand(request.argv) + const stdout = await this.dispatcher.dispatch(team, command, args, request.envPane, api) + return { ok: true, stdout, stderr: '', exitCode: 0 } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { ok: false, stdout: '', stderr: `tmux: ${message}\n`, exitCode: 1 } + } + } + + private resolveTeam(request: AgentTeamsTmuxCompatRequest): AgentTeam { + const team = this.teams.get(request.teamId) + if (!team || team.token !== request.token) { + throw new Error('stale or unauthorized agent team') + } + if (!team.panes.has(request.envPane)) { + throw new Error(`unknown pane: ${request.envPane}`) + } + return team + } +} diff --git a/src/main/runtime/claude-agent-teams-shim-env.test.ts b/src/main/runtime/claude-agent-teams-shim-env.test.ts new file mode 100644 index 00000000000..5eb3abcf119 --- /dev/null +++ b/src/main/runtime/claude-agent-teams-shim-env.test.ts @@ -0,0 +1,82 @@ +import { chmod, mkdtemp, readFile, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { + buildClaudeAgentTeamsLaunchPlan, + ensureClaudeAgentTeamsShimDir, + resolveClaudeAgentTeamsShimBin +} from './claude-agent-teams-shim-env' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))) + roots.length = 0 +}) + +describe('claude agent teams shim env', () => { + it('writes a private tmux shim that calls the Orca shim command', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-shim-')) + roots.push(root) + + await ensureClaudeAgentTeamsShimDir(root) + + await expect(readFile(join(root, 'tmux'), 'utf8')).resolves.toContain('agent-teams-tmux "$@"') + }) + + it('builds native shim env only for direct Claude commands', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-cli-')) + roots.push(root) + const cliName = process.platform === 'win32' ? 'orca-dev.cmd' : 'orca-dev' + const cliPath = join(root, cliName) + await writeFile(cliPath, '#!/usr/bin/env sh\n', 'utf8') + if (process.platform !== 'win32') { + await chmod(cliPath, 0o755) + } + + let capturedShimBin = '' + const plan = await buildClaudeAgentTeamsLaunchPlan({ + command: "claude 'hello'", + mode: 'native-panes-shim', + baseEnv: { PATH: root }, + createTeamEnv: (shimDir, shimBin) => { + capturedShimBin = shimBin + return { + PATH: `${shimDir}:/usr/bin`, + TMUX: '/tmp/orca/fake,0,0', + TMUX_PANE: '%1' + } + } + }) + + expect(plan).toMatchObject({ + command: "claude --teammate-mode auto 'hello'", + env: expect.objectContaining({ TMUX_PANE: '%1' }), + envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'] + }) + expect(capturedShimBin).toBe(cliPath) + + await expect( + buildClaudeAgentTeamsLaunchPlan({ + command: "echo ok; claude 'hello'", + mode: 'native-panes-shim', + baseEnv: {}, + createTeamEnv: () => ({}) + }) + ).resolves.toBeNull() + }) + + it('resolves the dev CLI wrapper for the tmux callback binary', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-cli-')) + roots.push(root) + const cliName = process.platform === 'win32' ? 'orca-dev.cmd' : 'orca-dev' + const cliPath = join(root, cliName) + await writeFile(cliPath, '#!/usr/bin/env sh\n', 'utf8') + if (process.platform !== 'win32') { + await chmod(cliPath, 0o755) + } + + expect(resolveClaudeAgentTeamsShimBin({ PATH: root })).toBe(cliPath) + }) +}) diff --git a/src/main/runtime/claude-agent-teams-shim-env.ts b/src/main/runtime/claude-agent-teams-shim-env.ts new file mode 100644 index 00000000000..2b5f472bd47 --- /dev/null +++ b/src/main/runtime/claude-agent-teams-shim-env.ts @@ -0,0 +1,169 @@ +import { chmod, mkdir, readFile, rename, rm, writeFile } from 'fs/promises' +import { accessSync, constants, existsSync } from 'fs' +import { homedir } from 'os' +import { delimiter, dirname, join } from 'path' +import { + addClaudeTeammateModeAuto, + addClaudeTeammateModeInProcess, + isDirectClaudeCommand, + type ClaudeAgentTeamsMode +} from '../../shared/claude-agent-teams-tmux-compat' + +export type ClaudeAgentTeamsLaunchPlan = { + command: string + env: Record + envToDelete?: string[] +} + +export async function ensureClaudeAgentTeamsShimDir(root = defaultShimRoot()): Promise { + await mkdir(root, { recursive: true }) + await writeIfChanged(join(root, 'tmux'), unixShimScript()) + if (process.platform === 'win32') { + await writeIfChanged(join(root, 'tmux.cmd'), windowsShimScript()) + } + return root +} + +export async function buildClaudeAgentTeamsLaunchPlan(args: { + command: string | undefined + mode: ClaudeAgentTeamsMode | undefined + baseEnv: Record + createTeamEnv: (shimDir: string, shimBin: string) => Record +}): Promise { + const mode = args.mode ?? 'off' + if (!args.command || mode === 'off' || !isDirectClaudeCommand(args.command)) { + return null + } + if (mode === 'in-process' || process.platform === 'win32') { + return { + command: addClaudeTeammateModeInProcess(args.command), + env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' } + } + } + const shimDir = await ensureClaudeAgentTeamsShimDir() + const shimBin = resolveClaudeAgentTeamsShimBin(args.baseEnv) + const env = args.createTeamEnv(shimDir, shimBin) + return { + command: addClaudeTeammateModeAuto(args.command), + env, + envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'] + } +} + +export function resolveClaudeAgentTeamsShimBin( + env: Record = process.env +): string { + if (env.ORCA_AGENT_TEAMS_SHIM_BIN) { + return env.ORCA_AGENT_TEAMS_SHIM_BIN + } + const bundled = bundledLauncherPath() + if (bundled && isExecutableFile(bundled)) { + return bundled + } + return ( + findExecutableOnPath(process.platform === 'win32' ? 'orca-dev.cmd' : 'orca-dev', env.PATH) ?? + findExecutableOnPath(platformCliCommandName(), env.PATH) ?? + platformCliCommandName() + ) +} + +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.cmd') + } + return null +} + +function platformCliCommandName(): string { + if (process.platform === 'linux') { + return 'orca-ide' + } + if (process.platform === 'win32') { + return 'orca.cmd' + } + return 'orca' +} + +function findExecutableOnPath(command: string, pathValue: string | undefined): string | null { + for (const directory of pathValue?.split(delimiter) ?? []) { + if (!directory) { + continue + } + const candidate = join(directory, command) + if (isExecutableFile(candidate)) { + return candidate + } + } + return null +} + +function isExecutableFile(candidate: string): boolean { + try { + if (!existsSync(candidate)) { + return false + } + accessSync(candidate, process.platform === 'win32' ? constants.F_OK : constants.X_OK) + return true + } catch { + return false + } +} + +function unixShimScript(): string { + return [ + '#!/usr/bin/env sh', + 'set -eu', + 'exec "${ORCA_AGENT_TEAMS_SHIM_BIN:-orca}" agent-teams-tmux "$@"', + '' + ].join('\n') +} + +function windowsShimScript(): string { + return [ + '@echo off', + 'setlocal', + 'if "%ORCA_AGENT_TEAMS_SHIM_BIN%"=="" (', + ' set "ORCA_AGENT_TEAMS_SHIM_BIN=orca"', + ')', + '"%ORCA_AGENT_TEAMS_SHIM_BIN%" agent-teams-tmux %*', + '' + ].join('\r\n') +} + +async function writeIfChanged(path: string, content: string): Promise { + try { + if ((await readFile(path, 'utf8')) === content) { + return + } + } catch { + // rewrite below + } + await mkdir(dirname(path), { recursive: true }) + const tmp = `${path}.${process.pid}.${Date.now()}.tmp` + let renamed = false + try { + await writeFile(tmp, content, 'utf8') + if (process.platform !== 'win32') { + await chmod(tmp, 0o755) + } + await rename(tmp, path) + renamed = true + } finally { + if (!renamed) { + await rm(tmp, { force: true }) + } + } +} diff --git a/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts b/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts new file mode 100644 index 00000000000..e73025b8eec --- /dev/null +++ b/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts @@ -0,0 +1,312 @@ +import { + parseTmuxArgs, + renderTmuxFormat, + tmuxSendKeysText, + tmuxValue +} from '../../shared/claude-agent-teams-tmux-compat' +import type { AgentTeam, AgentTeamsTerminalApi, TeamPane } from './claude-agent-teams-types' + +type ResolvedTarget = { type: 'pane'; pane: TeamPane } | { type: 'window' } + +export class ClaudeAgentTeamsTmuxDispatcher { + async dispatch( + team: AgentTeam, + command: string, + args: string[], + envPane: string, + api: AgentTeamsTerminalApi + ): Promise { + switch (command) { + case '-V': + case '-v': + return 'tmux 3.4\n' + case 'show-options': + case 'show-option': + case 'show': + return this.showOptions(args) + case 'display-message': + case 'display': + case 'displayp': + return this.displayMessage(team, args, envPane) + case 'split-window': + case 'splitw': + return await this.splitWindow(team, args, envPane, api) + case 'select-layout': + return this.selectLayout(team, args, envPane) + case 'resize-pane': + case 'resizep': + return '' + case 'list-panes': + case 'lsp': + return this.listPanes(team, args, envPane) + case 'send-keys': + case 'send': + return await this.sendKeys(team, args, envPane, api) + case 'capture-pane': + case 'capturep': + return await this.capturePane(team, args, envPane, api) + case 'select-pane': + case 'selectp': + return await this.selectPane(team, args, envPane, api) + case 'kill-pane': + case 'killp': + return await this.killPane(team, args, envPane, api) + case 'last-pane': + return await this.lastPane(team, args, api) + case 'set-option': + case 'set': + case 'set-window-option': + case 'setw': + case 'set-hook': + case 'refresh-client': + case 'attach-session': + case 'detach-client': + case 'source-file': + case 'wait-for': + case 'has-session': + case 'has': + return '' + default: + throw new Error(`unsupported command: ${command}`) + } + } + + private showOptions(args: string[]): string { + const parsed = parseTmuxArgs(args, ['-t'], ['-g', '-q', '-s', '-v', '-w']) + const optionName = parsed.positional.at(-1) ?? '' + if (optionName !== 'extended-keys') { + throw new Error(`unsupported option: ${optionName}`) + } + return parsed.flags.has('-v') ? 'on\n' : 'extended-keys on\n' + } + + private displayMessage(team: AgentTeam, args: string[], envPane: string): string { + const parsed = parseTmuxArgs(args, ['-F', '-t'], ['-p']) + const target = this.resolvePaneOrWindow(team, tmuxValue(parsed, '-t') ?? envPane) + const pane = target.type === 'window' ? this.resolvePane(team, envPane) : target.pane + const format = + parsed.positional.length > 0 ? parsed.positional.join(' ') : tmuxValue(parsed, '-F') + return `${renderTmuxFormat(format, this.formatContext(team, pane), '')}\n` + } + + private async splitWindow( + team: AgentTeam, + args: string[], + envPane: string, + api: AgentTeamsTerminalApi + ): Promise { + const parsed = parseTmuxArgs( + args, + ['-c', '-F', '-l', '-t'], + ['-P', '-b', '-d', '-f', '-h', '-v'] + ) + const targetPane = this.resolvePane(team, tmuxValue(parsed, '-t') ?? envPane) + const fakePaneId = `%${team.nextPaneNumber}` + team.nextPaneNumber += 1 + const splitTarget = this.resolveSplitTarget(team, targetPane, parsed.flags.has('-h')) + const env = { + ...team.baseEnv, + TMUX_PANE: fakePaneId, + ORCA_AGENT_TEAMS_LEADER_PANE: team.leaderPane + } + const split = await api.splitTerminal(splitTarget.pane.handle, { + direction: splitTarget.direction, + command: parsed.positional.join(' ') || undefined, + env, + envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'], + activate: false + }) + const pane: TeamPane = { + fakePaneId, + handle: split.handle, + index: team.paneOrder.length + } + team.panes.set(fakePaneId, pane) + team.paneOrder.push(fakePaneId) + this.updateMainVerticalAfterSplit(team, fakePaneId, splitTarget) + if (!parsed.flags.has('-P')) { + return '' + } + return `${renderTmuxFormat(tmuxValue(parsed, '-F'), this.formatContext(team, pane), fakePaneId)}\n` + } + + private selectLayout(team: AgentTeam, args: string[], envPane: string): string { + const parsed = parseTmuxArgs(args, ['-t'], []) + const layout = parsed.positional[0] ?? '' + if (layout === 'main-vertical') { + const target = this.resolvePaneOrWindow(team, tmuxValue(parsed, '-t') ?? envPane) + const targetPane = target.type === 'pane' ? target.pane : null + team.mainVertical = { + mainPane: team.leaderPane, + lastColumnPane: + team.mainVertical?.lastColumnPane ?? + (targetPane && targetPane.fakePaneId !== team.leaderPane ? targetPane.fakePaneId : null) + } + } else if (layout) { + team.mainVertical = null + } + return '' + } + + private listPanes(team: AgentTeam, args: string[], envPane: string): string { + const parsed = parseTmuxArgs(args, ['-F', '-t'], []) + this.resolvePaneOrWindow(team, tmuxValue(parsed, '-t') ?? envPane) + return team.paneOrder + .map((paneId) => { + const pane = team.panes.get(paneId)! + return renderTmuxFormat( + tmuxValue(parsed, '-F'), + this.formatContext(team, pane), + pane.fakePaneId + ) + }) + .join('\n') + .concat('\n') + } + + private async sendKeys( + team: AgentTeam, + args: string[], + envPane: string, + api: AgentTeamsTerminalApi + ): Promise { + const parsed = parseTmuxArgs(args, ['-t'], ['-l']) + const pane = this.resolvePane(team, tmuxValue(parsed, '-t') ?? envPane) + const text = tmuxSendKeysText(parsed.positional, parsed.flags.has('-l')) + if (text) { + await api.sendTerminal(pane.handle, { text }) + } + return '' + } + + private async capturePane( + team: AgentTeam, + args: string[], + envPane: string, + api: AgentTeamsTerminalApi + ): Promise { + const parsed = parseTmuxArgs(args, ['-E', '-S', '-t'], ['-J', '-N', '-p']) + const pane = this.resolvePane(team, tmuxValue(parsed, '-t') ?? envPane) + const read = await api.readTerminal(pane.handle, { limit: 1000 }) + const text = read.tail.join('\n') + return parsed.flags.has('-p') ? `${text}\n` : '' + } + + private async selectPane( + team: AgentTeam, + args: string[], + envPane: string, + api: AgentTeamsTerminalApi + ): Promise { + const parsed = parseTmuxArgs(args, ['-P', '-T', '-t'], []) + if (tmuxValue(parsed, '-P') || tmuxValue(parsed, '-T')) { + return '' + } + const pane = this.resolvePane(team, tmuxValue(parsed, '-t') ?? envPane) + team.previouslyFocusedPane = envPane + await api.focusTerminal(pane.handle) + return '' + } + + private async killPane( + team: AgentTeam, + args: string[], + envPane: string, + api: AgentTeamsTerminalApi + ): Promise { + const parsed = parseTmuxArgs(args, ['-t'], []) + const pane = this.resolvePane(team, tmuxValue(parsed, '-t') ?? envPane) + if (pane.fakePaneId === team.leaderPane) { + throw new Error('refusing to kill leader pane') + } + await api.closeTerminal(pane.handle) + team.panes.delete(pane.fakePaneId) + team.paneOrder = team.paneOrder.filter((id) => id !== pane.fakePaneId) + if (team.mainVertical?.lastColumnPane === pane.fakePaneId) { + team.mainVertical.lastColumnPane = + [...team.paneOrder].reverse().find((id) => id !== team.leaderPane) ?? null + } + return '' + } + + private async lastPane( + team: AgentTeam, + args: string[], + api: AgentTeamsTerminalApi + ): Promise { + parseTmuxArgs(args, ['-t'], []) + const pane = team.previouslyFocusedPane ? team.panes.get(team.previouslyFocusedPane) : null + if (pane) { + await api.focusTerminal(pane.handle) + } + return '' + } + + private updateMainVerticalAfterSplit( + team: AgentTeam, + fakePaneId: string, + splitTarget: { pane: TeamPane; direction: 'horizontal' | 'vertical' } + ): void { + if (team.mainVertical) { + team.mainVertical.lastColumnPane = fakePaneId + } else if ( + splitTarget.direction === 'vertical' && + splitTarget.pane.fakePaneId === team.leaderPane + ) { + team.mainVertical = { mainPane: team.leaderPane, lastColumnPane: fakePaneId } + } + } + + private resolveSplitTarget( + team: AgentTeam, + targetPane: TeamPane, + horizontal: boolean + ): { pane: TeamPane; direction: 'horizontal' | 'vertical' } { + if (horizontal && team.mainVertical?.lastColumnPane) { + return { + pane: team.panes.get(team.mainVertical.lastColumnPane) ?? targetPane, + direction: 'horizontal' + } + } + // Why: tmux `split-window -h` means left/right panes; Orca names that + // layout by the vertical divider it creates. + return { pane: targetPane, direction: horizontal ? 'vertical' : 'horizontal' } + } + + private resolvePaneOrWindow(team: AgentTeam, target: string): ResolvedTarget { + if (target.includes(':') || target === team.sessionName || target.startsWith('@')) { + return { type: 'window' } + } + return { type: 'pane', pane: this.resolvePane(team, target) } + } + + private resolvePane(team: AgentTeam, target: string): TeamPane { + const pane = team.panes.get(target) + if (!pane) { + throw new Error(`unknown pane: ${target}`) + } + return pane + } + + private formatContext(team: AgentTeam, pane: TeamPane): Record { + return { + session_name: team.sessionName, + session_id: '$0', + window_id: '@0', + window_index: team.windowIndex, + window_name: 'agent-teams', + window_active: '1', + window_flags: '*', + pane_id: pane.fakePaneId, + pane_index: String(pane.index), + pane_active: pane.fakePaneId === team.leaderPane ? '1' : '0', + pane_title: '', + pane_width: '', + pane_height: '', + pane_left: '', + pane_top: '', + window_width: '', + window_height: '' + } + } +} diff --git a/src/main/runtime/claude-agent-teams-types.ts b/src/main/runtime/claude-agent-teams-types.ts new file mode 100644 index 00000000000..6f98b25a88d --- /dev/null +++ b/src/main/runtime/claude-agent-teams-types.ts @@ -0,0 +1,76 @@ +import type { + RuntimeTerminalClose, + RuntimeTerminalFocus, + RuntimeTerminalRead, + RuntimeTerminalSend, + RuntimeTerminalShow, + RuntimeTerminalSplit +} from '../../shared/runtime-types' + +export type AgentTeamsTmuxCompatRequest = { + teamId: string + token: string + envPane: string + cwd?: string + argv: string[] +} + +export type AgentTeamsTmuxCompatResponse = { + ok: boolean + stdout: string + stderr: string + exitCode: number +} + +export type AgentTeamsLaunchEnv = { + teamId: string + token: string + leaderPane: string + env: Record +} + +export type AgentTeamsTerminalApi = { + splitTerminal( + handle: string, + opts: { + direction?: 'horizontal' | 'vertical' + command?: string + env?: Record + envToDelete?: string[] + activate?: boolean + } + ): Promise + readTerminal(handle: string, opts?: { limit?: number }): Promise + sendTerminal( + handle: string, + action: { text?: string; enter?: boolean; interrupt?: boolean } + ): Promise + focusTerminal(handle: string): Promise + closeTerminal(handle: string): Promise + showTerminal(handle: string): Promise +} + +export type TeamPane = { + fakePaneId: string + handle: string + index: number +} + +export type AgentTeam = { + teamId: string + token: string + leaderPane: string + leaderHandle: string + sessionName: string + windowIndex: string + tmuxValue: string + baseEnv: Record + panes: Map + paneOrder: string[] + nextPaneNumber: number + mainVertical: { + mainPane: string + lastColumnPane: string | null + } | null + previouslyFocusedPane: string | null +} diff --git a/src/main/runtime/fetch-remote-cache.test.ts b/src/main/runtime/fetch-remote-cache.test.ts index 400d4d07b27..848465d49a5 100644 --- a/src/main/runtime/fetch-remote-cache.test.ts +++ b/src/main/runtime/fetch-remote-cache.test.ts @@ -148,6 +148,20 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => { }) }) + it('resolves full remote-tracking refs with longest configured remote matching', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'foo\nfoo/bar\norigin\n', stderr: '' }) + const runtime = new OrcaRuntimeService(null) + + await expect( + runtime.resolveRemoteTrackingBase('/repo/e', 'refs/remotes/foo/bar/main') + ).resolves.toEqual({ + remote: 'foo/bar', + branch: 'main', + ref: 'refs/remotes/foo/bar/main', + base: 'foo/bar/main' + }) + }) + it('refreshes a remote-tracking base with an exact no-tags refspec', async () => { mockFetchResults([{ stdout: '', stderr: '' }]) const runtime = new OrcaRuntimeService(null) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 7cc17201d9b..db6c6593db1 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1377,7 +1377,17 @@ describe('OrcaRuntimeService', () => { createdWorktree.path, 'cli-fresh-base', 'origin/main', - false + false, + false, + { + suggestLocalBaseRefUpdate: true, + remoteTrackingBase: { + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + } + } ) expect(result.worktree).toMatchObject({ path: createdWorktree.path }) } finally { @@ -1385,6 +1395,56 @@ describe('OrcaRuntimeService', () => { } }) + it('returns runtime local base update suggestions from addWorktree', async () => { + const runtime = new OrcaRuntimeService(store) + const createdWorktree = { + path: '/tmp/workspaces/cli-stale-main', + head: 'def', + branch: 'cli-stale-main', + isBare: false, + isMainWorktree: false + } + computeWorktreePathMock.mockReturnValue(createdWorktree.path) + ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path) + vi.mocked(addWorktree).mockResolvedValueOnce({ + localBaseRefUpdateSuggestion: { + baseRef: 'origin/main', + localBranch: 'main', + behind: 5 + } + }) + vi.mocked(listWorktrees).mockResolvedValueOnce([createdWorktree]) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { + if (args[0] === 'rev-parse' && args.includes('refs/heads/cli-stale-main^{commit}')) { + throw new Error('branch not found') + } + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) { + return { stdout: '/tmp/repo/.git\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args[1] === '--verify') { + return { stdout: 'base-sha\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + try { + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'cli-stale-main' + }) + + expect(result.localBaseRefUpdateSuggestion).toEqual({ + baseRef: 'origin/main', + localBranch: 'main', + behind: 5 + }) + } finally { + gitSpy.mockRestore() + } + }) + it('does not create runtime local worktrees when remote-tracking base refresh fails', async () => { const runtime = new OrcaRuntimeService(store) const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { @@ -3852,6 +3912,59 @@ describe('OrcaRuntimeService', () => { }) }) + it('enables Claude Agent Teams only for direct Claude launches when configured in-process', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) + const runtimeStore = { + ...store, + getSettings: () => ({ + ...store.getSettings(), + claudeAgentTeamsMode: 'in-process' as const + }) + } + const runtime = new OrcaRuntimeService(runtimeStore) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: "claude 'hello'" + }) + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: "echo ok; claude 'hello'" + }) + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'codex' + }) + + const directClaude = spawn.mock.calls[0]?.[0] as { + command?: string + env?: Record + } + const compoundClaude = spawn.mock.calls[1]?.[0] as { + command?: string + env?: Record + } + const normalAgent = spawn.mock.calls[2]?.[0] as { + command?: string + env?: Record + } + + expect(directClaude.command).toBe("claude --teammate-mode in-process 'hello'") + expect(directClaude.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBe('1') + expect(directClaude.env?.TMUX).toBeUndefined() + + expect(compoundClaude.command).toBe("echo ok; claude 'hello'") + expect(compoundClaude.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBeUndefined() + expect(compoundClaude.env?.TMUX).toBeUndefined() + + expect(normalAgent.command).toBe('codex') + expect(normalAgent.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBeUndefined() + expect(normalAgent.env?.TMUX).toBeUndefined() + }) + it('adopts renderer pane identity for remote runtime terminal creates', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) const runtime = new OrcaRuntimeService(store) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index e9aa40be176..12935179c99 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -182,6 +182,16 @@ import { serveSimStateWatcher } from '../emulator/serve-sim-state-watcher' import type { EmulatorBridge } from '../emulator/emulator-bridge' import { RuntimeFileCommands } from './orca-runtime-files' import { RuntimeGitCommands } from './orca-runtime-git' +import { ClaudeAgentTeamsService } from './claude-agent-teams-service' +import type { + AgentTeamsTmuxCompatRequest, + AgentTeamsTmuxCompatResponse +} from './claude-agent-teams-service' +import { + buildClaudeAgentTeamsLaunchPlan, + ensureClaudeAgentTeamsShimDir, + resolveClaudeAgentTeamsShimBin +} from './claude-agent-teams-shim-env' import { joinWorktreeRelativePath } from './runtime-relative-paths' import { collectMemorySnapshot } from '../memory/collector' import { BrowserWindow, ipcMain } from 'electron' @@ -541,6 +551,7 @@ type RuntimeStore = { workspaceDir: string nestWorkspaces: boolean refreshLocalBaseRefOnWorktreeCreate: boolean + localBaseRefSuggestionDismissed?: boolean branchPrefix: string branchPrefixCustom: string defaultTuiAgent?: GlobalSettings['defaultTuiAgent'] @@ -559,6 +570,7 @@ type RuntimeStore = { mobileEmulatorEnabled?: boolean mobileEmulatorDefaultDeviceUdid?: string | null voice?: VoiceSettings + claudeAgentTeamsMode?: GlobalSettings['claudeAgentTeamsMode'] } // Why: narrow to `unknown` return so test mocks can return void without // a cast. The runtime never reads the return value — the persisted value @@ -720,6 +732,7 @@ type RuntimePtyController = { cwd?: string command?: string env?: Record + envToDelete?: string[] telemetry?: WorktreeStartupLaunch['telemetry'] connectionId?: string | null worktreeId?: string @@ -1570,6 +1583,7 @@ export class OrcaRuntimeService { private accountServices: RuntimeAccountServices | null = null private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null private automationService: AutomationService | null = null + private readonly claudeAgentTeams = new ClaudeAgentTeamsService() private mobileDictation: { id: string owner: string @@ -8990,7 +9004,16 @@ export class OrcaRuntimeService { ) } - const existingBranchOption = { checkoutExistingBranch } + const suggestLocalBaseRefUpdate = + !settings.refreshLocalBaseRefOnWorktreeCreate && + !settings.localBaseRefSuggestionDismissed && + Boolean(remoteTrackingBase) + const remoteTrackingBaseOption = remoteTrackingBase ? { remoteTrackingBase } : undefined + const existingBranchOption = { + checkoutExistingBranch, + ...remoteTrackingBaseOption, + ...(suggestLocalBaseRefUpdate ? { suggestLocalBaseRefUpdate } : {}) + } const addResult: AddWorktreeResult = (await (sparseDirectories.length > 0 ? checkoutExistingBranch @@ -9003,14 +9026,34 @@ export class OrcaRuntimeService { settings.refreshLocalBaseRefOnWorktreeCreate, existingBranchOption ) - : addSparseWorktree( - repo.path, - worktreePath, - branchName, - sparseDirectories, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate - ) + : suggestLocalBaseRefUpdate + ? addSparseWorktree( + repo.path, + worktreePath, + branchName, + sparseDirectories, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate, + { ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate } + ) + : remoteTrackingBaseOption + ? addSparseWorktree( + repo.path, + worktreePath, + branchName, + sparseDirectories, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate, + remoteTrackingBaseOption + ) + : addSparseWorktree( + repo.path, + worktreePath, + branchName, + sparseDirectories, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate + ) : checkoutExistingBranch ? addWorktree( repo.path, @@ -9021,13 +9064,33 @@ export class OrcaRuntimeService { false, existingBranchOption ) - : addWorktree( - repo.path, - worktreePath, - branchName, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate - ))) ?? {} + : suggestLocalBaseRefUpdate + ? addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate, + false, + { ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate } + ) + : remoteTrackingBaseOption + ? addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate, + false, + remoteTrackingBaseOption + ) + : addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate + ))) ?? {} let configuredPushTarget: GitPushTarget | undefined if (preparedPushTarget) { @@ -9307,6 +9370,9 @@ export class OrcaRuntimeService { ...(warning ? { warning } : {}), ...(addResult.localBaseRefRefresh ? { localBaseRefRefresh: addResult.localBaseRefRefresh } + : {}), + ...(addResult.localBaseRefUpdateSuggestion + ? { localBaseRefUpdateSuggestion: addResult.localBaseRefUpdateSuggestion } : {}) } } @@ -9690,13 +9756,17 @@ export class OrcaRuntimeService { return null } + const remoteRefPrefix = 'refs/remotes/' + const shortBaseBranch = baseBranch.startsWith(remoteRefPrefix) + ? baseBranch.slice(remoteRefPrefix.length) + : baseBranch const remote = remotes - .filter((candidate) => baseBranch.startsWith(`${candidate}/`)) + .filter((candidate) => shortBaseBranch.startsWith(`${candidate}/`)) .sort((a, b) => b.length - a.length)[0] if (!remote) { return null } - const branch = baseBranch.slice(remote.length + 1) + const branch = shortBaseBranch.slice(remote.length + 1) if (!branch) { return null } @@ -9704,7 +9774,7 @@ export class OrcaRuntimeService { remote, branch, ref: `refs/remotes/${remote}/${branch}`, - base: baseBranch + base: `${remote}/${branch}` } } @@ -10768,8 +10838,28 @@ export class OrcaRuntimeService { const tabId = canAdoptPaneIdentity ? (hintedTabId as string) : randomUUID() const leafId = canAdoptPaneIdentity ? (opts.leafId as string) : randomUUID() const paneKey = makePaneKey(tabId, leafId) + const baseEnv = opts.env ?? {} + const agentTeamsPlan = await buildClaudeAgentTeamsLaunchPlan({ + command: opts.command, + mode: this.store?.getSettings?.().claudeAgentTeamsMode, + baseEnv: { + ...process.env, + ...baseEnv + }, + createTeamEnv: (shimDir, shimBin) => + this.claudeAgentTeams.createLaunchEnv({ + leaderHandle: preAllocatedHandle, + baseEnv: { + ...process.env, + ...baseEnv + }, + shimDir, + shimBin + }).env + }) const env = { - ...opts.env, + ...baseEnv, + ...agentTeamsPlan?.env, ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: tabId, ORCA_WORKTREE_ID: worktree.id @@ -10778,8 +10868,9 @@ export class OrcaRuntimeService { cols: 120, rows: 40, cwd: worktree.path, - command: opts.command, + command: agentTeamsPlan?.command ?? opts.command, env, + envToDelete: agentTeamsPlan?.envToDelete, telemetry: opts.telemetry, connectionId: repo?.connectionId ?? null, worktreeId: worktree.id, @@ -11293,6 +11384,7 @@ export class OrcaRuntimeService { async closeTerminal(handle: string): Promise { this.assertGraphReady() const pty = this.getLivePtyForHandle(handle) + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) if (pty) { const ptyKilled = this.ptyController?.kill(pty.pty.ptyId) ?? false return { handle, tabId: pty.record.tabId, ptyKilled } @@ -11322,6 +11414,7 @@ export class OrcaRuntimeService { direction?: 'horizontal' | 'vertical' command?: string env?: Record + envToDelete?: string[] activate?: boolean telemetrySource?: TerminalPaneSplitSource } = {} @@ -11359,6 +11452,7 @@ export class OrcaRuntimeService { direction?: 'horizontal' | 'vertical' command?: string env?: Record + envToDelete?: string[] activate?: boolean telemetrySource?: TerminalPaneSplitSource } = {} @@ -11391,6 +11485,7 @@ export class OrcaRuntimeService { ORCA_TAB_ID: parentTabId, ORCA_WORKTREE_ID: worktree.id }, + envToDelete: opts.envToDelete, connectionId: repo?.connectionId ?? null, worktreeId: worktree.id, preAllocatedHandle @@ -11422,6 +11517,41 @@ export class OrcaRuntimeService { return { handle: this.issuePtyHandle(createdPty ?? pty), tabId: parentTabId, paneRuntimeId: -1 } } + async handleAgentTeamsTmuxCompat( + request: AgentTeamsTmuxCompatRequest + ): Promise { + return await this.claudeAgentTeams.handleTmuxCompat(request, { + splitTerminal: (handle, opts) => this.splitTerminal(handle, opts), + readTerminal: (handle, opts) => this.readTerminal(handle, opts), + sendTerminal: (handle, action) => this.sendTerminal(handle, action), + focusTerminal: (handle) => this.focusTerminal(handle), + closeTerminal: (handle) => this.closeTerminal(handle), + showTerminal: (handle) => this.showTerminal(handle) + }) + } + + async prepareClaudeAgentTeamsLeader(args: { + paneKey: string + baseEnv?: Record + }): Promise<{ env: Record }> { + const handle = this.getTerminalHandleForPaneKey(args.paneKey) + if (!handle) { + throw new Error('claude_agent_teams_requires_orca_terminal') + } + const baseEnv = { + ...process.env, + ...args.baseEnv + } + const shimDir = await ensureClaudeAgentTeamsShimDir() + const shimBin = resolveClaudeAgentTeamsShimBin(baseEnv) + return this.claudeAgentTeams.createLaunchEnv({ + leaderHandle: handle, + baseEnv, + shimDir, + shimBin + }) + } + private waitForNewLeafInTab( tabId: string, existingLeafKeys: Set, diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 21d4791c50f..fc22d899e2b 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -485,6 +485,19 @@ const TerminalStop = z.object({ worktree: requiredString('Missing worktree selector') }) +const AgentTeamsTmuxCompat = z.object({ + teamId: requiredString('Missing agent team ID'), + token: requiredString('Missing agent team token'), + envPane: requiredString('Missing tmux pane identity'), + cwd: OptionalString, + argv: z.array(z.string()) +}) + +const AgentTeamsPrepareLaunch = z.object({ + paneKey: requiredString('Missing pane key'), + env: z.record(z.string(), z.string()).optional() +}) + const TerminalResizeForClient = z.discriminatedUnion('mode', [ z.object({ terminal: requiredString('Missing terminal handle'), @@ -771,6 +784,23 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ close: await runtime.closeTerminal(params.terminal) }) }), + defineMethod({ + name: 'agentTeams.tmuxCompat', + params: AgentTeamsTmuxCompat, + handler: async (params, { runtime }) => ({ + tmux: await runtime.handleAgentTeamsTmuxCompat(params) + }) + }), + defineMethod({ + name: 'agentTeams.prepareLaunch', + params: AgentTeamsPrepareLaunch, + handler: async (params, { runtime }) => ({ + launch: await runtime.prepareClaudeAgentTeamsLeader({ + paneKey: params.paneKey, + baseEnv: params.env + }) + }) + }), defineMethod({ name: 'terminal.setDisplayMode', params: TerminalSetDisplayMode, diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index e692a10f7f4..68994b1d045 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -277,6 +277,8 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'speech.dictation.start', 'stats.summary', 'status.get', + 'agentTeams.prepareLaunch', + 'agentTeams.tmuxCompat', 'terminal.clearBuffer', 'terminal.close', 'terminal.create', diff --git a/src/main/startup/run-electron-vite-dev.test.ts b/src/main/startup/run-electron-vite-dev.test.ts index f9e93c9c400..7a30d115b16 100644 --- a/src/main/startup/run-electron-vite-dev.test.ts +++ b/src/main/startup/run-electron-vite-dev.test.ts @@ -240,6 +240,51 @@ describe('run-electron-vite-dev', () => { await stopWrapperAndTrackedPids(wrapper, trackedPids) }) + it.skipIf(process.platform === 'win32')( + 'prepares userData orca and orca-dev wrappers for dev terminals', + async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'orca-dev-wrapper-')) + const userDataPath = join(tempDir, 'userData') + const pidFile = join(tempDir, 'grandchild.pid') + const envFile = join(tempDir, 'env.json') + const wrapperPath = resolve('config/scripts/run-electron-vite-dev.mjs') + const fakeCliPath = resolve('src/main/startup/__fixtures__/fake-electron-vite-dev-cli.mjs') + + const wrapper = spawn(process.execPath, [wrapperPath], { + cwd: resolve('.'), + env: devWrapperTestEnv({ + ORCA_DEV_USER_DATA_PATH: userDataPath, + ORCA_ELECTRON_VITE_CLI: fakeCliPath, + ORCA_SKIP_DEV_ELECTRON_APP_PREPARE: '1', + ORCA_SKIP_DEV_WEB_PREPARE: '1', + ORCA_DEV_WRAPPER_TEST_PID_FILE: pidFile, + ORCA_DEV_WRAPPER_TEST_ENV_FILE: envFile + }), + stdio: 'ignore' + }) + + expect(wrapper.pid).toBeTypeOf('number') + processesToCleanUp.add(wrapper.pid!) + + await waitFor(() => { + try { + return readFileSync(envFile, 'utf8').trim().length > 0 + } catch { + return false + } + }) + + const trackedPids = trackPidFile(pidFile) + const devWrapper = readFileSync(join(userDataPath, 'cli', 'bin', 'orca-dev'), 'utf8') + const publicAliasWrapper = readFileSync(join(userDataPath, 'cli', 'bin', 'orca'), 'utf8') + expect(publicAliasWrapper).toBe(devWrapper) + expect(publicAliasWrapper).toContain('ORCA_USER_DATA_PATH') + expect(publicAliasWrapper).toContain('out/cli/index.js') + + await stopWrapperAndTrackedPids(wrapper, trackedPids) + } + ) + it('consumes the stable-name flag before forwarding args to electron-vite', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'orca-dev-wrapper-')) const pidFile = join(tempDir, 'grandchild.pid') diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index abfdd4c1b9a..951d71b53f1 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -821,6 +821,13 @@ export type PreloadApi = { listDetected: (args: { repoId: string }) => Promise listAll: () => Promise create: (args: CreateWorktreeArgs) => Promise + /** Two-phase progress for a background `create`, correlated by + * `creationId`. Renderer routes each event to its pending creation's + * status surface; the remote/runtime create path emits nothing, so the + * surface falls back to an indeterminate spinner. */ + onCreateProgress: ( + callback: (data: { creationId?: string; phase: 'fetching' | 'creating' }) => void + ) => () => void prefetchCreateBase: (args: { repoId: string; baseBranch?: string }) => Promise resolvePrBase: (args: { repoId: string diff --git a/src/preload/index.ts b/src/preload/index.ts index de4c9888b54..e479ef67851 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -544,6 +544,17 @@ const api = { create: (args) => ipcRenderer.invoke('worktrees:create', args), + onCreateProgress: ( + callback: (data: { creationId?: string; phase: 'fetching' | 'creating' }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { creationId?: string; phase: 'fetching' | 'creating' } + ) => callback(data) + ipcRenderer.on('createWorktree:progress', listener) + return () => ipcRenderer.removeListener('createWorktree:progress', listener) + }, + prefetchCreateBase: (args) => ipcRenderer.invoke('worktrees:prefetchCreateBase', args), resolvePrBase: (args) => ipcRenderer.invoke('worktrees:resolvePrBase', args), diff --git a/src/relay/git-handler-local-base-ref-refresh.ts b/src/relay/git-handler-local-base-ref-refresh.ts new file mode 100644 index 00000000000..2ff1967a7d3 --- /dev/null +++ b/src/relay/git-handler-local-base-ref-refresh.ts @@ -0,0 +1,86 @@ +import type { GitExec } from './git-handler-ops' +import { areRelayWorktreePathsEqual, readRelayWorktreeList } from './git-handler-worktree-ops' + +export async function refreshLocalBaseRefForWorktreeCreateOp( + git: GitExec, + params: Record +): Promise { + const repoPath = params.repoPath as string + const fullRef = params.fullRef as string + const remoteTrackingRef = params.remoteTrackingRef as string + const ownerWorktreePath = params.ownerWorktreePath as string | undefined + const checkOnly = params.checkOnly === true + + if ( + typeof repoPath !== 'string' || + typeof fullRef !== 'string' || + typeof remoteTrackingRef !== 'string' || + (ownerWorktreePath !== undefined && typeof ownerWorktreePath !== 'string') + ) { + throw new Error('Invalid local base ref refresh request.') + } + if (!fullRef.startsWith('refs/heads/') || !remoteTrackingRef.startsWith('refs/remotes/')) { + throw new Error('Invalid local base ref refresh refs.') + } + + await git(['check-ref-format', fullRef], repoPath) + await git(['check-ref-format', remoteTrackingRef], repoPath) + + const localOid = await revParseCommit(git, repoPath, fullRef, 'Local base ref is missing.') + const remoteOid = await revParseCommit( + git, + repoPath, + remoteTrackingRef, + 'Remote-tracking base ref is missing.' + ) + + // Why: this RPC mutates refs/worktrees, so the relay repeats main-process + // safety checks at mutation time to close stale-preflight and direct-call gaps. + try { + await git(['merge-base', '--is-ancestor', localOid, remoteOid], repoPath) + } catch { + throw new Error('Local base ref is not a fast-forward update.') + } + + const worktrees = await readRelayWorktreeList(git, repoPath) + const ownerWorktree = worktrees.find((worktree) => worktree.branch === fullRef) + if (ownerWorktree) { + if (ownerWorktreePath && !areRelayWorktreePathsEqual(ownerWorktree.path, ownerWorktreePath)) { + throw new Error('Local base ref is checked out in a different worktree.') + } + const { stdout } = await git( + ['status', '--porcelain', '--untracked-files=no'], + ownerWorktree.path + ) + if (stdout.trim()) { + throw new Error('Local base ref worktree has tracked changes.') + } + if (checkOnly) { + return + } + await git(['reset', '--hard', remoteOid], ownerWorktree.path) + return + } + + // Why: not checked out anywhere — fast-forward the bare ref. The + // expected-old-OID form is a no-op-safe compare-and-swap if the ref moved + // since the caller's evaluation snapshot. + if (checkOnly) { + return + } + await git(['update-ref', fullRef, remoteOid, localOid], repoPath) +} + +async function revParseCommit( + git: GitExec, + repoPath: string, + ref: string, + missingMessage: string +): Promise { + const { stdout } = await git(['rev-parse', '--verify', `${ref}^{commit}`], repoPath) + const oid = stdout.trim() + if (!oid) { + throw new Error(missingMessage) + } + return oid +} diff --git a/src/relay/git-handler-worktree-ops.ts b/src/relay/git-handler-worktree-ops.ts index 73ea16403fb..85a102e503b 100644 --- a/src/relay/git-handler-worktree-ops.ts +++ b/src/relay/git-handler-worktree-ops.ts @@ -216,7 +216,10 @@ async function listRelayWorktrees(git: GitExec, repoPath: string): Promise { +export async function readRelayWorktreeList( + git: GitExec, + repoPath: string +): Promise { try { const { stdout } = await git(['worktree', 'list', '--porcelain', '-z'], repoPath) return normalizeRelayWorktrees(parseWorktreeList(stdout, { nulDelimited: true })) @@ -245,7 +248,7 @@ function normalizeLocalBranchRef(branch: string): string { return branch.replace(/^refs\/heads\//, '') } -function areRelayWorktreePathsEqual(leftPath: string, rightPath: string): boolean { +export function areRelayWorktreePathsEqual(leftPath: string, rightPath: string): boolean { const left = path.normalize(path.resolve(leftPath)) const right = path.normalize(path.resolve(rightPath)) return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right @@ -256,7 +259,11 @@ export async function worktreeIsCleanOp( params: Record ): Promise<{ clean: boolean; stdout?: string }> { const worktreePath = params.worktreePath as string - const { stdout } = await git(['status', '--porcelain', '--untracked-files=all'], worktreePath) + const includeUntracked = params.includeUntracked !== false + const { stdout } = await git( + ['status', '--porcelain', includeUntracked ? '--untracked-files=all' : '--untracked-files=no'], + worktreePath + ) const clean = !stdout.trim() return { clean, stdout: clean ? undefined : stdout } } diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index 7bcef632f66..79d3d763a2b 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -34,6 +34,30 @@ describe('GitHandler', () => { await fs.rm(tmpDir, { recursive: true, force: true }) }) + function currentBranch(cwd: string): string { + return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd, + encoding: 'utf-8' + }).trim() + } + + function currentBranchFullRef(cwd: string): string { + return `refs/heads/${currentBranch(cwd)}` + } + + function reportedWorktreePath(cwd: string): string { + return ( + execFileSync('git', ['worktree', 'list', '--porcelain'], { + cwd, + encoding: 'utf-8' + }) + .split(/\r?\n/) + .find((line) => line.startsWith('worktree ')) + ?.slice('worktree '.length) + .trim() ?? cwd + ) + } + it('registers all expected handlers', () => { const methods = Array.from(dispatcher._requestHandlers.keys()) expect(methods).toContain('git.status') @@ -63,6 +87,7 @@ describe('GitHandler', () => { expect(methods).toContain('git.addWorktree') expect(methods).toContain('git.removeWorktree') expect(methods).toContain('git.worktreeIsClean') + expect(methods).toContain('git.refreshLocalBaseRefForWorktreeCreate') expect(methods).toContain('git.renameCurrentBranch') expect(methods).toContain('git.exec') expect(methods).toContain('git.isGitRepo') @@ -1102,6 +1127,357 @@ describe('GitHandler', () => { ) }) + describe('worktreeIsClean', () => { + it('can ignore untracked files', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'tracked.txt'), 'initial') + gitCommit(tmpDir, 'initial') + writeFileSync(path.join(tmpDir, 'scratch.txt'), 'untracked') + + await expect( + dispatcher.callRequest('git.worktreeIsClean', { worktreePath: tmpDir }) + ).resolves.toEqual({ + clean: false, + stdout: expect.stringContaining('scratch.txt') + }) + await expect( + dispatcher.callRequest('git.worktreeIsClean', { + worktreePath: tmpDir, + includeUntracked: false + }) + ).resolves.toEqual({ clean: true }) + }) + }) + + describe('refreshLocalBaseRefForWorktreeCreate', () => { + function setupMockedRefreshHandler() { + const localDispatcher = createMockDispatcher() + const localHandler = new GitHandler( + localDispatcher as unknown as RelayDispatcher, + new RelayContext() + ) + const gitMock = + vi.fn< + ( + args: string[], + cwd: string, + opts?: { maxBuffer?: number } + ) => Promise<{ stdout: string; stderr: string }> + >() + ;(localHandler as unknown as { git: typeof gitMock }).git = gitMock + return { localDispatcher, gitMock } + } + + it('resets the owning worktree to the remote-tracking ref', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'base.txt'), 'base') + gitCommit(tmpDir, 'initial') + const branchRef = currentBranchFullRef(tmpDir) + const ownerPath = reportedWorktreePath(tmpDir) + const firstSha = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + writeFileSync(path.join(tmpDir, 'base.txt'), 'remote') + gitCommit(tmpDir, 'remote update') + const remoteSha = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + execFileSync('git', ['update-ref', 'refs/remotes/origin/main', remoteSha], { + cwd: tmpDir, + stdio: 'pipe' + }) + execFileSync('git', ['reset', '--hard', firstSha], { cwd: tmpDir, stdio: 'pipe' }) + + await dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { + repoPath: tmpDir, + fullRef: branchRef, + remoteTrackingRef: 'refs/remotes/origin/main', + ownerWorktreePath: ownerPath + }) + + const actual = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + expect(actual).toBe(remoteSha) + await expect(fs.readFile(path.join(tmpDir, 'base.txt'), 'utf-8')).resolves.toBe('remote') + }) + + it('fast-forwards a non-checked-out local branch via update-ref', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'base.txt'), 'base') + gitCommit(tmpDir, 'initial') + execFileSync('git', ['branch', 'main-copy'], { cwd: tmpDir, stdio: 'pipe' }) + writeFileSync(path.join(tmpDir, 'base.txt'), 'remote') + gitCommit(tmpDir, 'remote update') + const remoteSha = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + execFileSync('git', ['update-ref', 'refs/remotes/origin/main', remoteSha], { + cwd: tmpDir, + stdio: 'pipe' + }) + + await dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { + repoPath: tmpDir, + fullRef: 'refs/heads/main-copy', + remoteTrackingRef: 'refs/remotes/origin/main' + }) + + // No working tree owns main-copy, so the bare ref fast-forwards. + const actual = execFileSync('git', ['rev-parse', 'refs/heads/main-copy'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + expect(actual).toBe(remoteSha) + }) + + it('does not move a non-checked-out local branch when checkOnly is set', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'base.txt'), 'base') + gitCommit(tmpDir, 'initial') + execFileSync('git', ['branch', 'main-copy'], { cwd: tmpDir, stdio: 'pipe' }) + const originalSha = execFileSync('git', ['rev-parse', 'refs/heads/main-copy'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + writeFileSync(path.join(tmpDir, 'base.txt'), 'remote') + gitCommit(tmpDir, 'remote update') + const remoteSha = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + execFileSync('git', ['update-ref', 'refs/remotes/origin/main', remoteSha], { + cwd: tmpDir, + stdio: 'pipe' + }) + + await dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { + repoPath: tmpDir, + fullRef: 'refs/heads/main-copy', + remoteTrackingRef: 'refs/remotes/origin/main', + checkOnly: true + }) + + const actual = execFileSync('git', ['rev-parse', 'refs/heads/main-copy'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + expect(actual).toBe(originalSha) + }) + + it('rejects invalid local base ref refresh refs', async () => { + gitInit(tmpDir) + + await expect( + dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { + repoPath: tmpDir, + fullRef: 'refs/tags/main', + remoteTrackingRef: 'refs/remotes/origin/main' + }) + ).rejects.toThrow('Invalid local base ref refresh refs.') + }) + + it('rejects a dirty owner worktree before resetting', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'base.txt'), 'base') + gitCommit(tmpDir, 'initial') + const branchRef = currentBranchFullRef(tmpDir) + const ownerPath = reportedWorktreePath(tmpDir) + const firstSha = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + writeFileSync(path.join(tmpDir, 'base.txt'), 'remote') + gitCommit(tmpDir, 'remote update') + const remoteSha = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + execFileSync('git', ['update-ref', 'refs/remotes/origin/main', remoteSha], { + cwd: tmpDir, + stdio: 'pipe' + }) + execFileSync('git', ['reset', '--hard', firstSha], { cwd: tmpDir, stdio: 'pipe' }) + writeFileSync(path.join(tmpDir, 'base.txt'), 'local dirty') + + await expect( + dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { + repoPath: tmpDir, + fullRef: branchRef, + remoteTrackingRef: 'refs/remotes/origin/main', + ownerWorktreePath: ownerPath + }) + ).rejects.toThrow('Local base ref worktree has tracked changes.') + + const actual = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + expect(actual).toBe(firstSha) + await expect(fs.readFile(path.join(tmpDir, 'base.txt'), 'utf-8')).resolves.toBe('local dirty') + }) + + it('rejects when the caller-supplied owner path is not the checked-out branch owner', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'base.txt'), 'base') + gitCommit(tmpDir, 'initial') + const branchRef = currentBranchFullRef(tmpDir) + const headSha = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + execFileSync('git', ['update-ref', 'refs/remotes/origin/main', headSha], { + cwd: tmpDir, + stdio: 'pipe' + }) + + await expect( + dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { + repoPath: tmpDir, + fullRef: branchRef, + remoteTrackingRef: 'refs/remotes/origin/main', + ownerWorktreePath: path.join(path.dirname(tmpDir), 'different-owner') + }) + ).rejects.toThrow('Local base ref is checked out in a different worktree.') + }) + + it('rejects diverged local refs before mutating', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'base.txt'), 'base') + gitCommit(tmpDir, 'initial') + execFileSync('git', ['branch', 'main-copy'], { cwd: tmpDir, stdio: 'pipe' }) + writeFileSync(path.join(tmpDir, 'remote.txt'), 'remote') + gitCommit(tmpDir, 'remote update') + const remoteSha = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + execFileSync('git', ['update-ref', 'refs/remotes/origin/main', remoteSha], { + cwd: tmpDir, + stdio: 'pipe' + }) + execFileSync('git', ['checkout', 'main-copy'], { cwd: tmpDir, stdio: 'pipe' }) + writeFileSync(path.join(tmpDir, 'local.txt'), 'local') + gitCommit(tmpDir, 'local update') + const localSha = execFileSync('git', ['rev-parse', 'refs/heads/main-copy'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + + await expect( + dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { + repoPath: tmpDir, + fullRef: 'refs/heads/main-copy', + remoteTrackingRef: 'refs/remotes/origin/main', + ownerWorktreePath: tmpDir + }) + ).rejects.toThrow('Local base ref is not a fast-forward update.') + + const actual = execFileSync('git', ['rev-parse', 'refs/heads/main-copy'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + expect(actual).toBe(localSha) + }) + + it('resets owner worktree to captured remote OID without update-ref', async () => { + const { localDispatcher, gitMock } = setupMockedRefreshHandler() + gitMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'check-ref-format') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'rev-parse' && args[2] === 'refs/remotes/origin/main^{commit}') { + return { stdout: 'remote-oid\n', stderr: '' } + } + if (args[0] === 'rev-parse') { + return { stdout: 'old-local-oid\n', stderr: '' } + } + if (args[0] === 'merge-base') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'worktree') { + return { + stdout: 'worktree /repo\nHEAD old-local-oid\nbranch refs/heads/main\n', + stderr: '' + } + } + if (args[0] === 'status') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'reset') { + return { stdout: '', stderr: '' } + } + throw new Error(`unexpected git call: ${args.join(' ')}`) + }) + + await expect( + localDispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { + repoPath: '/repo', + fullRef: 'refs/heads/main', + remoteTrackingRef: 'refs/remotes/origin/main' + }) + ).resolves.toBeUndefined() + + expect(gitMock).toHaveBeenCalledWith( + ['merge-base', '--is-ancestor', 'old-local-oid', 'remote-oid'], + '/repo' + ) + expect(gitMock).toHaveBeenCalledWith(['reset', '--hard', 'remote-oid'], '/repo') + expect(gitMock.mock.calls.map((call) => call[0])).not.toContainEqual([ + 'update-ref', + 'refs/heads/main', + 'remote-oid', + 'old-local-oid' + ]) + }) + + it('fails closed when worktree ownership cannot be listed', async () => { + const { localDispatcher, gitMock } = setupMockedRefreshHandler() + gitMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'check-ref-format') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'rev-parse' && args[2] === 'refs/remotes/origin/main^{commit}') { + return { stdout: 'remote-oid\n', stderr: '' } + } + if (args[0] === 'rev-parse') { + return { stdout: 'old-local-oid\n', stderr: '' } + } + if (args[0] === 'merge-base') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'worktree') { + throw new Error('worktree list failed') + } + throw new Error(`unexpected git call: ${args.join(' ')}`) + }) + + await expect( + localDispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { + repoPath: '/repo', + fullRef: 'refs/heads/main', + remoteTrackingRef: 'refs/remotes/origin/main' + }) + ).rejects.toThrow('worktree list failed') + + expect(gitMock.mock.calls.map((call) => call[0])).not.toContainEqual([ + 'update-ref', + 'refs/heads/main', + 'refs/remotes/origin/main', + 'old-local-oid' + ]) + expect(gitMock.mock.calls.map((call) => call[0])).not.toContainEqual([ + 'reset', + '--hard', + 'refs/heads/main' + ]) + }) + }) + describe('addWorktree', () => { // Why: relay handler tests for addWorktree use a mock-injection approach // to deterministically control git exit codes (in particular `--get` exit diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 2abfbd07008..1ae336b81e7 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -25,6 +25,7 @@ import { removeWorktreeOp, worktreeIsCleanOp } from './git-handler-worktree-ops' +import { refreshLocalBaseRefForWorktreeCreateOp } from './git-handler-local-base-ref-refresh' import { checkIgnoredPathsOp, detectConflictOperation, getStatusOp } from './git-handler-status-ops' import { resolveRelayPushTarget } from './git-handler-push-target' import { normalizeGitErrorMessage, isNoUpstreamError } from '../shared/git-remote-error' @@ -88,6 +89,9 @@ export class GitHandler { this.dispatcher.onRequest('git.addWorktree', (p) => this.addWorktree(p)) this.dispatcher.onRequest('git.removeWorktree', (p) => this.removeWorktree(p)) this.dispatcher.onRequest('git.worktreeIsClean', (p) => this.worktreeIsClean(p)) + this.dispatcher.onRequest('git.refreshLocalBaseRefForWorktreeCreate', (p) => + this.refreshLocalBaseRefForWorktreeCreate(p) + ) this.dispatcher.onRequest('git.renameCurrentBranch', (p) => this.renameCurrentBranch(p)) this.dispatcher.onRequest('git.exec', (p) => this.exec(p)) this.dispatcher.onRequest('git.isGitRepo', (p) => this.isGitRepo(p)) @@ -651,4 +655,8 @@ export class GitHandler { private async worktreeIsClean(params: Record) { return worktreeIsCleanOp(this.git.bind(this), params) } + + private async refreshLocalBaseRefForWorktreeCreate(params: Record) { + return refreshLocalBaseRefForWorktreeCreateOp(this.git.bind(this), params) + } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ad7ac871799..19a3a00c4f1 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -214,6 +214,9 @@ function WindowControls(): React.JSX.Element { } const Landing = lazy(() => import('./components/Landing')) +const WorktreeCreationPanel = lazy( + () => import('./components/worktree-creation/WorktreeCreationPanel') +) const TaskPage = lazy(() => import('./components/TaskPage')) const AutomationsPage = lazy(() => import('./components/automations/AutomationsPage')) const ActivityPrototypePage = lazy(() => import('./components/activity/ActivityPrototypePage')) @@ -325,6 +328,16 @@ function App(): React.JSX.Element { const featureInteractions = useAppStore((s) => s.featureInteractions) const contextualToursAutoEligible = useAppStore((s) => s.contextualToursAutoEligible) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) + const activePendingCreationId = useAppStore((s) => s.activePendingCreationId) + // Why: the creation loader is debounced — a fast create resolves before its + // entry's loaderVisible flips, so the content area keeps showing the prior + // workspace (or Landing) and never flashes a loader. Only a create still + // pending past the debounce gates the loader and hides the terminal. + const activeCreationLoaderVisible = useAppStore( + (s) => + s.activePendingCreationId != null && + s.pendingWorktreeCreations[s.activePendingCreationId]?.loaderVisible === true + ) // Why: App swaps the sidebar between workspace and landing layouts when the // active workspace is slept/deleted. Keep virtualized scroll memory above // that remount so the left workspace list doesn't restart at scrollTop 0. @@ -1797,7 +1810,9 @@ function App(): React.JSX.Element {
: null} {activeView === 'space' ? : null} {activeView === 'mobile' ? : null} - {activeView === 'terminal' && !activeWorktreeId ? : null} + {activeView === 'terminal' && + activeCreationLoaderVisible && + activePendingCreationId ? ( + + ) : null} + {activeView === 'terminal' && + !activeWorktreeId && + !activeCreationLoaderVisible ? ( + + ) : null}
diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 5116e9ca4d9..ca1987bab88 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -722,9 +722,6 @@ export default function BrowserPane({ const setBrowserPageUrl = useAppStore((s) => s.setBrowserPageUrl) const runtimeEnvironmentActive = Boolean(activeRuntimeEnvironmentId?.trim()) const activeBrowserPageId = activeBrowserPage?.id ?? null - const activeBrowserPageIsBlank = - !runtimeEnvironmentActive && - (activeBrowserPage?.url === 'about:blank' || activeBrowserPage?.url === ORCA_BROWSER_BLANK_URL) const browserPageIds = useMemo(() => browserPages.map((page) => page.id), [browserPages]) const automationVisiblePageIds = useBrowserAutomationVisiblePageIds(browserPageIds) // Why: inactive Electron webviews must stay mounted in their original DOM @@ -759,10 +756,7 @@ export default function BrowserPane({ useContextualTour( 'browser', - isActive && - activeBrowserPage !== null && - !runtimeEnvironmentActive && - !activeBrowserPageIsBlank, + isActive && activeBrowserPage !== null && !runtimeEnvironmentActive, 'browser_visible' ) @@ -4619,6 +4613,7 @@ function BrowserPagePane({ browserPageId={browserTab.id} viewportPresetId={browserTab.viewportPresetId ?? null} onDestroyWebview={() => destroyPersistentWebview(browserTab.id)} + isActive={isActive} />
{downloadState ? ( diff --git a/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx b/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx index 95b004f4aaa..fc5783bd8d1 100644 --- a/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx +++ b/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useLayoutEffect, useState } from 'react' import { Check, Ellipsis, Import, Monitor, Plus, Settings } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' @@ -40,6 +40,7 @@ type BrowserToolbarMenuProps = { browserPageId: string viewportPresetId: BrowserViewportPresetId | null onDestroyWebview: () => void + isActive: boolean } export function BrowserToolbarMenu({ @@ -47,7 +48,8 @@ export function BrowserToolbarMenu({ workspaceId, browserPageId, viewportPresetId, - onDestroyWebview + onDestroyWebview, + isActive }: BrowserToolbarMenuProps): React.JSX.Element { const browserSessionProfiles = useAppStore((s) => s.browserSessionProfiles) const detectedBrowsers = useAppStore((s) => s.detectedBrowsers) @@ -58,6 +60,10 @@ export function BrowserToolbarMenu({ const fetchDetectedBrowsers = useAppStore((s) => s.fetchDetectedBrowsers) const browserSessionImportState = useAppStore((s) => s.browserSessionImportState) const setBrowserPageViewportPreset = useAppStore((s) => s.setBrowserPageViewportPreset) + const browserCookieTourStepActive = useAppStore( + (s) => s.activeContextualTourId === 'browser' && s.activeContextualTourStepIndex === 2 + ) + const shouldForceMenuOpen = browserCookieTourStepActive && isActive const applyViewportPreset = (nextId: BrowserViewportPresetId | null): void => { setBrowserPageViewportPreset(browserPageId, nextId) @@ -72,8 +78,22 @@ export function BrowserToolbarMenu({ const [pendingSwitchProfileId, setPendingSwitchProfileId] = useState( undefined ) + const [menuOpen, setMenuOpen] = useState(false) const mountedRef = useMountedRef() + useLayoutEffect(() => { + // Why: step 3 anchors on Import Cookies inside this menu, so open it only + // once the tour reaches the final browser step. + setMenuOpen(shouldForceMenuOpen) + }, [shouldForceMenuOpen]) + + const handleMenuOpenChange = (open: boolean): void => { + if (shouldForceMenuOpen && !open) { + return + } + setMenuOpen(open) + } + const effectiveProfileId = currentProfileId ?? 'default' const defaultProfile = browserSessionProfiles.find((p) => p.id === 'default') @@ -165,7 +185,7 @@ export function BrowserToolbarMenu({ return ( <> - + - - - ) : null} - {hiddenExperimentalUnlocked ? : null} ) diff --git a/src/renderer/src/components/settings/GitPane.test.ts b/src/renderer/src/components/settings/GitPane.test.ts index 1c848fbae82..58789758dce 100644 --- a/src/renderer/src/components/settings/GitPane.test.ts +++ b/src/renderer/src/components/settings/GitPane.test.ts @@ -59,4 +59,13 @@ describe('GitPane', () => { expect(shouldOpenAutoRenameBranchAdvanced('')).toBe(false) expect(shouldOpenAutoRenameBranchAdvanced('creature name')).toBe(false) }) + + it('renders the local main freshness setting with outcome-focused copy', () => { + const markup = renderGitPane('behind main') + + expect(markup).toContain('Keep Local Main Up to Date') + expect(markup).toContain('git diff main...HEAD') + expect(markup).toContain('local-only commits') + expect(markup).not.toContain('Refresh Local Base Ref') + }) }) diff --git a/src/renderer/src/components/settings/GitPane.tsx b/src/renderer/src/components/settings/GitPane.tsx index 9e6dc147058..3ece231919f 100644 --- a/src/renderer/src/components/settings/GitPane.tsx +++ b/src/renderer/src/components/settings/GitPane.tsx @@ -13,6 +13,24 @@ import { AUTO_RENAME_BRANCH_SEARCH_ENTRIES } from './auto-rename-branch-search' export { GIT_PANE_SEARCH_ENTRIES } +const KEEP_LOCAL_MAIN_UP_TO_DATE_TITLE = 'Keep Local Main Up to Date' +const KEEP_LOCAL_MAIN_UP_TO_DATE_DESCRIPTION = + 'When you create a workspace, Orca refreshes the remote base and safely fast-forwards your matching local branch, such as main or master. This keeps commands like git diff main...HEAD from comparing against stale history. Orca skips the update if that branch has uncommitted changes or local-only commits.' +const KEEP_LOCAL_MAIN_UP_TO_DATE_KEYWORDS = [ + 'main', + 'master', + 'origin/main', + 'git diff', + 'behind main', + 'up to date', + 'stale main', + 'refresh local main', + 'base ref', + 'fresh base', + 'safely', + 'worktree' +] + export function shouldShowAutoRenameBranchSetting( searchQuery: string, hasUnsavedBranchPromptChanges: boolean @@ -101,43 +119,24 @@ export function GitPane({ ) : null, matchesSettingsSearch(searchQuery, { - title: 'Refresh Local Base Ref', - description: - 'Safely fast-forward local main or master so AI tools and diffs use a fresh base.', - keywords: [ - 'main', - 'master', - 'origin/main', - 'git diff', - 'base ref', - 'fresh base', - 'safely', - 'worktree' - ] + title: KEEP_LOCAL_MAIN_UP_TO_DATE_TITLE, + description: KEEP_LOCAL_MAIN_UP_TO_DATE_DESCRIPTION, + keywords: KEEP_LOCAL_MAIN_UP_TO_DATE_KEYWORDS }) ? (
- +

- Turn this on if you or AI tools use commands like git diff main...HEAD. - Orca first refreshes the remote base, then safely fast-forwards the matching local{' '} - main or master so those commands do not compare against stale - history. Orca skips the update if the local branch is dirty or diverged. + When you create a workspace, Orca refreshes the remote base and safely fast-forwards + your matching local branch, such as main or master. This keeps + commands like git diff main...HEAD from comparing against stale history. + Orca skips the update if that branch has uncommitted changes or local-only commits.

+ + + ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index da049255fd1..bfe158c86ed 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -20,6 +20,7 @@ import { Workflow } from 'lucide-react' import { useAppStore } from '@/store' +import { useShallow } from 'zustand/react/shallow' import type { AppState } from '@/store/types' import { getAllWorktreesFromState, @@ -28,6 +29,7 @@ import { useWorktreeMap } from '@/store/selectors' import WorktreeCard from './WorktreeCard' +import { PendingWorktreeRow } from './PendingWorktreeRow' import WorktreeCardAgents, { SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT } from './WorktreeCardAgents' @@ -639,6 +641,9 @@ export function getRenderRowKey(row: RenderRow): string { if (row.type === 'imported-worktrees-card') { return `imported:${row.key}` } + if (row.type === 'pending-creation') { + return `pending:${row.creationId}` + } return `wt:${row.worktree.id}` } @@ -652,7 +657,7 @@ export function getWorktreeDragGroups(rows: Row[]): WorktreeDragGroup[] { groups.push({ key: current.key, worktreeIds: current.ids }) continue } - if (row.type === 'imported-worktrees-card') { + if (row.type === 'imported-worktrees-card' || row.type === 'pending-creation') { continue } if (!current) { @@ -3563,6 +3568,24 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ) } + if (row.type === 'pending-creation') { + return ( +
+ +
+ ) + } + const itemWorkspaceStatus = groupBy === 'workspace-status' ? getWorkspaceStatus(row.worktree, workspaceStatuses) @@ -4082,6 +4105,27 @@ const WorktreeList = React.memo(function WorktreeList({ }, [filterRepoIds, groupBy, repos, worktreesByRepo]) const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos]) + // Why: buildRows only needs which creates exist and their repo. Subscribe on a + // flat key array (value-compared by useShallow) so progress updates + // (phase/loaderVisible) don't churn it and rebuild the whole sidebar row model + // on every creation tick. Split on the first space — the creationId is a UUID, + // so it has none and the repoId (which may contain spaces) stays intact. + const pendingCreationKeys = useAppStore( + useShallow((s) => + Object.values(s.pendingWorktreeCreations ?? {}).map( + (creation) => `${creation.creationId} ${creation.request.repoId}` + ) + ) + ) + const pendingCreations = useMemo( + () => + pendingCreationKeys.map((key) => { + const separator = key.indexOf(' ') + return { creationId: key.slice(0, separator), repoId: key.slice(separator + 1) } + }), + [pendingCreationKeys] + ) + // Build flat row list for rendering const rows: Row[] = useMemo( () => @@ -4100,7 +4144,8 @@ const WorktreeList = React.memo(function WorktreeList({ settings, projectGroups, placeholderRepoIds, - importedWorktreesByRepo + importedWorktreesByRepo, + pendingCreations ), [ groupBy, @@ -4116,7 +4161,8 @@ const WorktreeList = React.memo(function WorktreeList({ settings, projectGroups, placeholderRepoIds, - importedWorktreesByRepo + importedWorktreesByRepo, + pendingCreations ] ) // Why: status headers change during wake (inactive -> active). Key only on diff --git a/src/renderer/src/components/sidebar/worktree-drag-units.ts b/src/renderer/src/components/sidebar/worktree-drag-units.ts index 775feb0e828..29a8bc20910 100644 --- a/src/renderer/src/components/sidebar/worktree-drag-units.ts +++ b/src/renderer/src/components/sidebar/worktree-drag-units.ts @@ -9,6 +9,7 @@ type WorktreeDragUnitRow = | { type: 'header'; key: string } | { type: 'item'; worktree: { id: string }; depth: number } | { type: 'imported-worktrees-card' } + | { type: 'pending-creation' } export function getWorktreeDragUnitGroups( rows: readonly WorktreeDragUnitRow[] @@ -26,7 +27,7 @@ export function getWorktreeDragUnitGroups( }) continue } - if (row.type === 'imported-worktrees-card') { + if (row.type === 'imported-worktrees-card' || row.type === 'pending-creation') { continue } if (!current) { diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index f3ee0dbbb62..6b9ae31c9db 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -9,7 +9,8 @@ import { getGroupKeysForWorktree, getLineageGroupKey, getLineageRenderInfo, - getPRGroupKey + getPRGroupKey, + type PendingCreationRef } from './worktree-list-groups' import type { DetectedWorktree, @@ -1663,3 +1664,112 @@ describe('WorktreeList header styles', () => { expect(source).toContain('color={repoHeaderColor}') }) }) + +describe('buildRows pending creations', () => { + function makePendingCreation(creationId: string, repoId: string): PendingCreationRef { + return { creationId, repoId } + } + + it('nests a pending creation under its repo, above the repo worktrees', () => { + const rows = buildRows( + 'repo', + [worktree], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([[worktree.id, worktree]]), + false, + undefined, + [], + new Set(), + new Map(), + [makePendingCreation('c1', repo.id)] + ) + + const types = rows.map((row) => row.type) + const headerIndex = types.indexOf('header') + const pendingIndex = rows.findIndex( + (row) => row.type === 'pending-creation' && row.creationId === 'c1' + ) + const itemIndex = types.indexOf('item') + expect(headerIndex).toBeGreaterThanOrEqual(0) + expect(pendingIndex).toBe(headerIndex + 1) + expect(pendingIndex).toBeLessThan(itemIndex) + }) + + it('creates a repo group for a pending creation in a repo with no worktrees yet', () => { + const rows = buildRows( + 'repo', + [], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map(), + false, + undefined, + [], + new Set(), + new Map(), + [makePendingCreation('c1', repo.id)] + ) + + expect(rows.map((row) => row.type)).toEqual(['header', 'pending-creation']) + }) + + it('keeps a pending creation visible when its repo metadata is temporarily missing', () => { + const rows = buildRows( + 'repo', + [], + new Map(), + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map(), + false, + undefined, + [], + new Set(), + new Map(), + [makePendingCreation('c1', repo.id)] + ) + + expect(rows).toMatchObject([ + { type: 'header', key: `repo:${repo.id}`, label: 'Unknown' }, + { type: 'pending-creation', creationId: 'c1', repo: undefined } + ]) + }) + + it('surfaces pending creations at the top for non-repo groupings', () => { + const rows = buildRows( + 'none', + [worktree], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([[worktree.id, worktree]]), + false, + undefined, + [], + new Set(), + new Map(), + [makePendingCreation('c1', repo.id)] + ) + + expect(rows[0]).toMatchObject({ type: 'pending-creation', creationId: 'c1' }) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 7741cb82ffc..0c58bf5d3f8 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -70,7 +70,32 @@ export type ImportedWorktreesCardRow = { placement: 'repo-group' | 'pinned-fallback' } -export type Row = GroupHeaderRow | WorktreeRow | ImportedWorktreesCardRow +export type PendingCreationRow = { + type: 'pending-creation' + key: string + creationId: string + repo: Repo | undefined +} + +/** Minimal shape buildRows needs for an in-flight create. Deliberately not the + * full PendingWorktreeCreation: row identity depends only on which creates + * exist and their repo, so callers can subscribe on this stable shape and keep + * progress-field churn (phase/loaderVisible) from rebuilding the whole list. */ +export type PendingCreationRef = { creationId: string; repoId: string } + +export type Row = GroupHeaderRow | WorktreeRow | ImportedWorktreesCardRow | PendingCreationRow + +function buildPendingCreationRow( + creation: PendingCreationRef, + repoMap: Map +): PendingCreationRow { + return { + type: 'pending-creation', + key: `pending:${creation.creationId}`, + creationId: creation.creationId, + repo: repoMap.get(creation.repoId) + } +} type OrderedGroupEntry = [string, { label: string; items: Worktree[]; repo?: Repo }] @@ -506,10 +531,27 @@ export function buildRows( settings?: AppState['settings'], projectGroups: readonly ProjectGroup[] = [], placeholderRepoIds: ReadonlySet = new Set(), - importedWorktreesByRepo: ReadonlyMap = new Map() + importedWorktreesByRepo: ReadonlyMap = new Map(), + pendingCreations: readonly PendingCreationRef[] = [] ): Row[] { const result: Row[] = [] + const pendingByRepo = new Map() + for (const creation of pendingCreations) { + const list = pendingByRepo.get(creation.repoId) ?? [] + list.push(creation) + pendingByRepo.set(creation.repoId, list) + } + + // Why: non-repo groupings have no repo section to nest an in-progress create + // under, so surface them at the very top (where the old global strip sat) + // rather than dropping them. Repo grouping nests them under their repo below. + if (groupBy !== 'repo' && pendingCreations.length > 0) { + for (const creation of pendingCreations) { + result.push(buildPendingCreationRow(creation, repoMap)) + } + } + const visibleUnpinnedRepoIds = new Set( worktrees.filter((worktree) => !worktree.isPinned).map((worktree) => worktree.repoId) ) @@ -597,6 +639,18 @@ export function buildRows( } } } + if (groupBy === 'repo') { + for (const repoId of pendingByRepo.keys()) { + const key = `repo:${repoId}` + if (!grouped.has(key)) { + // Why: creating the first worktree in a repo leaves it with no group yet; + // ensure one so the in-progress row nests under its repo instead of being + // dropped. + const repo = repoMap.get(repoId) + grouped.set(key, { label: repo?.displayName ?? 'Unknown', items: [], repo }) + } + } + } const orderedGroups: OrderedGroupEntry[] = [] if (groupBy === 'pr-status') { @@ -680,11 +734,18 @@ export function buildRows( result.push(header) if (!isCollapsed) { - if (groupBy === 'repo' && repo) { - const candidate = importedWorktreesByRepo.get(repo.id) + if (groupBy === 'repo') { + const repoId = repo?.id ?? key.slice('repo:'.length) + const candidate = importedWorktreesByRepo.get(repoId) if (candidate) { result.push(buildImportedWorktreesCardRow(candidate, 'repo-group')) } + // Why: surface in-progress creates at the top of their own repo so the + // new workspace appears where it will land, not flashed to the very top + // of the sidebar. + for (const creation of pendingByRepo.get(repoId) ?? []) { + result.push(buildPendingCreationRow(creation, repoMap)) + } } const items = groupBy === 'repo' ? orderMainWorktreeFirst(group.items) : group.items appendWorktreeRows(result, items, repoMap, lineageById, worktreeMap, { diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts index ff28d2b4e32..89394356ba5 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts @@ -6,6 +6,7 @@ import { PINNED_GROUP_KEY } from './worktree-list-groups' export const GROUP_HEADER_ROW_HEIGHT = 28 const SECONDARY_GROUP_HEADER_TOP_MARGIN = 4 const IMPORTED_WORKTREES_LINE_ROW_HEIGHT = 36 +const PENDING_CREATION_ROW_HEIGHT = 56 type WorktreeItemRow = Extract export type RenderRow = Row | { type: 'lineage-group'; key: string; rows: WorktreeItemRow[] } @@ -46,6 +47,9 @@ export function estimateRenderRowSize( if (row?.type === 'imported-worktrees-card') { return IMPORTED_WORKTREES_LINE_ROW_HEIGHT } + if (row?.type === 'pending-creation') { + return PENDING_CREATION_ROW_HEIGHT + } return 116 } diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index db022b7d14f..968dc0838c4 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -46,6 +46,7 @@ import { ResourceUsageStatusSegment } from './ResourceUsageStatusSegment' import { PortsStatusSegment } from './PortsStatusSegment' import { isStatusBarItemAvailable } from './status-bar-agent-gating' import { isProviderConfigured } from './status-bar-provider-visibility' +import { StatusBarUsageEmptyCta } from './StatusBarUsageEmptyCta' import { shouldOpenStatusBarContextMenu } from './status-bar-context-menu-policy' import { PetStatusSegment } from './PetStatusSegment' import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' @@ -1621,6 +1622,17 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele floatingTerminalEnabled && floatingTerminalTriggerLocation === 'status-bar' const anyVisible = showClaude || showCodex || showGemini || showOpencodeGo || showKimi || showResourceUsage + // Why: a brand-new user with no provider configured would otherwise see an + // empty left side of the status bar and wonder what's missing. The CTA + // names the surface and points to the setup path. Detection is on + // configuration (not user toggles) so users who intentionally hid a + // provider's bar don't get the teaching prompt back. + const isEmptyUsageState = + !isProviderConfigured(claude) && + !isProviderConfigured(codex) && + !isProviderConfigured(gemini) && + !isProviderConfigured(opencodeGo) && + !isProviderConfigured(kimi) const anyFetching = claude?.status === 'fetching' || codex?.status === 'fetching' || @@ -1656,33 +1668,41 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele }} >
- {showClaude && } - {showCodex && } - {showGemini && ( - + {isEmptyUsageState ? ( + + ) : ( + <> + {showClaude && ( + + )} + {showCodex && } + {showGemini && ( + + )} + {showOpencodeGo && ( + + )} + {showKimi && ( + + )} + )} - {showOpencodeGo && ( - - )} - {showKimi && ( - - )} - {anyVisible && ( + {anyVisible && !isEmptyUsageState && ( + + +
+
Agent usage limits
+

+ Connect your AI provider accounts to see their usage in real time and easily switch + between accounts. +

+ {/* Why: name the full provider set so the feature doesn't read as + support for just one agent. */} +
+ Supports: + + + + + +
+ +
+
+ + ) +} diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx index d9d69baeb65..28a32b5818a 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -19,6 +19,7 @@ import { ACTIVE_TAB_INDICATOR_CLASSES, getDropIndicatorClasses, getTabRootStateClasses, + getTabStripBorderClasses, type DropIndicator } from './drop-indicator' import { preventMiddleButtonDefault } from './middle-button-default-guard' @@ -108,7 +109,8 @@ export default function BrowserTab({ onDuplicate, onTogglePin, dragData, - dropIndicator + dropIndicator, + includeTopTabBorder = true }: { tab: BrowserTabState isActive: boolean @@ -122,6 +124,7 @@ export default function BrowserTab({ onTogglePin: () => void dragData: TabDragItemData dropIndicator?: DropIndicator + includeTopTabBorder?: boolean }): React.JSX.Element { // Why: no transform/transition/isDragging styling — the drag design is // that tabs stay visually anchored; only the blue insertion bar moves. @@ -171,7 +174,7 @@ export default function BrowserTab({ data-pinned={isPinned ? 'true' : 'false'} {...attributes} {...listeners} - className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none shrink-0 outline-none focus:outline-none focus-visible:outline-none border-t ${hasTabsToRight ? 'border-r' : ''} border-border ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`} + className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none shrink-0 outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`} onPointerDown={(e) => { if (e.button !== 0) { return diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx index 2d51fe5e18b..79148528420 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx @@ -160,6 +160,7 @@ vi.mock('./SortableTab', () => ({ vi.mock('./drop-indicator', () => ({ ACTIVE_TAB_INDICATOR_CLASSES: 'active-tab-indicator', getDropIndicatorClasses: () => '', + getTabStripBorderClasses: () => '', getTabRootStateClasses: () => '' })) diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 4f82dd42878..4136abe77e8 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -21,6 +21,7 @@ import { ACTIVE_TAB_INDICATOR_CLASSES, getDropIndicatorClasses, getTabRootStateClasses, + getTabStripBorderClasses, type DropIndicator } from './drop-indicator' import { canOpenMarkdownPreview } from '@/components/editor/markdown-preview-controls' @@ -40,7 +41,8 @@ export default function EditorFileTab({ onTogglePin, onSplitGroup, dragData, - dropIndicator + dropIndicator, + includeTopTabBorder = true }: { file: OpenFile & { tabId?: string } isActive: boolean @@ -56,6 +58,7 @@ export default function EditorFileTab({ onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void dragData: TabDragItemData dropIndicator?: DropIndicator + includeTopTabBorder?: boolean }): React.JSX.Element { const worktree = useWorktreeById(file.worktreeId) const repo = useRepoById(worktree?.repoId ?? null) @@ -200,7 +203,7 @@ export default function EditorFileTab({ data-pinned={isPinned ? 'true' : 'false'} {...attributes} {...listeners} - className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none shrink-0 outline-none focus:outline-none focus-visible:outline-none border-t ${hasTabsToRight ? 'border-r' : ''} border-border ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`} + className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none shrink-0 outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`} onPointerDown={(e) => { if (e.button !== 0) { return diff --git a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx index 1fd737c154b..5690b163aa3 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx @@ -129,6 +129,7 @@ vi.mock('../sidebar/WorktreeCardHelpers', () => ({ vi.mock('./drop-indicator', () => ({ ACTIVE_TAB_INDICATOR_CLASSES: 'active-tab-indicator', getDropIndicatorClasses: () => '', + getTabStripBorderClasses: () => '', getTabRootStateClasses: () => '' })) diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 89fd74f71f6..78477fdd0fc 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -15,6 +15,7 @@ import { ACTIVE_TAB_INDICATOR_CLASSES, getDropIndicatorClasses, getTabRootStateClasses, + getTabStripBorderClasses, type DropIndicator } from './drop-indicator' import { preventMiddleButtonDefault } from './middle-button-default-guard' @@ -38,6 +39,7 @@ type SortableTabProps = { onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void dragData: TabDragItemData dropIndicator?: DropIndicator + includeTopTabBorder?: boolean } export const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus' @@ -59,7 +61,8 @@ export default function SortableTab({ onToggleExpand, onSplitGroup, dragData, - dropIndicator + dropIndicator, + includeTopTabBorder = true }: SortableTabProps): React.JSX.Element { // Why: subscribe to the per-tab boolean directly so only the tab whose unread // status actually flipped re-renders. Reading the whole `unreadTerminalTabs` @@ -213,7 +216,7 @@ export default function SortableTab({ // tab still reads as "selected + has activity". The wash is // rendered as an absolutely-positioned child below so the ::after // pseudo-element stays free for the drop indicator. - className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none shrink-0 outline-none focus:outline-none focus-visible:outline-none border-t ${hasTabsToRight ? 'border-r' : ''} border-border ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`} + className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none shrink-0 outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`} onDoubleClick={(e) => { if (isEditing) { return diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 228a91bce46..6443ea27508 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -108,6 +108,8 @@ type TabBarProps = { sourceVisibleTabId?: string ) => void hoveredTabInsertion?: HoveredTabInsertion | null + /** Floating workspace panels are rounded; skip tab top borders that clash with the curve. */ + tabStripChrome?: 'default' | 'floating-panel' } type TabItem = @@ -206,8 +208,10 @@ function TabBarInner({ onPinFile, tabBarOrder, onCreateSplitGroup, - hoveredTabInsertion + hoveredTabInsertion, + tabStripChrome = 'default' }: TabBarProps): React.JSX.Element { + const includeTopTabBorder = tabStripChrome !== 'floating-panel' const newTerminalShortcut = useShortcutLabel('tab.newTerminal') const newBrowserShortcut = useShortcutLabel('tab.newBrowser') const newSimulatorShortcut = useShortcutLabel('tab.newSimulator') @@ -237,17 +241,11 @@ function TabBarInner({ const repo = worktree ? s.repos?.find((entry) => entry.id === worktree.repoId) : null return Boolean(repo?.connectionId) }) - const unifiedNewTabLauncherEnabled = useAppStore( - (s) => s.settings?.experimentalUnifiedNewTabLauncher === true - ) const defaultAgent = useAppStore((s) => s.settings?.defaultTuiAgent) const agentCmdOverrides = useAppStore( (s) => s.settings?.agentCmdOverrides ?? EMPTY_AGENT_CMD_OVERRIDES ) const connectionId = useAppStore((s) => { - if (!unifiedNewTabLauncherEnabled) { - return undefined - } const allWorktrees = Object.values(s.worktreesByRepo ?? {}).flat() const worktree = allWorktrees.find((w) => w.id === worktreeId) if (!worktree) { @@ -865,6 +863,7 @@ function TabBarInner({ } dragData={dragData} dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null} + includeTopTabBorder={includeTopTabBorder} /> ) } @@ -886,6 +885,7 @@ function TabBarInner({ onTogglePin={() => togglePinned(item)} dragData={dragData} dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null} + includeTopTabBorder={includeTopTabBorder} /> ) } @@ -921,6 +921,7 @@ function TabBarInner({ } dragData={dragData} dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null} + includeTopTabBorder={includeTopTabBorder} /> ) } @@ -946,6 +947,7 @@ function TabBarInner({ } dragData={dragData} dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null} + includeTopTabBorder={includeTopTabBorder} /> ) })} @@ -969,7 +971,7 @@ function TabBarInner({ { // Why: terminal-producing menu actions activate a freshly-mounted // xterm. Radix's default focus restore sends focus back to the "+" @@ -978,7 +980,7 @@ function TabBarInner({ runPendingNewTabMenuFocusAfterClose() }} > - {!terminalOnly && onOpenEntry && unifiedNewTabLauncherEnabled ? ( + {!terminalOnly && onOpenEntry ? ( <> unifiedTabsByWorktree: Record activeGroupIdByWorktree: Record + detectedAgentIds: string[] | null + remoteDetectedAgentIds: Record + isDetectingAgents: boolean + isDetectingRemoteAgents: Record } = { activeTabId: null, activeTabType: null, @@ -18,7 +22,11 @@ const appStoreSnapshot: { repos: [], worktreesByRepo: {}, unifiedTabsByWorktree: {}, - activeGroupIdByWorktree: {} + activeGroupIdByWorktree: {}, + detectedAgentIds: null, + remoteDetectedAgentIds: {}, + isDetectingAgents: false, + isDetectingRemoteAgents: {} } const pinTabMock: (tabId: string) => void = vi.fn() const unpinTabMock: (tabId: string) => void = vi.fn() @@ -33,6 +41,10 @@ const useAppStoreMock = vi.fn( worktreesByRepo: Record unifiedTabsByWorktree: Record activeGroupIdByWorktree: Record + detectedAgentIds: string[] | null + remoteDetectedAgentIds: Record + isDetectingAgents: boolean + isDetectingRemoteAgents: Record pinTab: typeof pinTabMock unpinTab: typeof unpinTabMock settings: { @@ -50,6 +62,10 @@ const useAppStoreMock = vi.fn( worktreesByRepo: appStoreSnapshot.worktreesByRepo, unifiedTabsByWorktree: appStoreSnapshot.unifiedTabsByWorktree, activeGroupIdByWorktree: appStoreSnapshot.activeGroupIdByWorktree, + detectedAgentIds: appStoreSnapshot.detectedAgentIds, + remoteDetectedAgentIds: appStoreSnapshot.remoteDetectedAgentIds, + isDetectingAgents: appStoreSnapshot.isDetectingAgents, + isDetectingRemoteAgents: appStoreSnapshot.isDetectingRemoteAgents, pinTab: pinTabMock, unpinTab: unpinTabMock, settings: { @@ -107,6 +123,10 @@ useAppStoreExport.getState = vi.fn(() => ({ worktreesByRepo: appStoreSnapshot.worktreesByRepo, unifiedTabsByWorktree: appStoreSnapshot.unifiedTabsByWorktree, activeGroupIdByWorktree: appStoreSnapshot.activeGroupIdByWorktree, + detectedAgentIds: appStoreSnapshot.detectedAgentIds, + remoteDetectedAgentIds: appStoreSnapshot.remoteDetectedAgentIds, + isDetectingAgents: appStoreSnapshot.isDetectingAgents, + isDetectingRemoteAgents: appStoreSnapshot.isDetectingRemoteAgents, pinTab: pinTabMock, unpinTab: unpinTabMock, settings: { diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx index 8783fdecc61..90f07105945 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' -import { FilePlus, FileText, Globe, Loader2, Search } from 'lucide-react' +import { FilePlus, FileText, Globe, Loader2 } from 'lucide-react' import { Input } from '@/components/ui/input' import { AgentIcon } from '@/lib/agent-catalog' import { cn } from '@/lib/utils' @@ -100,7 +100,7 @@ export default function TabBarCreateEntry({ const statusMessage = statusOption?.classification.kind === 'empty' || statusOption?.classification.kind === 'blocked' ? statusOption.classification.message - : 'URL, file, or new file' + : 'Open any file, URL, agent, ...' const submitOption = (option?: ActiveOption) => { if (disabled || pending) { @@ -143,7 +143,7 @@ export default function TabBarCreateEntry({ return (
{ event.preventDefault() submitOption() @@ -164,11 +164,7 @@ export default function TabBarCreateEntry({ }} onPointerDown={(event) => event.stopPropagation()} > -
-