Improve Codex startup prompt reliability (#5686)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
3ce1a269ab
commit
bc0b1e28bd
|
|
@ -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<typeof DaemonHealthModule>()
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<string, string>
|
||||
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<typeof getShellReadyLaunchConfig> | 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 {
|
||||
|
|
|
|||
|
|
@ -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<string, string>
|
||||
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<string, string>
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<string, string>
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<string, string>
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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<typeof getShellReadyLaunchConfig> | 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<string, string>
|
||||
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
|
||||
|
|
|
|||
|
|
@ -902,6 +902,7 @@ type RuntimePtyController = {
|
|||
rows: number
|
||||
cwd?: string
|
||||
command?: string
|
||||
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
|
||||
env?: Record<string, string>
|
||||
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<string, string>
|
||||
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<RuntimeMobileSessionCreateTerminalResult> {
|
||||
|
|
@ -13111,6 +13133,7 @@ export class OrcaRuntimeService {
|
|||
const terminal = await this.createTerminal(`id:${worktreeId}`, {
|
||||
focus: false,
|
||||
command,
|
||||
startupCommandDelivery,
|
||||
...(identity
|
||||
? {
|
||||
tabId: identity.tabId,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<TuiAgent>(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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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<string, string>
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<string, string>
|
||||
command?: string
|
||||
startupCommandDelivery?: StartupCommandDelivery
|
||||
connectionId?: string | null
|
||||
worktreeId?: string
|
||||
sessionId?: string
|
||||
|
|
@ -1697,7 +1699,7 @@ const api = {
|
|||
copilotStatus: (): Promise<AgentHookInstallStatus> =>
|
||||
ipcRenderer.invoke('agentHooks:copilotStatus'),
|
||||
hermesStatus: (): Promise<AgentHookInstallStatus> =>
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string> }
|
||||
| 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<string, string> }
|
||||
| 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() }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<string, string>,
|
||||
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' } : {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string, string>
|
||||
/** Telemetry payload for `agent_started`. Forwarded to `pty:spawn`
|
||||
* so main fires the event only after the spawn succeeds. */
|
||||
|
|
|
|||
|
|
@ -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<typeof setTimeout>
|
||||
}) as unknown as typeof setTimeout
|
||||
|
||||
try {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
|
||||
const capturedDataCallback: { current: ((data: string) => void) | null } = {
|
||||
current: null
|
||||
}
|
||||
const transport = createMockTransport('pty-id')
|
||||
transport.connect.mockImplementation(
|
||||
async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return 'pty-ssh-1'
|
||||
}
|
||||
)
|
||||
transportFactoryQueue.push(transport)
|
||||
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
|
||||
repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }]
|
||||
}
|
||||
|
||||
const 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<typeof setTimeout>
|
||||
}) as unknown as typeof setTimeout
|
||||
|
||||
try {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
|
||||
const capturedDataCallback: { current: ((data: string) => void) | null } = {
|
||||
current: null
|
||||
}
|
||||
const transport = createMockTransport('pty-id')
|
||||
transport.connect.mockImplementation(
|
||||
async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return 'pty-ssh-1'
|
||||
}
|
||||
)
|
||||
transportFactoryQueue.push(transport)
|
||||
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
|
||||
repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }]
|
||||
}
|
||||
|
||||
const 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<typeof setTimeout>
|
||||
}) as unknown as typeof setTimeout
|
||||
|
||||
try {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
|
||||
const capturedDataCallback: { current: ((data: string) => void) | null } = {
|
||||
current: null
|
||||
}
|
||||
const transport = createMockTransport('pty-id')
|
||||
transport.connect.mockImplementation(
|
||||
async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return 'pty-ssh-1'
|
||||
}
|
||||
)
|
||||
transportFactoryQueue.push(transport)
|
||||
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
|
||||
repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }]
|
||||
}
|
||||
|
||||
const 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<typeof setTimeout>
|
||||
}) as unknown as typeof setTimeout
|
||||
|
||||
try {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
|
||||
const capturedDataCallback: { current: ((data: string) => void) | null } = {
|
||||
current: null
|
||||
}
|
||||
const transport = createMockTransport('pty-id')
|
||||
transport.connect.mockImplementation(
|
||||
async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return 'pty-ssh-1'
|
||||
}
|
||||
)
|
||||
transportFactoryQueue.push(transport)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
|
||||
repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }]
|
||||
}
|
||||
|
||||
const 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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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<typeof setTimeout> | null = null
|
||||
let sshShellReadyFallbackTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let agentTaskCompleteNotificationGraceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let agentTaskCompleteNotificationMaxTimer: ReturnType<typeof setTimeout> | 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()
|
||||
|
|
|
|||
|
|
@ -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<string, string>
|
||||
command?: string
|
||||
startupCommandDelivery?: StartupCommandDelivery
|
||||
connectionId?: string | null
|
||||
/** Orca worktree identity for scoped shell history. */
|
||||
worktreeId?: string
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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<string, string>
|
||||
/** Telemetry payload for `agent_started`. Forwarded to `pty:spawn`
|
||||
* so main fires the event only after the spawn succeeds. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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<typeof setTimeout> | 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)
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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<string, string>; telemetry?: AgentStartedTelemetry }
|
||||
startup?: {
|
||||
command: string
|
||||
env?: Record<string, string>
|
||||
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 } : {})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string, string>
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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<typeof setTimeout> | null = null
|
||||
let fallbackTimer: ReturnType<typeof setTimeout> | 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, string>
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<string, string>
|
||||
/** 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<string, string>
|
||||
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<string, string>
|
||||
initialAgentStatus?: { agent: TuiAgent; prompt: string }
|
||||
showSessionRestoredBanner?: boolean
|
||||
|
|
|
|||
|
|
@ -1691,6 +1691,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
? {
|
||||
startupCommand: startup.command,
|
||||
...(startup.env ? { startupEnv: startup.env } : {}),
|
||||
...(startup.startupCommandDelivery
|
||||
? { startupCommandDelivery: startup.startupCommandDelivery }
|
||||
: {}),
|
||||
activate: true
|
||||
}
|
||||
: {})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<string, string>
|
||||
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<string, string>
|
||||
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 }
|
||||
|
|
|
|||
|
|
@ -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<string, string>
|
||||
startupCommandDelivery?: StartupCommandDelivery
|
||||
telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind }
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue