mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
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:
@@ -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', {
|
||||
|
||||
+237
@@ -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) {
|
||||
|
||||
+1
@@ -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 }
|
||||
|
||||
+3
-2
@@ -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).
|
||||
|
||||
+2
-1
@@ -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()) {
|
||||
|
||||
+15
-9
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user