fix(terminal): judge live-agent reattach modes by the image's own title (#35920)

After a serve restart where the daemon survives, a paired client could
drop mouse and focus reporting for a still-live agent. The host publishes
the pane with its tab-default title until it seeds the daemon's live title
about 180 ms later. A reattach image replayed inside that window was judged
against the default title, read as a plain normal-buffer program, and got
the reset that disarms its modes, though the image itself carried them.

The snapshot now carries the host's tracked title when the image was taken,
as an additive `lastTitle` field in the snapshot info, gated on the image's
seq. Every reset that judges a replayed image as a live agent's (the
post-replay reset, the focus-in that follows it, the hidden-restore reset,
and the model-snapshot reattach) uses that title when present, so the
decision describes the same moment as the replayed bytes. Local Electron
model snapshots already carried this title and now use it the same way. The reattach policy itself is
unchanged: non-agent normal-buffer panes still shed modes a dead child may
have left (#7329, #7893).

Older hosts omit the field and clients fall back to the published title.
Older clients ignore it.

Refs #35559
This commit is contained in:
Neil
2026-10-29 04:45:26 -07:00
committed by GitHub
parent c40f32163e
commit 43ed9878b7
22 changed files with 321 additions and 21 deletions
@@ -67,6 +67,7 @@ export function installMultiplexFlowControl(state: TerminalMultiplexConnection):
kittyKeyboardFlags: serialized.kittyKeyboardFlags,
alternateScreen: serialized.alternateScreen,
terminalOwner: serialized.terminalOwner,
lastTitle: serialized.lastTitle,
truncatedByByteBudget: serialized.truncatedByByteBudget,
scrollbackRows: serialized.scrollbackRows,
data: serialized.data
@@ -116,6 +116,7 @@ export async function publishMultiplexInitialSnapshot(
kittyKeyboardFlags: serialized?.kittyKeyboardFlags,
alternateScreen: serialized?.alternateScreen,
terminalOwner: serialized?.terminalOwner,
lastTitle: serialized?.lastTitle,
oscLinks: serialized?.oscLinks,
pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi,
// E2E: a forced-unavailable host retains nothing, so it has no tail to fall back on either.
@@ -214,6 +214,7 @@ export function installMultiplexSlotFrames(state: TerminalMultiplexConnection):
kittyKeyboardFlags: serialized?.kittyKeyboardFlags,
alternateScreen: serialized?.alternateScreen,
terminalOwner: serialized?.terminalOwner,
lastTitle: serialized?.lastTitle,
oscLinks: serialized?.oscLinks,
pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi,
truncated: false,
@@ -23,6 +23,10 @@ export function buildSnapshotFrameMeta(options: SnapshotFrameOptions): Record<st
...(typeof options.seq === 'number' && options.terminalOwner
? { terminalOwner: options.terminalOwner }
: {}),
// Clients judge live-agent reattach modes by this image's own title, not a separately published one.
...(typeof options.seq === 'number' && options.lastTitle
? { lastTitle: options.lastTitle }
: {}),
// Consumers do not recheck terminalOwner; ungated alternateScreen changes mouse-reset selection on live TUI reattach.
...(typeof options.seq === 'number' &&
options.terminalOwner &&
@@ -97,6 +97,7 @@ type SnapshotVariableMeta = Pick<
| 'kittyKeyboardFlags'
| 'alternateScreen'
| 'terminalOwner'
| 'lastTitle'
>
type TerminalBufferSource = Pick<OrcaRuntimeService, 'serializeTerminalBuffer'>
@@ -117,6 +118,7 @@ export type MobileSnapshotByteBudget = {
| 'kittyKeyboardFlags'
| 'alternateScreen'
| 'terminalOwner'
| 'lastTitle'
| 'truncatedByByteBudget'
>
}
@@ -157,6 +159,7 @@ function budgetedPublication(
kittyKeyboardFlags: serialized.kittyKeyboardFlags,
alternateScreen: serialized.alternateScreen,
terminalOwner: serialized.terminalOwner,
lastTitle: serialized.lastTitle,
data
}
}
@@ -69,3 +69,14 @@ describe('SnapshotStart scrollbackRows', () => {
expect('scrollbackRows' in buildSnapshotFrameMeta(base)).toBe(false)
})
})
describe('snapshot frame title', () => {
const base = { kind: 'scrollback' as const, cols: 80, rows: 24, data: '' }
it("carries the image's title only with the seq that orders it", () => {
expect(buildSnapshotFrameMeta({ ...base, seq: 7, lastTitle: 'Claude' }).lastTitle).toBe(
'Claude'
)
expect('lastTitle' in buildSnapshotFrameMeta({ ...base, lastTitle: 'Claude' })).toBe(false)
})
})
@@ -31,6 +31,8 @@ export type SnapshotFrameOptions = {
kittyKeyboardFlags?: number
alternateScreen?: boolean
terminalOwner?: 'shell'
/** The host's tracked title when this image was taken. */
lastTitle?: string
}
export type SerializedSnapshot = {
@@ -48,6 +50,7 @@ export type SerializedSnapshot = {
kittyKeyboardFlags?: number
alternateScreen?: boolean
terminalOwner?: 'shell'
lastTitle?: string
} | null
export type TerminalViewportClient = {
@@ -208,6 +208,7 @@ export async function publishTerminalSubscribeBinarySnapshot(
oscLinks: serialized?.oscLinks,
alternateScreen: serialized?.alternateScreen,
terminalOwner: serialized?.terminalOwner,
lastTitle: serialized?.lastTitle,
data: serialized?.data ?? ''
})
console.log('[mobile-terminal-stream] snapshot', {
@@ -0,0 +1,237 @@
// A live agent's reattach image must keep its modes even when the separately published pane title lags it.
import type * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushAsyncTicks } from './pty-connection-test-async'
import {
terminalWriteText,
createMockTransport,
createPane,
createManager
} from './pty-connection-test-pane-fixtures'
import type { ConnectCallbacks, MockTransport } from './pty-connection-test-pane-fixtures'
import { buildPaneConnectionDeps } from './pty-connection-test-deps'
import {
createInitialStoreState,
buildActiveRuntimeEnvironmentState
} from './pty-connection-test-store-fixtures'
import type { StoreState } from './pty-connection-test-store-state'
import type { PtyReplayDataMeta } from './pty-transport-types'
import { mockPanePtyArgs } from './pty-connection-test-connect-mock-pane'
import {
installTerminalTestGlobals,
restoreTerminalTestGlobals
} from './pty-connection-test-environment'
const {
resetAndRefreshAllTerminalWebglAtlases,
scheduleTerminalWebglAtlasRecovery,
scheduleRuntimeGraphSync,
shouldSeedCacheTimerOnInitialTitle,
toastInfo,
notifyCodexPaneBoundForStaleSweep
} = vi.hoisted(() => ({
resetAndRefreshAllTerminalWebglAtlases: vi.fn(),
scheduleTerminalWebglAtlasRecovery: vi.fn(),
scheduleRuntimeGraphSync: vi.fn(),
shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false),
toastInfo: vi.fn(),
notifyCodexPaneBoundForStaleSweep: vi.fn()
}))
let mockStoreState: StoreState
let transportFactoryQueue: MockTransport[] = []
let createdTransportOptions: Record<string, unknown>[] = []
let storeSubscribers: ((state: StoreState) => void)[] = []
vi.mock('@/runtime/sync-runtime-graph', () => ({
scheduleRuntimeGraphSync
}))
vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
resetAndRefreshAllTerminalWebglAtlases
}))
vi.mock('./terminal-webgl-atlas-recovery', () => ({
scheduleTerminalWebglAtlasRecovery
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => mockStoreState,
subscribe: (listener: (state: StoreState) => void) => {
storeSubscribers.push(listener)
return () => {
storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener)
}
}
}
}))
vi.mock('@/lib/agent-status', async (importOriginal) => {
const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment')
return buildAgentStatusModuleMock(await importOriginal<Record<string, unknown>>())
})
vi.mock('./cache-timer-seeding', () => ({
shouldSeedCacheTimerOnInitialTitle
}))
vi.mock('sonner', () => ({
toast: { info: toastInfo }
}))
vi.mock('@/lib/codex-stale-pane-sweep', () => ({
notifyCodexPaneBoundForStaleSweep
}))
vi.mock('react', async (importOriginal) => {
const actual = await importOriginal<typeof React>()
return {
...actual,
useCallback: <T extends (...args: unknown[]) => unknown>(fn: T): T => fn
}
})
vi.mock('./pty-transport', () => ({
createIpcPtyTransport: vi.fn((options: Record<string, unknown>) => {
createdTransportOptions.push(options)
const nextTransport = transportFactoryQueue.shift()
if (!nextTransport) {
throw new Error('No mock transport queued')
}
return nextTransport
})
}))
vi.mock('./remote-runtime-pty-transport', () => ({
createRemoteRuntimePtyTransport: vi.fn(
(_environmentId: string, options: Record<string, unknown>) => {
createdTransportOptions.push(options)
const nextTransport = transportFactoryQueue.shift()
if (!nextTransport) {
throw new Error('No mock transport queued')
}
return nextTransport
}
)
}))
vi.mock('./pty-dispatcher', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>()
return {
...actual,
getEagerPtyBufferHandle: vi.fn(() => undefined)
}
})
const PANE_COLS = 120
const PANE_ROWS = 40
// Normal-buffer image whose live process armed mouse and focus reporting.
const LIVE_AGENT_IMAGE = 'agent output\r\n\x1b[?1003h\x1b[?1006h\x1b[?1004h'
const MOUSE_DISARM = '\x1b[?1003l'
function createDeps(overrides: Record<string, unknown> = {}) {
return buildPaneConnectionDeps(() => mockStoreState, overrides)
}
async function connectRemotePane(): Promise<{
operations: { kind: 'resize' | 'write'; value: string }[]
pane: ReturnType<typeof createPane>
transport: MockTransport
replay: (data: string, meta?: PtyReplayDataMeta) => void
dispose: () => void
}> {
const { connectPanePty } = await import('./pty-connection')
mockStoreState = buildActiveRuntimeEnvironmentState(mockStoreState, 'env-1')
const transport = createMockTransport('remote:env-1@@terminal-1')
const captured: { current: ConnectCallbacks['onReplayData'] | null } = { current: null }
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
captured.current = callbacks.onReplayData ?? null
return { id: 'remote:env-1@@terminal-1', replay: '' }
})
transportFactoryQueue.push(transport)
const pane = createPane(1)
pane.terminal.cols = PANE_COLS
pane.terminal.rows = PANE_ROWS
const operations: { kind: 'resize' | 'write'; value: string }[] = []
pane.terminal.write = vi.fn((data: string | Uint8Array, callback?: () => void) => {
data = terminalWriteText(data)
operations.push({ kind: 'write', value: data })
callback?.()
})
pane.terminal.resize = vi.fn((cols: number, rows: number) => {
operations.push({ kind: 'resize', value: `${cols}x${rows}` })
pane.terminal.cols = cols
pane.terminal.rows = rows
})
pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: PANE_COLS, rows: PANE_ROWS }))
pane.fitAddon.fit = vi.fn(() => {
pane.terminal.resize(PANE_COLS, PANE_ROWS)
})
const manager = createManager(1)
const disposable = connectPanePty(...mockPanePtyArgs(pane, manager, createDeps()))
await flushAsyncTicks(6)
transport.resize.mockClear()
return {
operations,
pane,
transport,
replay: (data, meta) => captured.current?.(data, meta),
dispose: () => disposable.dispose()
}
}
describe('reattach image title', () => {
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
transportFactoryQueue = []
createdTransportOptions = []
storeSubscribers = []
mockStoreState = createInitialStoreState(() => mockStoreState)
await installTerminalTestGlobals()
})
afterEach(async () => {
await restoreTerminalTestGlobals()
})
async function epilogueAfterImage(
storeTitle: string,
imageTitle: string | undefined
): Promise<string> {
const session = await connectRemotePane()
mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: storeTitle } }
session.replay(LIVE_AGENT_IMAGE, {
snapshotCols: PANE_COLS,
snapshotRows: PANE_ROWS,
...(imageTitle !== undefined ? { lastTitle: imageTitle } : {})
})
await flushAsyncTicks(20)
const imageIndex = session.operations.findIndex(
(operation) => operation.kind === 'write' && operation.value.includes(LIVE_AGENT_IMAGE)
)
expect(imageIndex).toBeGreaterThanOrEqual(0)
session.dispose()
return session.operations
.slice(imageIndex + 1)
.map((operation) => (operation.kind === 'write' ? operation.value : ''))
.join('')
}
it("keeps a live agent's modes when the published title still shows the tab default", async () => {
expect(await epilogueAfterImage('Terminal 1', 'Claude working')).not.toContain(MOUSE_DISARM)
})
it('disarms a plain program the published title still names as an agent', async () => {
expect(await epilogueAfterImage('Claude working', 'vim')).toContain(MOUSE_DISARM)
})
it('falls back to the published title when an older host sends no image title', async () => {
expect(await epilogueAfterImage('Terminal 1', undefined)).toContain(MOUSE_DISARM)
})
})
@@ -129,7 +129,10 @@ export function createReattachPayloadHandlers(
if (modelSnapshot) {
// Scan/reset needs composed snapshot bytes; actual writes use alt-screen choreography to preserve scrollback.
const modelData = `${modelSnapshot.scrollbackAnsi ?? ''}${modelSnapshot.data}`
session.rememberReattachPayloadAgentSignal(modelData, { fullScreenReplay: true })
session.rememberReattachPayloadAgentSignal(modelData, {
fullScreenReplay: true,
imageTitle: modelSnapshot.lastTitle
})
const modelCols = modelSnapshot.cols
const modelRows = modelSnapshot.rows
if (
@@ -164,7 +167,8 @@ export function createReattachPayloadHandlers(
modelData,
Boolean(ctx.connectResult?.coldRestore),
modelSnapshot.alternateScreen ?? ctx.connectResult?.isAlternateScreen,
modelSnapshot.terminalOwner
modelSnapshot.terminalOwner,
modelSnapshot.lastTitle
)
)
if (modelSnapshot.pendingEscapeTailAnsi) {
@@ -219,6 +219,7 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio
}
: {}),
...(meta.terminalOwner ? { terminalOwner: meta.terminalOwner } : {}),
...(meta.lastTitle !== undefined ? { lastTitle: meta.lastTitle } : {}),
...(meta.alternateScreen !== undefined ? { alternateScreen: meta.alternateScreen } : {}),
...(meta.snapshotCols !== undefined && meta.snapshotRows !== undefined
? { snapshotCols: meta.snapshotCols, snapshotRows: meta.snapshotRows }
@@ -151,7 +151,8 @@ export function bindFreshSpawnFollowReset(session: ConnectPanePtySession): void
payload: string,
ownerProcessEnded = false,
isAlternateScreen?: boolean,
terminalOwner?: 'shell'
terminalOwner?: 'shell',
imageTitle?: string
): string => {
// Cold restore replaces the dead process despite persisted agent titles/status; clear stale mouse/focus/paste modes before the fresh shell (#12101).
if (ownerProcessEnded) {
@@ -162,7 +163,7 @@ export function bindFreshSpawnFollowReset(session: ConnectPanePtySession): void
? POST_REPLAY_DEAD_TUI_RESET
: POST_REPLAY_REATTACH_RESET
}
if (session.shouldPreserveAgentReattachModes()) {
if (session.shouldPreserveAgentReattachModes(imageTitle)) {
return buildPostReplayLiveAgentReattachReset(payload)
}
// Alt-screen panes can be live unrecognized TUIs; preserve mouse modes the replay restored (#8291).
@@ -82,6 +82,7 @@ export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession):
pendingEscapeTailAnsi?: string
kittyKeyboardFlags?: number
terminalOwner?: 'shell'
lastTitle?: string
}): Promise<void> {
const restorePtyId = session.transport.getPtyId()
const restoreGeneration = session.hiddenOutputRestoreGeneration
@@ -165,7 +166,7 @@ export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession):
session.writeReplayData(replayChunk)
}
}
const hasLiveAgent = session.hasLiveAgentReattachStatusOrTitleSignal()
const hasLiveAgent = session.hasLiveAgentReattachStatusOrTitleSignal(snapshot.lastTitle)
// Match reattach’s live-pane fallback when alternateScreen is absent; even same-buffer ?1049l runs cursor restore.
const postReplayReset =
snapshot.terminalOwner === 'shell'
@@ -26,6 +26,7 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void {
expectedStreamGeneration = session.transportStreamGeneration
): void => {
const scheduledGeneration = session.reattachReplayPayloadSignalGeneration
const imageTitle: string | undefined = session.reattachReplayImageTitle
void waitForTerminalOutputParsed(session.pane.terminal).then(() => {
const currentPtyId = session.transport.getPtyId()
if (
@@ -41,7 +42,7 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void {
}
// Dead replay screens can remain above shell prompts; require parsed parked-cursor screen or corroborating status/title before retaining agent focus behavior.
if (
!session.hasLiveAgentReattachStatusOrTitleSignal() &&
!session.hasLiveAgentReattachStatusOrTitleSignal(imageTitle) &&
session.reattachReplayPayloadHasCursorAgentSignal
) {
if (parsedViewportShowsParkedCursorAgentScreen(session.pane.terminal) === false) {
@@ -53,7 +54,7 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void {
}
// Reattach preserves DOM focus, so live TUIs may miss focus-in needed to unpark their cursor; inject only with ?1004h to spare bare shells.
const sendFocusMode = terminalHasFocusReportingEnabled(session.pane.terminal)
if (!session.shouldSendFocusedAgentReattachFocusIn() || !sendFocusMode) {
if (!session.shouldSendFocusedAgentReattachFocusIn(imageTitle) || !sendFocusMode) {
return
}
session.transport.sendInput(TERMINAL_FOCUS_IN_SEQUENCE, 'query-reply')
@@ -91,6 +92,7 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void {
pendingEscapeTailAnsi,
alternateScreen,
terminalOwner,
lastTitle,
snapshotCols,
snapshotRows,
carriesNormalBuffer,
@@ -148,7 +150,10 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void {
}
if (clearBeforeReplay || data.length > 0) {
// Authoritative empty clearing frames must also retire prior agent-screen evidence.
session.rememberReattachPayloadAgentSignal(data, { fullScreenReplay: clearBeforeReplay })
session.rememberReattachPayloadAgentSignal(data, {
fullScreenReplay: clearBeforeReplay,
imageTitle: lastTitle
})
}
// Re-arm Kitty from application replay using set semantics so repeated windows cannot grow its stack.
session.applySnapshotKittyKeyboardModes(data, payload)
@@ -158,7 +163,7 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void {
}
if (clearBeforeReplay || data.length > 0) {
await session.writeReplayEpilogue(
session.chooseReattachReplayReset(data, false, alternateScreen, terminalOwner),
session.chooseReattachReplayReset(data, false, alternateScreen, terminalOwner, lastTitle),
session.writeReplayDataAsync
)
if (!isCurrentPayload()) {
@@ -197,9 +197,11 @@ export function installShellCommandInference(session: ConnectPanePtySession): vo
session.reattachReplayPayloadSignalGeneration = 0
session.rememberReattachPayloadAgentSignal = (
data: string,
opts: { fullScreenReplay: boolean }
opts: { fullScreenReplay: boolean; imageTitle?: string }
): void => {
session.reattachReplayPayloadSignalGeneration += 1
// Post-parse checks for this replay must judge the same title its reset did.
session.reattachReplayImageTitle = opts.imageTitle
// Agent names in history are not liveness; require restored Cursor screen shape.
const signal = hasCursorAgentReattachPayloadScreenSignal(data)
// Incremental frames may add evidence; only authoritative full-screen repaint may clear it.
@@ -210,27 +212,31 @@ export function installShellCommandInference(session: ConnectPanePtySession): vo
session.isCursorAgentNativeTitle = (title: string): boolean => {
return title.trim().toLowerCase() === CURSOR_AGENT_REATTACH_HEADER.toLowerCase()
}
session.hasLiveAgentReattachStatusOrTitleSignal = (): boolean => {
session.hasLiveAgentReattachStatusOrTitleSignal = (imageTitle?: string): boolean => {
// Permanent tab launch metadata cannot prove liveness; require current status/title or restored screen shape.
if (useAppStore.getState().agentStatusByPaneKey[session.cacheKey]) {
return true
}
const title = session.getCurrentTerminalTitle() ?? ''
// A replayed image's own title outranks the store's, which arrives on a separate channel.
const title = imageTitle ?? session.getCurrentTerminalTitle() ?? ''
// Loose label matching can classify ssh user names; mode preservation and input injection need exact/status titles.
return detectAgentStatusFromTitle(title) !== null || session.isCursorAgentNativeTitle(title)
}
session.hasLiveAgentReattachSignal = (): boolean => {
session.hasLiveAgentReattachSignal = (imageTitle?: string): boolean => {
return (
session.hasLiveAgentReattachStatusOrTitleSignal() ||
session.hasLiveAgentReattachStatusOrTitleSignal(imageTitle) ||
session.reattachReplayPayloadHasCursorAgentSignal
)
}
session.shouldPreserveAgentReattachModes = (): boolean => {
session.shouldPreserveAgentReattachModes = (imageTitle?: string): boolean => {
// Why: ordinary shells can inherit stale ?25l/?1004h from replay bytes.
// Preserve those modes only when reattach still looks agent-owned.
return session.hasLiveAgentReattachSignal()
return session.hasLiveAgentReattachSignal(imageTitle)
}
session.shouldSendFocusedAgentReattachFocusIn = (): boolean => {
return terminalOwnsDomFocus(session.pane.terminal) && session.shouldPreserveAgentReattachModes()
session.shouldSendFocusedAgentReattachFocusIn = (imageTitle?: string): boolean => {
return (
terminalOwnsDomFocus(session.pane.terminal) &&
session.shouldPreserveAgentReattachModes(imageTitle)
)
}
}
@@ -43,6 +43,7 @@ export type ProcessPtyOutputOptions = {
snapshotSeq?: number
alternateScreen?: boolean
terminalOwner?: 'shell'
lastTitle?: string
snapshotCols?: number
snapshotRows?: number
carriesNormalBuffer?: boolean
@@ -202,6 +203,7 @@ export function createPtyOutputProcessor({
? { alternateScreen: options.alternateScreen }
: {}),
...(options.terminalOwner ? { terminalOwner: options.terminalOwner } : {}),
...(options.lastTitle !== undefined ? { lastTitle: options.lastTitle } : {}),
...(options.snapshotCols !== undefined && options.snapshotRows !== undefined
? { snapshotCols: options.snapshotCols, snapshotRows: options.snapshotRows }
: {}),
@@ -53,6 +53,8 @@ export type PtyBufferSnapshot = {
* means unknown; never rewrite that silence into a known `0`. */
kittyKeyboardFlags?: number
terminalOwner?: 'shell'
/** The host's tracked title when this image was taken; absent from older hosts. */
lastTitle?: string
}
/** Metadata for one authoritative replay payload. */
@@ -66,6 +68,8 @@ export type PtyReplayDataMeta = {
snapshotSeq?: number
alternateScreen?: boolean
terminalOwner?: 'shell'
/** The host's tracked title when this image was taken, so live-agent judgement cannot race a separate title publication. */
lastTitle?: string
/** Grid the payload was serialized at. Present only when the producer proved
* it; the drain replays there and fits back to the pane afterwards. */
snapshotCols?: number
@@ -2951,6 +2951,9 @@ export function createRemoteRuntimePtyTransport(
...(meta?.terminalOwner && meta.seq !== undefined
? { terminalOwner: meta.terminalOwner }
: {}),
...(meta?.lastTitle !== undefined && meta.seq !== undefined
? { lastTitle: meta.lastTitle }
: {}),
...(meta?.alternateScreen !== undefined && meta.seq !== undefined
? { alternateScreen: meta.alternateScreen }
: {}),
@@ -92,6 +92,7 @@ export abstract class RemoteRuntimeTerminalBinarySnapshots extends RemoteRuntime
kittyKeyboardFlags: info?.kittyKeyboardFlags,
alternateScreen: info?.alternateScreen,
terminalOwner: info?.terminalOwner,
lastTitle: info?.lastTitle,
// Replay at host grid dimensions because wraps/cursor moves depend on it; pushes must carry the same dimensions as requests.
cols: info?.cols,
rows: info?.rows,
@@ -123,6 +124,7 @@ export abstract class RemoteRuntimeTerminalBinarySnapshots extends RemoteRuntime
kittyKeyboardFlags: info?.kittyKeyboardFlags,
alternateScreen: info?.alternateScreen,
terminalOwner: info?.terminalOwner,
lastTitle: info?.lastTitle,
pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi,
// The host folds the normal buffer into `data` (see terminal-snapshot-publication.ts).
carriesNormalBuffer: true
@@ -51,6 +51,8 @@ export type RemoteRuntimeMultiplexedTerminalCallbacks = {
kittyKeyboardFlags?: number
alternateScreen?: boolean
terminalOwner?: 'shell'
/** The host's tracked title when this image was taken; absent from older hosts. */
lastTitle?: string
/** Grid the host serialized this image at. Absent from hosts that omit
* it, which must read as unknown so replay keeps the pane's own grid. */
cols?: number
@@ -101,6 +103,7 @@ export type RemoteRuntimeSnapshotImage = {
kittyKeyboardFlags?: number
alternateScreen?: boolean
terminalOwner?: 'shell'
lastTitle?: string
/** `data` starts on the normal buffer and enters alt itself. */
carriesNormalBuffer?: boolean
}
@@ -226,6 +229,7 @@ export type RemoteRuntimeSnapshotInfo = {
kittyKeyboardFlags?: number
alternateScreen?: boolean
terminalOwner?: 'shell'
lastTitle?: string
requestId?: number
truncated?: boolean
unavailable?: TerminalSnapshotUnavailableReason
@@ -108,6 +108,7 @@ export function decodeSnapshotInfo(
kittyKeyboardFlags?: unknown
alternateScreen?: unknown
terminalOwner?: unknown
lastTitle?: unknown
scrollbackRows?: unknown
}>(payload)
if (!raw) {
@@ -125,6 +126,7 @@ export function decodeSnapshotInfo(
? raw.alternateScreen
: undefined,
terminalOwner: raw.terminalOwner === 'shell' ? raw.terminalOwner : undefined,
lastTitle: typeof raw.lastTitle === 'string' ? raw.lastTitle : undefined,
requestId: typeof raw.requestId === 'number' ? raw.requestId : undefined,
truncated: raw.truncated === true,
unavailable: parseTerminalSnapshotUnavailableReason(raw.unavailable),
@@ -103,7 +103,7 @@ test('a serve restart that respawns the shell drops the dead agent title, status
.toMatchObject({ mouse: 'any', focus: true, applicationCursor: true })
expect((await readPaneState(page, webTabId))?.runtimeTitle).toContain('Claude Code')
// Control: a serve restart that keeps the daemon reattaches the same process, so no process-boundary ground runs.
// A serve restart that keeps the daemon reattaches the same live agent, so its modes must survive.
await host.restartServeProcess()
await expect
.poll(
@@ -123,8 +123,11 @@ test('a serve restart that respawns the shell drops the dead agent title, status
{ timeout: 120_000, message: 'The paired pane never reattached after the serve restart' }
)
.toBe(true)
// Only a process-boundary ground clears cursor-key mode; reattach epilogues leave it.
expect((await readPaneState(page, webTabId))?.applicationCursor).toBe(true)
expect(await readPaneState(page, webTabId)).toMatchObject({
mouse: 'any',
focus: true,
applicationCursor: true
})
const before = await readPaneState(page, webTabId)
// The host machine restarted: serve and its daemon both lose every process.