refactor(pty-connection): extract reattach payload handlers as factories

- Replace bindApplyReattachPayload with createReattachPayloadHandlers factory that returns handlers instead of mutating session directly, enabling better composability and testing
- Extract waitForUserInitiatedSshConnect as standalone function for reuse across deferred session attach flows
- Create ReattachPayloadSession type to document and isolate required session capabilities
- Add test coverage for overlapping reattach payload attempts
- Clean up comments to remove redundant prefixes (session.pane → pane, session.transport → transport)
This commit is contained in:
Jinjing
2026-08-22 14:44:50 -07:00
parent 42ef1bd8ae
commit fd7caa7e8d
24 changed files with 302 additions and 128 deletions
@@ -210,7 +210,14 @@ describe('connectPanePty', () => {
await flushAsyncTicks(20)
await new Promise((resolve) => setTimeout(resolve, 70))
const reattachArgs = transport.connect.mock.calls.find(
([args]) => args.sessionId === 'lost-pty'
)?.[0]
const launchToken = (reattachArgs?.env as Record<string, string> | undefined)
?.ORCA_AGENT_LAUNCH_TOKEN
expect(transport.sendInput).not.toHaveBeenCalled()
expect(launchToken).toMatch(new RegExp(`^${UUID_RE}$`))
expect(transport.connect).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: 'lost-pty',
@@ -221,13 +228,13 @@ describe('connectPanePty', () => {
ORCA_TAB_ID: 'tab-1',
ORCA_WORKTREE_ID: 'wt-1',
ORCA_WORKSPACE_ID: 'wt-1',
ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`))
ORCA_AGENT_LAUNCH_TOKEN: launchToken
})
})
)
expect(mockStoreState.registerAgentLaunchConfig).toHaveBeenCalledWith(paneKey, launchConfig, {
agentType: 'codex',
launchToken: expect.stringMatching(new RegExp(`^${UUID_RE}$`)),
launchToken,
tabId: 'tab-1',
leafId: LEAF_1
})
@@ -22,14 +22,17 @@ import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
import type { ReattachPayloadContext } from './reattach-payload-context'
import type { ReattachPayloadSession } from './reattach-payload-session'
export function bindApplyReattachPayload(
session: ConnectPanePtySession,
export function createReattachPayloadHandlers(
session: ReattachPayloadSession,
ctx: ReattachPayloadContext
): void {
session.applyReattachPayload = async (): Promise<void> => {
): {
applyReattachPayload: () => Promise<void>
fitAfterReattachRestore: () => Promise<void>
} {
const applyReattachPayload = async (): Promise<void> => {
if (!ctx.isCurrentReattachPayload()) {
return
}
@@ -280,7 +283,7 @@ export function bindApplyReattachPayload(
}
}
session.fitAfterReattachRestore = async (): Promise<void> => {
const fitAfterReattachRestore = async (): Promise<void> => {
if (!ctx.isCurrentReattachPayload()) {
return
}
@@ -312,4 +315,6 @@ export function bindApplyReattachPayload(
window.api.pty.signal(reattachPtyId, 'SIGWINCH')
}
}
return { applyReattachPayload, fitAfterReattachRestore }
}
@@ -56,7 +56,7 @@ export function bindBuildColdRestoreAgentResumeStartup(session: ConnectPanePtySe
const launchConfig =
(useLiveEntry && entry ? state.getAgentLaunchConfigForStatusEntry(entry) : undefined) ??
matchingSleepingLaunchConfig
// Why: the resume line is typed into this session.pane's live shell, so its quoting must
// Why: the resume line is typed into this pane's live shell, so its quoting must
// follow the tab's effective Windows shell, not the win32 PowerShell default.
const resumeTarget = resolveAgentResumeLaunchTarget({
projectRuntime: session.projectRuntime,
@@ -6,9 +6,8 @@ import { isRuntimeOwnedSshTargetId } from '../../../../../shared/execution-host'
import { resolveSshPaneConnectGate } from '../ssh-pane-connect-gate'
import {
type UserInitiatedSshConnectOutcome,
isSshSessionExpiredError,
sshPromptConnectOutcomeForStatus,
waitForUserInitiatedSshConnect,
waitForSshConnection
} from './ssh-session-connect'
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
@@ -84,63 +83,7 @@ export function runDeferredSessionAttach(session: ConnectPanePtySession): void {
if (!alreadyConnected) {
// Wait for the user-driven connect (sidebar card control or terminal reconnect overlay → passphrase → ssh.connect) to complete.
// Why: resolve on terminal-failure statuses too ('auth-failed'/'error'/'reconnection-failed') so it can't hang forever if the user cancels or the connect fails.
const outcome = await new Promise<UserInitiatedSshConnectOutcome>((resolve) => {
// Why: 'disconnected' counts as terminal only after a non-disconnected status was seen (a real connect attempt that returned to 'disconnected').
// Treating the entry-time 'disconnected' as terminal would skip the gate, defeating the passphrase-prompt deferral.
let sawNonDisconnected =
useAppStore.getState().sshConnectionStates.get(session.connectionId)?.status !==
'disconnected' &&
useAppStore.getState().sshConnectionStates.get(session.connectionId)?.status !==
undefined
let resolvedOutcome: UserInitiatedSshConnectOutcome = 'cancelled'
let settled = false
const finish = (nextOutcome: UserInitiatedSshConnectOutcome): void => {
if (settled) {
return
}
resolvedOutcome = nextOutcome
settled = true
unsub()
const idx = session.waitTeardowns.indexOf(teardown)
if (idx !== -1) {
session.waitTeardowns.splice(idx, 1)
}
resolve(resolvedOutcome)
}
const teardown = (): void => finish('cancelled')
// Why: register a teardown so dispose() can unsubscribe+resolve if the session.pane is torn down mid-wait.
// Else the zustand subscriber + async IIFE leak: the callback only checks `session.disposed` when it next fires, which may never happen.
session.waitTeardowns.push(teardown)
const unsub = useAppStore.subscribe((state) => {
if (session.disposed) {
finish('cancelled')
return
}
const status = state.sshConnectionStates.get(session.connectionId)?.status
if (status && status !== 'disconnected') {
sawNonDisconnected = true
}
const nextOutcome = sshPromptConnectOutcomeForStatus(status, sawNonDisconnected)
if (nextOutcome) {
finish(nextOutcome)
}
})
// Why: re-read state after subscribing to catch a status change that landed between the alreadyConnected check and the subscribe — else we'd wait forever.
if (session.disposed) {
finish('cancelled')
return
}
const currentStatus = useAppStore
.getState()
.sshConnectionStates.get(session.connectionId)?.status
const currentOutcome = sshPromptConnectOutcomeForStatus(
currentStatus,
sawNonDisconnected
)
if (currentOutcome) {
finish(currentOutcome)
}
})
const outcome = await waitForUserInitiatedSshConnect(session)
if (session.disposed || !session.capturedDirectSshRetryLeaseMatches()) {
return
}
@@ -32,7 +32,7 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
)?.ptyId
const hasSleepingAgentSession = Boolean(session.getSleepingRecordForPane(storeSnapshot))
// Why: the tab-level fallback must not steal a PTY a setup sibling already published while the main session.pane waited for split geometry.
// Why: the tab-level fallback must not steal a PTY a setup sibling already published while the main pane waited for split geometry.
const tabFallbackPtyId =
existingPtyId &&
!Array.from(session.deps.paneTransportsRef.current.entries()).some(
@@ -82,7 +82,7 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
!isRemoteRuntimePtyId(candidateReattachSessionId) &&
getEagerPtyBufferHandle(candidateReattachSessionId)
)
// Why: a still-live locally-spawned PTY (e.g. a background automation agent) keeps an eager buffer until a session.pane adopts it.
// Why: a still-live locally-spawned PTY (e.g. a background automation agent) keeps an eager buffer until a pane adopts it.
// It must be adopted via attach()+replay — connect({ sessionId }) on its non-session ptyId would spawn a fresh shell and orphan the agent.
const eagerLivePtyId =
candidateReattachSessionId &&
@@ -135,7 +135,7 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
useAppStore.getState().removeDeferredSshSessionId(session.deps.tabId)
}
} else {
// Why: surface synchronous attach failures via session.reportError so the session.pane shows a diagnostic instead of a blank surface.
// Why: surface synchronous attach failures via session.reportError so the pane shows a diagnostic instead of a blank surface.
// On throw, clear the stale ptyId from the tab and fresh-spawn — else the next remount reads the same dead id and loops here.
try {
session.clearPaneMode2031State()
@@ -177,7 +177,7 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
}
if (!spawnedPtyId) {
// Why: React StrictMode can mount+spawn then immediately remount; if the first mount produced no PTY id,
// the remounted session.pane must issue its own spawn instead of attaching to a completed-but-empty promise (a dead surface).
// the remounted pane must issue its own spawn instead of attaching to a completed-but-empty promise (a dead surface).
if (!isWebTerminalSurfaceTabId(session.deps.tabId)) {
console.warn(
`Pending PTY spawn for tab ${session.deps.tabId} resolved without a PTY id, retrying fresh spawn`
@@ -203,7 +203,7 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
callbacks: outputCallbacks.callbacks
})
const attachedPtyId = session.transport.getPtyId() ?? spawnedPtyId
// Why: this reuses a PTY spawned by an earlier mount, so no later spawn event will bind this remounted session.pane's DOM/container.
// Why: this reuses a PTY spawned by an earlier mount, so no later spawn event will bind this remounted pane's DOM/container.
session.bindActivePanePty(attachedPtyId, {
updateTabPtyId: 'if-missing',
sampleVisibleForegroundAgent: true
@@ -149,7 +149,7 @@ export function installDirectSshRetryStatus(session: ConnectPanePtySession): voi
})
: undefined
session.shouldOwnAgentStatusInRenderer = session.runtimeEnvironmentId !== null
// Why: the host also mirrors agent status for this pane through session.tabs.
// Why: the host also mirrors agent status for this pane through tabs.
// Claiming here (decided once at transport creation, like the side-effect
// authority below) lets the mirror keep this renderer's byte-derived status
// instead of overwriting/deleting it on every republication.
@@ -66,7 +66,7 @@ export function bindForegroundOutputRefresh(session: ConnectPanePtySession): voi
})
// Why: dropped bytes invalidate cross-chunk carry — a partial OSC-9999 prefix spanning the gap would corrupt the next live chunk.
session.transport.resetCrossChunkParserState?.()
// Why gated (rc.7.perf loop): on a visible session.pane these markers come from our own restore starving ACKs; re-arming per marker kept the fetch loop alive all flood, so defer to one post-flood repaint.
// Why gated (rc.7.perf loop): on a visible pane these markers come from our own restore starving ACKs; re-arming per marker kept the fetch loop alive all flood, so defer to one post-flood repaint.
if (session.isForegroundRestoreBackpressureContext()) {
session.noteHiddenOutputRestoreFloodBackpressure()
return
@@ -82,7 +82,7 @@ export function bindForegroundOutputRefresh(session: ConnectPanePtySession): voi
// session.buildMainModelSnapshotReplayWrites, which grounds the pen itself, so
// nothing is lost by skipping it here.
session.writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true)
// Why: a marker during an in-flight restore means that snapshot may predate the drop, so a fresh one must follow; capture BEFORE the mark, which starts a restore synchronously on a visible session.pane.
// Why: a marker during an in-flight restore means that snapshot may predate the drop, so a fresh one must follow; capture BEFORE the mark, which starts a restore synchronously on a visible pane.
const restoreWasInFlight = session.hiddenOutputRestoreInFlight !== null
session.markHiddenOutputRestoreNeeded()
if (restoreWasInFlight) {
@@ -223,7 +223,7 @@ export function bindForegroundOutputRefresh(session: ConnectPanePtySession): voi
session.isLatencySensitiveForegroundOutput = function (data: string): boolean {
if (!session.isActiveSplitPane()) {
// Why: many visible split panes each emit tiny TUI frames; a shared budget keeps them live without letting aggregate xterm work starve typing in the active session.pane.
// Why: many visible split panes each emit tiny TUI frames; a shared budget keeps them live without letting aggregate xterm work starve typing in the active pane.
if (data.includes('\x1b[')) {
return false
}
@@ -142,7 +142,7 @@ export function bindFreshSpawnFollowReset(session: ConnectPanePtySession): void
isAlternateScreen?: boolean
): string => {
// Why a cold restore overrides the agent signal: liveness is read from the
// session.pane's status and title, both of which are persisted, so after a cold
// pane's status and title, both of which are persisted, so after a cold
// restore they describe the process that died. Preserving "its" modes arms
// mouse, focus and paste reporting against the fresh shell that replaces it,
// which then prints the reports as junk at the prompt (#12101).
@@ -152,7 +152,7 @@ export function bindFreshSpawnFollowReset(session: ConnectPanePtySession): void
if (session.shouldPreserveAgentReattachModes()) {
return buildPostReplayLiveAgentReattachReset(payload)
}
// Why: an alt-screen session.pane is a live TUI Orca just does not recognise as an agent, and the
// Why: an alt-screen pane is a live TUI Orca just does not recognise as an agent, and the
// replay already re-armed its mouse modes — keep them instead of wiping them (#8291).
return (isAlternateScreen ?? session.kittyKeyboardModes.isAlternateScreen)
? POST_REPLAY_REATTACH_RESET_KEEP_MOUSE
@@ -173,7 +173,7 @@ export function bindFreshSpawnFollowReset(session: ConnectPanePtySession): void
return
}
// Why: fresh Windows ConPTY output paints at screen coordinates, so
// restored session.rows must leave the viewport before the first prompt redraw.
// restored rows must leave the viewport before the first prompt redraw.
session.writeFreshShellViewportBlanking()
}
}
@@ -32,7 +32,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
if (useAppStore.getState().deleteStateByWorktreeId?.[session.deps.worktreeId]?.isDeleting) {
// Why: the worktree is being deleted; its PTYs were just killed for the
// filesystem teardown. A fresh shell must not spawn into a directory the
// removal is about to delete (main fences it anyway), and the session.pane is
// removal is about to delete (main fences it anyway), and the pane is
// about to unmount — so skip the doomed respawn instead of racing it.
return Promise.resolve(null)
}
@@ -158,7 +158,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
typeof spawnedPtyId === 'object' &&
spawnedPtyId.agentResumeUnavailable
) {
// Why: main dropped the resume argv, so this session.pane is a NEW session —
// Why: main dropped the resume argv, so this pane is a NEW session —
// the plain restored banner would claim the old one came back.
session.showSessionRestoredBanner('resume-unavailable')
} else if (coldRestoreOverride?.hasSleepingRecord) {
@@ -171,7 +171,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
) {
// Why: delayed draft/follow-up delivery keys off this launch
// registry. If spawn produced no PTY, the launch is no longer a
// viable delivery target and must not wait for a future session.pane.
// viable delivery target and must not wait for a future pane.
session.clearRegisteredStartupLaunchConfig()
}
if (
@@ -183,7 +183,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
session.transport.getPtyId() === resolvedPtyId
) {
// Why: daemon createOrAttach can turn an apparent fresh spawn into
// a reattach; the session.transport skips onPtySpawn there to preserve recency.
// a reattach; the transport skips onPtySpawn there to preserve recency.
session.bindActivePanePty(resolvedPtyId, {
updateTabPtyId: 'if-missing',
sampleVisibleForegroundAgent: true
@@ -250,8 +250,8 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
session.settleDirectSshPaneRetryAttempt(session.directSshRetryAttempt, 'failed')
})
})
// Why: split panes in the same tab can spawn concurrently. Key by session.pane
// as well as tab so a remount cannot attach to a sibling setup session.pane's PTY.
// Why: split panes in the same tab can spawn concurrently. Key by pane
// as well as tab so a remount cannot attach to a sibling setup pane's PTY.
pendingSpawnByPaneKey.set(session.pendingSpawnKey, trackedPromise)
return trackedPromise
}
@@ -136,14 +136,4 @@ export function bindHiddenOutputRestoreChunk(session: ConnectPanePtySession): vo
meta: { ...meta, rawLength: sliced.length }
}
}
/**
* Apply an authoritative snapshot to the session.pane's kitty mirror in the order:
* unproven reset, replay-semantics scan of the snapshot
* bytes (so screen selection lands first), then the owner's proven flags.
*
* Why the reset only happens when the owner proved something: an old host
* omits the field, and downgrading a mirror that is already tracking live
* output would lose correct state instead of preserving it.
*/
}
@@ -39,9 +39,9 @@ export function bindHiddenOutputRestoreRequest(session: ConnectPanePtySession):
session.requestHiddenOutputRestoreIfNeeded = function (opts?: {
bypassScheduler?: boolean
}): boolean {
// Why: once the write pipeline is probe-certified dead a restore can never parse; recovery owns the session.pane and the remount gets a fresh xterm + restore.
// Why: once the write pipeline is probe-certified dead a restore can never parse; recovery owns the pane and the remount gets a fresh xterm + restore.
if (isTerminalWritePipelineCertifiedDead(session.pane.terminal)) {
// Why the re-kick: certification's recovery request can be budget-declined or cancelled by a sibling remount; without this, a revealed dead session.pane keeps the stale frame forever.
// Why the re-kick: certification's recovery request can be budget-declined or cancelled by a sibling remount; without this, a revealed dead pane keeps the stale frame forever.
if (!session.certifiedDeadRestoreRecoveryRequested && !session.disposed) {
session.certifiedDeadRestoreRecoveryRequested = true
const storePtyId = useAppStore.getState().ptyIdsByTabId?.[session.deps.tabId]?.[0] ?? null
@@ -121,7 +121,7 @@ export function bindHiddenOutputRestoreRequest(session: ConnectPanePtySession):
if (session.hiddenOutputRestorePtyId === currentPtyId) {
session.clearHiddenOutputRestoreState()
}
// Remote-only path: the session.transport swapped PTYs mid-restore, which is a
// Remote-only path: the transport swapped PTYs mid-restore, which is a
// stream change, not proof the hidden bytes are unrecoverable.
if (
!session.rearmRemoteHiddenOutputRestoreInsteadOfWarning(
@@ -310,7 +310,7 @@ export function bindHiddenOutputRestoreRequest(session: ConnectPanePtySession):
typeof document.removeEventListener === 'function'
) {
const onDocumentVisibilityChange = (): void => {
// Why: document hide/show flips the foreground predicate with no session.pane lifecycle event; re-sync the hidden-delivery gate both ways.
// Why: document hide/show flips the foreground predicate with no pane lifecycle event; re-sync the hidden-delivery gate both ways.
session.syncHiddenRendererPtyDelivery()
if (shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)) {
session.requestHiddenOutputRestoreIfNeeded()
@@ -126,14 +126,14 @@ export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession):
snapshot.cols,
readProposedTerminalCols(session.pane)
)
// Why: an imageless success is not proof the session.pane is empty; normal replay clears screen and scrollback.
// Why: an imageless success is not proof the pane is empty; normal replay clears screen and scrollback.
const snapshotCarriesNoImage =
snapshot.alternateScreen !== true && snapshot.data === '' && !snapshot.scrollbackAnsi
if (snapshotCarriesNoImage) {
// Why still ground: a restore only runs because bytes were dropped, so
// the gap's pen outlives a snapshot that cannot repaint over it. The
// live-path constant, not the replay baseline — nothing repaints here,
// so the session.pane is handed back to whatever is still running.
// so the pane is handed back to whatever is still running.
session.writeReplayData(RESET_AFTER_BYTE_GAP)
} else {
for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot, {
@@ -129,6 +129,15 @@ export function bindHiddenOutputSeqAndSkip(session: ConnectPanePtySession): void
bindHiddenStartupRendererQueryWrite(session)
bindHiddenOutputRestoreChunk(session)
/**
* Apply an authoritative snapshot to the pane's kitty mirror in the order:
* unproven reset, replay-semantics scan of the snapshot
* bytes (so screen selection lands first), then the owner's proven flags.
*
* Why the reset only happens when the owner proved something: an old host
* omits the field, and downgrading a mirror that is already tracking live
* output would lose correct state instead of preserving it.
*/
session.applySnapshotKittyKeyboardModes = function (
snapshotData: string,
snapshot: { kittyKeyboardFlags?: number; snapshotSeq?: number }
@@ -152,7 +161,7 @@ export function bindHiddenOutputSeqAndSkip(session: ConnectPanePtySession): void
session.kittyKeyboardModes.resetForSnapshot()
session.kittyKeyboardModes.scanReplay(snapshotData)
session.kittyKeyboardModes.restoreSnapshotFlags(proven)
// Why in the same critical section: without the baseline a quiet session.pane
// Why in the same critical section: without the baseline a quiet pane
// could not publish a coherent snapshot until unrelated output arrived.
session.recordRendererOrderedSeq({ seq: snapshot.snapshotSeq })
}
@@ -89,7 +89,7 @@ export function bindSerializeHiddenOutputSnapshot(session: ConnectPanePtySession
}
// ── Hidden-delivery gate sync (Phase 4) ─────────────────────────────
// Why: marks this session.pane's PTY hidden in main while no visible view needs
// Why: marks this pane's PTY hidden in main while no visible view needs
// its bytes; main then drops delivery after model ingestion and reveal
// restores from the snapshot. The marked id is tracked locally so PTY
// changes (reattach/restart) can never leave a stale id gated.
@@ -102,8 +102,8 @@ export function bindSerializeHiddenOutputSnapshot(session: ConnectPanePtySession
return Date.now() < session.hiddenOutputRestoreFloodSuppressedUntil
}
// True when a drop/gap signal on a visible session.pane is attributable to this
// session.pane's OWN restore backpressure (a restore is replaying right now, or
// True when a drop/gap signal on a visible pane is attributable to this
// pane's OWN restore backpressure (a restore is replaying right now, or
// one was just cut off for outrunning the stream). Such signals must not
// re-arm restores — that is the rc.7.perf feedback loop.
session.isForegroundRestoreBackpressureContext = function (): boolean {
@@ -48,7 +48,7 @@ export function bindLiveDataCallback(session: ConnectPanePtySession): void {
session.resetHiddenOutputRestoreIfPtyChanged()
session.observeLiveMode2031Chunk(data)
if (meta?.droppedOutput === true) {
// Why gated (rc.7.perf loop): a visible session.pane's cap-drop during its own restore is self-caused backpressure; defer to one post-flood repaint instead of re-arming per sentinel.
// Why gated (rc.7.perf loop): a visible pane's cap-drop during its own restore is self-caused backpressure; defer to one post-flood repaint instead of re-arming per sentinel.
if (meta?.background !== true && session.isForegroundRestoreBackpressureContext()) {
session.noteHiddenOutputRestoreFloodBackpressure()
} else {
@@ -75,7 +75,7 @@ export function bindLiveDataCallback(session: ConnectPanePtySession): void {
if (codexBackfillNotice) {
session.reportError(codexBackfillNotice)
}
// Why: split panes have visible-but-inactive panes the user watches; throttle only when the session.pane or whole document is hidden.
// Why: split panes have visible-but-inactive panes the user watches; throttle only when the pane or whole document is hidden.
const foreground =
shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) && meta?.background !== true
// Why: latch the hidden-delivery gate from the byte path too, covering a PTY id that arrives after the initial sync (no-op when current).
@@ -94,7 +94,7 @@ export function bindLiveDataCallback(session: ConnectPanePtySession): void {
session.clearRestoredSnapshotBaseline()
// fall through with the ORIGINAL data/meta — post-gap bytes are new
} else {
// Why: capture in-flight BEFORE the mark — on a visible session.pane the mark starts the restore synchronously and must not flag itself.
// Why: capture in-flight BEFORE the mark — on a visible pane the mark starts the restore synchronously and must not flag itself.
const restoreWasInFlight = session.hiddenOutputRestoreInFlight !== null
session.markHiddenOutputRestoreNeeded()
if (restoreWasInFlight) {
@@ -58,7 +58,7 @@ export function bindRegisterPaneSerializer(session: ConnectPanePtySession): void
})
const orderedSeq =
session.rendererOrderedPtyId === ptyId ? session.rendererOrderedSeq : null
// Why snapshotFlags and not `flags`: this session.pane may itself have
// Why snapshotFlags and not `flags`: this pane may itself have
// consumed an old-host snapshot that proved nothing, and its
// conservative `0` fallback must not be republished downstream as
// a host-proven inactive protocol.
@@ -41,7 +41,7 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void {
return
}
} catch {
// Clear below so a failed parser/replay cannot leave the session.pane generation pending.
// Clear below so a failed parser/replay cannot leave the pane generation pending.
}
await window.api.pty.clearPendingPaneSerializer(session.cacheKey, generation).catch(() => {})
}
@@ -128,12 +128,12 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void {
useAppStore.getState(),
session.deps.worktreeId
)
// Why: xterm focus reports share this session.transport queue. Bypassing it can
// Why: xterm focus reports share this transport queue. Bypassing it can
// race CSI I against the draft on ConPTY and expose a literal `[I` prefix.
void sendAgentDraftPasteContent(settings, ptyId, session.startupDraftPrompt, async (data) => {
const accepted = await writeTerminalPastePtyInput(session.transport, data)
if (accepted && !startupDraftInputRecorded) {
// Why: this session.transport write bypasses xterm's user-input signal; keep
// Why: this transport write bypasses xterm's user-input signal; keep
// the composed draft from being discarded by later hibernation.
startupDraftInputRecorded = true
session.recordTerminalInputForHibernation()
@@ -221,7 +221,7 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void {
let sessionRestoredBannerShown: SessionRestoredBannerReason | null = null
session.showSessionRestoredBanner = (reason: SessionRestoredBannerReason = 'restored'): void => {
// Why: a plain 'restored' banner must not latch out the later 'resume-unavailable'
// upgrade — the session.pane would keep claiming a session it never got back.
// upgrade — the pane would keep claiming a session it never got back.
if (
sessionRestoredBannerShown === reason ||
sessionRestoredBannerShown === 'resume-unavailable'
@@ -0,0 +1,106 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPane } from '../pty-connection-test-pane-fixtures'
import {
installTerminalTestGlobals,
restoreTerminalTestGlobals
} from '../pty-connection-test-environment'
import { createReattachPayloadHandlers } from './apply-reattach-payload'
import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types'
import type { ReattachPayloadContext } from './reattach-payload-context'
import type { ReattachPayloadSession } from './reattach-payload-session'
function createSession(overrides: Record<string, unknown> = {}): ReattachPayloadSession {
return {
pane: createPane(1),
rememberReattachPayloadAgentSignal: vi.fn(),
writeReplayData: vi.fn(),
reattachReplayResetSequence: vi.fn(() => '<reset>'),
sendFocusedReattachFocusInAfterReplay: vi.fn(),
kittyKeyboardModes: {
hasProvenBaseline: true,
reset: vi.fn(),
resetForSnapshot: vi.fn(),
scanReplay: vi.fn()
},
...overrides
} as unknown as ReattachPayloadSession
}
function createContext(replay: string, attemptGeneration: number): ReattachPayloadContext {
return {
isCurrentReattachPayload: () => true,
connectResult: { id: 'pty-1', replay },
ptyId: 'pty-1',
attemptGeneration,
prefetchedParkModelSnapshot: null,
revealFollowsTerminalPark: false,
fetchSshMainModelReattachSnapshot: async () => null,
hasStructuralReplay: true,
coldRestoreStartup: undefined,
reattachPayloadApplied: false
}
}
describe('reattach payload context', () => {
beforeEach(() => installTerminalTestGlobals())
afterEach(async () => restoreTerminalTestGlobals())
it('keeps overlapping payload handlers bound to their own attempt', async () => {
const session = createSession()
const attemptA = createReattachPayloadHandlers(session, createContext('PAYLOAD-A', 1))
const attemptB = createReattachPayloadHandlers(session, createContext('PAYLOAD-B', 2))
await attemptB.applyReattachPayload()
await attemptA.applyReattachPayload()
expect(session.rememberReattachPayloadAgentSignal.mock.calls).toEqual([
['PAYLOAD-B', { fullScreenReplay: true }],
['PAYLOAD-A', { fullScreenReplay: true }]
])
expect(session.sendFocusedReattachFocusInAfterReplay.mock.calls).toEqual([
['pty-1', 2],
['pty-1', 1]
])
})
it('uses the connect startup plan without rebuilding or scheduling paste delivery', async () => {
const startup = {
command: 'codex resume provider-session',
agent: 'codex',
resumeProviderSession: { key: 'session_id', id: 'provider-session' },
launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} },
launchToken: 'transport-launch-token',
useLiveEntry: false,
hasSleepingRecord: false,
sleepingRecordEntry: null
} as ColdRestoreAgentResumeStartup
const buildColdRestoreAgentResumeStartup = vi.fn()
const applyColdRestoreAgentResumeStartup = vi.fn(() => true)
const schedulePendingStartupCommandDelivery = vi.fn()
const session = createSession({
buildColdRestoreAgentResumeStartup,
applyColdRestoreAgentResumeStartup,
schedulePendingStartupCommandDelivery,
showSessionRestoredBanner: vi.fn(),
clearSleepingRecordAfterColdRestoreSpawn: vi.fn(),
consumeRestoredViewportBlankingMarker: vi.fn(),
writeFreshShellViewportBlanking: vi.fn()
})
const context: ReattachPayloadContext = {
...createContext('', 1),
connectResult: {
id: 'pty-1',
coldRestore: { scrollback: 'restored scrollback', cwd: '/workspace' }
},
coldRestoreStartup: startup
}
await createReattachPayloadHandlers(session, context).applyReattachPayload()
expect(applyColdRestoreAgentResumeStartup).toHaveBeenCalledWith(startup)
expect(buildColdRestoreAgentResumeStartup).not.toHaveBeenCalled()
expect(schedulePendingStartupCommandDelivery).not.toHaveBeenCalled()
expect(context.reattachPayloadApplied).toBe(true)
})
})
@@ -11,6 +11,6 @@ export type ReattachPayloadContext = {
revealFollowsTerminalPark: boolean
fetchSshMainModelReattachSnapshot: () => Promise<PtyBufferSnapshot | null>
hasStructuralReplay: boolean
coldRestoreStartup?: ColdRestoreAgentResumeStartup | null
coldRestoreStartup: ColdRestoreAgentResumeStartup | null | undefined
reattachPayloadApplied: boolean
}
@@ -0,0 +1,27 @@
import type { ConnectPanePtySession } from './connect-pane-pty-session'
export type ReattachPayloadSession = Pick<
ConnectPanePtySession,
| 'applyColdRestoreAgentResumeStartup'
| 'applySnapshotKittyKeyboardModes'
| 'buildColdRestoreAgentResumeStartup'
| 'clearSleepingRecordAfterColdRestoreSpawn'
| 'consumeRestoredViewportBlankingMarker'
| 'createReattachGridPush'
| 'isPaneOnAlternateScreen'
| 'kittyKeyboardModes'
| 'pane'
| 'pendingReattachFit'
| 'reattachReplayResetSequence'
| 'recordRendererOrderedSeq'
| 'rememberReattachPayloadAgentSignal'
| 'schedulePendingStartupCommandDelivery'
| 'sendFocusedReattachFocusInAfterReplay'
| 'setRestoredSnapshotBaseline'
| 'shouldPreserveAgentReattachModes'
| 'showSessionRestoredBanner'
| 'suppressStructuralReplayPtyResize'
| 'transport'
| 'writeFreshShellViewportBlanking'
| 'writeReplayData'
>
@@ -19,9 +19,38 @@ import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
import type { ReattachPayloadContext } from './reattach-payload-context'
import { bindApplyReattachPayload } from './apply-reattach-payload'
import { createReattachPayloadHandlers } from './apply-reattach-payload'
import type { ReattachPayloadSession } from './reattach-payload-session'
export function bindHandleReattachResult(session: ConnectPanePtySession): void {
type ReattachResultSession = ReattachPayloadSession &
Pick<
ConnectPanePtySession,
| 'agentCompletionCoordinator'
| 'authoritativeReattachGeneration'
| 'capturedDirectSshRetryPtyAccepted'
| 'deps'
| 'directSshRetryAttempt'
| 'disposed'
| 'getSshMainModelSnapshotProbe'
| 'handleReattachResult'
| 'mountFollowsTerminalPark'
| 'registerEffectiveLaunchConfig'
| 'registerPaneSerializerFor'
| 'registerSideEffectFactConsumerForPty'
| 'rejectObsoleteDirectSshReattach'
| 'reportPanePtyVisibility'
| 'sampleVisiblePaneForegroundAgent'
| 'scheduleReattachIdleAgentCursorReset'
| 'serializeHiddenOutputSnapshot'
| 'setPanePtyFitBinding'
| 'startFreshColdRestoreAgentResume'
| 'structuralReplayCoordinator'
| 'syncHiddenRendererPtyDelivery'
| 'transportStreamGeneration'
>
export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): void {
const session = sessionBag as unknown as ReattachResultSession
session.handleReattachResult = async (
result: PtyConnectResult | string | void,
staleSessionId?: string | null,
@@ -39,7 +68,7 @@ export function bindHandleReattachResult(session: ConnectPanePtySession): void {
result && typeof result === 'object' && 'id' in result ? (result as PtyConnectResult) : null
if (connectResult?.exitedBeforeAttach) {
// Why: the session.transport already delivered the dead session's final frame + exit; treat as terminal state, not a failed reattach.
// Why: the transport already delivered the dead session's final frame + exit; treat as terminal state, not a failed reattach.
return true
}
@@ -206,15 +235,18 @@ export function bindHandleReattachResult(session: ConnectPanePtySession): void {
coldRestoreStartup,
reattachPayloadApplied: !hasStructuralReplay && prefetchedParkModelSnapshot === null
}
bindApplyReattachPayload(session, reattachPayload)
const { applyReattachPayload, fitAfterReattachRestore } = createReattachPayloadHandlers(
session,
reattachPayload
)
if (hasStructuralReplay || prefetchedParkModelSnapshot) {
await session.structuralReplayCoordinator.run(session.applyReattachPayload, {
await session.structuralReplayCoordinator.run(applyReattachPayload, {
shouldRestore: isCurrentReattachPayload,
afterRestore: session.fitAfterReattachRestore
afterRestore: fitAfterReattachRestore
})
} else {
await session.applyReattachPayload()
await session.fitAfterReattachRestore()
await applyReattachPayload()
await fitAfterReattachRestore()
}
if (!isCurrentReattachPayload() || !reattachPayload.reattachPayloadApplied) {
return false
@@ -1,12 +1,13 @@
import { useAppStore } from '@/store'
import { SSH_SESSION_EXPIRED_ERROR } from './pty-connect-limits'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
// Why: when multiple panes/tabs need the same deferred SSH connection,
// the first one calls ssh.connect() and subsequent ones must wait for it
// rather than returning early (which would leave them disconnected). This
// helper either connects or waits for an in-flight connect to finish.
export type SshConnectResult = { connected: true } | { connected: false; error: string }
export type UserInitiatedSshConnectOutcome = 'connected' | 'cancelled' | 'failed'
type UserInitiatedSshConnectOutcome = 'connected' | 'cancelled' | 'failed'
const sshConnectPromises = new Map<string, Promise<SshConnectResult>>()
@@ -14,7 +15,7 @@ export function isSshSessionExpiredError(err: unknown): boolean {
return (err instanceof Error ? err.message : String(err)).includes(SSH_SESSION_EXPIRED_ERROR)
}
export function sshPromptConnectOutcomeForStatus(
function sshPromptConnectOutcomeForStatus(
status: string | undefined,
sawNonDisconnected: boolean
): UserInitiatedSshConnectOutcome | null {
@@ -32,6 +33,60 @@ export function sshPromptConnectOutcomeForStatus(
return null
}
export function waitForUserInitiatedSshConnect(
session: ConnectPanePtySession
): Promise<UserInitiatedSshConnectOutcome> {
return new Promise((resolve) => {
// Entry-time disconnected means authentication has not started; it only cancels after another status was observed.
let sawNonDisconnected = !['disconnected', undefined].includes(
useAppStore.getState().sshConnectionStates.get(session.connectionId)?.status
)
let settled = false
const finish = (outcome: UserInitiatedSshConnectOutcome): void => {
if (settled) {
return
}
settled = true
unsubscribe()
const index = session.waitTeardowns.indexOf(teardown)
if (index !== -1) {
session.waitTeardowns.splice(index, 1)
}
resolve(outcome)
}
const teardown = (): void => finish('cancelled')
// Disposal must resolve the wait even if the SSH store never emits again.
session.waitTeardowns.push(teardown)
const readOutcome = (status: string | undefined): UserInitiatedSshConnectOutcome | null => {
if (status && status !== 'disconnected') {
sawNonDisconnected = true
}
return sshPromptConnectOutcomeForStatus(status, sawNonDisconnected)
}
const unsubscribe = useAppStore.subscribe((state) => {
if (session.disposed) {
finish('cancelled')
return
}
const outcome = readOutcome(state.sshConnectionStates.get(session.connectionId)?.status)
if (outcome) {
finish(outcome)
}
})
if (session.disposed) {
finish('cancelled')
return
}
// Catch a status change that landed between the caller's check and this subscription.
const currentOutcome = readOutcome(
useAppStore.getState().sshConnectionStates.get(session.connectionId)?.status
)
if (currentOutcome) {
finish(currentOutcome)
}
})
}
export async function waitForSshConnection(connectionId: string): Promise<SshConnectResult> {
const state = useAppStore.getState().sshConnectionStates.get(connectionId)
if (state?.status === 'connected') {
@@ -62,7 +62,7 @@ export function bindCaptureTransportOutputCallbacks(session: ConnectPanePtySessi
},
onRecoveryStateChange: (state: PtyTransportRecoveryState): void => {
if (isCurrent()) {
// Why: cached pixels remain visible while detached; expose session.transport truth for diagnostics and recovery UI.
// Why: cached pixels remain visible while detached; expose transport truth for diagnostics and recovery UI.
session.pane.container.dataset.ptyRecoveryState = state.phase
session.deps.onPtyRecoveryStateRef?.current?.(session.pane.id, state)
}
@@ -94,7 +94,7 @@ export function bindCaptureTransportOutputCallbacks(session: ConnectPanePtySessi
session.hiddenOutputRestoreRemoteAbandonCycles = 0
session.hiddenOutputSnapshotScrollRestore = null
// Why: hidden recovery state belongs to one PTY stream. Reattach/restart
// can reuse the session.pane object for a different session before visibility.
// can reuse the pane object for a different session before visibility.
session.hiddenOutputRestorePtyId = null
// One recovery re-kick per xterm instance. Generation-aware cooldown and
// window-cap retries keep a fresh-but-wedged replacement from fossilizing.
@@ -25,7 +25,7 @@ export function bindWritePtyOutputToXterm(session: ConnectPanePtySession): void
foreground: boolean,
opts?: { hiddenStartupRendererQuery?: boolean }
): void {
// Why: every application byte funnels through here, so it's the one place the kitty keyboard mirror observes the session.pane's protocol negotiation.
// Why: every application byte funnels through here, so it's the one place the kitty keyboard mirror observes the pane's protocol negotiation.
session.kittyKeyboardModes.scan(data)
if (foreground) {
session.resetHiddenOutputRestoreIfPtyChanged()