fix(terminal): wait for shell-ready before every SSH startup command (#12963)

* fix(terminal): wait for shell-ready before every SSH startup command

Renderer-delivered SSH launches wrote the startup command after a flat 50ms,
so a remote shell still reaching its prompt could drop it. Only Codex plans
opted into waiting.

Gate on whether a startup command exists rather than on which agent it names:
an unready shell drops whatever is written to it regardless. The relay already
gates its own delivery on the marker it armed; this mirrors that for the
renderer path, bounded by the existing 1.5s fallback.

* fix(terminal): arm SSH marker for every startup

* fix(terminal): cancel stale SSH startup writes
This commit is contained in:
Brennan Benson 2026-08-09 14:17:09 -07:00 committed by GitHub
parent 43bad61a93
commit b09f7bd431
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 42 additions and 21 deletions

View File

@ -6390,7 +6390,7 @@ describe('connectPanePty', () => {
expect(transport.sendInput).not.toHaveBeenCalled()
})
it('sends fast startup commands via sendInput for SSH connections', async () => {
it('waits for shell-ready before unhinted SSH startup commands', async () => {
// Capture the setTimeout callback directly so we can fire it without vi.useFakeTimers() (which would also replace beforeEach's rAF mock).
const pendingTimeouts: (() => void)[] = []
const originalSetTimeout = globalThis.setTimeout
@ -6414,7 +6414,7 @@ describe('connectPanePty', () => {
)
transportFactoryQueue.push(transport)
// SSH connection: connectionId set; relay gets command metadata for spawn context but the renderer owns fast command delivery.
// SSH connection: the relay gets command metadata while the renderer owns delivery.
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
@ -6430,14 +6430,17 @@ describe('connectPanePty', () => {
connectPanePty(pane as never, manager as never, deps as never)
expect(capturedDataCallback.current).not.toBeNull()
// Simulate shell prompt arriving — queues the debounce timer
capturedDataCallback.current?.('user@remote $ ')
expect(transport.sendInput).not.toHaveBeenCalled()
// Fire all queued setTimeout callbacks (the debounce)
for (const fn of pendingTimeouts) {
capturedDataCallback.current?.('\x1b]777;orca-shell-ready\x07user@remote $ ')
for (const fn of pendingTimeouts.splice(0)) {
fn()
}
expect(createdTransportOptions[0]).toEqual(
expect.objectContaining({ startupCommandDelivery: 'shell-ready' })
)
expect(transport.sendInput).toHaveBeenCalledWith("claude 'say test'\r")
} finally {
globalThis.setTimeout = originalSetTimeout
@ -8234,7 +8237,10 @@ describe('connectPanePty', () => {
connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(20)
capturedDataCallback.current?.('user@remote $ ')
for (const fn of pendingTimeouts) {
expect(transport.sendInput).not.toHaveBeenCalled()
capturedDataCallback.current?.('\x1b]777;orca-shell-ready\x07user@remote $ ')
for (const fn of pendingTimeouts.splice(0)) {
fn()
}
@ -8243,6 +8249,7 @@ describe('connectPanePty', () => {
2,
expect.objectContaining({
command: "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'",
startupCommandDelivery: 'shell-ready',
env: expect.objectContaining({
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: 'tab-1',

View File

@ -128,8 +128,6 @@ import {
} from '../../../../shared/terminal-mode-reset-profiles'
import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport'
import { createShellReadyMarkerScanState, scanForShellReadyMarker } from './shell-ready-marker-scan'
import { shouldUseShellReadyStartupDelivery } from '../../../../shared/codex-startup-delivery'
import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing'
import { getSystemPrefersDark } from '@/lib/terminal-theme'
import {
INITIAL_MODE_2031_REPLY_SCAN_STATE,
@ -3810,7 +3808,9 @@ export function connectPanePty(
command: shouldDeliverStartupViaTerminalPaste ? undefined : paneStartup?.command,
startupCommandDelivery: shouldDeliverStartupViaTerminalPaste
? undefined
: paneStartup?.startupCommandDelivery,
: connectionId && paneStartup?.command
? 'shell-ready'
: paneStartup?.startupCommandDelivery,
connectionId,
executionHostId,
worktreeId: deps.worktreeId,
@ -4794,21 +4794,31 @@ export function connectPanePty(
? { command: paneStartup.command }
: null
: null
const startupShellReadyCommandHint = resolveSetupAgentSequenceLaunchCommand(
paneStartup?.env ?? {},
paneStartup?.command
)
// Why: every renderer-delivered SSH command needs the relay's readiness marker;
// the existing fallback preserves shells that cannot emit it.
const shouldWaitForSshShellReady =
Boolean(connectionId) &&
shouldUseShellReadyStartupDelivery({
command: startupShellReadyCommandHint,
startupCommandDelivery: paneStartup?.startupCommandDelivery
}) &&
Boolean(pendingStartupCommand) &&
!shouldDeliverStartupViaTerminalPaste
const sshShellReadyMarkerScan = shouldWaitForSshShellReady
let sshShellReadyMarkerScan = shouldWaitForSshShellReady
? createShellReadyMarkerScanState()
: null
let sshStartupShellReady = !shouldWaitForSshShellReady
const armSshStartupShellReady = (): void => {
if (!connectionId || !pendingStartupCommand || shouldDeliverStartupViaTerminalPaste) {
return
}
if (startupInjectTimer !== null) {
clearTimeout(startupInjectTimer)
startupInjectTimer = null
}
if (sshShellReadyFallbackTimer !== null) {
clearTimeout(sshShellReadyFallbackTimer)
sshShellReadyFallbackTimer = null
}
sshShellReadyMarkerScan = createShellReadyMarkerScanState()
sshStartupShellReady = false
}
const markSshStartupShellReady = (): void => {
if (sshStartupShellReady) {
return
@ -5121,7 +5131,8 @@ export function connectPanePty(
return transport.sendInput('\r')
}
const schedulePendingStartupCommandDelivery = (): void => {
if (!pendingStartupCommand) {
const startup = pendingStartupCommand
if (!startup) {
return
}
if (!sshStartupShellReady) {
@ -5142,8 +5153,7 @@ export function connectPanePty(
startupInjectTimer = setTimeout(() => {
startupInjectTimer = null
void (async () => {
const startup = pendingStartupCommand
if (!startup || disposed) {
if (pendingStartupCommand !== startup || disposed) {
return
}
if (shouldDeliverStartupViaTerminalPaste) {
@ -5242,6 +5252,7 @@ export function connectPanePty(
// must still submit the resume command to the fresh remote shell.
pendingStartupCommand = { command: startupOverride.command }
}
armSshStartupShellReady()
const coldRestoreOverride =
startupOverride && 'launchConfig' in startupOverride
? (startupOverride as ColdRestoreAgentResumeStartup)
@ -5264,6 +5275,9 @@ export function connectPanePty(
cols,
rows,
...(startupOverride?.command ? { command: startupOverride.command } : {}),
...(connectionId && startupOverride?.command
? { startupCommandDelivery: 'shell-ready' as const }
: {}),
...(startupOverride?.env
? { env: mergeStartupEnvWithPaneIdentity(startupOverride.env) }
: {}),