diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index ace70f1f7..862540075 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -14,6 +14,8 @@ const { getMacDaemonSystemResolverHealthMock } = vi.hoisted(() => ({ getMacDaemonSystemResolverHealthMock: vi.fn(async () => 'unknown') })) +const itOnPosix = process.platform === 'win32' ? it.skip : it + vi.mock('./daemon-health', async (importOriginal) => { const actual = await importOriginal() return { @@ -123,6 +125,37 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { const result = await adapter.spawn({ cols: 80, rows: 24, worktreeId: 'wt-1' }) expect(result.id).toContain('wt-1') }) + + itOnPosix('keeps plain Codex startup on the short daemon shell-ready timeout', async () => { + await adapter.spawn({ + cols: 80, + rows: 24, + command: 'codex', + env: { SHELL: '/bin/zsh' } + }) + + await waitFor(() => vi.mocked(lastSubprocess.write).mock.calls.length > 0) + expect(lastSubprocess.write).toHaveBeenCalledWith('codex\n') + }) + + itOnPosix('waits for shell-ready for delivery-hinted Codex startup', async () => { + await adapter.spawn({ + cols: 80, + rows: 24, + command: "codex 'linked issue context'", + startupCommandDelivery: 'shell-ready', + env: { SHELL: '/bin/zsh' } + }) + + await new Promise((resolve) => setTimeout(resolve, 350)) + expect(lastSubprocess.write).not.toHaveBeenCalled() + + lastSubprocess._simulateData('\x1b]777;orca-shell-ready\x07') + lastSubprocess._simulateData('\r\nuser@host $ ') + + await waitFor(() => vi.mocked(lastSubprocess.write).mock.calls.length > 0) + expect(lastSubprocess.write).toHaveBeenCalledWith("codex 'linked issue context'\n") + }) }) describe('write', () => { diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 61140d91e..59fee26d3 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -22,6 +22,7 @@ import { import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' import { isShellProcess } from '../../shared/agent-detection' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' export type DaemonPtyAdapterOptions = { socketPath: string @@ -136,8 +137,16 @@ export class DaemonPtyAdapter implements IPtyProvider { await this.ensureConnected() const shellReadySupported = opts.command ? supportsPtyStartupBarrier(opts.env ?? {}) : false + const isCodexStartupCommand = + recognizeAgentProcessFromCommandLine(opts.command)?.agent === 'codex' + const shouldWaitForShellReady = + isCodexStartupCommand && + shouldUseShellReadyStartupDelivery({ + command: opts.command, + startupCommandDelivery: opts.startupCommandDelivery + }) const shellReadyTimeoutMs = - shellReadySupported && recognizeAgentProcessFromCommandLine(opts.command)?.agent === 'codex' + shellReadySupported && isCodexStartupCommand && !shouldWaitForShellReady ? CODEX_SHELL_READY_TIMEOUT_MS : undefined @@ -149,6 +158,7 @@ export class DaemonPtyAdapter implements IPtyProvider { env: opts.env, envToDelete: opts.envToDelete, command: opts.command, + startupCommandDelivery: opts.startupCommandDelivery, // Why: without this, the daemon always spawns cmd.exe (COMSPEC) or // PowerShell as a fallback — regardless of which shell the renderer // asked for in the "+" menu or persisted as the default. Forwarding diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index f7b93ea3c..af1afebb5 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -274,6 +274,7 @@ export class DaemonServer { env: p.env, envToDelete: p.envToDelete, command: p.command, + startupCommandDelivery: p.startupCommandDelivery, shellOverride: p.shellOverride, terminalWindowsWslDistro: p.terminalWindowsWslDistro, terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation, diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 93522066d..ada8a4f32 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -951,6 +951,85 @@ describe('createPtySubprocess', () => { expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0') }) + it('keeps plain Codex startup commands on the no-marker wrapper', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'linux' }) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + command: 'codex', + env: { SHELL: '/bin/zsh' } + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + const lastCall = spawnMock.mock.calls.at(-1)! + expect(lastCall[1]).toEqual(['-l']) + expect(lastCall[2].env.ZDOTDIR).toMatch(ZSH_SHELL_READY_DIR) + expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0') + }) + + it('uses shell-ready wrapper for delivery-hinted Codex startup commands', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'linux' }) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + command: "codex 'linked issue context'", + startupCommandDelivery: 'shell-ready', + env: { SHELL: '/bin/zsh' } + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + const lastCall = spawnMock.mock.calls.at(-1)! + expect(lastCall[1]).toEqual(['-l']) + expect(lastCall[2].env.ZDOTDIR).toMatch(ZSH_SHELL_READY_DIR) + expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('1') + }) + + it('uses shell-ready wrapper for Codex native prefill flags', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'linux' }) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + command: "codex --prefill 'linked issue context'", + env: { SHELL: '/bin/zsh' } + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + const lastCall = spawnMock.mock.calls.at(-1)! + expect(lastCall[1]).toEqual(['-l']) + expect(lastCall[2].env.ZDOTDIR).toMatch(ZSH_SHELL_READY_DIR) + expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('1') + }) + it('deletes requested env keys after merging daemon process env', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 291757945..60f046a8e 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -33,6 +33,10 @@ import { recognizeAgentProcess, recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import { + shouldUseShellReadyStartupDelivery, + type StartupCommandDelivery +} from '../../shared/codex-startup-delivery' import { isShellProcess } from '../../shared/shell-process-detection' import { parsePtySessionId } from './pty-session-id' import { getAgentForegroundContextPaths } from '../providers/agent-foreground-context-paths' @@ -51,6 +55,7 @@ export type PtySubprocessOptions = { env?: Record envToDelete?: string[] command?: string + startupCommandDelivery?: StartupCommandDelivery /** Explicit shell executable path/basename the renderer asked for. * Overrides env.COMSPEC / env.SHELL resolution inside the daemon so a user * who picks "New WSL terminal" from the "+" menu actually gets WSL. */ @@ -504,9 +509,15 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl // wrapper need shell-ready code after user startup files run. let shellLaunch: ReturnType | null = null if (opts.command && isCodexStartupCommand) { - // Why: Codex needs the env-restoring wrapper, but waiting for a shell - // marker delays the first useful TUI frame. - shellLaunch = getAttributionShellLaunchConfig(shellPath) + const shouldWaitForShellReady = shouldUseShellReadyStartupDelivery({ + command: opts.command, + startupCommandDelivery: opts.startupCommandDelivery + }) + // Why: payload-bearing Codex startup text can be dropped by rc-file noise; + // plain Codex stays markerless to preserve the startup-speed path. + shellLaunch = shouldWaitForShellReady + ? getShellReadyLaunchConfig(shellPath) + : getAttributionShellLaunchConfig(shellPath) } else if (opts.command) { shellLaunch = getShellReadyLaunchConfig(shellPath) } else { diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index dc96e72e8..2bb4473b4 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -1,6 +1,7 @@ import { Session, type SubprocessHandle } from './session' import { normalizePtySize } from './daemon-pty-size' import { resolveProcessCwd } from '../providers/process-cwd' +import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import type { SessionInfo, TakePendingOutputResult, @@ -19,6 +20,7 @@ export type CreateOrAttachOptions = { env?: Record envToDelete?: string[] command?: string + startupCommandDelivery?: StartupCommandDelivery /** Explicit shell the renderer asked for (e.g. 'wsl.exe' for "New WSL * terminal" from the "+" menu). Forwarded to the subprocess spawner so the * daemon path honors per-tab shell selection the same way LocalPtyProvider @@ -48,6 +50,7 @@ export type TerminalHostOptions = { env?: Record envToDelete?: string[] command?: string + startupCommandDelivery?: StartupCommandDelivery shellOverride?: string terminalWindowsWslDistro?: string | null terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' @@ -113,6 +116,7 @@ export class TerminalHost { env: opts.env, envToDelete: opts.envToDelete, command: opts.command, + startupCommandDelivery: opts.startupCommandDelivery, shellOverride: opts.shellOverride, terminalWindowsWslDistro: opts.terminalWindowsWslDistro, terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index e0afca9ad..609b41849 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -1,11 +1,13 @@ // ─── Protocol Version ──────────────────────────────────────────────── +import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' + // Why: daemons can survive app updates. Bump for IPC wire-shape changes, or // when daemon-baked behavior cannot be delivered by on-disk wrapper refresh. // Why: bump when adding daemon wire behavior so same-version old daemons do // not silently accept the handshake and then reject new RPCs. -export const PROTOCOL_VERSION = 15 +export const PROTOCOL_VERSION = 16 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14 + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 ] as const // ─── Session State Machine ────────────────────────────────────────── @@ -87,6 +89,7 @@ export type CreateOrAttachRequest = { env?: Record envToDelete?: string[] command?: string + startupCommandDelivery?: StartupCommandDelivery /** Explicit Windows shell override selected by the user (e.g. 'wsl.exe'). * The daemon forwards this to its subprocess spawner so each tab honors * the shell picked in the "+" menu or the persisted default-shell setting, diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index cd35b332c..9c875a1be 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -4826,6 +4826,72 @@ describe('registerPtyHandlers', () => { } ) + posixOnlyIt('waits for shell-ready before writing delivery-hinted Codex startup', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp', + command: "codex 'linked issue context'", + startupCommandDelivery: 'shell-ready' + }) + + const [, , options] = spawnMock.mock.calls[0]! + expect(options.env.ORCA_SHELL_READY_MARKER).toBe('1') + expect(mockProc.proc.write).not.toHaveBeenCalled() + + mockProc.emitData('last login: today\r\n') + vi.advanceTimersByTime(1499) + await Promise.resolve() + expect(mockProc.proc.write).not.toHaveBeenCalled() + + mockProc.emitData('\x1b]777;orca-shell-ready\x07') + await Promise.resolve() + vi.advanceTimersByTime(49) + await Promise.resolve() + expect(mockProc.proc.write).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + await Promise.resolve() + expect(mockProc.proc.write).toHaveBeenCalledWith("codex 'linked issue context'\n") + } finally { + vi.useRealTimers() + } + }) + + posixOnlyIt('waits for shell-ready when Codex uses the native prefill flag', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp', + command: "codex --prefill 'linked issue context'" + }) + + const [, , options] = spawnMock.mock.calls[0]! + expect(options.env.ORCA_SHELL_READY_MARKER).toBe('1') + expect(mockProc.proc.write).not.toHaveBeenCalled() + + mockProc.emitData('\x1b]777;orca-shell-ready\x07') + await Promise.resolve() + vi.runAllTimers() + await Promise.resolve() + expect(mockProc.proc.write).toHaveBeenCalledWith("codex --prefill 'linked issue context'\n") + } finally { + vi.useRealTimers() + } + }) + posixOnlyIt('keeps the conservative max wait for non-agent startup commands', async () => { vi.useFakeTimers() const mockProc = createMockProc() diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 21afd28c2..9fadd52cc 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -23,6 +23,7 @@ import { detectPiAgentKindFromCommand, type PiAgentKind } from '../../shared/pi- import { isPwshAvailable } from '../pwsh' import { LocalPtyProvider } from '../providers/local-pty-provider' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import { SSH_SESSION_EXPIRED_ERROR, isSshPtyNotFoundError } from '../providers/ssh-pty-provider' import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' import { mintPtySessionId, isSafePtySessionId } from '../daemon/pty-session-id' @@ -1750,6 +1751,9 @@ export function registerPtyHandlers( if (args.command !== undefined) { spawnOptions.command = args.command } + if (args.startupCommandDelivery !== undefined) { + spawnOptions.startupCommandDelivery = args.startupCommandDelivery + } if (args.worktreeId !== undefined) { spawnOptions.worktreeId = args.worktreeId } @@ -2098,6 +2102,7 @@ export function registerPtyHandlers( env?: Record envToDelete?: string[] command?: string + startupCommandDelivery?: StartupCommandDelivery connectionId?: string | null worktreeId?: string sessionId?: string @@ -2348,6 +2353,9 @@ export function registerPtyHandlers( if (args.command !== undefined) { spawnOptions.command = args.command } + if (args.startupCommandDelivery !== undefined) { + spawnOptions.startupCommandDelivery = args.startupCommandDelivery + } if (args.worktreeId !== undefined) { spawnOptions.worktreeId = args.worktreeId } diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 638f15f67..9a6f39dbe 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -245,6 +245,7 @@ async function spawnLocalStartupAndSetupTerminals(args: { const terminal = await runtime.createTerminal(`id:${worktree.id}`, { command: startup.command, env: startup.env, + startupCommandDelivery: startup.startupCommandDelivery, telemetry: startup.telemetry, activate: true }) diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 9beba9ace..e9187f341 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -191,6 +191,35 @@ describe('LocalPtyProvider', () => { expect(spawnCall[2].env.CUSTOM_VAR).toBe('custom-value') }) + it('uses fallback shell readiness when startup-command shell spawn falls back', async () => { + vi.useFakeTimers() + try { + process.env.SHELL = '/usr/bin/fish' + spawnMock.mockImplementationOnce(() => { + throw new Error('fish failed') + }) + spawnMock.mockReturnValue(mockProc) + + await provider.spawn({ cols: 80, rows: 24, command: "printf 'linked issue context'" }) + + expect(spawnMock.mock.calls[0]?.[0]).toBe('/bin/zsh') + await Promise.resolve() + vi.advanceTimersByTime(50) + await Promise.resolve() + expect(mockProc.write).not.toHaveBeenCalled() + + const dataCallback = mockProc.onData.mock.calls[0]?.[0] as (data: string) => void + dataCallback('\x1b]777;orca-shell-ready\x07user@host % ') + await Promise.resolve() + vi.advanceTimersByTime(50) + await Promise.resolve() + + expect(mockProc.write).toHaveBeenCalledWith("printf 'linked issue context'\n") + } finally { + vi.useRealTimers() + } + }) + it('honors explicit terminal env overrides after deleting requested defaults', async () => { provider.configure({ buildSpawnEnv: (_id, env) => { diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 966bd2efd..0b7da3deb 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -42,6 +42,7 @@ import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' import { resolveAgentForegroundProcess } from './agent-foreground-process' import { getAgentForegroundContextPaths } from './agent-foreground-context-paths' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const @@ -476,10 +477,19 @@ export class LocalPtyProvider implements IPtyProvider { recognizeAgentProcessFromCommandLine(args.command)?.agent === 'codex' let shellLaunch: ReturnType | null = null if (args.command && isCodexStartupCommand) { - // Why: Codex needs the env-restoring wrapper, but waiting for a shell - // marker delays the first useful TUI frame. - getFallbackShellReadyConfig = (shell) => getAttributionShellLaunchConfig(shell) - shellLaunch = getAttributionShellLaunchConfig(shellPath) + const shouldWaitForShellReady = shouldUseShellReadyStartupDelivery({ + command: args.command, + startupCommandDelivery: args.startupCommandDelivery + }) + // Why: payload-bearing Codex startup text can be dropped by rc-file noise; + // plain Codex stays markerless to preserve the startup-speed path. + getFallbackShellReadyConfig = (shell) => + shouldWaitForShellReady + ? getShellReadyLaunchConfig(shell) + : getAttributionShellLaunchConfig(shell) + shellLaunch = shouldWaitForShellReady + ? getShellReadyLaunchConfig(shellPath) + : getAttributionShellLaunchConfig(shellPath) } else if (args.command) { getFallbackShellReadyConfig = (shell) => getShellReadyLaunchConfig(shell) shellLaunch = getShellReadyLaunchConfig(shellPath) @@ -534,6 +544,9 @@ export class LocalPtyProvider implements IPtyProvider { : undefined }) shellPath = spawnResult.shellPath + if (args.command && getFallbackShellReadyConfig) { + shellReadyLaunch = getFallbackShellReadyConfig(shellPath) + } if (process.platform !== 'win32') { finalEnv.SHELL = shellPath diff --git a/src/main/providers/local-pty-shell-ready.test.ts b/src/main/providers/local-pty-shell-ready.test.ts index d029dba6b..629baf764 100644 --- a/src/main/providers/local-pty-shell-ready.test.ts +++ b/src/main/providers/local-pty-shell-ready.test.ts @@ -8,7 +8,11 @@ import { join, dirname } from 'path' import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'fs' import type * as pty from 'node-pty' import type * as LocalPtyShellReadyModule from './local-pty-shell-ready' -import { writeStartupCommandWhenShellReady } from './local-pty-shell-ready' +import { + createShellReadyScanState, + scanForShellReady, + writeStartupCommandWhenShellReady +} from './local-pty-shell-ready' const { getUserDataPathMock } = vi.hoisted(() => ({ getUserDataPathMock: vi.fn<() => string>() @@ -128,6 +132,21 @@ describe('writeStartupCommandWhenShellReady', () => { }) }) +describe('scanForShellReady', () => { + it('flushes marker-like output when the full marker is not BEL-terminated', () => { + const state = createShellReadyScanState() + + expect(scanForShellReady(state, 'before \x1b]777;orca-shell-readyx')).toEqual({ + output: 'before \x1b]777;orca-shell-readyx', + matched: false + }) + expect(scanForShellReady(state, ' after')).toEqual({ + output: ' after', + matched: false + }) + }) +}) + const describePosix = process.platform === 'win32' ? describe.skip : describe const hasBash = process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0 const itWithBash = hasBash ? it : it.skip diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 7872fcd84..1175ddaf4 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -74,7 +74,15 @@ export function scanForShellReady( state.matchPos = 0 return { output: output + remaining, matched: true } } else { - state.heldBytes += ch + output += state.heldBytes + state.heldBytes = '' + state.matchPos = 0 + if (ch === SHELL_READY_MARKER[0]) { + state.heldBytes = ch + state.matchPos = 1 + } else { + output += ch + } } } diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 8883b88a3..327bbb9d7 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -137,7 +137,10 @@ export class SshPtyProvider implements IPtyProvider { // 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. - ...(opts.command ? { command: opts.command } : {}) + ...(opts.command ? { command: opts.command } : {}), + ...(opts.startupCommandDelivery + ? { startupCommandDelivery: opts.startupCommandDelivery } + : {}) }) return { ...(result as PtySpawnResult), diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 85c5017ad..ceef32852 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -18,6 +18,7 @@ import type { import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' +import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' // ─── PTY Provider ─────────────────────────────────────────────────── @@ -28,6 +29,7 @@ export type PtySpawnOptions = { env?: Record envToDelete?: string[] command?: string + startupCommandDelivery?: StartupCommandDelivery /** Orca worktree identity. When present, the local provider scopes shell * history to this worktree so ArrowUp only surfaces local commands. */ worktreeId?: string diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index e6a63c232..512d642f3 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -902,6 +902,7 @@ type RuntimePtyController = { rows: number cwd?: string command?: string + startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] env?: Record envToDelete?: string[] telemetry?: WorktreeStartupLaunch['telemetry'] @@ -3161,6 +3162,7 @@ export class OrcaRuntimeService { true, undefined, undefined, + undefined, { tabId: tab.parentTabId, leafId: tab.leafId, @@ -9923,6 +9925,9 @@ export class OrcaRuntimeService { agent, startup: { command: draftLaunchPlan.launchCommand, + ...(draftLaunchPlan.startupCommandDelivery + ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } + : {}), ...(draftLaunchPlan.env ? { env: draftLaunchPlan.env } : {}) } } @@ -9944,6 +9949,9 @@ export class OrcaRuntimeService { agent, startup: { command: startupPlan.launchCommand, + ...(startupPlan.startupCommandDelivery + ? { startupCommandDelivery: startupPlan.startupCommandDelivery } + : {}), ...(startupPlan.env ? { env: startupPlan.env } : {}) }, draftPaste: { agent, content } @@ -9981,6 +9989,9 @@ export class OrcaRuntimeService { agent, startup: { command: startupPlan.launchCommand, + ...(startupPlan.startupCommandDelivery + ? { startupCommandDelivery: startupPlan.startupCommandDelivery } + : {}), ...(startupPlan.env ? { env: startupPlan.env } : {}) }, ...(startupPlan.followupPrompt @@ -10400,6 +10411,7 @@ export class OrcaRuntimeService { const terminal = await this.createTerminal(`id:${worktree.id}`, { command: effectiveStartup.command, env: effectiveStartup.env, + startupCommandDelivery: effectiveStartup.startupCommandDelivery, telemetry: effectiveStartup.telemetry }) if (effectiveDraftPaste) { @@ -10981,6 +10993,7 @@ export class OrcaRuntimeService { const terminal = await this.createTerminal(`id:${worktree.id}`, { command: effectiveStartup.command, env: effectiveStartup.env, + startupCommandDelivery: effectiveStartup.startupCommandDelivery, telemetry: effectiveStartup.telemetry }) if (effectiveDraftPaste) { @@ -11237,6 +11250,7 @@ export class OrcaRuntimeService { const terminal = await this.createTerminal(`path:${result.worktree.path}`, { command: args.startup.command, env: args.startup.env, + startupCommandDelivery: args.startup.startupCommandDelivery, telemetry: args.startup.telemetry }) if (args.startupDraftPaste) { @@ -12765,6 +12779,7 @@ export class OrcaRuntimeService { opts: { command?: string env?: Record + startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] telemetry?: WorktreeStartupLaunch['telemetry'] title?: string focus?: boolean @@ -12842,6 +12857,7 @@ export class OrcaRuntimeService { rows: 40, cwd: workspace.path, command: agentTeamsPlan?.command ?? opts.command, + startupCommandDelivery: opts.startupCommandDelivery, env, envToDelete: agentTeamsPlan?.envToDelete, telemetry: opts.telemetry, @@ -12938,6 +12954,7 @@ export class OrcaRuntimeService { requestId, worktreeId, command: opts.command, + startupCommandDelivery: opts.startupCommandDelivery, title: opts.title, activate: opts.focus === true || opts.activate === true }) @@ -12974,6 +12991,7 @@ export class OrcaRuntimeService { return await this.createTerminal(`id:${worktree.id}`, { command: startup.startup.command, env: startup.startup.env, + startupCommandDelivery: startup.startup.startupCommandDelivery, telemetry: startup.startup.telemetry, title: opts.title }) @@ -12985,6 +13003,7 @@ export class OrcaRuntimeService { afterTabId?: string targetGroupId?: string command?: string + startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] agent?: TuiAgent activate?: boolean } = {} @@ -13011,6 +13030,7 @@ export class OrcaRuntimeService { opts.activate !== false, opts.afterTabId, command, + opts.startupCommandDelivery, undefined, opts.agent ) @@ -13044,6 +13064,7 @@ export class OrcaRuntimeService { afterTabId: afterDesktopTabId, targetGroupId: opts.targetGroupId, command, + startupCommandDelivery: opts.startupCommandDelivery, activate: opts.activate }) }) @@ -13100,6 +13121,7 @@ export class OrcaRuntimeService { activate: boolean, afterTabId?: string, command?: string, + startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'], identity?: { tabId: string; leafId: string; sessionId?: string }, launchAgent?: TuiAgent ): Promise { @@ -13111,6 +13133,7 @@ export class OrcaRuntimeService { const terminal = await this.createTerminal(`id:${worktreeId}`, { focus: false, command, + startupCommandDelivery, ...(identity ? { tabId: identity.tabId, diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index 091747441..20112ef37 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -119,6 +119,7 @@ describe('session tab RPC methods', () => { afterTabId: undefined, targetGroupId: 'group-left', command: 'zsh', + startupCommandDelivery: undefined, agent: undefined, activate: true }) @@ -156,11 +157,51 @@ describe('session tab RPC methods', () => { afterTabId: undefined, targetGroupId: undefined, command: undefined, + startupCommandDelivery: undefined, agent: 'codex', activate: undefined }) }) + it('dispatches terminal creation with startup command delivery metadata', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + createMobileSessionTerminal: vi.fn().mockResolvedValue({ + tab: { + type: 'terminal', + id: 'tab-1::leaf-1', + parentTabId: 'tab-1', + leafId: 'leaf-1', + title: 'Terminal', + status: 'ready', + terminal: 'pty-1', + isActive: true + }, + publicationEpoch: 'epoch-1', + snapshotVersion: 1 + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('session.tabs.createTerminal', { + worktree: 'id:wt-1', + command: "codex 'linked issue context'", + startupCommandDelivery: 'shell-ready' + }) + ) + + expect(response.ok).toBe(true) + expect(runtime.createMobileSessionTerminal).toHaveBeenCalledWith('id:wt-1', { + afterTabId: undefined, + targetGroupId: undefined, + command: "codex 'linked issue context'", + startupCommandDelivery: 'shell-ready', + agent: undefined, + activate: undefined + }) + }) + it('rejects unknown agent presets without creating a terminal', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 55c2df802..19eadfd48 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -26,6 +26,7 @@ const CreateTerminalTab = WorktreeTabSelector.extend({ afterTabId: z.string().optional(), targetGroupId: z.string().optional(), command: z.string().optional(), + startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), agent: z .custom(isTuiAgent, { message: 'Unknown agent preset' @@ -111,6 +112,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ afterTabId: params.afterTabId, targetGroupId: params.targetGroupId, command: params.command, + startupCommandDelivery: params.startupCommandDelivery, agent: params.agent, activate: params.activate }) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 1363fa8c1..24bad0571 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -507,6 +507,7 @@ const TerminalWait = TerminalHandle.extend({ const TerminalCreateParams = z.object({ worktree: OptionalString, command: OptionalString, + startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), env: z.record(z.string(), z.string()).optional(), title: OptionalString, focus: z.unknown().optional(), @@ -774,6 +775,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ handler: async (params, { runtime }) => ({ terminal: await runtime.createTerminal(params.worktree, { command: params.command, + startupCommandDelivery: params.startupCommandDelivery, env: params.env, title: params.title, focus: params.focus === true, diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index 0d3ffeab4..f7c8d9178 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -118,6 +118,7 @@ export const WorktreeCreate = z // terminal pane launches the selected agent instead of an idle shell. startupCommand: OptionalString, startupEnv: z.record(z.string(), z.string()).optional(), + startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), // Why: CLI clients should not hardcode agent launch quoting because SSH // workspaces execute in a different shell than the client process. startupAgent: OptionalTuiAgent, diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index 1aa6c916e..4b04f8953 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -83,6 +83,7 @@ describe('worktree RPC methods', () => { repo: 'repo-1', name: 'agent-startup', startupCommand: "codex 'summarize repo'", + startupCommandDelivery: 'shell-ready', startupEnv: { ORCA_AGENT_MODE: 'direct' }, activate: true }) @@ -95,6 +96,7 @@ describe('worktree RPC methods', () => { activate: true, startup: { command: "codex 'summarize repo'", + startupCommandDelivery: 'shell-ready', env: { ORCA_AGENT_MODE: 'direct' } } }) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 5f09061dc..a707b539e 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -89,7 +89,10 @@ export const WORKTREE_METHODS: RpcMethod[] = [ startup: params.startupCommand ? { command: params.startupCommand, - ...(params.startupEnv ? { env: params.startupEnv } : {}) + ...(params.startupEnv ? { env: params.startupEnv } : {}), + ...(params.startupCommandDelivery + ? { startupCommandDelivery: params.startupCommandDelivery } + : {}) } : undefined, ...(params.startupAgent ? { startupAgent: params.startupAgent } : {}), diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 5d1b470f4..3acbc565d 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -13,6 +13,7 @@ import type { AppIdentity } from '../shared/app-identity' import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry' import type { TaskSourceContext } from '../shared/task-source-context' import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' +import type { StartupCommandDelivery } from '../shared/codex-startup-delivery' import type { FolderWorkspacePathStatus, FolderWorkspacePathStatusRequest @@ -1014,6 +1015,7 @@ export type PreloadApi = { cwd?: string env?: Record command?: string + startupCommandDelivery?: StartupCommandDelivery connectionId?: string | null worktreeId?: string sessionId?: string @@ -2385,6 +2387,7 @@ export type PreloadApi = { afterTabId?: string targetGroupId?: string command?: string + startupCommandDelivery?: StartupCommandDelivery title?: string activate?: boolean }) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index c1bfee59d..c8df88993 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -10,6 +10,7 @@ import type { CliInstallStatus } from '../shared/cli-install-types' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry' import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' +import type { StartupCommandDelivery } from '../shared/codex-startup-delivery' import type { BaseRefSearchResult, BaseRefDefaultResult, @@ -694,6 +695,7 @@ const api = { cwd?: string env?: Record command?: string + startupCommandDelivery?: StartupCommandDelivery connectionId?: string | null worktreeId?: string sessionId?: string @@ -1697,7 +1699,7 @@ const api = { copilotStatus: (): Promise => ipcRenderer.invoke('agentHooks:copilotStatus'), hermesStatus: (): Promise => - ipcRenderer.invoke('agentHooks:hermesStatus'), + ipcRenderer.invoke('agentHooks:hermesStatus') }, agentTrust: { @@ -3002,6 +3004,7 @@ const api = { afterTabId?: string targetGroupId?: string command?: string + startupCommandDelivery?: StartupCommandDelivery title?: string activate?: boolean }) => void @@ -3014,6 +3017,7 @@ const api = { afterTabId?: string targetGroupId?: string command?: string + startupCommandDelivery?: StartupCommandDelivery title?: string activate?: boolean } diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index e063094a3..983ac2348 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -165,6 +165,76 @@ describe('PtyHandler', () => { expect(handler.activePtyCount).toBe(1) }) + it.skipIf(process.platform === 'win32')( + 'enables shell-ready marker env for delivery-hinted startup commands', + async () => { + const oldShell = process.env.SHELL + const oldHome = process.env.HOME + const homeDir = mkdtempSync(join(tmpdir(), 'relay-shell-ready-spawn-')) + + process.env.SHELL = '/bin/bash' + process.env.HOME = homeDir + try { + await dispatcher.callRequest('pty.spawn', { + env: { HOME: homeDir }, + startupCommandDelivery: 'shell-ready' + }) + } finally { + if (oldShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = oldShell + } + if (oldHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = oldHome + } + rmSync(homeDir, { recursive: true, force: true }) + } + + const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as + | { env?: Record } + | undefined + expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1') + } + ) + + it.skipIf(process.platform === 'win32')( + 'enables shell-ready marker env for Codex native prefill commands', + async () => { + const oldShell = process.env.SHELL + const oldHome = process.env.HOME + const homeDir = mkdtempSync(join(tmpdir(), 'relay-codex-prefill-spawn-')) + + process.env.SHELL = '/bin/bash' + process.env.HOME = homeDir + try { + await dispatcher.callRequest('pty.spawn', { + env: { HOME: homeDir }, + command: "codex --prefill 'linked issue context'" + }) + } finally { + if (oldShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = oldShell + } + if (oldHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = oldHome + } + rmSync(homeDir, { recursive: true, force: true }) + } + + const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as + | { env?: Record } + | undefined + expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1') + } + ) + it('terminates spawned PTY when request becomes stale before response', async () => { const killSpy = vi.fn() const term = { ...mockPtyInstance, kill: killSpy, onData: vi.fn(), onExit: vi.fn() } diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 9f8eb7895..b1419e2fa 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -12,6 +12,7 @@ import { } from './pty-shell-utils' import { getRelayShellLaunchConfig } from './pty-shell-launch' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' +import { shouldUseShellReadyStartupDelivery } from '../shared/codex-startup-delivery' // 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 @@ -433,7 +434,15 @@ export class PtyHandler { const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined const command = typeof params.command === 'string' ? params.command : undefined const spawnEnv = this.buildSpawnEnv(env, { id, paneKey, shell, command }) - const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv) + // 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, + startupCommandDelivery: + params.startupCommandDelivery === 'shell-ready' ? 'shell-ready' : undefined + }) + }) // Why: SSH exec channels give the relay a minimal environment without // .zprofile/.bash_profile sourced. Spawning a login shell ensures PATH diff --git a/src/relay/pty-shell-launch.test.ts b/src/relay/pty-shell-launch.test.ts index 775845ca1..9f020b06e 100644 --- a/src/relay/pty-shell-launch.test.ts +++ b/src/relay/pty-shell-launch.test.ts @@ -151,6 +151,37 @@ describe('getRelayShellLaunchConfig', () => { } ) + it.skipIf(process.platform === 'win32')( + 'enables the shell-ready marker for requested zsh startup delivery', + () => { + const config = getRelayShellLaunchConfig('/bin/zsh', { HOME: homeDir }, 'linux', { + emitReadyMarker: true + }) + const zshRoot = join(homeDir, '.orca-relay', 'shell-ready', 'zsh') + const zlogin = readFileSync(join(zshRoot, '.zlogin'), 'utf8') + + expect(config.args).toEqual(['-l']) + expect(config.env.ZDOTDIR).toBe(zshRoot) + expect(config.env.ORCA_SHELL_READY_MARKER).toBe('1') + expect(zlogin).toContain('zle -N zle-line-init __orca_prompt_mark') + expect(zlogin).toContain('printf "\\033]777;orca-shell-ready\\007"') + } + ) + + it.skipIf(process.platform === 'win32')( + 'enables the shell-ready marker for requested bash startup delivery', + () => { + const config = getRelayShellLaunchConfig('/bin/bash', { HOME: homeDir }, 'linux', { + emitReadyMarker: true + }) + const bashRc = readFileSync(config.args[1] as string, 'utf8') + + expect(config.env.ORCA_SHELL_READY_MARKER).toBe('1') + expect(bashRc).toContain('__orca_append_prompt_command "__orca_prompt_mark"') + expect(bashRc).toContain('printf "\\033]777;orca-shell-ready\\007"') + } + ) + itWithBash('runs the relay bash wrapper without fake C/D markers before the first prompt', () => { const config = getRelayShellLaunchConfig('/bin/bash', { HOME: homeDir }) const output = runInteractiveBashRcfile(config.args[1] as string, homeDir) diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts index fb618283c..a9a3e221c 100644 --- a/src/relay/pty-shell-launch.ts +++ b/src/relay/pty-shell-launch.ts @@ -4,11 +4,13 @@ import { dirname, join } from 'path' import { getPosixOmpShellWrapper } from '../main/pty/omp-shell-wrapper' import { getZshFinalZdotdirRestoreBlock, + getZshShellReadyMarkerRegistrationBlock, getZshStartupFileSourceBlock } from '../main/shell-templates' const RELAY_SHELL_READY_DIR = '.orca-relay/shell-ready' const POSIX_LOGIN_ARGS = ['-l'] +const SHELL_READY_MARKER_ESCAPED = '\\033]777;orca-shell-ready\\007' export type RelayShellLaunchConfig = { args: string[] @@ -116,6 +118,7 @@ ${getZshStartupFileSourceBlock({ [[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac ${getPosixOmpShellWrapper()} ${getZshFinalZdotdirRestoreBlock('"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"')} +${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER_ESCAPED)} ` const bashRc = `# Orca relay bash overlay wrapper [[ -f /etc/profile ]] && source /etc/profile @@ -186,6 +189,14 @@ __orca_append_prompt_command() { fi } __orca_prepend_prompt_command +# Why: SSH startup commands are renderer-delivered; emit the same internal +# readiness marker as local shells only when that delivery mode asks for it. +if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then + __orca_prompt_mark() { + printf "${SHELL_READY_MARKER_ESCAPED}" + } + __orca_append_prompt_command "__orca_prompt_mark" +fi __orca_append_prompt_command "__orca_osc133_prompt_done" __orca_debug_trap_spec="$(trap -p DEBUG)" if [[ -n "$__orca_debug_trap_spec" ]]; then @@ -229,9 +240,11 @@ trap '__orca_osc133_preexec' DEBUG export function getRelayShellLaunchConfig( shellPath: string, env: Record, - platform: NodeJS.Platform = process.platform + platform: NodeJS.Platform = process.platform, + options: { emitReadyMarker?: boolean } = {} ): RelayShellLaunchConfig { const shellName = shellBasename(shellPath) + const emitReadyMarker = options.emitReadyMarker === true if (platform === 'win32') { // Why: pwsh also exists on POSIX remotes; Windows-specific shell args must // only apply when the relay itself is running on native Windows. @@ -241,7 +254,9 @@ export function getRelayShellLaunchConfig( if (shellName !== 'zsh' && shellName !== 'bash') { return { args: POSIX_LOGIN_ARGS, env: {} } } - if (shellName === 'zsh' && !hasOverlayRestoreEnv(env)) { + // Why: preserve plain zsh startup fast path; only force wrappers when + // shell-ready or overlay env restoration is requested. + if (shellName === 'zsh' && !hasOverlayRestoreEnv(env) && !emitReadyMarker) { return { args: POSIX_LOGIN_ARGS, env: {} } } @@ -253,13 +268,14 @@ export function getRelayShellLaunchConfig( args: POSIX_LOGIN_ARGS, env: { ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(env), - ZDOTDIR: join(root, 'zsh') + ZDOTDIR: join(root, 'zsh'), + ...(emitReadyMarker ? { ORCA_SHELL_READY_MARKER: '1' } : {}) } } } return { args: ['--rcfile', join(root, 'bash', 'rcfile')], - env: {} + env: emitReadyMarker ? { ORCA_SHELL_READY_MARKER: '1' } : {} } } diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx index e3fd7b00a..4e4248843 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx @@ -78,6 +78,9 @@ export function FloatingTerminalWindowControls({ state.queueTabStartupCommand(tab.id, { command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), + ...(startupPlan.startupCommandDelivery + ? { startupCommandDelivery: startupPlan.startupCommandDelivery } + : {}), telemetry: { agent_kind: tuiAgentToAgentKind(defaultAgent), launch_source: 'shortcut', diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 8620eefbb..deb619212 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -131,6 +131,9 @@ export async function submitFolderWorkspaceCreate({ ? { command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), + ...(startupPlan.startupCommandDelivery + ? { startupCommandDelivery: startupPlan.startupCommandDelivery } + : {}), telemetry: { agent_kind: tuiAgentToAgentKind(quickAgent), launch_source: launchSource, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 0dbadcd88..c3fe8a3e2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -3,6 +3,7 @@ import type { ReplayingPanesRef } from './replay-guard' import type { AgentCompletionStatusSnapshot } from './agent-completion-coordinator-types' import type { EventProps } from '../../../../shared/telemetry-events' import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol' +import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' import type { TuiAgent } from '../../../../shared/types' export type PtyConnectionDeps = { @@ -14,6 +15,7 @@ export type PtyConnectionDeps = { /** Renderer-delivered startup input for callers that need xterm paste * semantics before the submit Enter. */ delivery?: 'terminal-paste' + startupCommandDelivery?: StartupCommandDelivery env?: Record /** Telemetry payload for `agent_started`. Forwarded to `pty:spawn` * so main fires the event only after the spawn succeeds. */ diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index de5bafd3d..ba5a98d19 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -2432,7 +2432,7 @@ describe('connectPanePty', () => { expect(transport.sendInput).not.toHaveBeenCalled() }) - it('sends startup command via sendInput for SSH connections (relay has no shell-ready mechanism)', async () => { + it('sends fast startup commands via sendInput for SSH connections', async () => { // Capture the setTimeout callback directly so we can fire it without // vi.useFakeTimers() (which would also replace the rAF mock from beforeEach). const pendingTimeouts: (() => void)[] = [] @@ -2457,7 +2457,8 @@ describe('connectPanePty', () => { ) transportFactoryQueue.push(transport) - // SSH connection: connectionId is set, relay ignores the command field + // SSH connection: connectionId is set, relay receives command metadata + // for spawn context but the renderer owns fast command delivery. mockStoreState = { ...mockStoreState, tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, @@ -2485,6 +2486,227 @@ describe('connectPanePty', () => { } }) + it('waits for the SSH shell-ready marker before sending hinted startup commands', async () => { + const pendingTimeouts: (() => void)[] = [] + const originalSetTimeout = globalThis.setTimeout + globalThis.setTimeout = vi.fn((fn: () => void) => { + pendingTimeouts.push(fn) + return 999 as unknown as ReturnType + }) as unknown as typeof setTimeout + + try { + const { connectPanePty } = await import('./pty-connection') + + const capturedDataCallback: { current: ((data: string) => void) | null } = { + current: null + } + const transport = createMockTransport('pty-id') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-ssh-1' + } + ) + transportFactoryQueue.push(transport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }] + } + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + startup: { + command: "codex 'linked issue context'", + startupCommandDelivery: 'shell-ready' + } + }) + + connectPanePty(pane as never, manager as never, deps as never) + expect(capturedDataCallback.current).not.toBeNull() + + 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("codex 'linked issue context'\r") + } finally { + globalThis.setTimeout = originalSetTimeout + } + }) + + it('falls back for SSH shell-ready startup commands when no marker arrives', async () => { + const pendingTimeouts: (() => void)[] = [] + const originalSetTimeout = globalThis.setTimeout + globalThis.setTimeout = vi.fn((fn: () => void) => { + pendingTimeouts.push(fn) + return 999 as unknown as ReturnType + }) as unknown as typeof setTimeout + + try { + const { connectPanePty } = await import('./pty-connection') + + const capturedDataCallback: { current: ((data: string) => void) | null } = { + current: null + } + const transport = createMockTransport('pty-id') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-ssh-1' + } + ) + transportFactoryQueue.push(transport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }] + } + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + startup: { + command: "codex 'linked issue context'", + startupCommandDelivery: 'shell-ready' + } + }) + + connectPanePty(pane as never, manager as never, deps as never) + capturedDataCallback.current?.('fish prompt> ') + + expect(transport.sendInput).not.toHaveBeenCalled() + for (const fn of pendingTimeouts.splice(0)) { + fn() + } + for (const fn of pendingTimeouts.splice(0)) { + fn() + } + + expect(transport.sendInput).toHaveBeenCalledWith("codex 'linked issue context'\r") + } finally { + globalThis.setTimeout = originalSetTimeout + } + }) + + it('falls back for quiet SSH shell-ready startup commands with no output', async () => { + const pendingTimeouts: (() => void)[] = [] + const originalSetTimeout = globalThis.setTimeout + globalThis.setTimeout = vi.fn((fn: () => void) => { + pendingTimeouts.push(fn) + return 999 as unknown as ReturnType + }) as unknown as typeof setTimeout + + try { + const { connectPanePty } = await import('./pty-connection') + + const capturedDataCallback: { current: ((data: string) => void) | null } = { + current: null + } + const transport = createMockTransport('pty-id') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-ssh-1' + } + ) + transportFactoryQueue.push(transport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }] + } + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + startup: { + command: "codex 'linked issue context'", + startupCommandDelivery: 'shell-ready' + } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + expect(transport.sendInput).not.toHaveBeenCalled() + + for (const fn of pendingTimeouts.splice(0)) { + fn() + } + for (const fn of pendingTimeouts.splice(0)) { + fn() + } + + expect(transport.sendInput).toHaveBeenCalledWith("codex 'linked issue context'\r") + } finally { + globalThis.setTimeout = originalSetTimeout + } + }) + + it('waits for shell-ready for SSH Codex native prefill commands without an explicit hint', async () => { + const pendingTimeouts: (() => void)[] = [] + const originalSetTimeout = globalThis.setTimeout + globalThis.setTimeout = vi.fn((fn: () => void) => { + pendingTimeouts.push(fn) + return 999 as unknown as ReturnType + }) as unknown as typeof setTimeout + + try { + const { connectPanePty } = await import('./pty-connection') + + const capturedDataCallback: { current: ((data: string) => void) | null } = { + current: null + } + const transport = createMockTransport('pty-id') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-ssh-1' + } + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }] + } + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + startup: { command: "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("codex --prefill 'linked issue context'\r") + } finally { + globalThis.setTimeout = originalSetTimeout + } + }) + it('drops agent status without retaining when OSC 133 reports the command finished', async () => { const { connectPanePty } = await import('./pty-connection') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index ce020e799..72d69b3a9 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -37,6 +37,8 @@ import { POST_REPLAY_REATTACH_RESET, RESET_TERMINAL_CURSOR_STYLE } from './layout-serialization' +import { createShellReadyMarkerScanState, scanForShellReadyMarker } from './shell-ready-marker-scan' +import { shouldUseShellReadyStartupDelivery } from '../../../../shared/codex-startup-delivery' import { getSystemPrefersDark } from '@/lib/terminal-theme' import { mode2031SequenceFor, @@ -114,6 +116,7 @@ const AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS = 250 const AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS = 1500 const AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS = 10_000 const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500 +const SSH_SHELL_READY_STARTUP_FALLBACK_MS = 1500 const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000 const HIDDEN_OUTPUT_RESTORE_PENDING_CHARS = 512 * 1024 const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS = 50 @@ -793,6 +796,7 @@ export function connectPanePty( let cleanupHiddenOutputRestoreDeferredRetry = (): void => {} let unregisterE2ePtyDataInjection = (): void => {} let startupInjectTimer: ReturnType | null = null + let sshShellReadyFallbackTimer: ReturnType | null = null let agentTaskCompleteNotificationGraceTimer: ReturnType | null = null let agentTaskCompleteNotificationMaxTimer: ReturnType | null = null let agentTaskCompleteStatusUnsubscribe: (() => void) | null = null @@ -1666,6 +1670,9 @@ export function connectPanePty( cwd: deps.cwd, env: paneEnv, command: shouldDeliverStartupViaTerminalPaste ? undefined : paneStartup?.command, + startupCommandDelivery: shouldDeliverStartupViaTerminalPaste + ? undefined + : paneStartup?.startupCommandDelivery, connectionId, worktreeId: deps.worktreeId, // Why: closes the SIGKILL race documented in INVESTIGATION.md by letting @@ -1989,10 +1996,32 @@ export function connectPanePty( } // Why: for ordinary local startup commands, the local PTY provider already - // writes via the shell-ready barrier. terminal-paste startup commands must - // stay renderer-delivered so xterm can apply bracketed-paste semantics. + // writes via the shell-ready barrier. terminal-paste and SSH startup + // commands stay renderer-delivered so xterm/relay can apply their handling. let pendingStartupCommand = shouldDeliverStartupViaTerminalPaste || connectionId ? (paneStartup?.command ?? null) : null + const shouldWaitForSshShellReady = + Boolean(connectionId) && + shouldUseShellReadyStartupDelivery({ + command: paneStartup?.command, + startupCommandDelivery: paneStartup?.startupCommandDelivery + }) && + !shouldDeliverStartupViaTerminalPaste + const sshShellReadyMarkerScan = shouldWaitForSshShellReady + ? createShellReadyMarkerScanState() + : null + let sshStartupShellReady = !shouldWaitForSshShellReady + const markSshStartupShellReady = (): void => { + if (sshStartupShellReady) { + return + } + sshStartupShellReady = true + if (sshShellReadyFallbackTimer !== null) { + clearTimeout(sshShellReadyFallbackTimer) + sshShellReadyFallbackTimer = null + } + schedulePendingStartupCommandDelivery() + } let sessionRestoredBannerShown = false const showSessionRestoredBanner = (): void => { if (sessionRestoredBannerShown) { @@ -2057,6 +2086,18 @@ export function connectPanePty( if (!pendingStartupCommand) { return } + if (!sshStartupShellReady) { + if (sshShellReadyFallbackTimer === null) { + // Why: some SSH shells cannot emit Orca's ready marker. Prefer the + // marker when available, but fall back to the old renderer delivery + // behavior instead of dropping the startup command forever. + sshShellReadyFallbackTimer = setTimeout(() => { + sshShellReadyFallbackTimer = null + markSshStartupShellReady() + }, SSH_SHELL_READY_STARTUP_FALLBACK_MS) + } + return + } if (startupInjectTimer !== null) { clearTimeout(startupInjectTimer) } @@ -2124,6 +2165,9 @@ export function connectPanePty( } else if (typeof gen === 'number') { void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) } + if (resolvedPtyId && connectionId) { + schedulePendingStartupCommandDelivery() + } return resolvedPtyId }) .catch(async () => { @@ -2983,6 +3027,13 @@ export function connectPanePty( hasReceivedPtyOutput = true recordAgentHibernationPaneOutput(cacheKey) } + if (sshShellReadyMarkerScan) { + const scanned = scanForShellReadyMarker(sshShellReadyMarkerScan, data) + if (scanned.matched) { + markSshStartupShellReady() + } + data = scanned.output + } resetHiddenOutputRestoreIfPtyChanged() respondToTerminalPixelSizeQueries(data) observeTerminalBracketedPasteModeOutput(pane.terminal, data) @@ -3706,6 +3757,10 @@ export function connectPanePty( clearTimeout(startupInjectTimer) startupInjectTimer = null } + if (sshShellReadyFallbackTimer !== null) { + clearTimeout(sshShellReadyFallbackTimer) + sshShellReadyFallbackTimer = null + } clearPendingAgentTaskCompleteNotification() pendingTerminalBellNotification = false clearTerminalBellNotificationTimer() diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index 63044ad51..916fe63c3 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -8,6 +8,7 @@ import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' import type { EventProps } from '../../../../shared/telemetry-events' import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' +import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' import { ackPtyData, exposeE2eTerminalPtyAckGate } from './terminal-pty-ack-gate' // ── Singleton PTY event dispatcher ─────────────────────────────────── @@ -353,6 +354,7 @@ export type IpcPtyTransportOptions = { cwd?: string env?: Record command?: string + startupCommandDelivery?: StartupCommandDelivery connectionId?: string | null /** Orca worktree identity for scoped shell history. */ worktreeId?: string diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index d07b9de9f..00eb8dd54 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -421,6 +421,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra cwd, env, command, + startupCommandDelivery, connectionId, worktreeId, tabId, @@ -543,6 +544,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra cwd, env, command, + ...(startupCommandDelivery ? { startupCommandDelivery } : {}), ...(connectionId ? { connectionId } : {}), ...(options.sessionId ? { sessionId: options.sessionId } : {}), worktreeId, diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index b861e3ee8..2eda1a0d0 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -342,6 +342,30 @@ describe('createRemoteRuntimePtyTransport', () => { ) }) + it('passes startup command delivery when creating the remote runtime terminal', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1', + command: "codex 'linked issue context'", + startupCommandDelivery: 'shell-ready' + }) + + await transport.connect({ url: '', callbacks: {} }) + + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'terminal.create', + params: expect.objectContaining({ + command: "codex 'linked issue context'", + startupCommandDelivery: 'shell-ready' + }) + }) + ) + }) + it('activates pending host session mirrors instead of creating duplicate terminals', async () => { runtimeCall.mockImplementation((args) => { if (args.method === 'session.tabs.activate') { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index ea80c70cf..6d15565f6 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -47,6 +47,7 @@ export function createRemoteRuntimePtyTransport( ): PtyTransport { const { command, + startupCommandDelivery, env, worktreeId, tabId, @@ -375,6 +376,7 @@ export function createRemoteRuntimePtyTransport( const created = await callRuntime<{ terminal: RuntimeTerminalCreate }>('terminal.create', { worktree: toRuntimeWorktreeSelector(worktreeId), command, + startupCommandDelivery, env, tabId, leafId, diff --git a/src/renderer/src/components/terminal-pane/shell-ready-marker-scan.test.ts b/src/renderer/src/components/terminal-pane/shell-ready-marker-scan.test.ts new file mode 100644 index 000000000..c364b5a5d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/shell-ready-marker-scan.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { createShellReadyMarkerScanState, scanForShellReadyMarker } from './shell-ready-marker-scan' + +describe('scanForShellReadyMarker', () => { + it('strips the marker and preserves surrounding output', () => { + const state = createShellReadyMarkerScanState() + + expect(scanForShellReadyMarker(state, 'before \x1b]777;orca-shell-ready\x07 after')).toEqual({ + output: 'before after', + matched: true + }) + }) + + it('matches markers split across chunks', () => { + const state = createShellReadyMarkerScanState() + + expect(scanForShellReadyMarker(state, 'before \x1b]777;orca')).toEqual({ + output: 'before ', + matched: false + }) + expect(scanForShellReadyMarker(state, '-shell-ready\x07 after')).toEqual({ + output: ' after', + matched: true + }) + }) + + it('flushes marker-like output when the full marker is not BEL-terminated', () => { + const state = createShellReadyMarkerScanState() + + expect(scanForShellReadyMarker(state, 'before \x1b]777;orca-shell-readyx')).toEqual({ + output: 'before \x1b]777;orca-shell-readyx', + matched: false + }) + expect(scanForShellReadyMarker(state, ' after')).toEqual({ + output: ' after', + matched: false + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/shell-ready-marker-scan.ts b/src/renderer/src/components/terminal-pane/shell-ready-marker-scan.ts new file mode 100644 index 000000000..9b5b71956 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/shell-ready-marker-scan.ts @@ -0,0 +1,54 @@ +const SHELL_READY_MARKER = '\x1b]777;orca-shell-ready' + +export type ShellReadyMarkerScanState = { + matchPos: number + heldBytes: string +} + +export function createShellReadyMarkerScanState(): ShellReadyMarkerScanState { + return { matchPos: 0, heldBytes: '' } +} + +export function scanForShellReadyMarker( + state: ShellReadyMarkerScanState, + data: string +): { output: string; matched: boolean } { + let output = '' + + for (let i = 0; i < data.length; i += 1) { + const ch = data[i] as string + if (state.matchPos < SHELL_READY_MARKER.length) { + if (ch === SHELL_READY_MARKER[state.matchPos]) { + state.heldBytes += ch + state.matchPos += 1 + } else { + output += state.heldBytes + state.heldBytes = '' + state.matchPos = 0 + if (ch === SHELL_READY_MARKER[0]) { + state.heldBytes = ch + state.matchPos = 1 + } else { + output += ch + } + } + } else if (ch === '\x07') { + const remaining = data.slice(i + 1) + state.heldBytes = '' + state.matchPos = 0 + return { output: output + remaining, matched: true } + } else { + output += state.heldBytes + state.heldBytes = '' + state.matchPos = 0 + if (ch === SHELL_READY_MARKER[0]) { + state.heldBytes = ch + state.matchPos = 1 + } else { + output += ch + } + } + } + + return { output, matched: false } +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 9e80206fa..f2d49cf79 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -28,6 +28,7 @@ import type { } from '../../../../shared/types' import type { TerminalPaneSplitSource } from '../../../../shared/feature-education-telemetry' import type { EventProps } from '../../../../shared/telemetry-events' +import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts' import { buildFontFamily, @@ -120,6 +121,7 @@ type UseTerminalPaneLifecycleDeps = { /** Renderer-delivered startup input for callers that need xterm paste * semantics before the submit Enter. */ delivery?: 'terminal-paste' + startupCommandDelivery?: StartupCommandDelivery env?: Record /** Telemetry payload for `agent_started`. Forwarded to `pty:spawn` * so main fires the event only after the spawn succeeds. */ diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index cddf4f312..2f403add1 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -2892,6 +2892,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ? { command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), + ...(startupPlan.startupCommandDelivery + ? { startupCommandDelivery: startupPlan.startupCommandDelivery } + : {}), telemetry: composerTelemetry } : undefined @@ -2954,6 +2957,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS startup: { command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), + ...(startupPlan.startupCommandDelivery + ? { startupCommandDelivery: startupPlan.startupCommandDelivery } + : {}), ...(tuiAgent === 'command-code' && submitStartupPrompt.trim().length > 0 ? { initialAgentStatus: { @@ -3195,6 +3201,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS launchCommand: draftLaunchPlan.launchCommand, expectedProcess: draftLaunchPlan.expectedProcess, followupPrompt: null, + ...(draftLaunchPlan.startupCommandDelivery + ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } + : {}), ...(draftLaunchPlan.env ? { env: draftLaunchPlan.env } : {}) } } else if (agent !== null) { @@ -3226,6 +3235,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ? { command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), + ...(startupPlan.startupCommandDelivery + ? { startupCommandDelivery: startupPlan.startupCommandDelivery } + : {}), ...(quickTelemetry ? { telemetry: quickTelemetry } : {}) } : undefined diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 7ecae27ec..f536bcc30 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1491,7 +1491,12 @@ export function useIpcEvents(): void { store.setTabCustomTitle(tab.id, data.title, { recordInteraction: false }) } if (data.command) { - store.queueTabStartupCommand(tab.id, { command: data.command }) + store.queueTabStartupCommand(tab.id, { + command: data.command, + ...(data.startupCommandDelivery + ? { startupCommandDelivery: data.startupCommandDelivery } + : {}) + }) } window.api.ui.replyTerminalCreate({ requestId: data.requestId, diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 11fea568f..8e398004e 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -355,7 +355,7 @@ describe('launchAgentBackgroundSession', () => { ) }) - it('injects startup commands into SSH background sessions after shell output arrives', async () => { + it('injects fast startup commands into SSH background sessions after shell output arrives', async () => { vi.useFakeTimers() try { state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] @@ -368,7 +368,10 @@ describe('launchAgentBackgroundSession', () => { title: 'Nightly audit' }) - expect(mockSpawn.mock.calls[0]?.[0]?.command).toBeUndefined() + expect(mockSpawn.mock.calls[0]?.[0]?.command).toBe( + "claude '--dangerously-skip-permissions' 'run the automation'" + ) + expect(mockSpawn.mock.calls[0]?.[0]?.startupCommandDelivery).toBeUndefined() const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void dataSidecar('user@remote repo % ') vi.advanceTimersByTime(50) @@ -382,6 +385,108 @@ describe('launchAgentBackgroundSession', () => { } }) + it('waits for shell-ready before injecting payload-bearing SSH background commands', async () => { + vi.useFakeTimers() + try { + state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'run the automation', + title: 'Nightly audit' + }) + + expect(mockSpawn.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + command: "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'", + startupCommandDelivery: 'shell-ready' + }) + ) + const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + dataSidecar('user@remote repo % ') + vi.advanceTimersByTime(50) + expect(mockWrite).not.toHaveBeenCalled() + + dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') + vi.advanceTimersByTime(50) + + expect(mockWrite).toHaveBeenCalledWith( + 'pty-1', + "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r" + ) + } finally { + vi.useRealTimers() + } + }) + + it('waits for shell-ready for SSH background Codex native prefill commands without a hint', async () => { + vi.useFakeTimers() + try { + state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] + state.settings = { + agentCmdOverrides: { codex: "codex --prefill 'draft from override'" }, + activeRuntimeEnvironmentId: null + } + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'codex', + worktreeId: 'wt-1', + title: 'Nightly audit' + }) + + expect(mockSpawn.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + command: + "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'" + }) + ) + expect(mockSpawn.mock.calls[0]?.[0]).not.toHaveProperty('startupCommandDelivery') + const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + dataSidecar('user@remote repo % ') + vi.advanceTimersByTime(50) + expect(mockWrite).not.toHaveBeenCalled() + + dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') + vi.advanceTimersByTime(50) + + expect(mockWrite).toHaveBeenCalledWith( + 'pty-1', + "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'\r" + ) + } finally { + vi.useRealTimers() + } + }) + + it('does not rearm SSH background startup delivery after exit cleanup', async () => { + vi.useFakeTimers() + try { + state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'run the automation', + title: 'Nightly audit' + }) + + const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + const exitSidecar = mockSubscribeToPtyExit.mock.calls[0]?.[1] as (code: number) => void + exitSidecar(0) + + dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') + vi.advanceTimersByTime(50) + + expect(mockWrite).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('creates background sessions on the active runtime environment', async () => { state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: 'env-1' } const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 1977bc9c5..0e95c0753 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -33,6 +33,8 @@ import { createAgentStatusOscProcessor } from '../../../shared/agent-status-osc' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' import type { RuntimeTerminalCreate } from '../../../shared/runtime-types' import { translate } from '@/i18n/i18n' +import { createSshBackgroundStartupDelivery } from '@/lib/ssh-background-startup-delivery' +import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery' export type LaunchAgentBackgroundSessionArgs = { agent: TuiAgent @@ -138,39 +140,22 @@ export async function launchAgentBackgroundSession( ORCA_WORKTREE_ID: worktreeId } const sshConnectionId = repo?.connectionId ?? null - let pendingSshStartupCommand = sshConnectionId ? startupPlan.launchCommand : null - let sshStartupInjectTimer: ReturnType | null = null - const clearSshStartupInjectTimer = (): void => { - if (sshStartupInjectTimer !== null) { - clearTimeout(sshStartupInjectTimer) - sshStartupInjectTimer = null - } - } - const scheduleSshStartupInjection = (ptyId: string): void => { - if (!pendingSshStartupCommand) { - return - } - clearSshStartupInjectTimer() - sshStartupInjectTimer = setTimeout(() => { - sshStartupInjectTimer = null - const command = pendingSshStartupCommand - if (!command) { - return - } - pendingSshStartupCommand = null - // Why: the SSH relay ignores spawn.command for interactive PTYs; hidden - // automation tabs must type the startup command themselves after shell output. - const submittedCommand = - command.endsWith('\r') || command.endsWith('\n') ? command : `${command}\r` - window.api.pty.write(ptyId, submittedCommand) - }, 50) - } + const sshStartupDelivery = createSshBackgroundStartupDelivery({ + command: sshConnectionId ? startupPlan.launchCommand : null, + waitForShellReady: + Boolean(sshConnectionId) && + shouldUseShellReadyStartupDelivery({ + command: startupPlan.launchCommand, + startupCommandDelivery: startupPlan.startupCommandDelivery + }), + write: (ptyId, data) => window.api.pty.write(ptyId, data) + }) // Route by the worktree's owner host: the agent terminal must spawn on the host // that owns this worktree, not on the focused runtime. const runtimeTarget = getActiveRuntimeTarget( getSettingsForWorktreeRuntimeOwner(store, worktreeId) ) - let ptyId: string + let ptyId = '' try { if (runtimeTarget.kind === 'environment') { // Why: runtime environments execute on the server; using local pty.spawn @@ -181,6 +166,9 @@ export async function launchAgentBackgroundSession( { worktree: toRuntimeWorktreeSelector(worktreeId), command: startupPlan.launchCommand, + ...(startupPlan.startupCommandDelivery + ? { startupCommandDelivery: startupPlan.startupCommandDelivery } + : {}), env: paneEnv, title, tabId: tab.id, @@ -195,7 +183,10 @@ export async function launchAgentBackgroundSession( cols: 120, rows: 40, cwd: worktree.path, - ...(sshConnectionId ? {} : { command: startupPlan.launchCommand }), + command: startupPlan.launchCommand, + ...(!startupPlan.startupCommandDelivery + ? {} + : { startupCommandDelivery: startupPlan.startupCommandDelivery }), env: paneEnv, connectionId: sshConnectionId, worktreeId, @@ -234,14 +225,15 @@ export async function launchAgentBackgroundSession( exitHandled = true unsubscribeExit() unsubscribeData() - clearSshStartupInjectTimer() + sshStartupDelivery.clear() useAppStore.getState().clearTabPtyId(tab.id, ptyId) onExit?.(ptyId, code) } const processAgentStatus = createAgentStatusOscProcessor() const handleData = (data: string): void => { + data = sshStartupDelivery.handleData(data) onData?.(data) - scheduleSshStartupInjection(ptyId) + sshStartupDelivery.schedule(ptyId) const processed = processAgentStatus(data) for (const payload of processed.payloads) { useAppStore.getState().setAgentStatus(paneKey, payload, undefined) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index b9e752c97..df11c678f 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -3,7 +3,6 @@ import { useAppStore } from '@/store' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan, - type AgentDraftLaunchPlan, type AgentStartupPlan } from '@/lib/tui-agent-startup' import { CLIENT_PLATFORM } from '@/lib/new-workspace' @@ -28,8 +27,6 @@ import type { TuiAgent } from '../../../shared/types' import type { LaunchSource } from '../../../shared/telemetry-events' import { translate } from '@/i18n/i18n' -const WIN32_INLINE_DRAFT_LIMIT_CHARS = 24_000 - export type LaunchAgentInNewTabArgs = { agent: TuiAgent worktreeId: string @@ -88,23 +85,6 @@ function seedCommandCodeSubmittedPromptStatus(tabId: string, prompt: string): vo } } -function canUseInlineDraftLaunchPlan( - plan: AgentDraftLaunchPlan, - platform: NodeJS.Platform -): boolean { - if (platform !== 'win32') { - return true - } - const envChars = Object.entries(plan.env ?? {}).reduce( - (total, [key, value]) => total + key.length + value.length, - 0 - ) - // Why: Windows CreateProcess/env blocks have tight length ceilings. Large - // generated drafts should use the existing post-ready paste path instead of - // failing the PTY spawn before the agent starts. - return plan.launchCommand.length + envChars <= WIN32_INLINE_DRAFT_LIMIT_CHARS -} - /** * Create a new terminal tab and queue the agent's launch command, optionally * with an initial prompt. @@ -195,12 +175,15 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI agentArgs: effectiveAgentArgs, agentEnv }) - if (draftLaunchPlan && canUseInlineDraftLaunchPlan(draftLaunchPlan, resolvedLaunchPlatform)) { + if (draftLaunchPlan) { startupPlan = { agent: draftLaunchPlan.agent, launchCommand: draftLaunchPlan.launchCommand, expectedProcess: draftLaunchPlan.expectedProcess, followupPrompt: null, + ...(draftLaunchPlan.startupCommandDelivery + ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } + : {}), ...(draftLaunchPlan.env ? { env: draftLaunchPlan.env } : {}) } } else { @@ -253,7 +236,14 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI environmentId: runtimeEnvironmentId, targetGroupId: groupId, activate: true, - ...(hasPrompt ? { command: startupPlan.launchCommand } : { agent }) + ...(hasPrompt + ? { + command: startupPlan.launchCommand, + ...(startupPlan.startupCommandDelivery + ? { startupCommandDelivery: startupPlan.startupCommandDelivery } + : {}) + } + : { agent }) }).then((created) => { // Why: created means the host accepted the launch, not that a local tab // exists; keep pruning stale local rows until the snapshot mirrors. @@ -296,6 +286,9 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI store.queueTabStartupCommand(tab.id, { command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), + ...(startupPlan.startupCommandDelivery + ? { startupCommandDelivery: startupPlan.startupCommandDelivery } + : {}), ...(agent === 'command-code' && hasPrompt && promptDelivery === 'auto-submit' ? { initialAgentStatus: { agent, prompt: trimmedPrompt } } : {}), diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.test.ts b/src/renderer/src/lib/launch-work-item-direct-agent.test.ts new file mode 100644 index 000000000..3422f078b --- /dev/null +++ b/src/renderer/src/lib/launch-work-item-direct-agent.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('sonner', () => ({ toast: { message: vi.fn() } })) +vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: vi.fn() })) +vi.mock('@/lib/telemetry', () => ({ + track: vi.fn(), + tuiAgentToAgentKind: (agent: string) => agent +})) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, value: string) => value })) + +import { buildDirectWorkItemStartupOpts } from './launch-work-item-direct-agent' +import type { AgentStartupPlan } from './tui-agent-startup' + +describe('buildDirectWorkItemStartupOpts', () => { + it('preserves Codex startup command delivery for linked work-item launches', () => { + const plan: AgentStartupPlan = { + agent: 'codex', + launchCommand: "codex 'review linked issue'", + expectedProcess: 'codex', + followupPrompt: null, + startupCommandDelivery: 'shell-ready' + } + + expect(buildDirectWorkItemStartupOpts('codex', plan, 'task_page')).toEqual({ + startup: { + command: "codex 'review linked issue'", + startupCommandDelivery: 'shell-ready', + telemetry: { + agent_kind: 'codex', + launch_source: 'task_page', + request_kind: 'new' + } + } + }) + }) +}) diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.ts b/src/renderer/src/lib/launch-work-item-direct-agent.ts index 065c093e9..2260e747b 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.ts @@ -4,6 +4,7 @@ import { track, tuiAgentToAgentKind } from '@/lib/telemetry' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import type { AgentStartedTelemetry } from '@/lib/worktree-activation' import type { LaunchSource } from '../../../shared/telemetry-events' +import type { StartupCommandDelivery } from '../../../shared/codex-startup-delivery' import type { TuiAgent } from '../../../shared/types' import { translate } from '@/i18n/i18n' @@ -12,7 +13,12 @@ export function buildDirectWorkItemStartupOpts( plan: AgentStartupPlan | null, launchSource: LaunchSource ): { - startup?: { command: string; env?: Record; telemetry?: AgentStartedTelemetry } + startup?: { + command: string + env?: Record + startupCommandDelivery?: StartupCommandDelivery + telemetry?: AgentStartedTelemetry + } } { if (!plan) { return {} @@ -25,6 +31,9 @@ export function buildDirectWorkItemStartupOpts( startup: { command: plan.launchCommand, ...(plan.env ? { env: plan.env } : {}), + ...(plan.startupCommandDelivery + ? { startupCommandDelivery: plan.startupCommandDelivery } + : {}), ...(telemetry ? { telemetry } : {}) } } diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 1574ae19c..796bf3266 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -297,6 +297,9 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom launchCommand: draftLaunchPlan.launchCommand, expectedProcess: draftLaunchPlan.expectedProcess, followupPrompt: null, + ...(draftLaunchPlan.startupCommandDelivery + ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } + : {}), ...(draftLaunchPlan.env ? { env: draftLaunchPlan.env } : {}) } draftLaunchedNatively = true diff --git a/src/renderer/src/lib/onboarding-folder-agent-startup.ts b/src/renderer/src/lib/onboarding-folder-agent-startup.ts index 9f5fb1fe0..e551500e3 100644 --- a/src/renderer/src/lib/onboarding-folder-agent-startup.ts +++ b/src/renderer/src/lib/onboarding-folder-agent-startup.ts @@ -6,11 +6,13 @@ import { resolveTuiAgentLaunchEnv } from '../../../shared/tui-agent-launch-defaults' import type { AgentStartedTelemetry } from '@/lib/worktree-activation' +import type { StartupCommandDelivery } from '../../../shared/codex-startup-delivery' import type { GlobalSettings, OnboardingState } from '../../../shared/types' export type OnboardingFolderAgentStartup = { command: string env?: Record + startupCommandDelivery?: StartupCommandDelivery telemetry: AgentStartedTelemetry } @@ -50,6 +52,9 @@ export function buildOnboardingFolderAgentStartup( return { command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), + ...(startupPlan.startupCommandDelivery + ? { startupCommandDelivery: startupPlan.startupCommandDelivery } + : {}), telemetry: { agent_kind: tuiAgentToAgentKind(agent), launch_source: 'onboarding', diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index 793d5650f..59d4f02e5 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -73,6 +73,9 @@ function launchSleepingAgentSession(record: SleepingAgentSessionRecord): boolean }) state.queueTabStartupCommand(tab.id, { command: startupPlan.launchCommand, + ...(startupPlan.startupCommandDelivery + ? { startupCommandDelivery: startupPlan.startupCommandDelivery } + : {}), showSessionRestoredBanner: true, telemetry: { agent_kind: tuiAgentToAgentKind(record.agent), diff --git a/src/renderer/src/lib/source-control-agent-action-plan.ts b/src/renderer/src/lib/source-control-agent-action-plan.ts index ca5ac2a54..f1cf4ff0c 100644 --- a/src/renderer/src/lib/source-control-agent-action-plan.ts +++ b/src/renderer/src/lib/source-control-agent-action-plan.ts @@ -111,6 +111,9 @@ export function planSourceControlAgentActionLaunch(args: { launchCommand: draftLaunchPlan.launchCommand, expectedProcess: draftLaunchPlan.expectedProcess, followupPrompt: null, + ...(draftLaunchPlan.startupCommandDelivery + ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } + : {}), ...(draftLaunchPlan.env ? { env: draftLaunchPlan.env } : {}) } delivery = 'draft-native' diff --git a/src/renderer/src/lib/ssh-background-startup-delivery.ts b/src/renderer/src/lib/ssh-background-startup-delivery.ts new file mode 100644 index 000000000..a2c8cc6f7 --- /dev/null +++ b/src/renderer/src/lib/ssh-background-startup-delivery.ts @@ -0,0 +1,104 @@ +import { + createShellReadyMarkerScanState, + scanForShellReadyMarker +} from '@/components/terminal-pane/shell-ready-marker-scan' + +const SSH_SHELL_READY_STARTUP_FALLBACK_MS = 1500 + +type SshBackgroundStartupDeliveryOptions = { + command: string | null + waitForShellReady: boolean + write: (ptyId: string, data: string) => void +} + +export type SshBackgroundStartupDelivery = { + handleData(data: string): string + schedule(ptyId: string): void + clear(): void +} + +export function createSshBackgroundStartupDelivery( + options: SshBackgroundStartupDeliveryOptions +): SshBackgroundStartupDelivery { + let pendingCommand = options.command + let lastPtyId: string | null = null + let startupShellReady = !options.waitForShellReady + const markerScan = options.waitForShellReady ? createShellReadyMarkerScanState() : null + let injectTimer: ReturnType | null = null + let fallbackTimer: ReturnType | null = null + + const clearInjectTimer = (): void => { + if (injectTimer !== null) { + clearTimeout(injectTimer) + injectTimer = null + } + } + const clearFallbackTimer = (): void => { + if (fallbackTimer !== null) { + clearTimeout(fallbackTimer) + fallbackTimer = null + } + } + function markShellReady(): void { + if (startupShellReady) { + return + } + startupShellReady = true + clearFallbackTimer() + if (pendingCommand && lastPtyId) { + schedule(lastPtyId) + } + } + + const schedule = (ptyId: string): void => { + lastPtyId = ptyId + if (!pendingCommand) { + return + } + if (!startupShellReady) { + if (fallbackTimer === null) { + // Why: hidden SSH sessions can use shells that cannot emit Orca's + // marker. Prefer readiness, but never drop the startup command forever. + fallbackTimer = setTimeout(() => { + fallbackTimer = null + markShellReady() + }, SSH_SHELL_READY_STARTUP_FALLBACK_MS) + } + return + } + clearInjectTimer() + injectTimer = setTimeout(() => { + injectTimer = null + const command = pendingCommand + if (!command) { + return + } + pendingCommand = null + // Why: the SSH relay treats spawn.command as metadata for interactive + // PTYs; hidden automation tabs still submit the command themselves. + const submittedCommand = + command.endsWith('\r') || command.endsWith('\n') ? command : `${command}\r` + options.write(ptyId, submittedCommand) + }, 50) + } + + return { + handleData(data) { + if (!markerScan) { + return data + } + const scanned = scanForShellReadyMarker(markerScan, data) + if (scanned.matched) { + markShellReady() + } + return scanned.output + }, + schedule, + clear() { + clearInjectTimer() + clearFallbackTimer() + pendingCommand = null + lastPtyId = null + } + } +} diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index a82cb3a5e..2957c43d5 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -8,6 +8,7 @@ import type { WorktreeSetupLaunch } from '../../../shared/types' import type { EventProps } from '../../../shared/telemetry-events' +import type { StartupCommandDelivery } from '../../../shared/codex-startup-delivery' import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal' import { buildSetupRunnerCommand } from './setup-runner' import { buildAgentStartupPlan } from './tui-agent-startup' @@ -61,6 +62,7 @@ export type AgentStartedTelemetry = EventProps<'agent_started'> export type WorktreeStartupPayload = { command: string env?: Record + startupCommandDelivery?: StartupCommandDelivery initialAgentStatus?: { agent: TuiAgent; prompt: string } telemetry?: AgentStartedTelemetry } @@ -240,6 +242,9 @@ function buildCreatedAgentReopenStartup(worktree: Worktree): WorktreeStartupPayl return { command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), + ...(startupPlan.startupCommandDelivery + ? { startupCommandDelivery: startupPlan.startupCommandDelivery } + : {}), telemetry: { agent_kind: tuiAgentToAgentKind(agent), launch_source: 'sidebar', diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index f3da917d9..9c3fe6273 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -31,6 +31,7 @@ function buildStartupOpt( return { command: plan.launchCommand, ...(plan.env ? { env: plan.env } : {}), + ...(plan.startupCommandDelivery ? { startupCommandDelivery: plan.startupCommandDelivery } : {}), // Why: command-code shows its prompt in the tab status before the first // hook fires, so the prompt is threaded through here. ...(request.agent === 'command-code' && request.quickPrompt.trim().length > 0 diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index 7401ec55c..2524f8fd0 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -420,7 +420,8 @@ describe('createWebRuntimeSessionTerminal', () => { worktreeId: WORKTREE_ID, afterTabId: 'web-terminal-host-tab-1%3A%3Aleaf-1', targetGroupId: 'group-left', - command: 'zsh', + command: "codex 'linked issue context'", + startupCommandDelivery: 'shell-ready', activate: true }) ).resolves.toBe(true) @@ -432,7 +433,8 @@ describe('createWebRuntimeSessionTerminal', () => { worktree: `id:${WORKTREE_ID}`, afterTabId: 'host-tab-1::leaf-1', targetGroupId: 'group-left', - command: 'zsh', + command: "codex 'linked issue context'", + startupCommandDelivery: 'shell-ready', activate: true }, timeoutMs: 15_000 diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index 9dab68eea..9fb542226 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -10,6 +10,7 @@ import type { RuntimeTerminalSplit } from '../../../shared/runtime-types' import type { TerminalPaneSplitSource } from '../../../shared/feature-education-telemetry' +import type { StartupCommandDelivery } from '../../../shared/codex-startup-delivery' import type { TuiAgent } from '../../../shared/types' import type { AppState } from '../store/types' import { useAppStore } from '../store' @@ -44,6 +45,7 @@ export async function createWebRuntimeSessionTerminal(args: { afterTabId?: string targetGroupId?: string command?: string + startupCommandDelivery?: StartupCommandDelivery agent?: TuiAgent activate?: boolean selectWorktree?: boolean @@ -68,6 +70,7 @@ export async function createWebRuntimeSessionTerminal(args: { afterTabId: args.afterTabId ? toHostSessionTabId(args.afterTabId) : undefined, targetGroupId: args.targetGroupId, command: args.command, + startupCommandDelivery: args.startupCommandDelivery, agent: args.agent, activate: args.activate !== false }, diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 7afb004e4..ff3a76dc2 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -23,6 +23,7 @@ import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../../../shar import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../../../shared/worktree-id' import { isWslUncPath } from '../../../../shared/wsl-paths' import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' +import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../../shared/local-windows-terminal-runtime' import type { AgentStartedTelemetry } from '../../lib/worktree-activation' import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' @@ -308,6 +309,7 @@ export type TerminalSlice = { /** Renderer-delivered startup input for callers that need xterm paste * semantics before the submit Enter. */ delivery?: 'terminal-paste' + startupCommandDelivery?: StartupCommandDelivery env?: Record /** Initial prompt-start status for agents that lack native prompt hooks. */ initialAgentStatus?: { agent: TuiAgent; prompt: string } @@ -449,6 +451,7 @@ export type TerminalSlice = { startup: { command: string delivery?: 'terminal-paste' + startupCommandDelivery?: StartupCommandDelivery env?: Record initialAgentStatus?: { agent: TuiAgent; prompt: string } showSessionRestoredBanner?: boolean @@ -458,6 +461,7 @@ export type TerminalSlice = { consumeTabStartupCommand: (tabId: string) => { command: string delivery?: 'terminal-paste' + startupCommandDelivery?: StartupCommandDelivery env?: Record initialAgentStatus?: { agent: TuiAgent; prompt: string } showSessionRestoredBanner?: boolean diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index c7fc1be86..c4faadc87 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -1691,6 +1691,9 @@ export const createWorktreeSlice: StateCreator ? { startupCommand: startup.command, ...(startup.env ? { startupEnv: startup.env } : {}), + ...(startup.startupCommandDelivery + ? { startupCommandDelivery: startup.startupCommandDelivery } + : {}), activate: true } : {}) diff --git a/src/shared/codex-startup-delivery.test.ts b/src/shared/codex-startup-delivery.test.ts new file mode 100644 index 000000000..e7788f778 --- /dev/null +++ b/src/shared/codex-startup-delivery.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { hasCodexNativeDraftFlag } from './codex-startup-delivery' + +describe('hasCodexNativeDraftFlag', () => { + it('matches Codex --prefill option tokens', () => { + expect(hasCodexNativeDraftFlag("codex --prefill 'linked issue context'")).toBe(true) + expect(hasCodexNativeDraftFlag("codex --model gpt-5 --prefill 'draft'")).toBe(true) + }) + + it('matches Codex --prefill=value option tokens', () => { + expect(hasCodexNativeDraftFlag('codex --prefill=review')).toBe(true) + expect(hasCodexNativeDraftFlag("codex --prefill='linked issue context'")).toBe(true) + }) + + it('does not match quoted prompt text mentioning prefill', () => { + expect(hasCodexNativeDraftFlag("codex 'please compare --prefill behavior'")).toBe(false) + expect(hasCodexNativeDraftFlag("codex '--prefill=not-an-option'")).toBe(false) + }) + + it('does not match non-Codex commands', () => { + expect(hasCodexNativeDraftFlag("claude --prefill 'review this'")).toBe(false) + }) + + it('leaves plain Codex and normal Codex arguments on the fast path', () => { + expect(hasCodexNativeDraftFlag('codex')).toBe(false) + expect(hasCodexNativeDraftFlag('codex --model gpt-5')).toBe(false) + }) +}) diff --git a/src/shared/codex-startup-delivery.ts b/src/shared/codex-startup-delivery.ts new file mode 100644 index 000000000..a45337dd0 --- /dev/null +++ b/src/shared/codex-startup-delivery.ts @@ -0,0 +1,82 @@ +import { recognizeAgentProcessFromCommandLine } from './agent-process-recognition' + +export type StartupCommandDelivery = 'fast' | 'shell-ready' + +type CommandToken = { + value: string + startsQuoted: boolean +} + +function tokenizeCommandWithQuoteMetadata(command: string): CommandToken[] { + const tokens: CommandToken[] = [] + let current = '' + let inToken = false + let startsQuoted = false + let quote: '"' | "'" | null = null + let escaped = false + + for (let index = 0; index < command.length; index += 1) { + const char = command[index] + if (escaped) { + current += char + escaped = false + continue + } + if (char === '\\' && quote !== "'") { + const next = command[index + 1] + if (next && (/\s/.test(next) || next === '"' || next === "'" || next === '\\')) { + escaped = true + inToken = true + continue + } + } + if ((char === '"' || char === "'") && quote === null) { + if (!inToken) { + startsQuoted = true + } + quote = char + inToken = true + continue + } + if (quote === char) { + quote = null + continue + } + if (/\s/.test(char) && quote === null) { + if (inToken) { + tokens.push({ value: current, startsQuoted }) + current = '' + inToken = false + startsQuoted = false + } + continue + } + current += char + inToken = true + } + + if (inToken) { + tokens.push({ value: current, startsQuoted }) + } + return tokens +} + +export function hasCodexNativeDraftFlag(command: string | null | undefined): boolean { + if (recognizeAgentProcessFromCommandLine(command)?.agent !== 'codex' || !command) { + return false + } + const tokens = tokenizeCommandWithQuoteMetadata(command) + return tokens.some( + (token, index) => + index > 0 && + !token.startsQuoted && + (token.value === '--prefill' || token.value.startsWith('--prefill=')) + ) +} + +export function shouldUseShellReadyStartupDelivery(args: { + command: string | null | undefined + startupCommandDelivery?: StartupCommandDelivery +}): boolean { + return args.startupCommandDelivery === 'shell-ready' || hasCodexNativeDraftFlag(args.command) +} diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index 9ce0cd839..7ef17bfcf 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -57,6 +57,24 @@ describe('tui agent startup plans', () => { }) expect(plan?.launchCommand).toBe("codex 'fix it'") + expect(plan?.startupCommandDelivery).toBe('shell-ready') + }) + + it('keeps plain empty Codex startup on the fast delivery path', () => { + const plan = buildAgentStartupPlan({ + agent: 'codex', + prompt: '', + cmdOverrides: {}, + platform: 'linux', + allowEmptyPromptLaunch: true + }) + + expect(plan).toEqual({ + agent: 'codex', + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null + }) }) it('launches Claude without Orca settings injection', () => { @@ -266,6 +284,28 @@ describe('tui agent startup plans', () => { expect(plan?.launchCommand).toBe('omp; unset ORCA_OMP_PREFILL') }) + it('returns null for oversized Windows flag drafts so callers paste after ready', () => { + expect( + buildAgentDraftLaunchPlan({ + agent: 'claude', + draft: 'x'.repeat(25_000), + cmdOverrides: {}, + platform: 'win32' + }) + ).toBeNull() + }) + + it('returns null for oversized Windows env-var drafts so callers paste after ready', () => { + expect( + buildAgentDraftLaunchPlan({ + agent: 'pi', + draft: 'x'.repeat(25_000), + cmdOverrides: {}, + platform: 'win32' + }) + ).toBeNull() + }) + it('launches Devin with stdin-after-start prompt delivery', () => { const plan = buildAgentStartupPlan({ agent: 'devin', diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index f8a8c520e..026134e7e 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -6,8 +6,11 @@ import { } from './agent-session-resume' import { tokenizeCustomCommandTemplate } from './commit-message-prompt' import { TUI_AGENT_CONFIG } from './tui-agent-config' +import type { StartupCommandDelivery } from './codex-startup-delivery' import type { TuiAgent } from './types' +const WIN32_INLINE_DRAFT_LIMIT_CHARS = 24_000 + export type AgentStartupPlan = { agent: TuiAgent launchCommand: string @@ -15,6 +18,7 @@ export type AgentStartupPlan = { followupPrompt: string | null draftPrompt?: string | null env?: Record + startupCommandDelivery?: StartupCommandDelivery } export type AgentStartupShell = 'posix' | 'powershell' | 'cmd' @@ -143,6 +147,7 @@ export function buildAgentStartupPlan(args: { launchCommand: `${baseCommand.command} ${quotedPrompt}`, expectedProcess: config.expectedProcess, followupPrompt: null, + ...(agent === 'codex' ? { startupCommandDelivery: 'shell-ready' as const } : {}), ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) } } @@ -229,6 +234,23 @@ export type AgentDraftLaunchPlan = { launchCommand: string expectedProcess: string env?: Record + startupCommandDelivery?: StartupCommandDelivery +} + +function inlineDraftPlanFitsPlatform( + plan: AgentDraftLaunchPlan, + platform: NodeJS.Platform +): boolean { + if (platform !== 'win32') { + return true + } + const envChars = Object.entries(plan.env ?? {}).reduce( + (total, [key, value]) => total + key.length + value.length, + 0 + ) + // Why: Windows CreateProcess/env blocks have tight length ceilings. Large + // generated drafts should use the existing post-ready paste fallback. + return plan.launchCommand.length + envChars <= WIN32_INLINE_DRAFT_LIMIT_CHARS } export function buildAgentDraftLaunchPlan(args: { @@ -256,25 +278,30 @@ export function buildAgentDraftLaunchPlan(args: { if (!baseCommand.ok) { return null } + let plan: AgentDraftLaunchPlan | null = null if (config.draftPromptFlag) { const quoted = quoteStartupArg(trimmed, shell) - return { + plan = { agent, launchCommand: `${baseCommand.command} ${config.draftPromptFlag} ${quoted}`, expectedProcess: config.expectedProcess, + // Why: native draft flags carry user text on argv and must survive rc-file startup. + ...(agent === 'codex' ? { startupCommandDelivery: 'shell-ready' as const } : {}), ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) } - } - if (config.draftPromptEnvVar) { + } else if (config.draftPromptEnvVar) { const clearVar = clearEnvCommand(config.draftPromptEnvVar, shell) - return { + plan = { agent, launchCommand: `${baseCommand.command}${commandSeparator(shell)}${clearVar}`, expectedProcess: config.expectedProcess, env: { ...args.agentEnv, [config.draftPromptEnvVar]: trimmed } } } - return null + if (!plan || !inlineDraftPlanFitsPlatform(plan, platform)) { + return null + } + return plan } export { isShellProcess } diff --git a/src/shared/types.ts b/src/shared/types.ts index 29b6ddb0f..81f977637 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -28,6 +28,7 @@ import type { RepoSourceControlAiOverrides, SourceControlAiSettings } from './source-control-ai-types' +import type { StartupCommandDelivery } from './codex-startup-delivery' import type { AgentKind, LaunchSource, RequestKind } from './telemetry-events' import type { SleepingAgentSessionRecord } from './agent-session-resume' import type { ClaudeAgentTeamsMode } from './claude-agent-teams-tmux-compat' @@ -1865,6 +1866,7 @@ export type WorktreeSetupLaunch = { export type WorktreeStartupLaunch = { command: string env?: Record + startupCommandDelivery?: StartupCommandDelivery telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } }