Let agents wait for setup when requested (#6298)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
ca6c3c207a
commit
a278a30b1c
|
|
@ -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') =>
|
||||
({
|
||||
|
|
|
|||
|
|
@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
: {})
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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> = {}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: '' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}}",
|
||||
|
|
|
|||
|
|
@ -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 カラーを使用する",
|
||||
|
|
|
|||
|
|
@ -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 색상 사용",
|
||||
|
|
|
|||
|
|
@ -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}} 仓库颜色",
|
||||
|
|
|
|||
|
|
@ -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"'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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': [] },
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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: ''
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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] || '/'
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue