From a278a30b1cacfbe71cf8ace287cf68d8c6b2ed5e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:50:00 -0700 Subject: [PATCH] Let agents wait for setup when requested (#6298) Co-authored-by: Orca --- src/main/hooks.test.ts | 34 ++ src/main/hooks.ts | 13 +- src/main/ipc/pty.test.ts | 61 +++ src/main/ipc/pty.ts | 15 +- src/main/ipc/worktree-remote.ts | 73 ++- src/main/ipc/worktrees.test.ts | 63 ++- src/main/providers/ssh-pty-provider.test.ts | 22 + src/main/providers/ssh-pty-provider.ts | 8 +- src/main/providers/types.ts | 1 + src/main/runtime/orca-runtime.test.ts | 352 ++++++++++++++- src/main/runtime/orca-runtime.ts | 211 +++++++-- src/relay/pty-handler.test.ts | 277 +++++++++++- src/relay/pty-handler.ts | 149 +++++- src/relay/relay.ts | 7 +- .../NewWorkspaceComposerCard.test.tsx | 54 +++ .../components/NewWorkspaceComposerCard.tsx | 43 ++ .../settings/RepositoryHooksSection.test.ts | 87 +++- .../settings/RepositoryHooksSection.tsx | 78 +++- .../repository-git-hooks-search-entries.ts | 4 + .../terminal-pane/pty-connection.test.ts | 59 +++ .../terminal-pane/pty-connection.ts | 7 +- ...poserState-host-context-boundaries.test.ts | 30 ++ src/renderer/src/hooks/useComposerState.ts | 154 +++++++ src/renderer/src/i18n/locales/en.json | 11 +- src/renderer/src/i18n/locales/es.json | 11 +- src/renderer/src/i18n/locales/ja.json | 11 +- src/renderer/src/i18n/locales/ko.json | 11 +- src/renderer/src/i18n/locales/zh.json | 11 +- src/renderer/src/lib/setup-runner.test.ts | 10 + src/renderer/src/lib/setup-runner.ts | 12 +- .../src/lib/worktree-activation.test.ts | 238 ++++++++++ src/renderer/src/lib/worktree-activation.ts | 109 ++++- src/shared/constants.ts | 2 + src/shared/setup-agent-sequencing.test.ts | 425 ++++++++++++++++++ src/shared/setup-agent-sequencing.ts | 257 +++++++++++ src/shared/setup-agent-startup-policy.ts | 11 + src/shared/setup-runner-command.test.ts | 58 ++- src/shared/setup-runner-command.ts | 73 ++- src/shared/types.ts | 4 + 39 files changed, 2880 insertions(+), 176 deletions(-) create mode 100644 src/shared/setup-agent-sequencing.test.ts create mode 100644 src/shared/setup-agent-sequencing.ts create mode 100644 src/shared/setup-agent-startup-policy.ts diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index 4ead1dc4d..87b7b5905 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -1020,6 +1020,40 @@ describe('runHook', () => { }) }) +describe('createSetupRunnerScript', () => { + const makeRepo = (setupAgentStartupPolicy?: 'start-immediately' | 'wait-for-setup') => + ({ + id: 'test-id', + path: '/test/repo', + displayName: 'Test Repo', + badgeColor: '#000', + addedAt: Date.now(), + hookSettings: { + mode: 'auto', + setupAgentStartupPolicy, + scripts: { setup: '', archive: '' } + } + }) as unknown as Repo + + it('omits waitForAgentStartup unless the repo explicitly waits for setup', async () => { + gitExecFileSyncMock.mockReset() + gitExecFileSyncMock.mockReturnValue('/test/repo/.git/orca/setup-runner.sh\n') + const { createSetupRunnerScript } = await import('./hooks') + + expect( + createSetupRunnerScript(makeRepo(), '/test/worktree', 'echo setup').waitForAgentStartup + ).toBeUndefined() + expect( + createSetupRunnerScript(makeRepo('start-immediately'), '/test/worktree', 'echo setup') + .waitForAgentStartup + ).toBeUndefined() + expect( + createSetupRunnerScript(makeRepo('wait-for-setup'), '/test/worktree', 'echo setup') + .waitForAgentStartup + ).toBe(true) + }) +}) + describe('shouldRunSetupForCreate', () => { const makeRepo = (setupRunPolicy?: 'ask' | 'run-by-default' | 'skip-by-default') => ({ diff --git a/src/main/hooks.ts b/src/main/hooks.ts index 17a87757f..7cb4ba5a1 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -6,6 +6,7 @@ import { parse } from 'yaml' import { getDefaultRepoHookSettings } from '../shared/constants' import { getRuntimePathBasename } from '../shared/cross-platform-path' import { resolveHookCommandSourcePolicy } from '../shared/hook-command-source-policy' +import { shouldWaitForSetupBeforeAgentStartup } from '../shared/setup-agent-startup-policy' import { gitExecFileSync } from './git/runner' import { isWslPath, parseWslPath, toWindowsWslPath, toLinuxPath } from './wsl' import type { @@ -517,7 +518,8 @@ export function createSetupRunnerScript( worktreePath, script, 'setup-runner', - getHookRuntimeTarget(projectRuntime) + getHookRuntimeTarget(projectRuntime), + shouldWaitForSetupBeforeAgentStartup(repo.hookSettings?.setupAgentStartupPolicy) ) } @@ -575,7 +577,8 @@ function createWorktreeRunnerScript( worktreePath: string, script: string, runnerBaseName: 'setup-runner' | 'issue-command-runner', - runtimeTarget?: HookRuntimeTarget + runtimeTarget?: HookRuntimeTarget, + waitForAgentStartup?: boolean ): WorktreeSetupLaunch { const envVars = getSetupEnvVars(repo, worktreePath) // Why: WSL worktrees run on a Linux filesystem even though process.platform @@ -618,7 +621,11 @@ function createWorktreeRunnerScript( } } - return { runnerScriptPath, envVars } + return { + runnerScriptPath, + envVars, + ...(waitForAgentStartup === true ? { waitForAgentStartup: true } : {}) + } } /** diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 4b9ce9508..add52d563 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -172,6 +172,7 @@ vi.mock('../agent-hooks/migration-unsupported-pty-state', () => ({ })) import { LocalPtyProvider } from '../providers/local-pty-provider' import { makePaneKey } from '../../shared/stable-pane-id' +import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing' import { registerPtyHandlers, registerSshPtyProvider, @@ -796,6 +797,20 @@ describe('registerPtyHandlers', () => { } ) + it('uses sequenced startup env as the MiMo launch hint when command is a wrapper', async () => { + const env = await spawnAndGetEnv( + { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'mimo --prompt hi' }, + undefined, + undefined, + undefined, + 'bash -lc wait-wrapper' + ) + + expect(mimoCodeBuildPtyEnvMock).toHaveBeenCalledTimes(1) + expect(env.MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared') + expect(env.ORCA_MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared') + }) + it('does not inject MiMo overlay for non-mimo launches', async () => { await spawnAndGetEnv() @@ -897,6 +912,29 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() }) + it('uses sequenced startup env as the OMP launch hint when command is a wrapper', async () => { + const env = await spawnAndGetEnv( + { + PI_CODING_AGENT_DIR: '/tmp/user-omp-agent', + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'omp --resume' + }, + undefined, + undefined, + undefined, + 'powershell wait-wrapper' + ) + + expect(piBuildPtyEnvMock).toHaveBeenCalledWith( + expect.any(String), + '/tmp/user-omp-agent', + 'omp' + ) + expect(env.ORCA_OMP_STATUS_EXTENSION).toBe( + '/tmp/user-omp-agent/extensions/orca-agent-status.ts' + ) + expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() + }) + it('mirrors the original Pi source dir when launched from an Orca overlay shell', async () => { const env = await spawnAndGetEnv({ PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay', @@ -1347,6 +1385,29 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() }) + it('uses sequenced startup env as the daemon OMP launch hint when command is a wrapper', async () => { + const env = await daemonSpawnAndGetEnv( + { + PI_CODING_AGENT_DIR: '/user/.omp/agent', + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'omp --resume' + }, + undefined, + undefined, + undefined, + { command: 'powershell wait-wrapper' } + ) + + expect(piBuildPtyEnvMock).toHaveBeenCalledWith( + expect.any(String), + '/user/.omp/agent', + 'omp' + ) + expect(env.ORCA_OMP_STATUS_EXTENSION).toBe( + '/user/.omp/agent/extensions/orca-agent-status.ts' + ) + expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() + }) + it('injects the selected Codex home on the daemon path', async () => { const env = await daemonSpawnAndGetEnv({}, () => TEST_CODEX_HOME) expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 65290d011..09fb8f690 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -86,6 +86,7 @@ import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' +import { resolveSetupAgentSequenceLaunchCommand } from '../../shared/setup-agent-sequencing' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { assertFolderWorkspacePathUsable, @@ -751,9 +752,10 @@ export function buildPtyHostEnv( // in lock-step across spawn paths without pushing process.env onto the // IPC wire unnecessarily. const preexistingOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv) - const piAgentKind = detectPiAgentKindFromCommand(opts.launchCommand) + const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand) + const piAgentKind = detectPiAgentKindFromCommand(launchCommandHint) const hasLaunchCommand = - typeof opts.launchCommand === 'string' && opts.launchCommand.trim().length > 0 + typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0 const shouldPrepareOmpShadow = piAgentKind === 'omp' || !hasLaunchCommand // Why: source shadows are agent-scoped. Trusting the other kind's source // would reintroduce the exact Pi/OMP extension-state shadowing this PR fixes. @@ -783,7 +785,7 @@ export function buildPtyHostEnv( delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR } } - if (isMimoLaunchCommand(opts.launchCommand)) { + if (isMimoLaunchCommand(launchCommandHint)) { const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv) Object.assign(baseEnv, mimoCodeHookService.buildPtyEnv(id, preexistingMimocodeHome)) if (baseEnv.MIMOCODE_HOME) { @@ -1880,6 +1882,9 @@ export function registerPtyHandlers( if (args.command !== undefined) { spawnOptions.command = args.command } + if (args.commandDelivery !== undefined) { + spawnOptions.commandDelivery = args.commandDelivery + } if (args.startupCommandDelivery !== undefined) { spawnOptions.startupCommandDelivery = args.startupCommandDelivery } @@ -2256,6 +2261,7 @@ export function registerPtyHandlers( env?: Record envToDelete?: string[] command?: string + commandDelivery?: 'renderer' | 'provider' launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery @@ -2557,6 +2563,9 @@ export function registerPtyHandlers( if (args.command !== undefined) { spawnOptions.command = args.command } + if (args.commandDelivery !== undefined) { + spawnOptions.commandDelivery = args.commandDelivery + } if (args.startupCommandDelivery !== undefined) { spawnOptions.startupCommandDelivery = args.startupCommandDelivery } diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 74dfb7db9..ae4459278 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -93,7 +93,12 @@ import { createWorktreeLinkedPaths } from './worktree-symlinks' import { normalizeSparseDirectories } from './sparse-checkout-directories' import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' import type { IFilesystemProvider } from '../providers/types' -import { buildSetupRunnerCommand } from '../../shared/setup-runner-command' +import { + buildSetupRunnerCommand, + getSetupRunnerCommandPlatformForPath +} from '../../shared/setup-runner-command' +import { createSequencedSetupAgentCommands } from '../../shared/setup-agent-sequencing' +import { shouldWaitForSetupBeforeAgentStartup } from '../../shared/setup-agent-startup-policy' import { createWorktreeCreateTimingRecorder } from '../worktree-create-timing' import { markCodexProjectTrusted, @@ -122,6 +127,7 @@ type RemoteWorktreeCreateBasePlan = { type StagedStartupResult = { startupTerminal?: CreateWorktreeResult['startupTerminal'] + activationSetup?: CreateWorktreeResult['setup'] didSpawnSetup: boolean warning?: string } @@ -230,6 +236,26 @@ async function spawnLocalStartupAndSetupTerminals(args: { let startupTerminalHandle: string | null = null let startupTerminal: CreateWorktreeResult['startupTerminal'] + let sequencedStartup = startup + let wrappedSetupCommandStr: string | undefined + if (startup && setup?.waitForAgentStartup === true) { + const platform = getSetupRunnerCommandPlatformForPath( + setup.runnerScriptPath, + process.platform === 'win32' ? 'windows' : 'posix' + ) + const sequenced = createSequencedSetupAgentCommands({ + runnerScriptPath: setup.runnerScriptPath, + startupCommand: startup.command, + platform + }) + sequencedStartup = { + ...startup, + command: sequenced.startupCommand, + ...(sequenced.startupEnv ? { env: { ...startup.env, ...sequenced.startupEnv } } : {}) + } + wrappedSetupCommandStr = sequenced.setupCommand + } + try { // Why: after `git worktree add` and metadata registration, a runtime-owned // PTY can begin booting the selected agent while setup runs in a sibling @@ -249,12 +275,13 @@ async function spawnLocalStartupAndSetupTerminals(args: { } } const terminal = await runtime.createTerminal(`id:${worktree.id}`, { - command: startup.command, - env: startup.env, - ...(startup.launchConfig ? { launchConfig: startup.launchConfig } : {}), + command: sequencedStartup.command, + ...(setup ? { claudeAgentTeamsSourceCommand: startup.command } : {}), + env: sequencedStartup.env, + ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), ...(isTuiAgent(createdWithAgent) ? { launchAgent: createdWithAgent } : {}), - startupCommandDelivery: startup.startupCommandDelivery, - telemetry: startup.telemetry, + startupCommandDelivery: sequencedStartup.startupCommandDelivery, + telemetry: sequencedStartup.telemetry, activate: true }) startupTerminalHandle = terminal.handle @@ -272,10 +299,15 @@ async function spawnLocalStartupAndSetupTerminals(args: { let didSpawnSetup = false if (setup) { try { - const setupCommand = buildSetupRunnerCommand( - setup.runnerScriptPath, - process.platform === 'win32' ? 'windows' : 'posix' - ) + const setupCommand = + wrappedSetupCommandStr ?? + buildSetupRunnerCommand( + setup.runnerScriptPath, + getSetupRunnerCommandPlatformForPath( + setup.runnerScriptPath, + process.platform === 'win32' ? 'windows' : 'posix' + ) + ) const setupLaunchMode = (settings as Partial>) .setupScriptLaunchMode ?? 'new-tab' @@ -307,6 +339,16 @@ async function spawnLocalStartupAndSetupTerminals(args: { } return { + ...(setup && !didSpawnSetup + ? { + activationSetup: { + ...setup, + ...(startupTerminalHandle && wrappedSetupCommandStr + ? { command: wrappedSetupCommandStr } + : {}) + } + } + : {}), ...(startupTerminal ? { startupTerminal } : {}), didSpawnSetup, ...(warning ? { warning } : {}) @@ -1072,7 +1114,10 @@ async function createRemoteSetupRunnerScript( ) return { runnerScriptPath, - envVars: getSetupRunnerEnvVars(repo, worktreePath) + envVars: getSetupRunnerEnvVars(repo, worktreePath), + ...(shouldWaitForSetupBeforeAgentStartup(repo.hookSettings?.setupAgentStartupPolicy) + ? { waitForAgentStartup: true } + : {}) } } @@ -2390,7 +2435,11 @@ export async function createLocalWorktree( return { worktree: { ...worktree, workspaceLineage }, ...(workspaceLineage ? { workspaceLineage } : {}), - ...(setup && !stagedStartup.didSpawnSetup ? { setup } : {}), + ...(stagedStartup.activationSetup + ? { setup: stagedStartup.activationSetup } + : setup && !stagedStartup.didSpawnSetup + ? { setup } + : {}), ...(defaultTabs ? { defaultTabs } : {}), ...(addResult.localBaseRefRefresh ? { localBaseRefRefresh: addResult.localBaseRefRefresh } diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 51324c45c..5ce5d0bab 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -940,6 +940,7 @@ describe('registerWorktreeHandlers', () => { 1, 'id:repo-1::/workspace/improve-dashboard', { + claudeAgentTeamsSourceCommand: 'claude --prefill test', command: 'claude --prefill test', env: { ORCA_AGENT_MODE: 'direct' }, launchAgent: 'claude', @@ -957,7 +958,7 @@ describe('registerWorktreeHandlers', () => { 'id:repo-1::/workspace/improve-dashboard', { title: 'Setup', - command: 'bash /workspace/repo/.git/orca/setup-runner.sh', + command: expect.stringContaining('bash /workspace/repo/.git/orca/setup-runner.sh'), env: { ORCA_ROOT_PATH: '/workspace/repo', ORCA_WORKTREE_PATH: '/workspace/improve-dashboard' @@ -965,6 +966,15 @@ describe('registerWorktreeHandlers', () => { activate: false } ) + const startupCreateCall = runtimeStub.createTerminal.mock.calls[0] + const setupCreateCall = runtimeStub.createTerminal.mock.calls[1] + if (!startupCreateCall || !setupCreateCall) { + throw new Error('expected startup and setup terminal calls') + } + const startupCommand = (startupCreateCall[1] as { command: string }).command + const setupCommand = (setupCreateCall[1] as { command: string }).command + expect(startupCommand).toBe('claude --prefill test') + expect(setupCommand).toBe('bash /workspace/repo/.git/orca/setup-runner.sh') expect(result.setup).toBeUndefined() expect(result.startupTerminal).toEqual({ spawned: true, surface: 'visible' }) expect(result.timing?.phases.map((phase) => phase.phase)).toEqual( @@ -977,6 +987,57 @@ describe('registerWorktreeHandlers', () => { ) }) + it('returns the wrapped setup command when startup spawned but setup creation failed', async () => { + addWorktreeMock.mockResolvedValue({}) + listWorktreesMock.mockResolvedValueOnce([ + { + path: '/workspace/improve-dashboard', + head: 'def', + branch: 'improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + loadHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + getEffectiveHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + shouldRunSetupForCreateMock.mockReturnValue(true) + createSetupRunnerScriptMock.mockReturnValueOnce({ + runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh', + envVars: { + ORCA_ROOT_PATH: '/workspace/repo', + ORCA_WORKTREE_PATH: '/workspace/improve-dashboard' + }, + waitForAgentStartup: true + }) + runtimeStub.createTerminal + .mockResolvedValueOnce({ handle: 'term-startup', surface: 'visible' }) + .mockRejectedValueOnce(new Error('setup creation failed')) + + const result = (await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + createdWithAgent: 'claude', + startup: { + command: 'claude --prefill test', + env: { ORCA_AGENT_MODE: 'direct' }, + telemetry: { + agent_kind: 'claude', + launch_source: 'new_workspace_composer', + request_kind: 'new' + } + } + })) as { setup?: { command?: string; runnerScriptPath: string } } + + expect(result.setup).toEqual( + expect.objectContaining({ + runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh', + command: expect.stringContaining('bash /workspace/repo/.git/orca/setup-runner.sh') + }) + ) + expect(result.setup?.command).toContain('printf') + }) + it('checks out a selected existing local branch exactly', async () => { listWorktreesMock .mockResolvedValueOnce([ diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 5adfdecf2..7a6af3da3 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -102,6 +102,28 @@ describe('SshPtyProvider', () => { }) }) + it('forwards provider command delivery to the relay', async () => { + mux.request.mockResolvedValue({ id: 'pty-provider-command' }) + + await provider.spawn({ + cols: 120, + rows: 40, + command: 'echo from-runtime', + commandDelivery: 'provider', + startupCommandDelivery: 'shell-ready' + }) + + expect(mux.request).toHaveBeenCalledWith('pty.spawn', { + cols: 120, + rows: 40, + cwd: undefined, + env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' }, + command: 'echo from-runtime', + commandDelivery: 'provider', + startupCommandDelivery: 'shell-ready' + }) + }) + it('injects the relay-backed Orca CLI bridge into remote PTY env', async () => { mux.request.mockResolvedValue({ id: 'pty-bridge' }) provider = new SshPtyProvider('conn-1', mux as never, { diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 54c2ce582..c33910bad 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -133,12 +133,10 @@ export class SshPtyProvider implements IPtyProvider { cwd: opts.cwd, env: this.withRemoteCliBridgeEnv(opts.env, opts.envToDelete), // Why: the relay's plugin-overlay env augmenter needs to know which - // Pi-compatible agent is being launched (`pi` vs `omp`) so it mirrors - // the right `~/./agent` source dir on the remote disk. The - // relay does not execute `command` itself — the user types it into - // the shell — but receiving it as a hint lets overlay resolution be - // per-launch instead of always-Pi. + // Pi-compatible agent is being launched, while commandDelivery tells it + // whether to submit the command itself for runtime-owned background PTYs. ...(opts.command ? { command: opts.command } : {}), + ...(opts.commandDelivery ? { commandDelivery: opts.commandDelivery } : {}), ...(opts.startupCommandDelivery ? { startupCommandDelivery: opts.startupCommandDelivery } : {}) diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 3a3ebdd94..6561d1202 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -30,6 +30,7 @@ export type PtySpawnOptions = { env?: Record envToDelete?: string[] command?: string + commandDelivery?: 'renderer' | 'provider' startupCommandDelivery?: StartupCommandDelivery /** Orca worktree identity. When present, the local provider scopes shell * history to this worktree so ArrowUp only surfaces local commands. */ diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 3487ea983..5f8cd7fc5 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -65,6 +65,7 @@ import { } from '../../shared/constants' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import { makePaneKey } from '../../shared/stable-pane-id' +import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id' import { RpcDispatcher } from './rpc/dispatcher' import type { RpcRequest } from './rpc/core' @@ -2908,7 +2909,13 @@ describe('OrcaRuntimeService', () => { displayName: 'repo', badgeColor: 'blue', addedAt: 1, - connectionId: 'ssh-1' + connectionId: 'ssh-1', + hookSettings: { + mode: 'auto' as const, + setupRunPolicy: 'run-by-default' as const, + setupAgentStartupPolicy: 'wait-for-setup' as const, + scripts: { setup: '', archive: '' } + } } const parent = { path: '/remote/repo-parent', @@ -3075,7 +3082,13 @@ describe('OrcaRuntimeService', () => { displayName: 'repo', badgeColor: 'blue', addedAt: 1, - connectionId: 'ssh-1' + connectionId: 'ssh-1', + hookSettings: { + mode: 'auto' as const, + setupRunPolicy: 'run-by-default' as const, + setupAgentStartupPolicy: 'wait-for-setup' as const, + scripts: { setup: '', archive: '' } + } } const metaById: Record = {} const remoteStore = { @@ -3285,7 +3298,13 @@ describe('OrcaRuntimeService', () => { displayName: 'repo', badgeColor: 'blue', addedAt: 1, - connectionId: 'ssh-1' + connectionId: 'ssh-1', + hookSettings: { + mode: 'auto' as const, + setupRunPolicy: 'run-by-default' as const, + setupAgentStartupPolicy: 'wait-for-setup' as const, + scripts: { setup: '', archive: '' } + } } const metaById: Record = {} const remoteStore = { @@ -3372,14 +3391,12 @@ describe('OrcaRuntimeService', () => { startup: { command: 'claude' } }) - expect(result.setup).toMatchObject({ - runnerScriptPath: '/remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh' - }) + expect(result.setup).toBeUndefined() expect(spawn).toHaveBeenNthCalledWith( 1, expect.objectContaining({ cwd: '/remote/mobile-setup', - command: 'claude', + command: expect.stringContaining('exec claude'), worktreeId: result.worktree.id }) ) @@ -3387,10 +3404,21 @@ describe('OrcaRuntimeService', () => { 2, expect.objectContaining({ cwd: '/remote/mobile-setup', - command: 'bash /remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh', + command: expect.stringContaining( + 'bash /remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh' + ), worktreeId: result.worktree.id }) ) + const startupCommand = (spawn.mock.calls[0]![0] as { command: string }).command + const setupCommand = (spawn.mock.calls[1]![0] as { command: string }).command + const nonceMatch = startupCommand.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/) + expect(nonceMatch?.[1]).toBeTruthy() + const markerPath = `/remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh.${nonceMatch![1]}.done` + expect(setupCommand).toContain('printf') + expect(setupCommand).toContain(`${nonceMatch![1]} "$status"`) + expect(startupCommand).toContain(markerPath) + expect(setupCommand).toContain(markerPath) expect(revealTerminalSession).toHaveBeenLastCalledWith( result.worktree.id, expect.objectContaining({ @@ -6420,6 +6448,7 @@ describe('OrcaRuntimeService', () => { expect.objectContaining({ cwd: TEST_WORKTREE_PATH, command: 'codex', + commandDelivery: 'provider', worktreeId: TEST_WORKTREE_ID, preAllocatedHandle: expect.stringMatching(/^term_/) }) @@ -6700,6 +6729,49 @@ describe('OrcaRuntimeService', () => { }) }) + it('preserves Claude Agent Teams for sequenced Claude launches', 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: + 'bash -lc \'echo Waiting for setup to finish before starting agent... >&2; exec claude "hello"\'', + claudeAgentTeamsSourceCommand: 'claude "hello"', + launchAgent: 'claude', + launchConfig: { + agentCommand: 'claude', + agentArgs: '', + agentEnv: { CLAUDE_PROFILE: 'captured' } + } + }) + + const sequencedClaude = spawn.mock.calls[0]?.[0] as { + command?: string + env?: Record + } + + expect(sequencedClaude.command).toBe( + 'bash -lc \'echo Waiting for setup to finish before starting agent... >&2; exec claude "hello"\'' + ) + expect(sequencedClaude.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBe('1') + expect(sequencedClaude.env?.[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]).toBe( + 'claude --teammate-mode in-process "hello"' + ) + }) + it('restores captured native Claude Agent Teams mode with fresh service env', async () => { setPlatform('linux') const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) @@ -18700,15 +18772,9 @@ describe('OrcaRuntimeService', () => { repoId: 'repo-1', path: '/tmp/workspaces/runtime-hook-skip', branch: 'runtime-hook-skip' - }), - setup: { - runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', - envVars: { - ORCA_ROOT_PATH: '/tmp/repo', - ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-hook-skip' - } - } + }) }) + expect(result.setup).toBeUndefined() expect(activateWorktree).not.toHaveBeenCalled() expect(spawn).toHaveBeenNthCalledWith( 1, @@ -18749,6 +18815,162 @@ describe('OrcaRuntimeService', () => { }) }) + it('sequences setup before startup for opted-in local headless worktree creates', async () => { + const waitRepo = { + ...store.getRepo('repo-1')!, + hookSettings: { + mode: 'auto' as const, + setupRunPolicy: 'run-by-default' as const, + setupAgentStartupPolicy: 'wait-for-setup' as const, + scripts: { setup: '', archive: '' } + } + } + const runtimeStore = { + ...store, + getRepos: () => [waitRepo], + getRepo: (id: string) => (id === 'repo-1' ? waitRepo : undefined) + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-headless-startup' }) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-headless-startup' }) + .mockResolvedValueOnce({ id: 'pty-headless-setup' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-headless-startup-setup') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-headless-startup-setup') + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-headless-startup-setup' + }, + waitForAgentStartup: true + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-headless-startup-setup', + head: 'def', + branch: 'runtime-headless-startup-setup', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-headless-startup-setup', + setupDecision: 'run', + startup: { command: 'claude' } + }) + + expect(createSetupRunnerScript).toHaveBeenCalled() + expect(runHook).not.toHaveBeenCalled() + expect(spawn).toHaveBeenCalledTimes(2) + const startupCommand = (spawn.mock.calls[0]![0] as { command: string }).command + const setupCommand = (spawn.mock.calls[1]![0] as { command: string }).command + const nonceMatch = startupCommand.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/) + expect(nonceMatch?.[1]).toBeTruthy() + expect(startupCommand).toContain('exec claude') + expect(setupCommand).toContain('printf') + expect(setupCommand).toContain(`${nonceMatch![1]} "$status"`) + expect(result.setup).toBeUndefined() + }) + + it('starts setup and startup side by side by default for local headless worktree creates', async () => { + const runtime = new OrcaRuntimeService(store) + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-headless-parallel' }) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-headless-parallel-startup' }) + .mockResolvedValueOnce({ id: 'pty-headless-parallel-setup' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-headless-parallel') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-headless-parallel') + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-headless-parallel' + } + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-headless-parallel', + head: 'def', + branch: 'runtime-headless-parallel', + isBare: false, + isMainWorktree: false + } + ]) + + await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-headless-parallel', + setupDecision: 'run', + startup: { command: 'claude' } + }) + + expect(spawn).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenNthCalledWith(1, expect.objectContaining({ command: 'claude' })) + expect(spawn).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ command: 'bash /tmp/repo/.git/orca/setup-runner.sh' }) + ) + }) + it('creates the first terminal for CLI-created worktrees without activating them', async () => { const runtime = new OrcaRuntimeService(store) const activateWorktree = vi.fn() @@ -19356,7 +19578,7 @@ describe('OrcaRuntimeService', () => { expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'claude' }) }) - it('honors split setup placement for local startup-draft worktrees', async () => { + it('honors split setup placement for opted-in local startup-draft worktrees', async () => { const metaById: Record = {} const runtimeStore = { ...store, @@ -19413,7 +19635,8 @@ describe('OrcaRuntimeService', () => { envVars: { ORCA_ROOT_PATH: '/tmp/repo', ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-split' - } + }, + waitForAgentStartup: true }) vi.mocked(listWorktrees).mockResolvedValue([ { @@ -19438,7 +19661,7 @@ describe('OrcaRuntimeService', () => { 1, expect.objectContaining({ cwd: '/tmp/workspaces/runtime-startup-setup-split', - command: "codex '--dangerously-bypass-approvals-and-sandbox'", + command: expect.stringContaining('codex'), worktreeId: result.worktree.id }) ) @@ -19446,7 +19669,7 @@ describe('OrcaRuntimeService', () => { 2, expect.objectContaining({ cwd: '/tmp/workspaces/runtime-startup-setup-split', - command: 'bash /tmp/repo/.git/orca/setup-runner.sh', + command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh'), env: expect.objectContaining({ ORCA_ROOT_PATH: '/tmp/repo', ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-split', @@ -19455,8 +19678,19 @@ describe('OrcaRuntimeService', () => { worktreeId: result.worktree.id }) ) + const startupCommand = (spawn.mock.calls[0]![0] as { command: string }).command + const setupCommand = (spawn.mock.calls[1]![0] as { command: string }).command + const nonceMatch = startupCommand.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/) + expect(nonceMatch?.[1]).toBeTruthy() + const markerPath = `/tmp/repo/.git/orca/setup-runner.sh.${nonceMatch![1]}.done` + expect(startupCommand).toContain('--dangerously-bypass-approvals-and-sandbox') + expect(setupCommand).toContain('printf') + expect(setupCommand).toContain(`${nonceMatch![1]} "$status"`) + expect(startupCommand).toContain(markerPath) + expect(setupCommand).toContain(markerPath) const mainEnv = (spawn.mock.calls[0]![0] as { env?: Record }).env ?? {} const setupEnv = (spawn.mock.calls[1]![0] as { env?: Record }).env ?? {} + expect(result.setup).toBeUndefined() expect(mainEnv.ORCA_TAB_ID).toBeDefined() expect(mainEnv.ORCA_PANE_KEY).toBeDefined() expect(setupEnv.ORCA_TAB_ID).toBe(mainEnv.ORCA_TAB_ID) @@ -19473,6 +19707,84 @@ describe('OrcaRuntimeService', () => { ) }) + it('passes the wrapped setup command to activation when startup spawned but setup did not', async () => { + const runtime = new OrcaRuntimeService(store) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-startup-main' }) + .mockRejectedValueOnce(new Error('setup spawn failed')) + const activateWorktree = vi.fn() + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + createTerminal: vi.fn(), + revealTerminalSession: vi.fn().mockResolvedValue({ tabId: 'tab-startup-main' }), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-startup-setup-retry') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-startup-setup-retry') + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-retry' + }, + waitForAgentStartup: true + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-startup-setup-retry', + head: 'def', + branch: 'runtime-startup-setup-retry', + isBare: false, + isMainWorktree: false + } + ]) + + await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-startup-setup-retry', + setupDecision: 'run', + activate: true, + startup: { command: 'claude' } + }) + + expect(spawn).toHaveBeenCalledTimes(2) + expect(activateWorktree).toHaveBeenCalledWith( + 'repo-1', + expect.any(String), + expect.objectContaining({ + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh') + }), + undefined, + undefined + ) + const activationSetup = activateWorktree.mock.calls[0]?.[2] as { command?: string } | undefined + expect(activationSetup?.command).toContain('printf') + }) + it('lets explicit startup draft agents override the desktop default', async () => { detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue([]) const metaById: Record = {} diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 94716e894..f8dc05fcf 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -154,7 +154,14 @@ import { parsePtySessionId } from '../../shared/pty-session-id-format' import { clampLinearIssueListLimit } from '../../shared/linear-issue-read-limits' import { isFolderRepo } from '../../shared/repo-kind' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' -import { buildSetupRunnerCommand } from '../../shared/setup-runner-command' +import { + buildSetupRunnerCommand, + getSetupRunnerCommandPlatformForPath +} from '../../shared/setup-runner-command' +import { + createSequencedSetupAgentCommands, + SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV +} from '../../shared/setup-agent-sequencing' import { TASK_PROVIDERS } from '../../shared/task-providers' import { FIRST_PANE_ID } from '../../shared/pane-key' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' @@ -991,6 +998,7 @@ type RuntimePtyController = { rows: number cwd?: string command?: string + commandDelivery?: 'renderer' | 'provider' startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] env?: Record envToDelete?: string[] @@ -12717,12 +12725,11 @@ export class OrcaRuntimeService { } const shouldRunSetup = hooks?.scripts.setup && shouldRunSetupForCreate(repo, effectiveDecision) if (shouldRunSetup && hooks?.scripts.setup) { - if (this.authoritativeWindowId !== null) { + const shouldUseSetupRunner = this.authoritativeWindowId !== null || Boolean(effectiveStartup) + if (shouldUseSetupRunner) { try { - // Why: CLI-created worktrees must use the same runner-script path as the - // renderer create flow so repo-committed `orca.yaml` setup hooks run in - // the visible first terminal instead of a hidden background shell with - // different failure and prompt behavior. + // Why: setup+startup must share the terminal runner path even without + // a renderer window, so the startup shell can wait on setup completion. setup = createSetupRunnerScript( repo, worktreePath, @@ -12770,7 +12777,30 @@ export class OrcaRuntimeService { let startupTerminalTabId: string | null = null let startupTerminalPaneKey: string | null = null let startupTerminalPtyId: string | null = null - if (effectiveStartup && this.ptyController?.spawn) { + + let sequencedStartup = effectiveStartup + let wrappedSetupCommandStr: string | undefined + if (effectiveStartup && setup?.waitForAgentStartup === true) { + const platform = getSetupRunnerCommandPlatformForPath( + setup.runnerScriptPath, + process.platform === 'win32' ? 'windows' : 'posix' + ) + const sequenced = createSequencedSetupAgentCommands({ + runnerScriptPath: setup.runnerScriptPath, + startupCommand: effectiveStartup.command, + platform + }) + sequencedStartup = { + ...effectiveStartup, + command: sequenced.startupCommand, + ...(sequenced.startupEnv + ? { env: { ...effectiveStartup.env, ...sequenced.startupEnv } } + : {}) + } + wrappedSetupCommandStr = sequenced.setupCommand + } + + if (sequencedStartup && this.ptyController?.spawn) { try { // Why: automation startup must not depend on a renderer TerminalPane // mounting. Runtime-spawned PTYs run immediately and the UI adopts the @@ -12780,12 +12810,15 @@ export class OrcaRuntimeService { this.markLocalWorkspaceTrustedForAgent(startupTrustAgent, worktreePath) } const terminal = await this.createTerminal(`id:${worktree.id}`, { - command: effectiveStartup.command, - env: effectiveStartup.env, - ...(effectiveStartup.launchConfig ? { launchConfig: effectiveStartup.launchConfig } : {}), + command: sequencedStartup.command, + ...(setup && effectiveStartup + ? { claudeAgentTeamsSourceCommand: effectiveStartup.command } + : {}), + env: sequencedStartup.env, + ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), ...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}), - startupCommandDelivery: effectiveStartup.startupCommandDelivery, - telemetry: effectiveStartup.telemetry + startupCommandDelivery: sequencedStartup.startupCommandDelivery, + telemetry: sequencedStartup.telemetry }) if (effectiveDraftPaste) { this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste) @@ -12811,10 +12844,15 @@ export class OrcaRuntimeService { // Why: reveal-on-adopt can create the startup tab before renderer // activation handles setup. Honor the same split-vs-tab setting here // because renderer activation will skip setup once the startup tab exists. - const setupCommand = buildSetupRunnerCommand( - setup.runnerScriptPath, - process.platform === 'win32' ? 'windows' : 'posix' - ) + const setupCommand = + wrappedSetupCommandStr ?? + buildSetupRunnerCommand( + setup.runnerScriptPath, + getSetupRunnerCommandPlatformForPath( + setup.runnerScriptPath, + process.platform === 'win32' ? 'windows' : 'posix' + ) + ) const setupLaunchMode = (this.store.getSettings() as Partial>) .setupScriptLaunchMode ?? 'new-tab' @@ -12848,7 +12886,16 @@ export class OrcaRuntimeService { // Why: plain CLI creates should not steal the user's current workspace. // Explicit activation and hook-running still use renderer activation so // the user can watch prompts/output in a visible pane. - const activationSetup = didSpawnSetup ? undefined : setup + const activationSetup = didSpawnSetup + ? undefined + : setup + ? { + ...setup, + ...(didSpawnStartup && wrappedSetupCommandStr + ? { command: wrappedSetupCommandStr } + : {}) + } + : undefined if (effectiveStartup && !didSpawnStartup) { this.notifyActivateWorktree( repo.id, @@ -12868,10 +12915,15 @@ export class OrcaRuntimeService { initialTerminalHandle = terminal.handle } if (setup && !didSpawnSetup) { - const setupCommand = buildSetupRunnerCommand( - setup.runnerScriptPath, - process.platform === 'win32' ? 'windows' : 'posix' - ) + const setupCommand = + wrappedSetupCommandStr ?? + buildSetupRunnerCommand( + setup.runnerScriptPath, + getSetupRunnerCommandPlatformForPath( + setup.runnerScriptPath, + process.platform === 'win32' ? 'windows' : 'posix' + ) + ) const setupLaunchMode = (this.store.getSettings() as Partial>) .setupScriptLaunchMode ?? 'new-tab' @@ -12900,6 +12952,16 @@ export class OrcaRuntimeService { console.warn(`[worktree-create] ${warning}`) } } + const returnedSetup = didSpawnSetup + ? undefined + : setup + ? { + ...setup, + ...(didSpawnStartup && wrappedSetupCommandStr + ? { command: wrappedSetupCommandStr } + : {}) + } + : undefined return { worktree: { ...worktree, @@ -12910,7 +12972,7 @@ export class OrcaRuntimeService { git: created }, ...(lineageInput ? { lineage, workspaceLineage, warnings: lineageWarnings } : {}), - ...(setup ? { setup } : {}), + ...(returnedSetup ? { setup: returnedSetup } : {}), ...(defaultTabs ? { defaultTabs } : {}), ...(warning ? { warning } : {}), ...(addResult.localBaseRefRefresh @@ -13036,7 +13098,25 @@ export class OrcaRuntimeService { let startupTerminalTabId: string | null = null let startupTerminalPaneKey: string | null = null let startupTerminalPtyId: string | null = null - if (args.startup && this.ptyController?.spawn) { + + let sequencedStartup = args.startup + let wrappedSetupCommandStr: string | undefined + if (args.startup && result.setup?.waitForAgentStartup === true) { + const platform = getSetupRunnerCommandPlatformForPath(result.setup.runnerScriptPath, 'posix') + const sequenced = createSequencedSetupAgentCommands({ + runnerScriptPath: result.setup.runnerScriptPath, + startupCommand: args.startup.command, + platform + }) + sequencedStartup = { + ...args.startup, + command: sequenced.startupCommand, + ...(sequenced.startupEnv ? { env: { ...args.startup.env, ...sequenced.startupEnv } } : {}) + } + wrappedSetupCommandStr = sequenced.setupCommand + } + + if (sequencedStartup && this.ptyController?.spawn) { try { const startupTrustAgent = args.startupDraftPaste?.agent ?? args.createdWithAgent if (startupTrustAgent) { @@ -13047,12 +13127,15 @@ export class OrcaRuntimeService { ) } const terminal = await this.createTerminal(`path:${result.worktree.path}`, { - command: args.startup.command, - env: args.startup.env, - ...(args.startup.launchConfig ? { launchConfig: args.startup.launchConfig } : {}), + command: sequencedStartup.command, + ...(result.setup && args.startup + ? { claudeAgentTeamsSourceCommand: args.startup.command } + : {}), + env: sequencedStartup.env, + ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), ...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}), - startupCommandDelivery: args.startup.startupCommandDelivery, - telemetry: args.startup.telemetry + startupCommandDelivery: sequencedStartup.startupCommandDelivery, + telemetry: sequencedStartup.telemetry }) if (args.startupDraftPaste) { this.pasteStartupDraftWhenReady(terminal.handle, args.startupDraftPaste) @@ -13078,10 +13161,12 @@ export class OrcaRuntimeService { // Why: remote/mobile task creates spawn the agent terminal in runtime, // so renderer activation never receives the setup payload. Runtime // must apply the same user-selected split-vs-tab setup placement. - const setupCommand = buildSetupRunnerCommand( - result.setup.runnerScriptPath, - isWindowsAbsolutePathLike(result.setup.runnerScriptPath) ? 'windows' : 'posix' - ) + const setupCommand = + wrappedSetupCommandStr ?? + buildSetupRunnerCommand( + result.setup.runnerScriptPath, + getSetupRunnerCommandPlatformForPath(result.setup.runnerScriptPath, 'posix') + ) const setupLaunchMode = (this.store.getSettings() as Partial>) .setupScriptLaunchMode ?? 'new-tab' @@ -13113,7 +13198,16 @@ export class OrcaRuntimeService { const shouldActivate = args.activate === true || args.runHooks === true if (shouldActivate) { - const activationSetup = didSpawnSetup ? undefined : result.setup + const activationSetup = didSpawnSetup + ? undefined + : result.setup + ? { + ...result.setup, + ...(didSpawnStartup && wrappedSetupCommandStr + ? { command: wrappedSetupCommandStr } + : {}) + } + : undefined if (args.startup && !didSpawnStartup) { this.notifyActivateWorktree( repo.id, @@ -13139,7 +13233,7 @@ export class OrcaRuntimeService { if (result.setup && !didSpawnSetup) { const setupCommand = buildSetupRunnerCommand( result.setup.runnerScriptPath, - isWindowsAbsolutePathLike(result.setup.runnerScriptPath) ? 'windows' : 'posix' + getSetupRunnerCommandPlatformForPath(result.setup.runnerScriptPath, 'posix') ) const setupLaunchMode = (this.store.getSettings() as Partial>) @@ -13166,10 +13260,27 @@ export class OrcaRuntimeService { } } + const returnedSetup = didSpawnSetup + ? undefined + : result.setup + ? { + ...result.setup, + ...(didSpawnStartup && wrappedSetupCommandStr + ? { command: wrappedSetupCommandStr } + : {}) + } + : undefined + const resultForRenderer = returnedSetup + ? { ...result, setup: returnedSetup } + : (() => { + const { setup: _setup, ...resultWithoutSetup } = result + return resultWithoutSetup + })() + const resultWithStartupTerminal = didSpawnStartup && startupTerminalHandle ? { - ...result, + ...resultForRenderer, startupTerminal: { spawned: true, handle: startupTerminalHandle, @@ -13179,7 +13290,7 @@ export class OrcaRuntimeService { surface: 'background' as const } } - : result + : resultForRenderer return warning ? { ...resultWithStartupTerminal, warning } : resultWithStartupTerminal } @@ -14678,6 +14789,7 @@ export class OrcaRuntimeService { worktreeSelector?: string, opts: { command?: string + claudeAgentTeamsSourceCommand?: string env?: Record launchConfig?: WorktreeStartupLaunch['launchConfig'] launchToken?: string @@ -14739,14 +14851,16 @@ export class OrcaRuntimeService { ...opts.env, ...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {}) } + const claudeAgentTeamsSourceCommand = + opts.claudeAgentTeamsSourceCommand?.trim() || opts.command?.trim() || undefined const claudeAgentTeamsMode = this.store?.getSettings?.().claudeAgentTeamsMode const effectiveClaudeAgentTeamsMode = inferCapturedClaudeAgentTeamsMode( opts.launchConfig, - opts.command, + claudeAgentTeamsSourceCommand, claudeAgentTeamsMode ) const agentTeamsPlan = await buildClaudeAgentTeamsLaunchPlan({ - command: opts.command, + command: claudeAgentTeamsSourceCommand, mode: effectiveClaudeAgentTeamsMode, baseEnv: { ...process.env, @@ -14763,6 +14877,13 @@ export class OrcaRuntimeService { shimBin }).env }) + const sequencedStartupCommand = + agentTeamsPlan && + claudeAgentTeamsSourceCommand && + opts.command && + claudeAgentTeamsSourceCommand !== opts.command + ? agentTeamsPlan.command + : undefined const effectiveLaunchConfig = opts.launchConfig && agentTeamsPlan ? { @@ -14778,9 +14899,17 @@ export class OrcaRuntimeService { } } : opts.launchConfig + // Why: setup/agent sequencing wraps the PTY launch in a wait shell before + // Claude Agent Teams runs. Preserve the direct Claude command separately + // so the wrapper can exec the teammate-mode variant after setup completes. const env = this.buildTerminalWorkspaceEnv( workspace, - baseEnv, + { + ...baseEnv, + ...(sequencedStartupCommand + ? { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: sequencedStartupCommand } + : {}) + }, paneKey, tabId, agentTeamsPlan?.env @@ -14789,7 +14918,8 @@ export class OrcaRuntimeService { cols: 120, rows: 40, cwd: workspace.path, - command: agentTeamsPlan?.command ?? opts.command, + command: sequencedStartupCommand ? opts.command : (agentTeamsPlan?.command ?? opts.command), + commandDelivery: 'provider', startupCommandDelivery: opts.startupCommandDelivery, env, envToDelete: agentTeamsPlan?.envToDelete, @@ -15634,6 +15764,7 @@ export class OrcaRuntimeService { rows: 40, cwd: workspace.path, command: opts.command, + commandDelivery: 'provider', env: this.buildTerminalWorkspaceEnv(workspace, opts.env ?? {}, paneKey, parentTabId), envToDelete: opts.envToDelete, connectionId: workspace.connectionId, diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 983ac2348..3c9b05970 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -4,6 +4,10 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' +import { + resolveSetupAgentSequenceLaunchCommand, + SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV +} from '../shared/setup-agent-sequencing' const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({ mockPtySpawn: vi.fn(), @@ -165,8 +169,34 @@ describe('PtyHandler', () => { expect(handler.activePtyCount).toBe(1) }) + it('keeps SSH spawn commands as hints unless provider delivery is requested', async () => { + await dispatcher.callRequest('pty.spawn', { command: 'echo renderer-owned' }) + + vi.advanceTimersByTime(50) + + const term = mockPtySpawn.mock.results[0]?.value + expect(term.write).not.toHaveBeenCalled() + }) + + it('submits provider-delivered spawn commands to the relay shell', async () => { + await dispatcher.callRequest('pty.spawn', { + command: 'echo provider-owned', + commandDelivery: 'provider' + }) + + vi.advanceTimersByTime(49) + const term = mockPtySpawn.mock.results[0]?.value + expect(handler.retainedStartupCommandCount).toBe(1) + expect(term.write).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + const submit = process.platform === 'win32' ? '\r' : '\n' + expect(term.write).toHaveBeenCalledWith(`echo provider-owned${submit}`) + expect(handler.retainedStartupCommandCount).toBe(0) + }) + it.skipIf(process.platform === 'win32')( - 'enables shell-ready marker env for delivery-hinted startup commands', + 'emits shell-ready markers for renderer-delivered startup commands', async () => { const oldShell = process.env.SHELL const oldHome = process.env.HOME @@ -177,6 +207,7 @@ describe('PtyHandler', () => { try { await dispatcher.callRequest('pty.spawn', { env: { HOME: homeDir }, + command: 'echo renderer-owned', startupCommandDelivery: 'shell-ready' }) } finally { @@ -197,11 +228,12 @@ describe('PtyHandler', () => { | { env?: Record } | undefined expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1') + expect(handler.retainedStartupCommandCount).toBe(0) } ) it.skipIf(process.platform === 'win32')( - 'enables shell-ready marker env for Codex native prefill commands', + 'emits shell-ready markers for renderer-delivered Codex native prefill commands', async () => { const oldShell = process.env.SHELL const oldHome = process.env.HOME @@ -232,6 +264,197 @@ describe('PtyHandler', () => { | { env?: Record } | undefined expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1') + expect(handler.retainedStartupCommandCount).toBe(0) + } + ) + + it.skipIf(process.platform === 'win32')( + 'enables shell-ready marker env for provider-delivered startup commands', + async () => { + const oldShell = process.env.SHELL + const oldHome = process.env.HOME + const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-ready-env-')) + + process.env.SHELL = '/bin/bash' + process.env.HOME = homeDir + try { + await dispatcher.callRequest('pty.spawn', { + env: { HOME: homeDir }, + command: 'echo provider-owned', + commandDelivery: 'provider', + startupCommandDelivery: 'shell-ready' + }) + } finally { + if (oldShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = oldShell + } + if (oldHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = oldHome + } + rmSync(homeDir, { recursive: true, force: true }) + } + + const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as + | { env?: Record } + | undefined + expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1') + } + ) + + it.skipIf(process.platform === 'win32')( + 'uses the sequenced startup command hint for provider shell-ready detection', + async () => { + const oldShell = process.env.SHELL + const oldHome = process.env.HOME + const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-sequenced-ready-env-')) + + process.env.SHELL = '/bin/bash' + process.env.HOME = homeDir + try { + await dispatcher.callRequest('pty.spawn', { + env: { + HOME: homeDir, + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex --prefill 'linked issue context'" + }, + command: 'bash -lc wait-for-setup-wrapper', + commandDelivery: 'provider' + }) + } finally { + if (oldShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = oldShell + } + if (oldHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = oldHome + } + rmSync(homeDir, { recursive: true, force: true }) + } + + const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as + | { env?: Record } + | undefined + expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1') + } + ) + + it.skipIf(process.platform === 'win32')( + 'waits for the shell-ready marker before provider-delivered startup commands', + async () => { + let dataCallback: ((data: string) => void) | undefined + const term = { + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallback = cb + }), + onExit: vi.fn() + } + mockPtySpawn.mockReturnValue(term) + const oldShell = process.env.SHELL + const oldHome = process.env.HOME + const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-ready-spawn-')) + + process.env.SHELL = '/bin/bash' + process.env.HOME = homeDir + try { + await dispatcher.callRequest('pty.spawn', { + env: { HOME: homeDir }, + command: 'echo after-ready', + commandDelivery: 'provider', + startupCommandDelivery: 'shell-ready' + }) + } finally { + if (oldShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = oldShell + } + if (oldHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = oldHome + } + rmSync(homeDir, { recursive: true, force: true }) + } + + vi.advanceTimersByTime(1499) + expect(term.write).not.toHaveBeenCalled() + + dataCallback?.('\x1b]777;orca-shell-ready\x07user@remote $ ') + vi.advanceTimersByTime(49) + expect(term.write).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + + expect(term.write).toHaveBeenCalledWith('echo after-ready\n') + expect(handler.retainedStartupCommandCount).toBe(0) + vi.advanceTimersByTime(8) + expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { + id: 'pty-1', + data: 'user@remote $ ' + }) + } + ) + + it.skipIf(process.platform === 'win32')( + 'flushes held shell-ready marker bytes when provider delivery falls back', + async () => { + let dataCallback: ((data: string) => void) | undefined + const term = { + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallback = cb + }), + onExit: vi.fn() + } + mockPtySpawn.mockReturnValue(term) + const oldShell = process.env.SHELL + const oldHome = process.env.HOME + const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-fallback-spawn-')) + + process.env.SHELL = '/bin/bash' + process.env.HOME = homeDir + try { + await dispatcher.callRequest('pty.spawn', { + env: { HOME: homeDir }, + command: 'echo fallback', + commandDelivery: 'provider', + startupCommandDelivery: 'shell-ready' + }) + } finally { + if (oldShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = oldShell + } + if (oldHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = oldHome + } + rmSync(homeDir, { recursive: true, force: true }) + } + + dataCallback?.('\x1b]777;orca-shell-ready') + vi.advanceTimersByTime(1500) + + expect(term.write).toHaveBeenCalledWith('echo fallback\n') + vi.advanceTimersByTime(8) + expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { + id: 'pty-1', + data: '\x1b]777;orca-shell-ready' + }) + + const result = await dispatcher.callRequest('pty.attach', { + id: 'pty-1', + suppressReplayNotification: true + }) + expect(result).toEqual({ replay: '\x1b]777;orca-shell-ready' }) } ) @@ -252,6 +475,42 @@ describe('PtyHandler', () => { expect(killSpy).toHaveBeenCalledWith('SIGKILL') }) + it('does not submit provider-delivered commands for stale spawn responses', async () => { + const killSpy = vi.fn() + const term = { ...mockPtyInstance, kill: killSpy, onData: vi.fn(), onExit: vi.fn() } + mockPtySpawn.mockReturnValue(term) + + await dispatcher.callRequest( + 'pty.spawn', + { command: 'echo stale', commandDelivery: 'provider' }, + { isStale: () => true } + ) + + vi.advanceTimersByTime(50) + expect(term.write).not.toHaveBeenCalled() + expect(handler.retainedStartupCommandCount).toBe(0) + expect(killSpy).toHaveBeenCalledWith('SIGTERM') + }) + + it('releases pending provider-delivered commands on shutdown before delivery', async () => { + const killSpy = vi.fn() + const term = { ...mockPtyInstance, kill: killSpy, onData: vi.fn(), onExit: vi.fn() } + mockPtySpawn.mockReturnValue(term) + + await dispatcher.callRequest('pty.spawn', { + command: 'echo stop-before-run', + commandDelivery: 'provider' + }) + expect(handler.retainedStartupCommandCount).toBe(1) + + await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true }) + vi.advanceTimersByTime(50) + + expect(handler.retainedStartupCommandCount).toBe(0) + expect(term.write).not.toHaveBeenCalled() + expect(killSpy).toHaveBeenCalledWith('SIGKILL') + }) + it('increments PTY ids on each spawn', async () => { const r1 = await dispatcher.callRequest('pty.spawn', {}) const r2 = await dispatcher.callRequest('pty.spawn', {}) @@ -759,6 +1018,20 @@ describe('PtyHandler', () => { expect(spawnEnv.env.SEEN_PI_CODING_AGENT_DIR).toBe('/remote/pi') }) + it('lets relay env augmenters resolve the original sequenced startup command hint', async () => { + handler.addEnvAugmenter((ctx) => ({ + SEEN_LAUNCH_COMMAND_HINT: resolveSetupAgentSequenceLaunchCommand(ctx.env, ctx.command) ?? '' + })) + + await dispatcher.callRequest('pty.spawn', { + command: 'powershell wait-wrapper', + env: { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'omp --resume' } + }) + + const spawnEnv = mockPtySpawn.mock.calls[0][2] as { env: Record } + expect(spawnEnv.env.SEEN_LAUNCH_COMMAND_HINT).toBe('omp --resume') + }) + it.skipIf(process.platform === 'win32')( 'wraps bash spawns to restore overlay env after remote startup files', async () => { diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index b1419e2fa..8ac4259d3 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -13,6 +13,13 @@ import { import { getRelayShellLaunchConfig } from './pty-shell-launch' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' import { shouldUseShellReadyStartupDelivery } from '../shared/codex-startup-delivery' +import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' +import { + createShellReadyScanState, + drainShellReadyHeldBytes, + scanForShellReady, + type ShellReadyScanState +} from '../main/shell-ready-marker-scanner' // Why: node-pty is a native addon that may not be installed on the remote. // Dynamic import keeps the require() lazy so loadPty() returns null gracefully @@ -53,12 +60,21 @@ type ManagedPty = { paneKey?: string tabId?: string worktreeId?: string + startupCommand?: ManagedStartupCommand } type PendingPtyOutput = { data: string } +type ManagedStartupCommand = { + command: string + delivered: boolean + waitForShellReady: boolean + scanState: ShellReadyScanState | null + timer: ReturnType | null +} + function disposeManagedPty(managed: ManagedPty): void { if (managed.disposed) { return @@ -98,6 +114,8 @@ const INTERACTIVE_OUTPUT_WINDOW_MS = 100 const INTERACTIVE_OUTPUT_MAX_CHARS = 1024 const INTERACTIVE_REDRAW_MAX_CHARS = PTY_OUTPUT_FLUSH_CHUNK_CHARS const INTERACTIVE_OUTPUT_BUDGET_CHARS = 32 * 1024 +const STARTUP_COMMAND_WRITE_DELAY_MS = 50 +const STARTUP_COMMAND_SHELL_READY_FALLBACK_MS = 1500 const ALLOWED_SIGNALS = new Set([ 'SIGINT', 'SIGTERM', @@ -222,14 +240,71 @@ export class PtyHandler { return { ...baseEnv, ...augmented } } + private clearStartupCommandTimer(managed: ManagedPty): void { + if (managed.startupCommand?.timer) { + clearTimeout(managed.startupCommand.timer) + managed.startupCommand.timer = null + } + } + + private appendReplayBuffer(managed: ManagedPty, data: string): void { + managed.buffered += data + if (managed.buffered.length > REPLAY_BUFFER_MAX) { + managed.buffered = managed.buffered.slice(-REPLAY_BUFFER_MAX) + } + } + + private releaseStartupCommand(managed: ManagedPty): void { + this.clearStartupCommandTimer(managed) + managed.startupCommand = undefined + } + + private scheduleStartupCommandDelivery(managed: ManagedPty, delayMs: number): void { + const startup = managed.startupCommand + if (!startup || startup.delivered || managed.disposed) { + return + } + this.clearStartupCommandTimer(managed) + startup.timer = setTimeout(() => { + startup.timer = null + this.deliverStartupCommand(managed) + }, delayMs) + } + + private deliverStartupCommand(managed: ManagedPty): void { + const startup = managed.startupCommand + if (!startup || startup.delivered || managed.disposed) { + return + } + startup.delivered = true + this.clearStartupCommandTimer(managed) + if (startup.scanState) { + const heldBytes = drainShellReadyHeldBytes(startup.scanState) + if (heldBytes) { + this.appendReplayBuffer(managed, heldBytes) + this.enqueuePtyOutput(managed.id, heldBytes) + } + } + const submit = process.platform === 'win32' ? '\r' : '\n' + const endsWithSubmit = startup.command.endsWith('\r') || startup.command.endsWith('\n') + const payload = endsWithSubmit ? startup.command : `${startup.command}${submit}` + managed.startupCommand = undefined + managed.pty.write(payload) + } + /** Wire onData/onExit listeners for a managed PTY and store it. */ private wireAndStore(managed: ManagedPty): void { this.ptys.set(managed.id, managed) managed.pty.onData((data: string) => { - managed.buffered += data - if (managed.buffered.length > REPLAY_BUFFER_MAX) { - managed.buffered = managed.buffered.slice(-REPLAY_BUFFER_MAX) + const startup = managed.startupCommand + if (startup?.waitForShellReady && startup.scanState && !startup.delivered) { + const scanned = scanForShellReady(startup.scanState, data) + data = scanned.output + if (scanned.matched) { + this.scheduleStartupCommandDelivery(managed, STARTUP_COMMAND_WRITE_DELAY_MS) + } } + this.appendReplayBuffer(managed, data) this.enqueuePtyOutput(managed.id, data) }) managed.pty.onExit(({ exitCode }: { exitCode: number }) => { @@ -253,6 +328,7 @@ export class PtyHandler { clearTimeout(managed.killTimer) managed.killTimer = undefined } + this.clearStartupCommandTimer(managed) this.flushPtyOutput(managed.id) this.dispatcher.notify('pty.exit', { id: managed.id, code: exitCode }) this.notifyExitListener(managed) @@ -428,20 +504,26 @@ export class PtyHandler { // dirs) override renderer-supplied env so live remote paths and hook coords // win over local userData paths. The context lets overlay augmenters derive // per-PTY OpenCode/Pi directories from the stable paneKey when present. - // `command` is forwarded by ssh-pty-provider.ts only as a hint for - // overlay resolution — the relay still launches a login shell and the - // command is typed in via pty.data writes. + // `command` is usually forwarded by ssh-pty-provider.ts only as a hint + // for overlay resolution; runtime-owned PTYs opt into relay delivery + // because no renderer TerminalPane exists to type the command. const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined const command = typeof params.command === 'string' ? params.command : undefined + const commandDelivery = params.commandDelivery === 'provider' ? 'provider' : 'renderer' + const shouldProviderDeliverCommand = commandDelivery === 'provider' && command !== undefined const spawnEnv = this.buildSpawnEnv(env, { id, paneKey, shell, command }) - // Why: only explicit shell-ready hints are trusted here; native Codex - // prefill detection still auto-enables readiness through the predicate. - const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv, process.platform, { - emitReadyMarker: shouldUseShellReadyStartupDelivery({ - command, + const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(spawnEnv, command) + const shouldEmitShellReadyMarker = + launchCommandHint !== undefined && + shouldUseShellReadyStartupDelivery({ + command: launchCommandHint, startupCommandDelivery: params.startupCommandDelivery === 'shell-ready' ? 'shell-ready' : undefined }) + // Why: renderer- and provider-delivered startup commands both use this + // marker; the side responsible for delivery also strips it from output. + const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv, process.platform, { + emitReadyMarker: shouldEmitShellReadyMarker }) // Why: SSH exec channels give the relay a minimal environment without @@ -454,7 +536,9 @@ export class PtyHandler { cols, rows, cwd, - env: { ...spawnEnv, ...shellLaunch.env } + // Why: relay shells inherit process.env; never let an ambient Orca marker + // enable shell-ready behavior unless this spawn explicitly requested it. + env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env } }) // Why: capture the renderer-supplied paneKey on the managed entry so the @@ -470,13 +554,28 @@ export class PtyHandler { buffered: '', paneKey, tabId, - worktreeId + worktreeId, + ...(shouldProviderDeliverCommand + ? { + startupCommand: { + command, + delivered: false, + waitForShellReady: shellLaunch.env.ORCA_SHELL_READY_MARKER === '1', + scanState: + shellLaunch.env.ORCA_SHELL_READY_MARKER === '1' + ? createShellReadyScanState() + : null, + timer: null + } + } + : {}) } this.wireAndStore(managed) if (context?.isStale()) { // Why: if the client reconnected while pty.spawn was in flight, the // response is discarded and no renderer can own this PTY. Shut it down // immediately so it does not linger as an unreachable remote shell. + this.releaseStartupCommand(managed) term.kill('SIGTERM') managed.killTimer = setTimeout(() => { const still = this.ptys.get(id) @@ -491,6 +590,13 @@ export class PtyHandler { this.ptys.delete(id) } }, 5000) + } else if (managed.startupCommand) { + this.scheduleStartupCommandDelivery( + managed, + managed.startupCommand.waitForShellReady + ? STARTUP_COMMAND_SHELL_READY_FALLBACK_MS + : STARTUP_COMMAND_WRITE_DELAY_MS + ) } return { id } } @@ -562,6 +668,7 @@ export class PtyHandler { } if (immediate) { + this.releaseStartupCommand(managed) this.flushPtyOutput(id) managed.pty.kill('SIGKILL') // Why: SIGKILL has already reaped the child; release the ptmx fd on the @@ -581,6 +688,7 @@ export class PtyHandler { this.ptys.delete(id) this.clearPtyFlowState(id) } else { + this.releaseStartupCommand(managed) managed.pty.kill('SIGTERM') // Why: Some processes ignore SIGTERM (e.g. a hung child, a custom signal @@ -761,7 +869,9 @@ export class PtyHandler { cols: entry.cols, rows: entry.rows, cwd: entry.cwd, - env: { ...spawnEnv, ...shellLaunch.env } + // Why: revived shells should not inherit an ambient shell-ready marker + // because no provider-delivered startup command is waiting on it. + env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env } }) this.wireAndStore({ id: entry.id, @@ -821,6 +931,7 @@ export class PtyHandler { clearTimeout(managed.killTimer) managed.killTimer = undefined } + this.clearStartupCommandTimer(managed) // Why: SIGKILL (not SIGTERM) before destroy. The relay process is // exiting; any SIGTERM-ignoring remote shell (editor with unsaved // buffers, a hung child with a bad handler, a process in @@ -843,6 +954,16 @@ export class PtyHandler { return this.ptys.size } + get retainedStartupCommandCount(): number { + let count = 0 + for (const managed of this.ptys.values()) { + if (managed.startupCommand) { + count += 1 + } + } + return count + } + get graceTimerActive(): boolean { return this.graceTimer !== null } diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 356d77812..7f1e62120 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -57,6 +57,7 @@ import { import { assertPluginSourceUnderByteCap } from './plugin-source-limit' import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugin-overlay-env' import { detectPiAgentKindFromCommand } from '../shared/pi-agent-kind' +import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' import { pickRemoteCliEnv } from './remote-cli-env' import { remoteCliRequestTimeoutMs } from './remote-cli-timeout' import { shouldReadRemoteCliStdin } from './remote-cli-stdin' @@ -536,8 +537,10 @@ async function main(): Promise { // Why: source-dir defaulting is keyed on which Pi-compatible agent is // being launched (Pi vs OMP). Install Orca's guarded extension into that // real remote agent dir without redirecting PI_CODING_AGENT_DIR. - const kind = detectPiAgentKindFromCommand(ctx.command) - const hasLaunchCommand = typeof ctx.command === 'string' && ctx.command.trim().length > 0 + const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(ctx.env, ctx.command) + const kind = detectPiAgentKindFromCommand(launchCommandHint) + const hasLaunchCommand = + typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0 const shouldPrepareOmpShadow = kind === 'omp' || !hasLaunchCommand if (kind === 'pi') { const sourceDir = resolvePiSourceAgentDir(ctx.env, ctx.shell, 'pi') diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index 899524a11..43d07dc28 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -150,6 +150,8 @@ function renderCard( requiresExplicitSetupChoice={false} setupDecision={null} onSetupDecisionChange={() => {}} + setupAgentStartupPolicy="start-immediately" + onSetupAgentStartupPolicyChange={() => {}} shouldWaitForSetupCheck={false} resolvedSetupDecision={null} createError={null} @@ -266,6 +268,58 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { expect(onChanges).toEqual([true]) }) + it('shows the setup startup policy toggle only when setup is available', () => { + current = renderCard({ + advancedOpen: true, + setupControlsEnabled: true, + setupConfig: { + source: 'yaml', + command: '# defaultTabs[1]\npnpm dev', + kind: 'default-tabs' + } + }) + expect(current.container.textContent).not.toContain( + 'Wait for setup to complete before starting agent' + ) + + act(() => current?.root.unmount()) + current?.container.remove() + + current = renderCard({ + advancedOpen: true, + setupControlsEnabled: true, + setupConfig: { + source: 'yaml', + command: 'pnpm install', + kind: 'setup' + } + }) + expect(current.container.textContent).toContain( + 'Wait for setup to complete before starting agent' + ) + }) + + it('emits the setup startup policy toggle value', () => { + const changes: string[] = [] + current = renderCard({ + advancedOpen: true, + setupControlsEnabled: true, + setupConfig: { + source: 'yaml', + command: 'pnpm install', + kind: 'setup' + }, + onSetupAgentStartupPolicyChange: (next) => changes.push(next) + }) + + const waitSwitch = current.container.querySelector( + '[role="switch"][aria-label="Wait for setup to complete before starting agent"]' + ) + expect(waitSwitch).toBeTruthy() + act(() => waitSwitch?.click()) + expect(changes).toEqual(['wait-for-setup']) + }) + it('does not disable folder workspace creation when only source lookup needs SSH', () => { current = renderCard({ eligibleRepos: [ diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 6f68d168b..aa3bd8a60 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -16,6 +16,7 @@ import { } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { SettingsSwitch } from '@/components/settings/SettingsFormControls' import type RepoCombobox from '@/components/repo/RepoCombobox' import AgentCombobox from '@/components/agent/AgentCombobox' import { getAgentCatalog } from '@/lib/agent-catalog' @@ -35,6 +36,7 @@ import type { GitHubWorkItem, GitLabWorkItem, LinearIssue, + SetupAgentStartupPolicy, SparsePreset, TuiAgent } from '../../../shared/types' @@ -116,6 +118,8 @@ type NewWorkspaceComposerCardProps = { requiresExplicitSetupChoice: boolean setupDecision: 'run' | 'skip' | null onSetupDecisionChange: (value: 'run' | 'skip') => void + setupAgentStartupPolicy: SetupAgentStartupPolicy + onSetupAgentStartupPolicyChange: (value: SetupAgentStartupPolicy) => void shouldWaitForSetupCheck: boolean resolvedSetupDecision: 'run' | 'skip' | null createError: WorkspaceCreateErrorDisplay | null @@ -354,6 +358,8 @@ export default function NewWorkspaceComposerCard({ requiresExplicitSetupChoice, setupDecision, onSetupDecisionChange, + setupAgentStartupPolicy, + onSetupAgentStartupPolicyChange, shouldWaitForSetupCheck, resolvedSetupDecision, createError, @@ -421,6 +427,10 @@ export default function NewWorkspaceComposerCard({ ? 'Run commands now' : 'Run setup now' const setupSkipButtonLabel = setupConfig?.kind === 'setup' ? 'Skip for now' : 'Skip commands' + // Why: defaultTabs launch commands can be long-running too, but they are not + // the setup command this setting gates agent startup on. + const showSetupAgentStartupPolicy = + setupControlsEnabled && setupConfig !== null && setupConfig.kind !== 'default-tabs' const handleSetDefaultAgent = React.useCallback( (next: TuiAgent | 'blank' | null) => { @@ -1004,6 +1014,39 @@ export default function NewWorkspaceComposerCard({ ) : null} ) : null} + + {showSetupAgentStartupPolicy ? ( +
+ + + {translate( + 'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgent', + 'Wait for setup to complete before starting agent' + )} + + + {translate( + 'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgentHelp', + 'Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup.' + )} + + + + onSetupAgentStartupPolicyChange( + setupAgentStartupPolicy === 'wait-for-setup' + ? 'start-immediately' + : 'wait-for-setup' + ) + } + ariaLabel={translate( + 'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgent', + 'Wait for setup to complete before starting agent' + )} + /> +
+ ) : null} ) : null} diff --git a/src/renderer/src/components/settings/RepositoryHooksSection.test.ts b/src/renderer/src/components/settings/RepositoryHooksSection.test.ts index ac4bb19b8..c1f4390e5 100644 --- a/src/renderer/src/components/settings/RepositoryHooksSection.test.ts +++ b/src/renderer/src/components/settings/RepositoryHooksSection.test.ts @@ -1,5 +1,66 @@ -import { describe, expect, it } from 'vitest' -import { getLocalCommandSourcePolicyNotice } from './RepositoryHooksSection' +// @vitest-environment happy-dom + +import React from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/types' +import { getLocalCommandSourcePolicyNotice, RepositoryHooksSection } from './RepositoryHooksSection' + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ + settings: {}, + settingsSearchQuery: '' + }) +})) + +vi.mock('@/runtime/runtime-hooks-client', () => ({ + readRuntimeIssueCommand: vi.fn().mockResolvedValue({ command: '', exists: false }), + writeRuntimeIssueCommand: vi.fn().mockResolvedValue(undefined) +})) + +const repo: Repo = { + id: 'repo-1', + kind: 'git', + path: '/workspace/repo', + displayName: 'Repo', + badgeColor: 'blue', + addedAt: 1, + gitUsername: '' +} + +function renderRepositoryHooksSection(args: { + onUpdateHookSettings: (settings: NonNullable) => void +}): { container: HTMLDivElement; root: Root } { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => { + root.render( + React.createElement(RepositoryHooksSection, { + repo, + yamlHooks: null, + hasHooksFile: false, + hooksInspectionReady: true, + mayNeedUpdate: false, + copiedTemplate: false, + forceVisible: true, + onCopyTemplate: () => {}, + onUpdateHookSettings: args.onUpdateHookSettings + }) + ) + }) + return { container, root } +} + +let rendered: { container: HTMLDivElement; root: Root } | null = null + +afterEach(() => { + act(() => rendered?.root.unmount()) + rendered?.container.remove() + rendered = null +}) describe('getLocalCommandSourcePolicyNotice', () => { it('does not show a notice when no local scripts are saved', () => { @@ -72,3 +133,25 @@ describe('getLocalCommandSourcePolicyNotice', () => { ).toEqual({ kind: 'action', policy: 'run-both', label: 'Run both' }) }) }) + +describe('RepositoryHooksSection setup startup policy', () => { + it('persists wait-for-setup when the repository toggle is checked', () => { + const updates: NonNullable[] = [] + rendered = renderRepositoryHooksSection({ + onUpdateHookSettings: (settings) => updates.push(settings) + }) + + const waitSwitch = rendered.container.querySelector( + '[role="switch"][aria-label="Wait for setup to complete before starting agent"]' + ) + expect(waitSwitch).toBeTruthy() + + act(() => waitSwitch?.click()) + + expect(updates.at(-1)).toMatchObject({ + setupAgentStartupPolicy: 'wait-for-setup', + setupRunPolicy: 'run-by-default', + scripts: { setup: '', archive: '' } + }) + }) +}) diff --git a/src/renderer/src/components/settings/RepositoryHooksSection.tsx b/src/renderer/src/components/settings/RepositoryHooksSection.tsx index 3231f8458..c10516dfd 100644 --- a/src/renderer/src/components/settings/RepositoryHooksSection.tsx +++ b/src/renderer/src/components/settings/RepositoryHooksSection.tsx @@ -6,6 +6,7 @@ import type { OrcaHooks, Repo, RepoHookSettings, + SetupAgentStartupPolicy, SetupRunPolicy } from '../../../../shared/types' import { AlertTriangle, ChevronRight, Plus } from 'lucide-react' @@ -14,6 +15,7 @@ import { useTranslation } from 'react-i18next' import { Button } from '../ui/button' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import { SearchableSetting } from './SearchableSetting' +import { SettingsSwitch } from './SettingsFormControls' import { useAppStore } from '@/store' import { readRuntimeIssueCommand, writeRuntimeIssueCommand } from '@/runtime/runtime-hooks-client' import { DEFAULT_REPO_HOOK_SETTINGS } from './SettingsConstants' @@ -39,7 +41,7 @@ type PolicyOption

= { policy: P; label: string; description: string } const LOCAL_HOOK_NAMES = ['setup', 'archive'] as const type LocalHookName = (typeof LOCAL_HOOK_NAMES)[number] type HookSettingsPolicyDraft = Partial< - Pick + Pick > // Why: this is a literal issue-command template token, not app data for i18next to fill. @@ -75,6 +77,7 @@ function areHookSettingsDraftsEqual(a: RepoHookSettings, b: RepoHookSettings): b return ( a.mode === b.mode && a.setupRunPolicy === b.setupRunPolicy && + a.setupAgentStartupPolicy === b.setupAgentStartupPolicy && a.commandSourcePolicy === b.commandSourcePolicy && a.scripts.setup === b.scripts.setup && a.scripts.archive === b.scripts.archive @@ -770,6 +773,8 @@ export function RepositoryHooksSection({ const selectedSetupRunPolicy: SetupRunPolicy = hookSettingsDraft.setupRunPolicy ?? 'run-by-default' + const selectedSetupAgentStartupPolicy: SetupAgentStartupPolicy = + hookSettingsDraft.setupAgentStartupPolicy ?? 'start-immediately' const setupRunPolicyOptions = getSetupRunPolicyOptions() const commandSourcePolicyOptions = getCommandSourcePolicyOptions() const localHookFields = getLocalHookFields() @@ -1043,26 +1048,59 @@ export function RepositoryHooksSection({ forceVisible={forceVisible} keywords={['setup run policy', 'ask', 'run by default', 'skip by default']} > -

-
-
- {translate( - 'auto.components.settings.RepositoryHooksSection.793dcee97d', - 'When to run' - )} -
-

- {translate( - 'auto.components.settings.RepositoryHooksSection.21fb607a87', - 'Default behavior when a new worktree is created.' - )} -

+
+
+
+
+ {translate( + 'auto.components.settings.RepositoryHooksSection.793dcee97d', + 'When to run' + )} +
+

+ {translate( + 'auto.components.settings.RepositoryHooksSection.21fb607a87', + 'Default behavior when a new worktree is created.' + )} +

+
+ updateHookSettingsPolicyDraft({ setupRunPolicy: policy })} + /> +
+
+
+
+ {translate( + 'auto.components.settings.RepositoryHooksSection.waitForSetupBeforeAgent', + 'Wait for setup to complete before starting agent' + )} +
+

+ {translate( + 'auto.components.settings.RepositoryHooksSection.waitForSetupBeforeAgentHelp', + 'Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup.' + )} +

+
+ + updateHookSettingsPolicyDraft({ + setupAgentStartupPolicy: + selectedSetupAgentStartupPolicy === 'wait-for-setup' + ? 'start-immediately' + : 'wait-for-setup' + }) + } + ariaLabel={translate( + 'auto.components.settings.RepositoryHooksSection.waitForSetupBeforeAgent', + 'Wait for setup to complete before starting agent' + )} + />
- updateHookSettingsPolicyDraft({ setupRunPolicy: policy })} - />
diff --git a/src/renderer/src/components/settings/repository-git-hooks-search-entries.ts b/src/renderer/src/components/settings/repository-git-hooks-search-entries.ts index 96146826b..a5323265f 100644 --- a/src/renderer/src/components/settings/repository-git-hooks-search-entries.ts +++ b/src/renderer/src/components/settings/repository-git-hooks-search-entries.ts @@ -117,6 +117,10 @@ export function getRepositoryGitHooksSearchEntries(repo: Repo): SettingsSearchEn 'auto.components.settings.repository.search.f9d84b7971', 'setup run policy' ), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.waitForSetupBeforeAgent', + 'wait for setup before starting agent' + ), ...translateSearchKeyword('auto.components.settings.repository.search.80c490b012', 'ask'), ...translateSearchKeyword( 'auto.components.settings.repository.search.a69c5cbe90', diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index ef0430160..1d7ba1281 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -11,6 +11,7 @@ import type * as UseNotificationDispatchModule from './use-notification-dispatch import { getEagerPtyBufferHandle } from './pty-dispatcher' import { makePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../../../shared/setup-agent-sequencing' // Repro command: // pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts -t "OpenTUI-style small ANSI redraw" @@ -2879,6 +2880,64 @@ describe('connectPanePty', () => { } }) + it('uses the sequenced startup command hint for SSH shell-ready detection', async () => { + const pendingTimeouts: (() => void)[] = [] + const originalSetTimeout = globalThis.setTimeout + globalThis.setTimeout = vi.fn((fn: () => void) => { + pendingTimeouts.push(fn) + return 999 as unknown as ReturnType + }) as unknown as typeof setTimeout + + try { + const { connectPanePty } = await import('./pty-connection') + + const capturedDataCallback: { current: ((data: string) => void) | null } = { + current: null + } + const transport = createMockTransport('pty-id') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-ssh-1' + } + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }] + } + + const wrapperCommand = 'bash -lc wait-for-setup-wrapper' + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + startup: { + command: wrapperCommand, + env: { + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex --prefill 'linked issue context'" + } + } + }) + + connectPanePty(pane as never, manager as never, deps as never) + capturedDataCallback.current?.('user@remote $ ') + for (const fn of pendingTimeouts.splice(0)) { + fn() + } + expect(transport.sendInput).not.toHaveBeenCalled() + + capturedDataCallback.current?.('\x1b]777;orca-shell-ready\x07user@remote $ ') + for (const fn of pendingTimeouts.splice(0)) { + fn() + } + + expect(transport.sendInput).toHaveBeenCalledWith(`${wrapperCommand}\r`) + } finally { + globalThis.setTimeout = originalSetTimeout + } + }) + it('drops agent status without retaining when OSC 133 reports the command finished', async () => { const { connectPanePty } = await import('./pty-connection') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 7475a1d26..eac18e483 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -46,6 +46,7 @@ import { } from './layout-serialization' import { createShellReadyMarkerScanState, scanForShellReadyMarker } from './shell-ready-marker-scan' import { shouldUseShellReadyStartupDelivery } from '../../../../shared/codex-startup-delivery' +import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing' import { getSystemPrefersDark } from '@/lib/terminal-theme' import { mode2031SequenceFor, @@ -2198,10 +2199,14 @@ export function connectPanePty( ? { command: paneStartup.command } : null : null + const startupShellReadyCommandHint = resolveSetupAgentSequenceLaunchCommand( + paneStartup?.env ?? {}, + paneStartup?.command + ) const shouldWaitForSshShellReady = Boolean(connectionId) && shouldUseShellReadyStartupDelivery({ - command: paneStartup?.command, + command: startupShellReadyCommandHint, startupCommandDelivery: paneStartup?.startupCommandDelivery }) && !shouldDeliverStartupViaTerminalPaste diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index e4546a8ed..5a8bdf55a 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -187,6 +187,36 @@ describe('useComposerState host-context boundaries', () => { ) }) + it('saves setup startup policy before creating a workspace', () => { + const persistSection = sourceBetween( + HOOK_SOURCE, + 'const persistSetupAgentStartupPolicy = useCallback', + 'const handleSetupAgentStartupPolicyChange' + ) + expect(persistSection).toContain('setupAgentStartupPolicySaveRef.current') + expect(persistSection).toContain('pendingSave?.repoId === currentRepo.id') + expect(persistSection).toContain('pendingSave.policy === policy') + expect(persistSection).toContain('await pendingSave.promise') + expect(persistSection).toContain('continue') + expect(HOOK_SOURCE).toContain('setupAgentStartupPolicyDraftRef.current') + + const fullSubmit = sourceBetween( + HOOK_SOURCE, + 'const submit = useCallback', + 'const submitQuick = useCallback' + ) + const fullPolicySave = fullSubmit.indexOf('await persistSetupAgentStartupPolicy()') + const fullCreate = fullSubmit.indexOf('const result = await createWorktree(') + expect(fullPolicySave).toBeGreaterThanOrEqual(0) + expect(fullCreate).toBeGreaterThan(fullPolicySave) + + const quickSubmit = sourceBetween(HOOK_SOURCE, 'const submitQuick = useCallback', 'return {') + const quickPolicySave = quickSubmit.indexOf('await persistSetupAgentStartupPolicy()') + const quickCreate = quickSubmit.indexOf('const request: WorktreeCreationRequest = {') + expect(quickPolicySave).toBeGreaterThanOrEqual(0) + expect(quickCreate).toBeGreaterThan(quickPolicySave) + }) + it('resolves submit-time GitHub smart input when folder child repos exist', () => { expect( canResolveFolderSmartGitHubSubmit({ diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 486cbd1e4..8dfce31d8 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' +import { getDefaultRepoHookSettings } from '../../../shared/constants' import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform' import { getAgentCatalog } from '@/lib/agent-catalog' import { createBrowserUuid } from '@/lib/browser-uuid' @@ -39,6 +40,8 @@ import type { GitLabWorkItem, LinearIssue, OrcaHooks, + RepoHookSettings, + SetupAgentStartupPolicy, SetupDecision, SetupRunPolicy, SparsePreset, @@ -331,6 +334,8 @@ export type ComposerCardProps = { requiresExplicitSetupChoice: boolean setupDecision: 'run' | 'skip' | null onSetupDecisionChange: (value: 'run' | 'skip') => void + setupAgentStartupPolicy: SetupAgentStartupPolicy + onSetupAgentStartupPolicyChange: (value: SetupAgentStartupPolicy) => void shouldWaitForSetupCheck: boolean resolvedSetupDecision: 'run' | 'skip' | null createError: WorkspaceCreateErrorDisplay | null @@ -368,6 +373,30 @@ export type InitialWorkspaceRunSeedInput = { > | null } +function getRepoSetupAgentStartupPolicy(repo?: { + hookSettings?: Pick +}): SetupAgentStartupPolicy { + return repo?.hookSettings?.setupAgentStartupPolicy ?? 'start-immediately' +} + +function buildSetupAgentStartupHookSettings( + current: RepoHookSettings | undefined, + setupAgentStartupPolicy: SetupAgentStartupPolicy +): RepoHookSettings { + const defaults = getDefaultRepoHookSettings() + return { + ...defaults, + ...current, + setupRunPolicy: current?.setupRunPolicy ?? defaults.setupRunPolicy, + setupAgentStartupPolicy, + commandSourcePolicy: current?.commandSourcePolicy ?? defaults.commandSourcePolicy, + scripts: { + ...defaults.scripts, + ...current?.scripts + } + } +} + export function resolveInitialWorkspaceRunSeed({ draftProjectId, draftHostId, @@ -424,6 +453,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setNewWorkspaceDraft: s.setNewWorkspaceDraft, clearNewWorkspaceDraft: s.clearNewWorkspaceDraft, createWorktree: s.createWorktree, + updateRepo: s.updateRepo, updateWorktreeMeta: s.updateWorktreeMeta, createFolderWorkspace: s.createFolderWorkspace, setSidebarOpen: s.setSidebarOpen, @@ -439,6 +469,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setNewWorkspaceDraft, clearNewWorkspaceDraft, createWorktree, + updateRepo, updateWorktreeMeta, createFolderWorkspace, setSidebarOpen, @@ -900,6 +931,20 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const [issueCommandTemplate, setIssueCommandTemplate] = useState('') const [hasLoadedIssueCommand, setHasLoadedIssueCommand] = useState(false) const [setupDecision, setSetupDecision] = useState<'run' | 'skip' | null>(null) + const [setupAgentStartupPolicy, setSetupAgentStartupPolicy] = useState( + () => getRepoSetupAgentStartupPolicy(selectedRepo) + ) + const setupAgentStartupPolicyRef = useRef(setupAgentStartupPolicy) + setupAgentStartupPolicyRef.current = setupAgentStartupPolicy + const setupAgentStartupPolicySaveRef = useRef<{ + repoId: string + policy: SetupAgentStartupPolicy + promise: Promise + } | null>(null) + const setupAgentStartupPolicyDraftRef = useRef<{ + repoId: string + policy: SetupAgentStartupPolicy + } | null>(null) const [creating, setCreating] = useState(false) const [createError, setCreateError] = useState(null) // Why: when checked, a successful worktree create keeps the modal open and @@ -982,6 +1027,93 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const selectedRepoSettingsRef = useRef(selectedRepoSettings) selectedRepoSettingsRef.current = selectedRepoSettings + useEffect(() => { + const nextPolicy = getRepoSetupAgentStartupPolicy(selectedRepo) + const draft = setupAgentStartupPolicyDraftRef.current + if (draft?.repoId === repoId && draft.policy !== nextPolicy) { + return + } + setupAgentStartupPolicyRef.current = nextPolicy + setSetupAgentStartupPolicy(nextPolicy) + }, [repoId, selectedRepo]) + + const persistSetupAgentStartupPolicy = useCallback( + async ( + policy: SetupAgentStartupPolicy = setupAgentStartupPolicyRef.current + ): Promise => { + while (true) { + const currentRepo = useAppStore.getState().repos.find((repo) => repo.id === repoId) + if (!currentRepo || !isGitRepoKind(currentRepo)) { + return true + } + const pendingSave = setupAgentStartupPolicySaveRef.current + if (pendingSave?.repoId === currentRepo.id) { + if (pendingSave.policy === policy) { + const saved = await pendingSave.promise + if ( + saved && + setupAgentStartupPolicyDraftRef.current?.repoId === currentRepo.id && + setupAgentStartupPolicyDraftRef.current.policy === policy + ) { + setupAgentStartupPolicyDraftRef.current = null + } + return saved + } + await pendingSave.promise + continue + } + if (getRepoSetupAgentStartupPolicy(currentRepo) === policy) { + if ( + setupAgentStartupPolicyDraftRef.current?.repoId === currentRepo.id && + setupAgentStartupPolicyDraftRef.current.policy === policy + ) { + setupAgentStartupPolicyDraftRef.current = null + } + return true + } + const promise = updateRepo(currentRepo.id, { + hookSettings: buildSetupAgentStartupHookSettings(currentRepo.hookSettings, policy) + }).finally(() => { + if (setupAgentStartupPolicySaveRef.current?.promise === promise) { + setupAgentStartupPolicySaveRef.current = null + } + }) + setupAgentStartupPolicySaveRef.current = { repoId: currentRepo.id, policy, promise } + const saved = await promise + if ( + saved && + setupAgentStartupPolicyDraftRef.current?.repoId === currentRepo.id && + setupAgentStartupPolicyDraftRef.current.policy === policy + ) { + setupAgentStartupPolicyDraftRef.current = null + } + return saved + } + }, + [repoId, updateRepo] + ) + + const handleSetupAgentStartupPolicyChange = useCallback( + (policy: SetupAgentStartupPolicy) => { + setupAgentStartupPolicyRef.current = policy + if (repoId) { + setupAgentStartupPolicyDraftRef.current = { repoId, policy } + } + setSetupAgentStartupPolicy(policy) + void persistSetupAgentStartupPolicy(policy).then((saved) => { + if (!saved) { + toast.error( + translate( + 'auto.hooks.useComposerState.setupAgentStartupPolicySaveFailed', + 'Failed to save setup startup behavior.' + ) + ) + } + }) + }, + [persistSetupAgentStartupPolicy, repoId] + ) + const cancelPromptCaretFrame = useCallback((): void => { if (promptCaretFrameRef.current === null) { return @@ -3090,6 +3222,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS telemetry: composerTelemetry } : undefined + if (!(await persistSetupAgentStartupPolicy())) { + throw new Error( + translate( + 'auto.hooks.useComposerState.setupAgentStartupPolicySaveFailed', + 'Failed to save setup startup behavior.' + ) + ) + } const result = await createWorktree( repoId, workspaceName, @@ -3215,6 +3355,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS note, onCreated, parsedLinkedIssueNumber, + persistSetupAgentStartupPolicy, persistDraft, pushTarget, repoId, @@ -3511,6 +3652,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ...(quickTelemetry ? { telemetry: quickTelemetry } : {}) } : undefined + if (!(await persistSetupAgentStartupPolicy())) { + throw new Error( + translate( + 'auto.hooks.useComposerState.setupAgentStartupPolicySaveFailed', + 'Failed to save setup startup behavior.' + ) + ) + } const request: WorktreeCreationRequest = { repoId, worktreeCreateProgressMode: @@ -3615,6 +3764,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS note, onCreated, parsedLinkedIssueNumber, + persistSetupAgentStartupPolicy, persistDraft, pushTarget, repoId, @@ -3763,6 +3913,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS requiresExplicitSetupChoice: isProjectGroupTarget ? false : requiresExplicitSetupChoice, setupDecision: isProjectGroupTarget ? null : setupDecision, onSetupDecisionChange: isProjectGroupTarget ? () => {} : setSetupDecision, + setupAgentStartupPolicy: isProjectGroupTarget ? 'start-immediately' : setupAgentStartupPolicy, + onSetupAgentStartupPolicyChange: isProjectGroupTarget + ? () => {} + : handleSetupAgentStartupPolicyChange, shouldWaitForSetupCheck: isProjectGroupTarget ? false : shouldWaitForSetupCheck, resolvedSetupDecision: isProjectGroupTarget ? null : resolvedSetupDecision, createError, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 474f51919..14b4834a4 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -534,7 +534,8 @@ "ba6cb77082": "Failed to connect to project.", "chooseOrAddProjectBeforeWorkspace": "Choose or add a project before creating a workspace.", "folderWorkspaceCreateFailedTitle": "Folder workspace creation failed", - "folderWorkspaceCreateFailedMessage": "The folder workspace could not be created. Check the error details above, then try again." + "folderWorkspaceCreateFailedMessage": "The folder workspace could not be created. Check the error details above, then try again.", + "setupAgentStartupPolicySaveFailed": "Failed to save setup startup behavior." }, "useGlobalFileDrop": { "38c9f034ff": "Failed to upload dropped files.", @@ -1123,7 +1124,9 @@ "reconnectingSsh": "Reconnecting SSH...", "sshReconnectionFailed": "SSH reconnection failed", "notConnected": "Not connected", - "notePasteTooLarge": "Paste is too large for the note field." + "notePasteTooLarge": "Paste is too large for the note field.", + "waitForSetupBeforeAgent": "Wait for setup to complete before starting agent", + "waitForSetupBeforeAgentHelp": "Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup." }, "NewWorkspaceComposerModal": { "createWorktree": "Create worktree", @@ -5683,7 +5686,9 @@ "2b6356e744": "Saved", "81057d5f71": "Saving...", "da37d6f10e": "Copy", - "3149964b66": "Copied" + "3149964b66": "Copied", + "waitForSetupBeforeAgent": "Wait for setup to complete before starting agent", + "waitForSetupBeforeAgentHelp": "Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup." }, "RepositoryIconPicker": { "2b7d27b93c": "Use {{value0}} repo color", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 8fa176862..5247ced79 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -534,7 +534,8 @@ "ba6cb77082": "No se pudo conectar al proyecto.", "chooseOrAddProjectBeforeWorkspace": "Elige o agrega un proyecto antes de crear un espacio de trabajo.", "folderWorkspaceCreateFailedTitle": "No se pudo crear el espacio de trabajo de carpeta", - "folderWorkspaceCreateFailedMessage": "No se pudo crear el espacio de trabajo de carpeta. Revisa los detalles del error de arriba e inténtalo de nuevo." + "folderWorkspaceCreateFailedMessage": "No se pudo crear el espacio de trabajo de carpeta. Revisa los detalles del error de arriba e inténtalo de nuevo.", + "setupAgentStartupPolicySaveFailed": "Failed to save setup startup behavior." }, "useGlobalFileDrop": { "38c9f034ff": "No se pudieron cargar los archivos eliminados.", @@ -1123,7 +1124,9 @@ "notePasteTooLarge": "El contenido pegado es demasiado grande para el campo de nota.", "reuseExistingBranch": "Reutilizar rama", "reuseExistingBranchHint": "Hacer checkout de la rama existente en lugar de crear una nueva a partir de ella.", - "createMultiple": "Crear más" + "createMultiple": "Crear más", + "waitForSetupBeforeAgent": "Wait for setup to complete before starting agent", + "waitForSetupBeforeAgentHelp": "Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup." }, "NewWorkspaceComposerModal": { "createWorktree": "Crear árbol de trabajo", @@ -5646,7 +5649,9 @@ "2b6356e744": "Guardado", "81057d5f71": "Ahorro...", "da37d6f10e": "Copiar", - "3149964b66": "copiado" + "3149964b66": "copiado", + "waitForSetupBeforeAgent": "Wait for setup to complete before starting agent", + "waitForSetupBeforeAgentHelp": "Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup." }, "RepositoryIconPicker": { "2b7d27b93c": "Utilice el color del repo {{value0}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index bc1e1766a..7c12bb98c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -534,7 +534,8 @@ "ba6cb77082": "プロジェクトへの接続に失敗しました。", "chooseOrAddProjectBeforeWorkspace": "ワークスペースを作成する前に、プロジェクトを選択または追加。", "folderWorkspaceCreateFailedTitle": "フォルダーワークスペースを作成できませんでした", - "folderWorkspaceCreateFailedMessage": "フォルダーワークスペースを作成できませんでした。上のエラー詳細を確認して、もう一度お試しください。" + "folderWorkspaceCreateFailedMessage": "フォルダーワークスペースを作成できませんでした。上のエラー詳細を確認して、もう一度お試しください。", + "setupAgentStartupPolicySaveFailed": "Failed to save setup startup behavior." }, "useGlobalFileDrop": { "38c9f034ff": "ドロップされたファイルのアップロードに失敗しました。", @@ -1123,7 +1124,9 @@ "notePasteTooLarge": "メモ欄への貼り付け内容が大きすぎます。", "reuseExistingBranch": "ブランチを再利用", "reuseExistingBranchHint": "既存のブランチから新しいブランチを作成せず、そのブランチをチェックアウトします。", - "createMultiple": "さらに作成" + "createMultiple": "さらに作成", + "waitForSetupBeforeAgent": "Wait for setup to complete before starting agent", + "waitForSetupBeforeAgentHelp": "Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup." }, "NewWorkspaceComposerModal": { "createWorktree": "ワークツリーを作成する", @@ -5668,7 +5671,9 @@ "2b6356e744": "保存されました", "81057d5f71": "保存中...", "da37d6f10e": "コピー", - "3149964b66": "コピーされました" + "3149964b66": "コピーされました", + "waitForSetupBeforeAgent": "Wait for setup to complete before starting agent", + "waitForSetupBeforeAgentHelp": "Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup." }, "RepositoryIconPicker": { "2b7d27b93c": "{{value0}} repo カラーを使用する", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3abd871fc..2304381dd 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -534,7 +534,8 @@ "ba6cb77082": "프로젝트에 연결하지 못했습니다.", "chooseOrAddProjectBeforeWorkspace": "워크스페이스를 만들기 전에 프로젝트를 선택하거나 추가하세요.", "folderWorkspaceCreateFailedTitle": "폴더 워크스페이스를 만들지 못했습니다", - "folderWorkspaceCreateFailedMessage": "폴더 워크스페이스를 만들 수 없습니다. 위의 오류 세부 정보를 확인한 뒤 다시 시도하세요." + "folderWorkspaceCreateFailedMessage": "폴더 워크스페이스를 만들 수 없습니다. 위의 오류 세부 정보를 확인한 뒤 다시 시도하세요.", + "setupAgentStartupPolicySaveFailed": "Failed to save setup startup behavior." }, "useGlobalFileDrop": { "38c9f034ff": "드롭한 파일을 업로드하지 못했습니다.", @@ -1123,7 +1124,9 @@ "notePasteTooLarge": "메모 필드에 붙여넣을 내용이 너무 큽니다.", "reuseExistingBranch": "브랜치 재사용", "reuseExistingBranchHint": "기존 브랜치에서 새 브랜치를 만들지 않고 해당 브랜치를 체크아웃합니다.", - "createMultiple": "더 만들기" + "createMultiple": "더 만들기", + "waitForSetupBeforeAgent": "Wait for setup to complete before starting agent", + "waitForSetupBeforeAgentHelp": "Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup." }, "NewWorkspaceComposerModal": { "createWorktree": "작업 트리 만들기", @@ -5631,7 +5634,9 @@ "2b6356e744": "저장됨", "81057d5f71": "저장 중...", "da37d6f10e": "복사", - "3149964b66": "복사됨" + "3149964b66": "복사됨", + "waitForSetupBeforeAgent": "Wait for setup to complete before starting agent", + "waitForSetupBeforeAgentHelp": "Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup." }, "RepositoryIconPicker": { "2b7d27b93c": "{{value0}} repo 색상 사용", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 9fc998217..98467e2c3 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -534,7 +534,8 @@ "ba6cb77082": "无法连接到项目。", "chooseOrAddProjectBeforeWorkspace": "创建工作区前,请选择或添加项目。", "folderWorkspaceCreateFailedTitle": "文件夹工作区创建失败", - "folderWorkspaceCreateFailedMessage": "无法创建文件夹工作区。请查看上方错误详情,然后重试。" + "folderWorkspaceCreateFailedMessage": "无法创建文件夹工作区。请查看上方错误详情,然后重试。", + "setupAgentStartupPolicySaveFailed": "Failed to save setup startup behavior." }, "useGlobalFileDrop": { "38c9f034ff": "无法上传删除的文件。", @@ -1123,7 +1124,9 @@ "notePasteTooLarge": "笔记字段的粘贴内容过大。", "reuseExistingBranch": "复用分支", "reuseExistingBranchHint": "检出已有分支,而不是从它创建新分支。", - "createMultiple": "创建更多" + "createMultiple": "创建更多", + "waitForSetupBeforeAgent": "Wait for setup to complete before starting agent", + "waitForSetupBeforeAgentHelp": "Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup." }, "NewWorkspaceComposerModal": { "createWorktree": "创建工作树", @@ -5631,7 +5634,9 @@ "2b6356e744": "已保存", "81057d5f71": "保存中...", "da37d6f10e": "复制", - "3149964b66": "已复制" + "3149964b66": "已复制", + "waitForSetupBeforeAgent": "Wait for setup to complete before starting agent", + "waitForSetupBeforeAgentHelp": "Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup." }, "RepositoryIconPicker": { "2b7d27b93c": "使用 {{value0}} 仓库颜色", diff --git a/src/renderer/src/lib/setup-runner.test.ts b/src/renderer/src/lib/setup-runner.test.ts index a2491d8d2..c8e8da93f 100644 --- a/src/renderer/src/lib/setup-runner.test.ts +++ b/src/renderer/src/lib/setup-runner.test.ts @@ -38,4 +38,14 @@ describe('buildSetupRunnerCommand', () => { 'bash /home/dev/repo/.git/orca/setup-runner.sh' ) }) + + it('uses cmd.exe for native Windows runner scripts on non-Windows clients', () => { + vi.stubGlobal('navigator', { + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' + }) + + expect(buildSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.cmd')).toBe( + 'cmd.exe /c "C:\\repo\\.git\\orca\\setup-runner.cmd"' + ) + }) }) diff --git a/src/renderer/src/lib/setup-runner.ts b/src/renderer/src/lib/setup-runner.ts index b4e57fdc0..1779f5677 100644 --- a/src/renderer/src/lib/setup-runner.ts +++ b/src/renderer/src/lib/setup-runner.ts @@ -1,8 +1,16 @@ -import { buildSetupRunnerCommand as buildSharedSetupRunnerCommand } from '../../../shared/setup-runner-command' +import { + buildSetupRunnerCommand as buildSharedSetupRunnerCommand, + getSetupRunnerCommandPlatformForPath +} from '../../../shared/setup-runner-command' export function buildSetupRunnerCommand(runnerScriptPath: string): string { + // Why: the runner may live on a remote/WSL filesystem, so the shell follows + // the runner path format rather than the local renderer OS. return buildSharedSetupRunnerCommand( runnerScriptPath, - navigator.userAgent.includes('Windows') ? 'windows' : 'posix' + getSetupRunnerCommandPlatformForPath( + runnerScriptPath, + navigator.userAgent.includes('Windows') ? 'windows' : 'posix' + ) ) } diff --git a/src/renderer/src/lib/worktree-activation.test.ts b/src/renderer/src/lib/worktree-activation.test.ts index 7321641de..a2afa59fc 100644 --- a/src/renderer/src/lib/worktree-activation.test.ts +++ b/src/renderer/src/lib/worktree-activation.test.ts @@ -176,6 +176,53 @@ describe('ensureWorktreeHasInitialTerminal', () => { expect(store.setActiveTab).not.toHaveBeenCalled() }) + it('queues returned setup fallback on an existing web runtime tab', () => { + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + useAppStore.setState((state) => ({ + settings: state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: 'web-runtime-1' } + : ({ activeRuntimeEnvironmentId: 'web-runtime-1' } as unknown as typeof state.settings) + })) + let createdIndex = 1 + const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` })) + const store = createMockStore({ + tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, + createTab, + settings: { activeRuntimeEnvironmentId: 'web-runtime-1' }, + reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 1 })) + }) + + const result = ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + { command: 'claude' }, + { + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { ORCA_ROOT_PATH: '/tmp/repo' }, + waitForAgentStartup: true + } + ) + + expect(result).toBe('tab-1') + expect(createTab).toHaveBeenCalledTimes(1) + expect(store.setActiveTab).toHaveBeenCalledWith('tab-1') + expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup', { + recordInteraction: false + }) + expect(store.queueTabStartupCommand).toHaveBeenCalledWith( + 'tab-2', + expect.objectContaining({ + command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh') + }) + ) + expect(store.queueTabStartupCommand).toHaveBeenCalledWith( + 'tab-2', + expect.objectContaining({ + command: expect.stringContaining('printf') + }) + ) + }) + it('creates a local initial terminal for explicitly local worktrees while a runtime is focused', () => { useAppStore.setState((state) => ({ settings: state.settings @@ -214,6 +261,71 @@ describe('ensureWorktreeHasInitialTerminal', () => { expect(store.queueTabIssueCommandSplit).not.toHaveBeenCalled() }) + it('queues returned setup on an existing terminal tab when startup was already adopted', () => { + let createdIndex = 1 + const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` })) + const store = createMockStore({ + tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, + createTab, + reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 1 })) + }) + + const result = ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, { + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + command: 'bash -lc wrapped-setup', + envVars: { ORCA_ROOT_PATH: '/tmp/repo' } + }) + + expect(result).toBe('tab-1') + expect(createTab).toHaveBeenCalledTimes(1) + expect(store.setActiveTab).toHaveBeenCalledWith('tab-1') + expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup', { + recordInteraction: false + }) + expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-2', { + command: 'bash -lc wrapped-setup', + env: { ORCA_ROOT_PATH: '/tmp/repo' } + }) + expect(store.queueTabSetupSplit).not.toHaveBeenCalled() + }) + + it('queues wrapped setup on an existing terminal tab when setup gates startup', () => { + let createdIndex = 1 + const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` })) + const store = createMockStore({ + tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, + createTab, + reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 1 })) + }) + + const result = ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + { command: 'claude' }, + { + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { ORCA_ROOT_PATH: '/tmp/repo' }, + waitForAgentStartup: true + } + ) + + expect(result).toBe('tab-1') + expect(createTab).toHaveBeenCalledTimes(1) + expect(store.queueTabStartupCommand).toHaveBeenCalledWith( + 'tab-2', + expect.objectContaining({ + command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh') + }) + ) + expect(store.queueTabStartupCommand).toHaveBeenCalledWith( + 'tab-2', + expect.objectContaining({ + command: expect.stringContaining('printf') + }) + ) + expect(store.queueTabSetupSplit).not.toHaveBeenCalled() + }) + it('queues a startup command when agent launch is provided', () => { const store = createMockStore() @@ -236,6 +348,108 @@ describe('ensureWorktreeHasInitialTerminal', () => { expect(store.queueTabIssueCommandSplit).not.toHaveBeenCalled() }) + it('gates startup behind setup completion when both are provided in new-tab mode', () => { + setSetupScriptLaunchMode('new-tab') + let createdIndex = 0 + const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` })) + const store = createMockStore({ createTab }) + + ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + { command: 'claude' }, + { + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { ORCA_ROOT_PATH: '/tmp/repo' }, + waitForAgentStartup: true + } + ) + + expect(store.queueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: expect.stringContaining('Timed out waiting for setup before starting agent.') + }) + ) + expect(store.queueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: expect.stringContaining('exec claude') + }) + ) + expect(store.queueTabStartupCommand).toHaveBeenCalledWith( + 'tab-2', + expect.objectContaining({ + command: expect.stringContaining('printf') + }) + ) + expect(store.queueTabStartupCommand).toHaveBeenCalledWith( + 'tab-2', + expect.objectContaining({ + command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh') + }) + ) + expect(store.queueTabSetupSplit).not.toHaveBeenCalled() + }) + + it('starts setup and agent side by side by default', () => { + setSetupScriptLaunchMode('new-tab') + let createdIndex = 0 + const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` })) + const store = createMockStore({ createTab }) + + ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + { command: 'claude' }, + { + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { ORCA_ROOT_PATH: '/tmp/repo' } + } + ) + + expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-1', { + command: 'claude' + }) + expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-2', { + command: 'bash /tmp/repo/.git/orca/setup-runner.sh', + env: { ORCA_ROOT_PATH: '/tmp/repo' } + }) + }) + + it('gates startup behind setup completion when setup is a split', () => { + setSetupScriptLaunchMode('split-vertical') + const store = createMockStore() + + ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + { command: 'claude' }, + { + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { ORCA_ROOT_PATH: '/tmp/repo' }, + waitForAgentStartup: true + } + ) + + expect(store.queueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: expect.stringContaining('exec claude') + }) + ) + expect(store.queueTabSetupSplit).toHaveBeenCalledWith('tab-1', { + command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh'), + env: { ORCA_ROOT_PATH: '/tmp/repo' }, + direction: 'vertical' + }) + expect(store.queueTabSetupSplit).toHaveBeenCalledWith('tab-1', { + command: expect.stringContaining('printf'), + env: { ORCA_ROOT_PATH: '/tmp/repo' }, + direction: 'vertical' + }) + }) + it('forwards telemetry on the queued startup so main can fire agent_started', () => { const store = createMockStore() @@ -268,6 +482,30 @@ describe('ensureWorktreeHasInitialTerminal', () => { }) }) + it('stamps the tab agent from startup launchAgent without telemetry', () => { + const store = createMockStore() + + ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + { + command: 'codex', + launchAgent: 'codex' + }, + undefined, + undefined + ) + + expect(store.createTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + pendingActivationSpawn: true, + launchAgent: 'codex' + }) + expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-1', { + command: 'codex', + launchAgent: 'codex' + }) + }) + it('does not create a terminal just because the legacy terminal slice is empty', () => { const store = createMockStore({ tabsByWorktree: { 'wt-1': [] }, diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 818ed7959..451575469 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -12,6 +12,8 @@ import type { StartupCommandDelivery } from '../../../shared/codex-startup-deliv import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal' import { buildSetupRunnerCommand } from './setup-runner' +import { createSequencedSetupAgentCommands } from '../../../shared/setup-agent-sequencing' +import { getSetupRunnerCommandPlatformForPath } from '../../../shared/setup-runner-command' import { buildAgentStartupPlan } from './tui-agent-startup' import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform' import { CLIENT_PLATFORM } from './new-workspace' @@ -440,26 +442,76 @@ export function ensureWorktreeHasInitialTerminal( // Why: activation can now restore editor- or browser-only worktrees from the // reconciled tab-group model. Creating a terminal just because the legacy // terminal slice is empty would reopen worktrees with an unexpected extra tab. - if (!shouldAutoCreateInitialTerminal(renderableTabCount)) { - return null - } - // Why: remote web clients mirror the runtime server's session tabs. A local - // activation fallback can spawn a second host terminal before the mirror lands. const ownerState = store.settings !== undefined || store.repos !== undefined || store.worktreesByRepo !== undefined ? store : useAppStore.getState() + let sequencedStartup = startup + let wrappedSetupCommandStr: string | undefined + + if (startup && setup?.waitForAgentStartup === true) { + const platform = getSetupRunnerCommandPlatformForPath( + setup.runnerScriptPath, + navigator.userAgent.includes('Windows') ? 'windows' : 'posix' + ) + const sequenced = createSequencedSetupAgentCommands({ + runnerScriptPath: setup.runnerScriptPath, + startupCommand: startup.command, + platform + }) + sequencedStartup = { + ...startup, + command: sequenced.startupCommand, + ...(sequenced.startupEnv ? { env: { ...startup.env, ...sequenced.startupEnv } } : {}) + } + wrappedSetupCommandStr = sequenced.setupCommand + } + + // Why: remote web clients mirror the runtime server's session tabs. A local + // activation fallback can spawn a second host terminal before the mirror lands, + // but returned setup fallbacks still need to run on an already mirrored tab. if (isWebRuntimeSessionActive(getRuntimeEnvironmentIdForWorktree(ownerState, worktreeId))) { + const existingTerminalTabId = store.tabsByWorktree[worktreeId]?.[0]?.id + if (existingTerminalTabId && (setup || issueCommand)) { + queueSetupAndIssueCommands( + store, + worktreeId, + existingTerminalTabId, + setup, + issueCommand, + wrappedSetupCommandStr + ) + return existingTerminalTabId + } + return null + } + + if (!shouldAutoCreateInitialTerminal(renderableTabCount)) { + const existingTerminalTabId = store.tabsByWorktree[worktreeId]?.[0]?.id + if (existingTerminalTabId && (setup || issueCommand)) { + // Why: main may have already adopted the startup tab but failed to spawn + // setup; renderer activation must still launch the returned fallback setup. + queueSetupAndIssueCommands( + store, + worktreeId, + existingTerminalTabId, + setup, + issueCommand, + wrappedSetupCommandStr + ) + return existingTerminalTabId + } return null } const templatedTabId = applyDefaultTerminalTabs( store, worktreeId, - startup, + sequencedStartup, setup, issueCommand, - defaultTabs + defaultTabs, + wrappedSetupCommandStr ) if (templatedTabId) { return templatedTabId @@ -472,11 +524,13 @@ export function ensureWorktreeHasInitialTerminal( // // Why: the initial terminal can be seeded with a coding agent (new-workspace // flow, or reopening an empty worktree created with an agent). The startup - // payload only carries telemetry's agent_kind, so reverse it back to a - // TuiAgent to stamp the tab — giving it the provider icon before any hook. - const launchAgent = startup?.telemetry - ? (agentKindToTuiAgent(startup.telemetry.agent_kind) ?? undefined) - : undefined + // payload may carry explicit launchAgent; older flows only carry telemetry's + // agent_kind, so reverse that back to a TuiAgent when needed for the icon. + const launchAgent = + sequencedStartup?.launchAgent ?? + (sequencedStartup?.telemetry + ? (agentKindToTuiAgent(sequencedStartup.telemetry.agent_kind) ?? undefined) + : undefined) const terminalTab = store.createTab(worktreeId, undefined, undefined, { pendingActivationSpawn: true, ...(launchAgent ? { launchAgent } : {}) @@ -487,10 +541,17 @@ export function ensureWorktreeHasInitialTerminal( // coding agent and user prompt. Queue that startup command on the initial // pane so the main terminal begins in the requested agent session instead of // opening to an idle shell and forcing the user to repeat the same prompt. - if (startup) { - store.queueTabStartupCommand(terminalTab.id, startup) + if (sequencedStartup) { + store.queueTabStartupCommand(terminalTab.id, sequencedStartup) } - queueSetupAndIssueCommands(store, worktreeId, terminalTab.id, setup, issueCommand) + queueSetupAndIssueCommands( + store, + worktreeId, + terminalTab.id, + setup, + issueCommand, + wrappedSetupCommandStr + ) return terminalTab.id } @@ -501,7 +562,8 @@ function applyDefaultTerminalTabs( startup: WorktreeStartupPayload | undefined, setup: WorktreeSetupLaunch | undefined, issueCommand: IssueCommandLaunch | undefined, - defaultTabs: WorktreeDefaultTabsLaunch | undefined + defaultTabs: WorktreeDefaultTabsLaunch | undefined, + wrappedSetupCommandStr?: string ): string | null { if (!defaultTabs || store.defaultTerminalTabsAppliedByWorktreeId[worktreeId]) { return null @@ -539,7 +601,14 @@ function applyDefaultTerminalTabs( if (startup) { store.queueTabStartupCommand(firstTabId, startup) } - queueSetupAndIssueCommands(store, worktreeId, firstTabId, setup, issueCommand) + queueSetupAndIssueCommands( + store, + worktreeId, + firstTabId, + setup, + issueCommand, + wrappedSetupCommandStr + ) return firstTabId } @@ -548,7 +617,8 @@ function queueSetupAndIssueCommands( worktreeId: string, terminalTabId: string, setup: WorktreeSetupLaunch | undefined, - issueCommand: IssueCommandLaunch | undefined + issueCommand: IssueCommandLaunch | undefined, + wrappedSetupCommandStr?: string ): void { // Why: the setup script launch location is user-configurable. The default // 'new-tab' creates a separate background tab titled "Setup" without @@ -558,7 +628,8 @@ function queueSetupAndIssueCommands( if (setup) { const mode = useAppStore.getState().settings?.setupScriptLaunchMode ?? 'new-tab' const setupCommand = { - command: buildSetupRunnerCommand(setup.runnerScriptPath), + command: + wrappedSetupCommandStr ?? setup.command ?? buildSetupRunnerCommand(setup.runnerScriptPath), env: setup.envVars } if (mode === 'new-tab') { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index ca2e5deb3..95c3871e4 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -29,6 +29,7 @@ import { DEFAULT_LEFT_SIDEBAR_TINT_OPACITY } from './left-sidebar-appearance' import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from './source-control-group-order' +import { DEFAULT_SETUP_AGENT_STARTUP_POLICY } from './setup-agent-startup-policy' export { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults' export { @@ -397,6 +398,7 @@ export function getDefaultRepoHookSettings(): RepoHookSettings { return { mode: 'auto', setupRunPolicy: 'run-by-default', + setupAgentStartupPolicy: DEFAULT_SETUP_AGENT_STARTUP_POLICY, scripts: { setup: '', archive: '' diff --git a/src/shared/setup-agent-sequencing.test.ts b/src/shared/setup-agent-sequencing.test.ts new file mode 100644 index 000000000..9b599a87c --- /dev/null +++ b/src/shared/setup-agent-sequencing.test.ts @@ -0,0 +1,425 @@ +import { spawn } from 'child_process' +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { getDefaultRepoHookSettings } from './constants' +import { + createSequencedSetupAgentCommands, + createSetupAgentSequenceNonce, + getSetupAgentSequenceShellForTests, + resolveSetupAgentSequenceLaunchCommand, + SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV +} from './setup-agent-sequencing' +import { + DEFAULT_SETUP_AGENT_STARTUP_POLICY, + shouldWaitForSetupBeforeAgentStartup +} from './setup-agent-startup-policy' + +const TEMP_DIRS: string[] = [] + +afterEach(() => { + for (const dir of TEMP_DIRS.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('createSequencedSetupAgentCommands', () => { + it('defaults agent startup to immediate unless the wait policy is explicit', () => { + expect(DEFAULT_SETUP_AGENT_STARTUP_POLICY).toBe('start-immediately') + expect(getDefaultRepoHookSettings().setupAgentStartupPolicy).toBe('start-immediately') + expect(shouldWaitForSetupBeforeAgentStartup(undefined)).toBe(false) + expect(shouldWaitForSetupBeforeAgentStartup('start-immediately')).toBe(false) + expect(shouldWaitForSetupBeforeAgentStartup('wait-for-setup')).toBe(true) + }) + + it('uses the original sequenced startup command as the launch hint when present', () => { + expect( + resolveSetupAgentSequenceLaunchCommand( + { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'omp --resume' }, + 'powershell wait-wrapper' + ) + ).toBe('omp --resume') + expect( + resolveSetupAgentSequenceLaunchCommand( + { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: ' ' }, + 'powershell wait-wrapper' + ) + ).toBe('powershell wait-wrapper') + }) + + it('wraps POSIX setup and startup commands with a matching nonce marker', () => { + const result = createSequencedSetupAgentCommands({ + runnerScriptPath: '/repo/.git/orca/setup-runner.sh', + startupCommand: "codex 'fix bug'", + platform: 'posix', + nonce: 'nonce-123', + waitTimeoutSeconds: 9 + }) + + expect(result.setupCommand).toMatch(/^bash -lc /) + expect(result.setupCommand).toContain('bash /repo/.git/orca/setup-runner.sh') + expect(result.setupCommand).toContain('printf') + expect(result.setupCommand).toContain('nonce-123 "$status"') + expect(result.setupCommand).toContain( + 'mv -f /repo/.git/orca/setup-runner.sh.nonce-123.done.tmp' + ) + expect(result.startupCommand).toMatch(/^bash -lc /) + expect(result.startupCommand).toContain('deadline=$((SECONDS + 9))') + expect(result.startupCommand).not.toContain('date +%s') + expect(result.startupCommand).toContain('Waiting for setup to finish before starting agent...') + expect(result.startupCommand).toContain('[ "$seen" = nonce-123 ]') + expect(result.startupCommand).toContain( + 'rm -f /repo/.git/orca/setup-runner.sh.nonce-123.done /repo/.git/orca/setup-runner.sh.nonce-123.done.tmp' + ) + expect(result.startupCommand).toContain('exec codex') + expect(result.startupCommand).toContain('fix bug') + expect(result.startupEnv).toEqual( + expect.objectContaining({ + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex 'fix bug'" + }) + ) + }) + + it('uses launch-specific marker paths for overlapping setup gates', () => { + const first = createSequencedSetupAgentCommands({ + runnerScriptPath: '/repo/.git/orca/setup-runner.sh', + startupCommand: 'claude', + platform: 'posix', + nonce: 'first-launch' + }) + const second = createSequencedSetupAgentCommands({ + runnerScriptPath: '/repo/.git/orca/setup-runner.sh', + startupCommand: 'codex', + platform: 'posix', + nonce: 'second-launch' + }) + + expect(first.setupCommand).toContain('/repo/.git/orca/setup-runner.sh.first-launch.done') + expect(first.startupCommand).toContain('/repo/.git/orca/setup-runner.sh.first-launch.done') + expect(second.setupCommand).toContain('/repo/.git/orca/setup-runner.sh.second-launch.done') + expect(second.startupCommand).toContain('/repo/.git/orca/setup-runner.sh.second-launch.done') + expect(first.setupCommand).not.toContain('/repo/.git/orca/setup-runner.sh.second-launch.done') + expect(second.setupCommand).not.toContain('/repo/.git/orca/setup-runner.sh.first-launch.done') + }) + + it('keeps simple POSIX startup commands eligible for exec when quoted text has separators', () => { + const result = createSequencedSetupAgentCommands({ + runnerScriptPath: '/repo/.git/orca/setup-runner.sh', + startupCommand: "codex 'fix this; then test'", + platform: 'posix', + nonce: 'nonce-quoted', + waitTimeoutSeconds: 9 + }) + + expect(result.startupCommand).toContain("exec codex '\\''fix this; then test'\\''") + expect(result.startupCommand).not.toContain('eval codex') + }) + + it('preserves POSIX inline environment assignment startup commands', () => { + const result = createSequencedSetupAgentCommands({ + runnerScriptPath: '/repo/.git/orca/setup-runner.sh', + startupCommand: 'FOO=bar claude', + platform: 'posix', + nonce: 'nonce-env', + waitTimeoutSeconds: 9 + }) + + expect(result.startupCommand).toContain('FOO=bar claude') + expect(result.startupCommand).toContain('exit "$?"') + expect(result.startupCommand).not.toContain('exec FOO=bar claude') + }) + + it('uses the converted Linux marker path for WSL UNC runners on Windows', () => { + const result = createSequencedSetupAgentCommands({ + runnerScriptPath: + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh', + startupCommand: 'claude', + platform: 'windows', + nonce: 'nonce-wsl' + }) + + expect(getSetupAgentSequenceShellForTests(resultPathWsl(), 'windows')).toBe('posix') + expect(result.setupCommand).toContain( + 'bash /home/jin/repo/.git/worktrees/feature/orca/setup-runner.sh' + ) + expect(result.setupCommand).toContain( + '/home/jin/repo/.git/worktrees/feature/orca/setup-runner.sh.nonce-wsl.done' + ) + expect(result.setupCommand).not.toContain('wsl.localhost') + }) + + it('keeps remote POSIX runners in bash even from a Windows client', () => { + const result = createSequencedSetupAgentCommands({ + runnerScriptPath: '/remote/repo/.git/worktrees/feature/orca/setup-runner.sh', + startupCommand: 'claude', + platform: 'windows', + nonce: 'nonce-remote' + }) + + expect(result.setupCommand).toContain( + 'bash /remote/repo/.git/worktrees/feature/orca/setup-runner.sh' + ) + expect(result.startupCommand).toContain('[ "$seen" = nonce-remote ]') + }) + + it('wraps native Windows runners in a cmd-pinned setup and startup gate', () => { + const result = createSequencedSetupAgentCommands({ + runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.cmd', + startupCommand: "codex --model gpt-5 'fix !PATH! & test'", + platform: 'windows', + nonce: 'nonce-win', + waitTimeoutSeconds: 3 + }) + + expect(result.setupCommand).toContain('cmd.exe /d /s /v:on /c') + expect(result.setupCommand).toContain('cmd.exe /c ""C:\\repo\\.git\\orca\\setup-runner.cmd""') + expect(result.setupCommand).toContain('echo !ORCA_SETUP_NONCE!:!ORCA_SETUP_STATUS!') + expect(result.startupCommand.match(/powershell\.exe/g)).toHaveLength(1) + expect(result.startupCommand).toContain('powershell.exe -NoProfile -ExecutionPolicy Bypass') + expect(result.startupCommand).toContain('AddSeconds(3)') + expect(result.startupCommand).toContain('!ORCA_SETUP_STATUS!') + expect(result.startupCommand).toContain('Timed out waiting for setup before starting agent.') + expect(result.startupCommand).toContain('Setup failed; skipping agent startup.') + expect(result.startupCommand).toContain( + 'Remove-Item -LiteralPath $marker, $tmp -Force -ErrorAction SilentlyContinue' + ) + expect(result.startupCommand).not.toContain('%ERRORLEVEL%') + expect(result.startupCommand).not.toContain(' & ) else') + expect(result.startupCommand).not.toContain('if ""!ORCA_SETUP_STATUS!""==""124""') + expect(result.startupCommand).not.toContain('if not ""!ORCA_SETUP_STATUS!""==""0""') + expect(result.startupCommand).not.toContain( + `call !${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}!` + ) + expect(result.startupCommand).toContain('Invoke-Expression') + expect(result.startupCommand).not.toContain('fix !PATH! & test') + expect(result.startupEnv).toEqual({ + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex --model gpt-5 'fix !PATH! & test'" + }) + }) + + it.skipIf(process.platform === 'win32')( + 'ignores stale markers until the matching setup run finishes, even when startup launches first', + async () => { + const tempDir = makeTempDir() + const runnerScriptPath = join(tempDir, 'setup-runner.sh') + const startupScriptPath = join(tempDir, 'startup.sh') + const logPath = join(tempDir, 'sequence.log') + const markerPath = `${runnerScriptPath}.fresh-sequence.done` + + writeExecutable( + runnerScriptPath, + [ + '#!/bin/sh', + `printf 'setup-start\\n' >> ${quoteSh(logPath)}`, + 'sleep 1', + `printf 'setup-done\\n' >> ${quoteSh(logPath)}` + ].join('\n') + ) + writeExecutable( + startupScriptPath, + ['#!/bin/sh', `printf 'agent-start\\n' >> ${quoteSh(logPath)}`].join('\n') + ) + writeFileSync(markerPath, 'stale:0\n', 'utf8') + + const commands = createSequencedSetupAgentCommands({ + runnerScriptPath, + startupCommand: `bash ${quoteSh(startupScriptPath)}`, + platform: 'posix', + nonce: 'fresh-sequence', + waitTimeoutSeconds: 5 + }) + + const startupExitPromise = waitForExit( + spawn('bash', ['-lc', commands.startupCommand], { stdio: 'pipe' }) + ) + await sleep(250) + expect(readIfExists(logPath)).toBe('') + expect(readFileSync(markerPath, 'utf8')).toBe('stale:0\n') + + const setupExit = await waitForExit( + spawn('bash', ['-lc', commands.setupCommand], { stdio: 'pipe' }) + ) + expect(setupExit.code).toBe(0) + + const startupExit = await startupExitPromise + expect(startupExit.code).toBe(0) + + expect(readFileSync(logPath, 'utf8')).toBe('setup-start\nsetup-done\nagent-start\n') + expect(readIfExists(markerPath)).toBe('') + expect(readIfExists(`${markerPath}.tmp`)).toBe('') + } + ) + + it.skipIf(process.platform === 'win32')( + 'runs compound POSIX startup cleanup commands after setup succeeds', + async () => { + const tempDir = makeTempDir() + const runnerScriptPath = join(tempDir, 'setup-runner.sh') + const logPath = join(tempDir, 'sequence.log') + + writeExecutable( + runnerScriptPath, + ['#!/bin/sh', `printf 'setup-done\\n' >> ${quoteSh(logPath)}`].join('\n') + ) + + const commands = createSequencedSetupAgentCommands({ + runnerScriptPath, + startupCommand: `printf 'agent-start\\n' >> ${quoteSh(logPath)}; printf 'cleanup\\n' >> ${quoteSh(logPath)}`, + platform: 'posix', + nonce: 'compound-sequence', + waitTimeoutSeconds: 5 + }) + + const setupExitPromise = waitForExit( + spawn('bash', ['-lc', commands.setupCommand], { stdio: 'pipe' }) + ) + const startupExit = await waitForExit( + spawn('bash', ['-lc', commands.startupCommand], { stdio: 'pipe' }) + ) + const setupExit = await setupExitPromise + + expect(setupExit.code).toBe(0) + expect(startupExit.code).toBe(0) + expect(readFileSync(logPath, 'utf8')).toBe('setup-done\nagent-start\ncleanup\n') + expect(commands.startupCommand).toContain('eval') + expect(commands.startupCommand).not.toContain('exec printf') + } + ) + + it.skipIf(process.platform === 'win32')( + 'prefers the env-provided startup command after setup succeeds', + async () => { + const tempDir = makeTempDir() + const runnerScriptPath = join(tempDir, 'setup-runner.sh') + const startupScriptPath = join(tempDir, 'startup.sh') + const logPath = join(tempDir, 'sequence.log') + + writeExecutable( + runnerScriptPath, + ['#!/bin/sh', `printf 'setup-done\\n' >> ${quoteSh(logPath)}`].join('\n') + ) + writeExecutable( + startupScriptPath, + [ + '#!/bin/sh', + 'if [ "$FOO" = "bar" ]; then', + ` printf 'env-start\\n' >> ${quoteSh(logPath)}`, + 'fi' + ].join('\n') + ) + + const commands = createSequencedSetupAgentCommands({ + runnerScriptPath, + startupCommand: `printf 'inline-start\\n' >> ${quoteSh(logPath)}`, + platform: 'posix', + nonce: 'env-sequence', + waitTimeoutSeconds: 5 + }) + + const setupExitPromise = waitForExit( + spawn('bash', ['-lc', commands.setupCommand], { stdio: 'pipe' }) + ) + const startupExit = await waitForExit( + spawn('bash', ['-lc', commands.startupCommand], { + stdio: 'pipe', + env: { + ...process.env, + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: `FOO=bar bash ${quoteSh(startupScriptPath)}; printf 'env-cleanup\\n' >> ${quoteSh(logPath)}` + } + }) + ) + const setupExit = await setupExitPromise + + expect(setupExit.code).toBe(0) + expect(startupExit.code).toBe(0) + expect(readFileSync(logPath, 'utf8')).toBe('setup-done\nenv-start\nenv-cleanup\n') + } + ) + + it.skipIf(process.platform === 'win32')( + 'times out instead of hanging forever when setup never writes a matching marker', + async () => { + const tempDir = makeTempDir() + const runnerScriptPath = join(tempDir, 'setup-runner.sh') + + writeExecutable(runnerScriptPath, '#!/bin/sh\nexit 0\n') + + const commands = createSequencedSetupAgentCommands({ + runnerScriptPath, + startupCommand: 'printf ready', + platform: 'posix', + nonce: 'timeout-sequence', + waitTimeoutSeconds: 1 + }) + + const startupExit = await waitForExit( + spawn('bash', ['-lc', commands.startupCommand], { stdio: 'pipe' }) + ) + + expect(startupExit.code).toBe(124) + expect(startupExit.stderr).toContain('Timed out waiting for setup before starting agent.') + } + ) +}) + +describe('createSetupAgentSequenceNonce', () => { + it('prefers crypto.randomUUID when available', () => { + const originalCrypto = globalThis.crypto + vi.stubGlobal('crypto', { randomUUID: () => 'uuid-1' }) + + expect(createSetupAgentSequenceNonce()).toBe('uuid-1') + + vi.stubGlobal('crypto', originalCrypto) + }) +}) + +function resultPathWsl(): string { + return '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh' +} + +function makeTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-setup-sequencing-')) + TEMP_DIRS.push(dir) + return dir +} + +function writeExecutable(path: string, contents: string): void { + writeFileSync(path, contents, 'utf8') + chmodSync(path, 0o755) +} + +function quoteSh(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +function readIfExists(path: string): string { + try { + return readFileSync(path, 'utf8') + } catch { + return '' + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +function waitForExit( + child: ReturnType +): Promise<{ code: number | null; stderr: string }> { + return new Promise((resolve, reject) => { + let stderr = '' + child.stderr?.on('data', (chunk: Buffer | string) => { + stderr += chunk.toString() + }) + child.once('error', reject) + child.once('close', (code) => { + resolve({ code, stderr }) + }) + }) +} diff --git a/src/shared/setup-agent-sequencing.ts b/src/shared/setup-agent-sequencing.ts new file mode 100644 index 000000000..33a5f96a6 --- /dev/null +++ b/src/shared/setup-agent-sequencing.ts @@ -0,0 +1,257 @@ +import { + resolveSetupRunnerCommand, + type SetupRunnerCommandPlatform, + type SetupRunnerCommandShell +} from './setup-runner-command' + +const DEFAULT_WAIT_TIMEOUT_SECONDS = 2 * 60 * 60 +export const SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV = 'ORCA_SEQUENCED_STARTUP_COMMAND' + +export type SequencedSetupAgentCommands = { + setupCommand: string + startupCommand: string + startupEnv?: Record +} + +export function resolveSetupAgentSequenceLaunchCommand( + env: Record, + fallbackCommand: string | undefined +): string | undefined { + const sequencedStartup = env[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]?.trim() + return sequencedStartup || fallbackCommand +} + +export function createSetupAgentSequenceNonce(): string { + const cryptoApi = globalThis.crypto + if (typeof cryptoApi?.randomUUID === 'function') { + return cryptoApi.randomUUID() + } + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` +} + +export function createSequencedSetupAgentCommands(args: { + runnerScriptPath: string + startupCommand: string + platform: SetupRunnerCommandPlatform + nonce?: string + waitTimeoutSeconds?: number +}): SequencedSetupAgentCommands { + const nonce = args.nonce ?? createSetupAgentSequenceNonce() + const resolution = resolveSetupRunnerCommand(args.runnerScriptPath, args.platform) + // Why: overlapping gated launches of the same setup runner must not race on + // a shared completion marker. + const markerPath = `${resolution.runnerScriptPathForShell}.${nonce}.done` + const waitTimeoutSeconds = args.waitTimeoutSeconds ?? DEFAULT_WAIT_TIMEOUT_SECONDS + + if (resolution.shell === 'windows') { + return { + setupCommand: buildWindowsSetupCommand(resolution.command, markerPath, nonce), + startupCommand: buildWindowsStartupCommand(markerPath, nonce, waitTimeoutSeconds), + startupEnv: { + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: args.startupCommand + } + } + } + + return { + setupCommand: buildPosixSetupCommand(resolution.command, markerPath, nonce), + startupCommand: buildPosixStartupCommand( + args.startupCommand, + markerPath, + nonce, + waitTimeoutSeconds + ), + startupEnv: { + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: args.startupCommand + } + } +} + +function buildPosixSetupCommand(setupCommand: string, markerPath: string, nonce: string): string { + const marker = quotePosixArg(markerPath) + const tmp = quotePosixArg(`${markerPath}.tmp`) + const nonceValue = quotePosixArg(nonce) + + const script = [ + `rm -f ${marker} ${tmp} 2>/dev/null`, + `( ${setupCommand} )`, + 'status=$?', + `printf '%s:%s\\n' ${nonceValue} "$status" > ${tmp}`, + `mv -f ${tmp} ${marker}`, + 'exit "$status"' + ].join('; ') + + return `bash -lc ${quotePosixArg(script)}` +} + +function buildPosixStartupCommand( + startupCommand: string, + markerPath: string, + nonce: string, + waitTimeoutSeconds: number +): string { + const marker = quotePosixArg(markerPath) + const tmp = quotePosixArg(`${markerPath}.tmp`) + const nonceValue = quotePosixArg(nonce) + const timeout = Math.max(1, Math.floor(waitTimeoutSeconds)) + const startupSuccessCommand = buildPosixStartupSuccessCommand(startupCommand) + // Why: the PTY launch path feeds this command through an interactive shell, + // so keeping the wrapper on one line avoids visible `quote>` continuation + // prompts while still preserving valid `while`/`if` shell syntax. + const script = [ + `deadline=$((SECONDS + ${timeout}));`, + 'echo "Waiting for setup to finish before starting agent..." >&2;', + 'while :; do', + `if [ -f ${marker} ]; then`, + `IFS=: read -r seen status < ${marker} || true;`, + `if [ "$seen" = ${nonceValue} ]; then`, + `rm -f ${marker} ${tmp} 2>/dev/null;`, + `if [ "$status" = "0" ]; then if [ -n "\${${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}:-}" ]; then eval "\$${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}"; exit "$?"; else ${startupSuccessCommand}; fi; fi;`, + 'echo "Setup failed; skipping agent startup." >&2;', + 'exit "${status:-1}";', + 'fi;', + 'fi;', + 'if [ "$SECONDS" -ge "$deadline" ]; then', + 'echo "Timed out waiting for setup before starting agent." >&2;', + 'exit 124;', + 'fi;', + 'sleep 1;', + 'done' + ].join(' ') + + return `bash -lc ${quotePosixArg(script)}` +} + +function buildPosixStartupSuccessCommand(startupCommand: string): string { + if ( + hasUnquotedPosixCommandSeparator(startupCommand) || + hasLeadingPosixEnvAssignment(startupCommand) + ) { + return `eval ${quotePosixArg(startupCommand)}; exit "$?"` + } + return `exec ${startupCommand}` +} + +function hasLeadingPosixEnvAssignment(command: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*=/.test(command.trimStart()) +} + +function hasUnquotedPosixCommandSeparator(command: string): boolean { + let quote: "'" | '"' | null = null + let escaped = false + for (const char of command) { + if (escaped) { + escaped = false + continue + } + if (char === '\\') { + escaped = true + continue + } + if (quote) { + if (char === quote) { + quote = null + } + continue + } + if (char === "'" || char === '"') { + quote = char + continue + } + if (char === ';' || char === '&' || char === '|' || char === '\n' || char === '\r') { + return true + } + } + return false +} + +function buildWindowsSetupCommand(setupCommand: string, markerPath: string, nonce: string): string { + return wrapCmd([ + `set "ORCA_SETUP_MARKER=${escapeCmdSetValue(markerPath)}"`, + `set "ORCA_SETUP_NONCE=${escapeCmdSetValue(nonce)}"`, + 'del /f /q "!ORCA_SETUP_MARKER!" "!ORCA_SETUP_MARKER!.tmp" 2>nul', + `call ${setupCommand}`, + 'set "ORCA_SETUP_STATUS=!ERRORLEVEL!"', + '> "!ORCA_SETUP_MARKER!.tmp" echo !ORCA_SETUP_NONCE!:!ORCA_SETUP_STATUS!', + 'move /y "!ORCA_SETUP_MARKER!.tmp" "!ORCA_SETUP_MARKER!" >nul', + 'exit /b !ORCA_SETUP_STATUS!' + ]) +} + +function buildWindowsStartupCommand( + markerPath: string, + nonce: string, + waitTimeoutSeconds: number +): string { + const timeout = Math.max(1, Math.floor(waitTimeoutSeconds)) + // Why: native Windows setup runners launch through cmd.exe, but PowerShell + // gives us safe bounded file polling/parsing without a fragile batch label loop. + const script = [ + '$marker = $env:ORCA_SETUP_MARKER', + '$tmp = $marker + ".tmp"', + '$nonce = $env:ORCA_SETUP_NONCE', + `$deadline = (Get-Date).AddSeconds(${timeout})`, + 'while ($true) {', + ' if (Test-Path -LiteralPath $marker) {', + ' $content = Get-Content -LiteralPath $marker -TotalCount 1', + ' if ($content -match "^([0-9A-Za-z_-]+):([0-9]+)$" -and $Matches[1] -eq $nonce) {', + ' $setupStatus = [int]$Matches[2]', + ' Remove-Item -LiteralPath $marker, $tmp -Force -ErrorAction SilentlyContinue', + ' if ($setupStatus -ne 0) {', + ' [Console]::Error.WriteLine("Setup failed; skipping agent startup.")', + ' exit $setupStatus', + ' }', + ` $startup = $env:${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}`, + ' if ([string]::IsNullOrWhiteSpace($startup)) {', + ' [Console]::Error.WriteLine("Missing sequenced startup command.")', + ' exit 1', + ' }', + ' Invoke-Expression $startup', + ' if ($global:LASTEXITCODE -ne $null) { exit $global:LASTEXITCODE }', + ' if (-not $?) { exit 1 }', + ' exit 0', + ' }', + ' }', + ' if ((Get-Date) -ge $deadline) {', + ' [Console]::Error.WriteLine("Timed out waiting for setup before starting agent.")', + ' exit 124', + ' }', + ' Start-Sleep -Seconds 1', + '}' + ].join('; ') + + return wrapCmd([ + `set "ORCA_SETUP_MARKER=${escapeCmdSetValue(markerPath)}"`, + `set "ORCA_SETUP_NONCE=${escapeCmdSetValue(nonce)}"`, + 'echo Waiting for setup to finish before starting agent... 1>&2', + `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${quoteWindowsArg(script)}`, + 'set "ORCA_SETUP_STATUS=!ERRORLEVEL!"', + 'exit /b !ORCA_SETUP_STATUS!' + ]) +} + +function wrapCmd(parts: string[]): string { + return `cmd.exe /d /s /v:on /c ${quoteWindowsArg(parts.join(' & '))}` +} + +function quotePosixArg(value: string): string { + if (/^[A-Za-z0-9_./:-]+$/.test(value)) { + return value + } + return `'${value.replace(/'/g, `'\\''`)}'` +} + +function quoteWindowsArg(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +function escapeCmdSetValue(value: string): string { + return value.replace(/"/g, '""').replace(/[%!^]/g, (char) => `^${char}`) +} + +export function getSetupAgentSequenceShellForTests( + runnerScriptPath: string, + platform: SetupRunnerCommandPlatform +): SetupRunnerCommandShell { + return resolveSetupRunnerCommand(runnerScriptPath, platform).shell +} diff --git a/src/shared/setup-agent-startup-policy.ts b/src/shared/setup-agent-startup-policy.ts new file mode 100644 index 000000000..4c4fe2485 --- /dev/null +++ b/src/shared/setup-agent-startup-policy.ts @@ -0,0 +1,11 @@ +import type { SetupAgentStartupPolicy } from './types' + +// Why: existing repos should keep launching setup and agents side by side unless +// the user explicitly opts into waiting for setup completion. +export const DEFAULT_SETUP_AGENT_STARTUP_POLICY: SetupAgentStartupPolicy = 'start-immediately' + +export function shouldWaitForSetupBeforeAgentStartup( + policy: SetupAgentStartupPolicy | undefined +): boolean { + return policy === 'wait-for-setup' +} diff --git a/src/shared/setup-runner-command.test.ts b/src/shared/setup-runner-command.test.ts index 3362e890c..1d2dd5c36 100644 --- a/src/shared/setup-runner-command.test.ts +++ b/src/shared/setup-runner-command.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { buildSetupRunnerCommand } from './setup-runner-command' +import { + buildSetupRunnerCommand, + getSetupRunnerCommandPlatformForPath +} from './setup-runner-command' describe('buildSetupRunnerCommand', () => { it('uses bash for WSL UNC runner scripts regardless of host casing', () => { @@ -10,4 +13,57 @@ describe('buildSetupRunnerCommand', () => { ) ).toBe('bash /home/jin/repo/.git/worktrees/feature/orca/setup-runner.sh') }) + + it('uses bash with Linux paths for forward-slash WSL UNC runner scripts', () => { + expect( + buildSetupRunnerCommand( + '//wsl.localhost/Ubuntu/home/jin/repo/.git/worktrees/feature/orca/setup-runner.sh', + 'windows' + ) + ).toBe('bash /home/jin/repo/.git/worktrees/feature/orca/setup-runner.sh') + }) + + it('keeps generic forward-slash UNC runner scripts on cmd.exe', () => { + expect( + buildSetupRunnerCommand('//server/share/repo/.git/orca/setup-runner.cmd', 'windows') + ).toBe('cmd.exe /c "//server/share/repo/.git/orca/setup-runner.cmd"') + }) +}) + +describe('getSetupRunnerCommandPlatformForPath', () => { + it('prefers POSIX for absolute POSIX runner paths even from Windows clients', () => { + expect( + getSetupRunnerCommandPlatformForPath('/remote/repo/.git/orca/setup-runner.sh', 'windows') + ).toBe('posix') + }) + + it('prefers Windows for native Windows runner paths even from POSIX clients', () => { + expect( + getSetupRunnerCommandPlatformForPath('C:\\repo\\.git\\orca\\setup-runner.cmd', 'posix') + ).toBe('windows') + }) + + it('keeps WSL UNC paths on the Windows resolver so they can be converted', () => { + expect( + getSetupRunnerCommandPlatformForPath( + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo\\.git\\orca\\setup-runner.sh', + 'posix' + ) + ).toBe('windows') + }) + + it('keeps forward-slash UNC paths on the Windows resolver', () => { + expect( + getSetupRunnerCommandPlatformForPath( + '//wsl.localhost/Ubuntu/home/jin/repo/.git/orca/setup-runner.sh', + 'posix' + ) + ).toBe('windows') + expect( + getSetupRunnerCommandPlatformForPath( + '//server/share/repo/.git/orca/setup-runner.cmd', + 'posix' + ) + ).toBe('windows') + }) }) diff --git a/src/shared/setup-runner-command.ts b/src/shared/setup-runner-command.ts index 95f12d737..d750f5b9e 100644 --- a/src/shared/setup-runner-command.ts +++ b/src/shared/setup-runner-command.ts @@ -1,29 +1,74 @@ +import { isWindowsAbsolutePathLike } from './cross-platform-path' + export type SetupRunnerCommandPlatform = 'windows' | 'posix' +export type SetupRunnerCommandShell = 'posix' | 'windows' + +export type SetupRunnerCommandResolution = { + command: string + runnerScriptPathForShell: string + shell: SetupRunnerCommandShell +} export function buildSetupRunnerCommand( runnerScriptPath: string, platform: SetupRunnerCommandPlatform ): string { - if (platform === 'windows') { - if (runnerScriptPath.startsWith('/')) { - return `bash ${quotePosixArg(runnerScriptPath)}` - } - if (isWslUncPath(runnerScriptPath)) { - const linuxPath = wslUncToLinuxPath(runnerScriptPath) - return `bash ${quotePosixArg(linuxPath)}` - } - return `cmd.exe /c ${quoteWindowsArg(runnerScriptPath)}` - } - - return `bash ${quotePosixArg(runnerScriptPath)}` + return resolveSetupRunnerCommand(runnerScriptPath, platform).command } -function isWslUncPath(path: string): boolean { +export function getSetupRunnerCommandPlatformForPath( + runnerScriptPath: string, + fallbackPlatform: SetupRunnerCommandPlatform +): SetupRunnerCommandPlatform { + if (isWindowsAbsolutePathLike(runnerScriptPath)) { + return 'windows' + } + if (runnerScriptPath.startsWith('/')) { + return 'posix' + } + return fallbackPlatform +} + +export function resolveSetupRunnerCommand( + runnerScriptPath: string, + platform: SetupRunnerCommandPlatform +): SetupRunnerCommandResolution { + if (platform === 'windows') { + if (isWslUncPath(runnerScriptPath)) { + const linuxPath = wslUncToLinuxPath(runnerScriptPath) + return { + command: `bash ${quotePosixArg(linuxPath)}`, + runnerScriptPathForShell: linuxPath, + shell: 'posix' + } + } + if (runnerScriptPath.startsWith('/') && !isWindowsAbsolutePathLike(runnerScriptPath)) { + return { + command: `bash ${quotePosixArg(runnerScriptPath)}`, + runnerScriptPathForShell: runnerScriptPath, + shell: 'posix' + } + } + return { + command: `cmd.exe /c ${quoteWindowsArg(runnerScriptPath)}`, + runnerScriptPathForShell: runnerScriptPath, + shell: 'windows' + } + } + + return { + command: `bash ${quotePosixArg(runnerScriptPath)}`, + runnerScriptPathForShell: runnerScriptPath, + shell: 'posix' + } +} + +export function isWslUncPath(path: string): boolean { const normalized = path.replace(/\\/g, '/') return /^\/\/(wsl\.localhost|wsl\$)\//i.test(normalized) } -function wslUncToLinuxPath(windowsPath: string): string { +export function wslUncToLinuxPath(windowsPath: string): string { const normalized = windowsPath.replace(/\\/g, '/') const match = normalized.match(/^\/\/(wsl\.localhost|wsl\$)\/[^/]+(\/.*)?$/i) return match?.[2] || '/' diff --git a/src/shared/types.ts b/src/shared/types.ts index a6c498da5..8338a5999 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -389,6 +389,7 @@ export type ProjectGroupImportResult = { } export type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default' +export type SetupAgentStartupPolicy = 'start-immediately' | 'wait-for-setup' export type SetupDecision = 'inherit' | 'run' | 'skip' export type HookCommandSourcePolicy = 'shared-only' | 'local-only' | 'run-both' @@ -1926,6 +1927,7 @@ export type RepoHookSettings = { // hook UI. Keep it in the shape so existing local state reads without a migration. mode: 'auto' | 'override' setupRunPolicy?: SetupRunPolicy + setupAgentStartupPolicy?: SetupAgentStartupPolicy commandSourcePolicy?: HookCommandSourcePolicy scripts: { setup: string @@ -1936,6 +1938,8 @@ export type RepoHookSettings = { export type WorktreeSetupLaunch = { runnerScriptPath: string envVars: Record + command?: string + waitForAgentStartup?: boolean } export type WorktreeStartupLaunch = {