Let agents wait for setup when requested (#6298)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-28 13:50:00 -07:00 committed by GitHub
parent ca6c3c207a
commit a278a30b1c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 2880 additions and 176 deletions

View File

@ -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') =>
({

View File

@ -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 } : {})
}
}
/**

View File

@ -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)

View File

@ -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<string, string>
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
}

View File

@ -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<Pick<GlobalSettings, 'setupScriptLaunchMode'>>)
.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 }

View File

@ -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([

View File

@ -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, {

View File

@ -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 `~/.<kind>/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 }
: {})

View File

@ -30,6 +30,7 @@ export type PtySpawnOptions = {
env?: Record<string, string>
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. */

View File

@ -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<string, WorktreeMeta> = {}
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<string, WorktreeMeta> = {}
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<string, string>
}
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<string, WorktreeMeta> = {}
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<string, string> }).env ?? {}
const setupEnv = (spawn.mock.calls[1]![0] as { env?: Record<string, string> }).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<string, WorktreeMeta> = {}

View File

@ -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<string, string>
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<Pick<GlobalSettings, 'setupScriptLaunchMode'>>)
.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<Pick<GlobalSettings, 'setupScriptLaunchMode'>>)
.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<Pick<GlobalSettings, 'setupScriptLaunchMode'>>)
.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<Pick<GlobalSettings, 'setupScriptLaunchMode'>>)
@ -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<string, string>
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,

View File

@ -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<string, string> }
| 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<string, string> }
| 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<string, string> }
| 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<string, string> }
| 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<string, string> }
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 () => {

View File

@ -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<typeof setTimeout> | 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
}

View File

@ -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<void> {
// 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')

View File

@ -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<HTMLElement>(
'[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: [

View File

@ -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}
</div>
) : null}
{showSetupAgentStartupPolicy ? (
<div className="flex items-start justify-between gap-3 rounded-md border border-border/60 bg-muted/25 p-3">
<span className="min-w-0 space-y-1">
<span className="block text-xs font-medium text-foreground">
{translate(
'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgent',
'Wait for setup to complete before starting agent'
)}
</span>
<span className="block text-[11px] text-muted-foreground">
{translate(
'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgentHelp',
'Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup.'
)}
</span>
</span>
<SettingsSwitch
checked={setupAgentStartupPolicy === 'wait-for-setup'}
onChange={() =>
onSetupAgentStartupPolicyChange(
setupAgentStartupPolicy === 'wait-for-setup'
? 'start-immediately'
: 'wait-for-setup'
)
}
ariaLabel={translate(
'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgent',
'Wait for setup to complete before starting agent'
)}
/>
</div>
) : null}
</div>
) : null}

View File

@ -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<Repo['hookSettings']>) => 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<Repo['hookSettings']>[] = []
rendered = renderRepositoryHooksSection({
onUpdateHookSettings: (settings) => updates.push(settings)
})
const waitSwitch = rendered.container.querySelector<HTMLElement>(
'[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: '' }
})
})
})

View File

@ -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<P> = { 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<RepoHookSettings, 'setupRunPolicy' | 'commandSourcePolicy'>
Pick<RepoHookSettings, 'setupRunPolicy' | 'setupAgentStartupPolicy' | 'commandSourcePolicy'>
>
// 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']}
>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-border/50 bg-background/80 p-4 shadow-sm">
<div className="min-w-0">
<h5 className="text-sm font-semibold">
{translate(
'auto.components.settings.RepositoryHooksSection.793dcee97d',
'When to run'
)}
</h5>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RepositoryHooksSection.21fb607a87',
'Default behavior when a new worktree is created.'
)}
</p>
<div className="space-y-4 rounded-2xl border border-border/50 bg-background/80 p-4 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<h5 className="text-sm font-semibold">
{translate(
'auto.components.settings.RepositoryHooksSection.793dcee97d',
'When to run'
)}
</h5>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RepositoryHooksSection.21fb607a87',
'Default behavior when a new worktree is created.'
)}
</p>
</div>
<SegmentedPolicyToggle
options={setupRunPolicyOptions}
selected={selectedSetupRunPolicy}
onSelect={(policy) => updateHookSettingsPolicyDraft({ setupRunPolicy: policy })}
/>
</div>
<div className="flex items-start justify-between gap-4 border-t border-border/60 pt-4">
<div className="min-w-0 space-y-1">
<h5 className="text-sm font-semibold">
{translate(
'auto.components.settings.RepositoryHooksSection.waitForSetupBeforeAgent',
'Wait for setup to complete before starting agent'
)}
</h5>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RepositoryHooksSection.waitForSetupBeforeAgentHelp',
'Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup.'
)}
</p>
</div>
<SettingsSwitch
checked={selectedSetupAgentStartupPolicy === 'wait-for-setup'}
onChange={() =>
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'
)}
/>
</div>
<SegmentedPolicyToggle
options={setupRunPolicyOptions}
selected={selectedSetupRunPolicy}
onSelect={(policy) => updateHookSettingsPolicyDraft({ setupRunPolicy: policy })}
/>
</div>
</SearchableSetting>

View File

@ -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',

View File

@ -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<typeof setTimeout>
}) 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')

View File

@ -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

View File

@ -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({

View File

@ -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<RepoHookSettings, 'setupAgentStartupPolicy'>
}): 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<SetupAgentStartupPolicy>(
() => getRepoSetupAgentStartupPolicy(selectedRepo)
)
const setupAgentStartupPolicyRef = useRef(setupAgentStartupPolicy)
setupAgentStartupPolicyRef.current = setupAgentStartupPolicy
const setupAgentStartupPolicySaveRef = useRef<{
repoId: string
policy: SetupAgentStartupPolicy
promise: Promise<boolean>
} | null>(null)
const setupAgentStartupPolicyDraftRef = useRef<{
repoId: string
policy: SetupAgentStartupPolicy
} | null>(null)
const [creating, setCreating] = useState(false)
const [createError, setCreateError] = useState<WorkspaceCreateErrorDisplay | null>(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<boolean> => {
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,

View File

@ -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",

View File

@ -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}}",

View File

@ -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 カラーを使用する",

View File

@ -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 색상 사용",

View File

@ -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}} 仓库颜色",

View File

@ -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"'
)
})
})

View File

@ -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'
)
)
}

View File

@ -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': [] },

View File

@ -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') {

View File

@ -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: ''

View File

@ -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<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
function waitForExit(
child: ReturnType<typeof spawn>
): 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 })
})
})
}

View File

@ -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<string, string>
}
export function resolveSetupAgentSequenceLaunchCommand(
env: Record<string, string | undefined>,
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
}

View File

@ -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'
}

View File

@ -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')
})
})

View File

@ -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] || '/'

View File

@ -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<string, string>
command?: string
waitForAgentStartup?: boolean
}
export type WorktreeStartupLaunch = {