From 9bfda101084c6ad5d586a478d737331ebbf2761f Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 14 Jul 2026 17:23:51 -0400 Subject: [PATCH 1/3] Fix restored terminals rendering blank on mobile Co-authored-by: Orca --- src/main/ipc/pty.test.ts | 36 ++++++++++++++++++++++++ src/main/ipc/pty.ts | 9 ++++++ src/main/runtime/orca-runtime.test.ts | 40 +++++++++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 29 +++++++++++++++++-- 4 files changed, 112 insertions(+), 2 deletions(-) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index d7c2b11ca71..da46ddd3079 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -11291,6 +11291,42 @@ describe('registerPtyHandlers', () => { }) }) + describe('provider buffer snapshot dispatch', () => { + it('exposes daemon history when no renderer pane is mounted', async () => { + const provider = installObservableDaemonTestProvider() + provider.getBufferSnapshot.mockResolvedValue({ + data: 'restored screen\r\n', + scrollbackAnsi: 'restored history\r\n', + cols: 120, + rows: 40, + cwd: '/projects/restored', + seq: 900, + source: 'headless' + }) + const runtime = { setPtyController: vi.fn() } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + serializeProviderBuffer(ptyId: string, opts?: { scrollbackRows?: number }): Promise + } + + await expect( + controller.serializeProviderBuffer('daemon-restored', { scrollbackRows: 5000 }) + ).resolves.toEqual({ + data: 'restored screen\r\n', + scrollbackAnsi: 'restored history\r\n', + cols: 120, + rows: 40, + cwd: '/projects/restored', + seq: 900, + source: 'headless' + }) + expect(provider.getBufferSnapshot).toHaveBeenCalledWith('daemon-restored', { + scrollbackRows: 5000 + }) + }) + }) + describe('main buffer snapshot dispatch', () => { it('returns a hidden-output recovery snapshot with clamped scrollback', async () => { const runtime = { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index d1c501a3230..55e30acd84b 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -3466,6 +3466,15 @@ export function registerPtyHandlers( // state and dimensions before live TUI chunks can render correctly. return requestSerializedBuffer(ptyId, opts) }, + serializeProviderBuffer: async (ptyId, opts) => { + try { + // Why: restored daemon PTYs can be live while their desktop pane stays + // unmounted; query the provider model so phone-local navigation works. + return (await getProviderForPty(ptyId).getBufferSnapshot?.(ptyId, opts)) ?? null + } catch { + return null + } + }, hasRendererSerializer: (ptyId) => { // Why: the runtime needs a synchronous probe so it can decide whether to // skip the daemon-snapshot seed (the renderer will hydrate it) or run the diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index e7b11ef8bb2..d830ca55cce 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -7217,6 +7217,46 @@ describe('OrcaRuntimeService', () => { expect(snapshot?.lastTitle).toBe('⠋ Cursor Agent') }) + it('falls back to the provider snapshot for a restored PTY with no mounted renderer', async () => { + const { runtime } = createSideEffectRuntime() + const serializeBuffer = vi.fn() + const serializeProviderBuffer = vi.fn().mockResolvedValue({ + data: 'restored screen\r\n', + scrollbackAnsi: 'restored history\r\n', + cols: 120, + rows: 40, + cwd: '/projects/restored', + seq: 900, + source: 'headless' + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeBuffer, + serializeProviderBuffer, + hasRendererSerializer: () => false + }) + + const snapshot = await runtime.serializeTerminalBuffer('pty-restored', { + scrollbackRows: 5000 + }) + + expect(serializeBuffer).not.toHaveBeenCalled() + expect(serializeProviderBuffer).toHaveBeenCalledWith('pty-restored', { + scrollbackRows: 5000 + }) + expect(snapshot).toEqual({ + data: 'restored screen\r\n', + scrollbackAnsi: 'restored history\r\n', + cols: 120, + rows: 40, + cwd: '/projects/restored', + seq: 900, + source: 'headless' + }) + }) + it('prefers the tracked title over the headless emulator lastTitle', async () => { const { runtime } = createSideEffectRuntime() syncSinglePty(runtime) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 0458efbdc82..701cdaafb50 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -351,6 +351,7 @@ import { serveSimStateWatcher } from '../emulator/serve-sim-state-watcher' import type { EmulatorBridge } from '../emulator/emulator-bridge' import { RuntimeFileCommands } from './orca-runtime-files' import { RuntimeGitCommands } from './orca-runtime-git' +import type { PtyProviderBufferSnapshot } from '../providers/types' import { ClaudeAgentTeamsService } from './claude-agent-teams-service' import type { AgentTeamsTmuxCompatRequest, @@ -1218,6 +1219,11 @@ type RuntimePtyController = { ptyId: string, opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } ): Promise<{ data: string; cols: number; rows: number; lastTitle?: string } | null> + /** Authoritative provider-owned snapshot for restored PTYs with no mounted renderer. */ + serializeProviderBuffer?( + ptyId: string, + opts?: { scrollbackRows?: number } + ): Promise // Why: synchronous probe used by maybeHydrateHeadlessFromRenderer to skip // hydration when no renderer is authoritative for this PTY. See // docs/mobile-prefer-renderer-scrollback.md. @@ -6765,7 +6771,8 @@ export class OrcaRuntimeService { // Why: hidden-output recovery is initiated by the desktop renderer. If the // runtime has not built headless state yet, the mounted xterm is still the // best available state and avoids a false "snapshot unavailable" result. - return this.serializeRendererTerminalBuffer(ptyId, opts) + const rendererSnapshot = await this.serializeRendererTerminalBuffer(ptyId, opts) + return rendererSnapshot ?? this.serializeProviderTerminalBuffer(ptyId, opts) } async clearTerminalBuffer(handle: string): Promise<{ handle: string; cleared: boolean }> { @@ -7114,7 +7121,8 @@ export class OrcaRuntimeService { return headlessSnapshot } - return this.serializeRendererTerminalBuffer(ptyId, opts) + const rendererSnapshot = await this.serializeRendererTerminalBuffer(ptyId, opts) + return rendererSnapshot ?? this.serializeProviderTerminalBuffer(ptyId, opts) } private async serializeRendererTerminalBuffer( @@ -7129,6 +7137,9 @@ export class OrcaRuntimeService { source?: 'renderer' oscLinks?: TerminalOscLinkRange[] } | null> { + if (this.ptyController?.hasRendererSerializer?.(ptyId) === false) { + return null + } let rendererSnapshot: { data: string cols: number @@ -7159,6 +7170,20 @@ export class OrcaRuntimeService { : null } + private async serializeProviderTerminalBuffer( + ptyId: string, + opts: { scrollbackRows?: number } = {} + ): Promise { + try { + // Why: daemon PTYs survive an app relaunch before any renderer mounts. + // Mobile still needs their retained history without navigating desktop. + const snapshot = await this.ptyController?.serializeProviderBuffer?.(ptyId, opts) + return snapshot ? this.preferTrackedLastTitle(ptyId, snapshot) : null + } catch { + return null + } + } + private async withVisibleSnapshotFallback( ptyId: string, read: RuntimeTerminalRead, From 49303fec80cbe857a468fa29551692ff458d46d3 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 14 Jul 2026 17:42:17 -0400 Subject: [PATCH 2/3] Harden restored PTY snapshot sequencing Co-authored-by: Orca --- src/main/daemon/daemon-pty-adapter.test.ts | 1 + src/main/daemon/daemon-pty-adapter.ts | 3 + src/main/ipc/pty.test.ts | 36 +++++++ src/main/ipc/pty.ts | 12 +++ src/main/providers/types.ts | 3 + src/main/runtime/orca-runtime.test.ts | 18 ++++ src/main/runtime/orca-runtime.ts | 26 +++++ ...erminal-provider-snapshot-sequence.test.ts | 94 +++++++++++++++++++ 8 files changed, 193 insertions(+) create mode 100644 src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 37896ab8336..7358d5d35ef 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -506,6 +506,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(second.launchAgent).toBe('droid') expect(second.snapshot).toBeDefined() expect(second.snapshot).toContain('hello from shell') + expect(second.snapshotOutputSequence).toBe('hello from shell\r\n'.length) }) it('includes rehydrateSequences in snapshot when terminal modes are active', async () => { diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 25a9be9754a..6f0a1fe1089 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -410,6 +410,9 @@ export class DaemonPtyAdapter implements IPtyProvider { snapshot: snapshotPayload, snapshotCols: result.snapshot.cols, snapshotRows: result.snapshot.rows, + ...(typeof result.snapshot.outputSequence === 'number' + ? { snapshotOutputSequence: result.snapshot.outputSequence } + : {}), ...(typeof kittyKeyboardFlags === 'number' && kittyKeyboardFlags > 0 ? { snapshotKittyKeyboardFlags: kittyKeyboardFlags } : {}), diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index da46ddd3079..693bcd28e1a 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -10437,6 +10437,42 @@ describe('registerPtyHandlers', () => { expect(mockProc.proc.write).not.toHaveBeenCalled() }) + it('anchors runtime output sequencing from a provider reattach snapshot', async () => { + setLocalPtyProvider({ + spawn: vi.fn(async () => ({ + id: 'pty-restored', + isReattach: true, + snapshotOutputSequence: 900 + })), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => []), + getForegroundProcess: vi.fn(async () => null) + } as never) + const runtime = { + setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + anchorPtyOutputSequenceFromProviderSnapshot: vi.fn(), + onPtySpawned: vi.fn(), + onPtyData: vi.fn(), + onPtyExit: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + preAllocateHandleForPty: vi.fn() + } + registerPtyHandlers(mainWindow as never, runtime as never) + + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) + + expect(runtime.anchorPtyOutputSequenceFromProviderSnapshot).toHaveBeenCalledWith( + 'pty-restored', + 900 + ) + }) + it('seeds headless terminal state with cold-restore cwd metadata', async () => { const oscLinks = [{ row: 0, startCol: 0, endCol: 8, uri: 'https://example.com/restored' }] const coldRestore = { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 55e30acd84b..b1b81169537 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -3126,6 +3126,12 @@ export function registerPtyHandlers( trustedTerminalHandleEnv.add(args.preAllocatedHandle) } result = await provider.spawn(spawnOptions) + if (typeof result.snapshotOutputSequence === 'number') { + runtime?.anchorPtyOutputSequenceFromProviderSnapshot?.( + result.id, + result.snapshotOutputSequence + ) + } } catch (err) { const rawMessage = err instanceof Error ? err.message : String(err) const spawnError = normalizeNodePtySpawnError(err) @@ -4044,6 +4050,12 @@ export function registerPtyHandlers( } spawnTiming.mark('options') result = await provider.spawn(spawnOptions) + if (typeof result.snapshotOutputSequence === 'number') { + runtime?.anchorPtyOutputSequenceFromProviderSnapshot?.( + result.id, + result.snapshotOutputSequence + ) + } spawnTiming.mark('provider_spawn') } catch (err) { // Why: a failed spawn must not leave a stale hidden mark on a session diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 66642e38ad1..0f6c919d330 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -117,6 +117,9 @@ export type PtySpawnResult = { * writing the snapshot so ANSI cursor positions land correctly. */ snapshotCols?: number snapshotRows?: number + /** Provider output sequence at snapshot capture. Main uses this to keep + * post-reattach live bytes in the same deduplication domain. */ + snapshotOutputSequence?: number /** Kitty keyboard flags persisted in the daemon snapshot, threaded so the * re-seeded runtime emulator answers hidden `CSI ? u` with the real flags * (terminal-query-authority.md §kitty). Never replayed into a renderer diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index d830ca55cce..c2a379f2074 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -7117,6 +7117,24 @@ describe('OrcaRuntimeService', () => { expect(runtime.getPtyOutputSequence('pty-1')).toBe(0) }) + it('anchors post-reattach live output to the provider snapshot sequence', async () => { + const { runtime } = createSideEffectRuntime() + + // Simulate a stream-socket byte winning the race with the spawn response. + runtime.onPtyData('pty-restored', 'queued', 100) + expect(runtime.anchorPtyOutputSequenceFromProviderSnapshot('pty-restored', 900)).toBe(906) + runtime.onPtyData('pty-restored', 'fresh', 101) + + expect(runtime.getPtyOutputSequence('pty-restored')).toBe(911) + await expect(runtime.serializeMainTerminalBuffer('pty-restored')).resolves.toMatchObject({ + data: expect.stringContaining('queuedfresh'), + seq: 911, + source: 'headless' + }) + // Renderer remounts can attach the same provider PTY again in one main lifetime. + expect(runtime.anchorPtyOutputSequenceFromProviderSnapshot('pty-restored', 911)).toBe(911) + }) + it('carries the synthetic permission BEL as a bell fact', () => { const { runtime, batches } = createSideEffectRuntime() syncSinglePty(runtime) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 701cdaafb50..910acf41c59 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2249,6 +2249,7 @@ export class OrcaRuntimeService { private titleObservationSequence = 0 private headlessTerminals = new Map() private ptyOutputSequenceById = new Map() + private providerSequenceAnchoredPtys = new Set() private recentPtyPathCandidatesById = new Map() // Why: OSC 9999 status can span PTY chunks. Keeping parser state in the // runtime lets hidden/model-owned terminals observe agent state without a @@ -6599,6 +6600,29 @@ export class OrcaRuntimeService { return this.ptyOutputSequenceById.get(ptyId) ?? 0 } + anchorPtyOutputSequenceFromProviderSnapshot(ptyId: string, providerSequence: number): number { + if ( + this.providerSequenceAnchoredPtys.has(ptyId) || + !Number.isFinite(providerSequence) || + providerSequence < 0 + ) { + return this.getPtyOutputSequence(ptyId) + } + const baseline = Math.floor(providerSequence) + const anchoredSequence = baseline + this.getPtyOutputSequence(ptyId) + this.ptyOutputSequenceById.set(ptyId, anchoredSequence) + this.providerSequenceAnchoredPtys.add(ptyId) + const headless = this.headlessTerminals.get(ptyId) + if (headless) { + // Why: daemon bytes can reach main just before spawn resolves. Queue the + // baseline behind those writes so their emulator sequence is rebased too. + headless.writeChain = headless.writeChain.then(() => { + headless.outputSequence += baseline + }) + } + return anchoredSequence + } + subscribeToTerminalData( ptyId: string, listener: (data: string, meta?: { seq?: number; rawLength?: number; cwd?: string }) => void @@ -8269,6 +8293,7 @@ export class OrcaRuntimeService { this.clearWaitBlockedCheckState(ptyId) this.recentPtyPathCandidatesById.delete(ptyId) this.ptyOutputSequenceById.delete(ptyId) + this.providerSequenceAnchoredPtys.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) this.terminalSpawnCommandsByPtyId.delete(ptyId) this.disposePtyTitleTracker(ptyId) @@ -20486,6 +20511,7 @@ export class OrcaRuntimeService { this.clearWaitBlockedCheckState(ptyId) this.recentPtyPathCandidatesById.delete(ptyId) this.ptyOutputSequenceById.delete(ptyId) + this.providerSequenceAnchoredPtys.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) this.terminalSpawnCommandsByPtyId.delete(ptyId) this.disposePtyTitleTracker(ptyId) diff --git a/src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts b/src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts new file mode 100644 index 00000000000..b6e265c7f80 --- /dev/null +++ b/src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts @@ -0,0 +1,94 @@ +import { expect, it, vi } from 'vitest' +import { RpcDispatcher } from './dispatcher' +import type { RpcRequest } from './core' +import { TERMINAL_METHODS } from './methods/terminal' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' + +const request: RpcRequest = { + id: 'req-provider-sequence', + authToken: 'tok', + method: 'terminal.subscribe', + params: { + terminal: 'terminal-restored', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + } +} + +it('replays post-capture output in the provider snapshot sequence domain', async () => { + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let dataListener: + | ((data: string, meta?: { seq?: number; rawLength?: number }) => void) + | undefined + let resolveSnapshot: + | ((snapshot: { data: string; cols: number; rows: number; seq: number }) => void) + | undefined + const runtime = { + getRuntimeId: () => 'test-runtime', + registerRemoteTerminalViewSubscriber: () => () => {}, + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-restored' }), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn((_ptyId, listener) => { + dataListener = listener + return vi.fn() + }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn( + () => + new Promise<{ data: string; cols: number; rows: number; seq: number }>((resolve) => { + resolveSnapshot = resolve + }) + ), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming(request, vi.fn(), { + connectionId: 'conn-restored', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + } + }) + + await vi.waitFor(() => expect(resolveSnapshot).toBeDefined()) + resolveSnapshot?.({ data: 'restored snapshot', cols: 80, rows: 24, seq: 900 }) + // Why: provider attach anchoring makes this first new chunk continue at + // 905 instead of restarting at 5, so snapshot reconciliation keeps it. + dataListener?.('fresh', { seq: 905, rawLength: 5 }) + + await vi.waitFor(() => + expect( + binaryFrames.some((bytes) => { + const frame = decodeTerminalStreamFrame(bytes) + return ( + frame?.opcode === TerminalStreamOpcode.Output && + decodeTerminalStreamText(frame.payload) === 'fresh' + ) + }) + ).toBe(true) + ) + + runtime.cleanupSubscription('terminal-restored:phone-1') + await dispatchPromise +}) From 02de3c565c0fdbe41bb98a473748c7f624bd0570 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 14 Jul 2026 18:19:43 -0400 Subject: [PATCH 3/3] Harden daemon PTY generation handoff Co-authored-by: Orca --- src/main/daemon/daemon-pty-adapter.test.ts | 7 +- src/main/daemon/daemon-pty-adapter.ts | 25 +- src/main/ipc/pty.test.ts | 14 +- src/main/ipc/pty.ts | 25 +- src/main/providers/types.ts | 9 +- src/main/runtime/orca-runtime.test.ts | 345 +++++++++++++++++- src/main/runtime/orca-runtime.ts | 197 +++++++++- ...erminal-provider-snapshot-sequence.test.ts | 93 +++++ ...rminal-kitty-keyboard-mode-tracker.test.ts | 3 + .../terminal-kitty-keyboard-mode-tracker.ts | 12 + 10 files changed, 686 insertions(+), 44 deletions(-) diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 7358d5d35ef..1b3b5fb0b7e 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -132,6 +132,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { const result = await adapter.spawn({ cols: 80, rows: 24 }) expect(result.id).toBeDefined() expect(typeof result.id).toBe('string') + expect(result.providerSequence).toEqual({ value: 0, generation: 'reset' }) }) it('uses worktreeId as session prefix when provided', async () => { @@ -506,7 +507,10 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(second.launchAgent).toBe('droid') expect(second.snapshot).toBeDefined() expect(second.snapshot).toContain('hello from shell') - expect(second.snapshotOutputSequence).toBe('hello from shell\r\n'.length) + expect(second.providerSequence).toEqual({ + value: 'hello from shell\r\n'.length, + generation: 'continued' + }) }) it('includes rehydrateSequences in snapshot when terminal modes are active', async () => { @@ -529,6 +533,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(result.id).toBe('brand-new') expect(result.isReattach).toBeUndefined() expect(result.snapshot).toBeUndefined() + expect(result.providerSequence).toEqual({ value: 0, generation: 'reset' }) }) }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 6f0a1fe1089..81056a9c9c1 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -46,6 +46,17 @@ function getRecoveredHistorySeed(restoreInfo: ColdRestoreInfo): string | null { : restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi } +function providerSequenceForSpawn( + result: CreateOrAttachResult +): PtySpawnResult['providerSequence'] { + if (result.isNew) { + return { value: 0, generation: 'reset' } + } + return typeof result.snapshot?.outputSequence === 'number' + ? { value: result.snapshot.outputSequence, generation: 'continued' } + : undefined +} + export type DaemonPtyAdapterOptions = { socketPath: string tokenPath: string @@ -317,6 +328,7 @@ export class DaemonPtyAdapter implements IPtyProvider { const wasAlreadyManaged = this.activeSessionIds.has(sessionId) this.activeSessionIds.add(sessionId) + const providerSequence = providerSequenceForSpawn(result) // Cold restore: daemon created a new session but disk history shows // an unclean shutdown → return saved scrollback so the renderer can @@ -348,10 +360,16 @@ export class DaemonPtyAdapter implements IPtyProvider { pid, ...launchIdentity(), coldRestore, + ...(providerSequence ? { providerSequence } : {}), ...(!result.isNew ? { isReattach: true } : {}) } } - return { id: sessionId, pid, ...launchIdentity() } + return { + id: sessionId, + pid, + ...launchIdentity(), + ...(providerSequence ? { providerSequence } : {}) + } } if (this.historyManager && result.isNew) { @@ -389,6 +407,7 @@ export class DaemonPtyAdapter implements IPtyProvider { id: sessionId, pid, ...launchIdentity(), + ...(providerSequence ? { providerSequence } : {}), ...(isReattach ? { isReattach: true } : {}) } } @@ -410,9 +429,7 @@ export class DaemonPtyAdapter implements IPtyProvider { snapshot: snapshotPayload, snapshotCols: result.snapshot.cols, snapshotRows: result.snapshot.rows, - ...(typeof result.snapshot.outputSequence === 'number' - ? { snapshotOutputSequence: result.snapshot.outputSequence } - : {}), + ...(providerSequence ? { providerSequence } : {}), ...(typeof kittyKeyboardFlags === 'number' && kittyKeyboardFlags > 0 ? { snapshotKittyKeyboardFlags: kittyKeyboardFlags } : {}), diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 693bcd28e1a..f10ebd7ff7e 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -10437,12 +10437,12 @@ describe('registerPtyHandlers', () => { expect(mockProc.proc.write).not.toHaveBeenCalled() }) - it('anchors runtime output sequencing from a provider reattach snapshot', async () => { + it('synchronizes runtime output sequencing from a provider reattach snapshot', async () => { setLocalPtyProvider({ spawn: vi.fn(async () => ({ id: 'pty-restored', isReattach: true, - snapshotOutputSequence: 900 + providerSequence: { value: 900, generation: 'continued' as const } })), write: vi.fn(), resize: vi.fn(), @@ -10456,7 +10456,8 @@ describe('registerPtyHandlers', () => { const runtime = { setPtyController: vi.fn(), noteTerminalSpawnCommand: vi.fn(), - anchorPtyOutputSequenceFromProviderSnapshot: vi.fn(), + getPtyOutputSequence: vi.fn().mockReturnValue(7), + synchronizePtyOutputSequenceFromProvider: vi.fn(), onPtySpawned: vi.fn(), onPtyData: vi.fn(), onPtyExit: vi.fn(), @@ -10467,9 +10468,10 @@ describe('registerPtyHandlers', () => { await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) - expect(runtime.anchorPtyOutputSequenceFromProviderSnapshot).toHaveBeenCalledWith( + expect(runtime.synchronizePtyOutputSequenceFromProvider).toHaveBeenCalledWith( 'pty-restored', - 900 + { value: 900, generation: 'continued' }, + 7 ) }) @@ -10510,7 +10512,7 @@ describe('registerPtyHandlers', () => { 'pty-cold-restore', 'restored history\r\n', undefined, - { cwd: '/projects/restored', oscLinks } + { cwd: '/projects/restored', oscLinks, preferProviderIfExisting: true } ) }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index b1b81169537..49ee43cfb4f 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -3125,11 +3125,16 @@ export function registerPtyHandlers( if (args.preAllocatedHandle) { trustedTerminalHandleEnv.add(args.preAllocatedHandle) } + const expectedPtyId = effectiveSessionAppId ?? sessionId + const sequenceBeforeProviderSpawn = expectedPtyId + ? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0) + : 0 result = await provider.spawn(spawnOptions) - if (typeof result.snapshotOutputSequence === 'number') { - runtime?.anchorPtyOutputSequenceFromProviderSnapshot?.( + if (result.providerSequence) { + runtime?.synchronizePtyOutputSequenceFromProvider?.( result.id, - result.snapshotOutputSequence + result.providerSequence, + sequenceBeforeProviderSpawn ) } } catch (err) { @@ -4049,11 +4054,16 @@ export function registerPtyHandlers( ) } spawnTiming.mark('options') + const expectedPtyId = effectiveSessionAppId ?? effectiveSessionId + const sequenceBeforeProviderSpawn = expectedPtyId + ? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0) + : 0 result = await provider.spawn(spawnOptions) - if (typeof result.snapshotOutputSequence === 'number') { - runtime?.anchorPtyOutputSequenceFromProviderSnapshot?.( + if (result.providerSequence) { + runtime?.synchronizePtyOutputSequenceFromProvider?.( result.id, - result.snapshotOutputSequence + result.providerSequence, + sequenceBeforeProviderSpawn ) } spawnTiming.mark('provider_spawn') @@ -4290,7 +4300,8 @@ export function registerPtyHandlers( ) { runtime.seedHeadlessTerminal(result.id, result.coldRestore.scrollback, seedSize, { cwd: result.coldRestore.cwd, - oscLinks: result.coldRestore.oscLinks + oscLinks: result.coldRestore.oscLinks, + preferProviderIfExisting: true }) } } diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 0f6c919d330..52fd412389e 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -117,9 +117,12 @@ export type PtySpawnResult = { * writing the snapshot so ANSI cursor positions land correctly. */ snapshotCols?: number snapshotRows?: number - /** Provider output sequence at snapshot capture. Main uses this to keep - * post-reattach live bytes in the same deduplication domain. */ - snapshotOutputSequence?: number + /** Provider sequence at the attach boundary. `reset` starts a new provider + * generation; `continued` resumes the existing absolute domain. */ + providerSequence?: { + value: number + generation: 'continued' | 'reset' + } /** Kitty keyboard flags persisted in the daemon snapshot, threaded so the * re-seeded runtime emulator answers hidden `CSI ? u` with the real flags * (terminal-query-authority.md §kitty). Never replayed into a renderer diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index c2a379f2074..f7e02817e6a 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -7117,12 +7117,18 @@ describe('OrcaRuntimeService', () => { expect(runtime.getPtyOutputSequence('pty-1')).toBe(0) }) - it('anchors post-reattach live output to the provider snapshot sequence', async () => { + it('aligns a restored session and pre-response bytes to the provider sequence', async () => { const { runtime } = createSideEffectRuntime() // Simulate a stream-socket byte winning the race with the spawn response. runtime.onPtyData('pty-restored', 'queued', 100) - expect(runtime.anchorPtyOutputSequenceFromProviderSnapshot('pty-restored', 900)).toBe(906) + expect( + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-restored', + { value: 900, generation: 'continued' }, + 0 + ) + ).toBe(906) runtime.onPtyData('pty-restored', 'fresh', 101) expect(runtime.getPtyOutputSequence('pty-restored')).toBe(911) @@ -7131,8 +7137,131 @@ describe('OrcaRuntimeService', () => { seq: 911, source: 'headless' }) - // Renderer remounts can attach the same provider PTY again in one main lifetime. - expect(runtime.anchorPtyOutputSequenceFromProviderSnapshot('pty-restored', 911)).toBe(911) + }) + + it('does not double a fresh daemon sequence on same-main reattach', () => { + const { runtime } = createSideEffectRuntime() + + expect( + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-fresh', + { value: 0, generation: 'reset' }, + 0 + ) + ).toBe(0) + runtime.onPtyData('pty-fresh', 'fresh output', 100) + const sequenceBeforeReattach = runtime.getPtyOutputSequence('pty-fresh') + + expect( + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-fresh', + { value: sequenceBeforeReattach, generation: 'continued' }, + sequenceBeforeReattach + ) + ).toBe(sequenceBeforeReattach) + }) + + it('does not jump ahead of delayed bytes covered by a reattach snapshot', () => { + const { runtime } = createSideEffectRuntime() + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-delayed', + { value: 0, generation: 'reset' }, + 0 + ) + runtime.onPtyData('pty-delayed', 'before', 100) + const sequenceBeforeReattach = runtime.getPtyOutputSequence('pty-delayed') + const delayedCoveredBytes = 'queued' + + expect( + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-delayed', + { + value: sequenceBeforeReattach + delayedCoveredBytes.length, + generation: 'continued' + }, + sequenceBeforeReattach + ) + ).toBe(sequenceBeforeReattach) + runtime.onPtyData('pty-delayed', delayedCoveredBytes, 101) + + expect(runtime.getPtyOutputSequence('pty-delayed')).toBe( + sequenceBeforeReattach + delayedCoveredBytes.length + ) + }) + + it('retains bytes emitted before a fresh daemon spawn resolves', async () => { + const { runtime } = createSideEffectRuntime() + const earlyOutput = '\x1b]0;Fresh shell\x07early prompt' + runtime.onPtyData('pty-fresh', earlyOutput, 100) + + expect( + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-fresh', + { value: 0, generation: 'reset' }, + 0 + ) + ).toBe(earlyOutput.length) + + await expect(runtime.serializeMainTerminalBuffer('pty-fresh')).resolves.toMatchObject({ + data: expect.stringContaining('early prompt'), + lastTitle: 'Fresh shell', + seq: earlyOutput.length + }) + }) + + it('drops stale headless state without rewinding the runtime sequence', async () => { + const { runtime } = createSideEffectRuntime() + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-restarted', + { value: 0, generation: 'reset' }, + 0 + ) + runtime.onPtyData('pty-restarted', 'old generation', 100) + const sequenceBeforeRespawn = runtime.getPtyOutputSequence('pty-restarted') + + expect( + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-restarted', + { value: 0, generation: 'reset' }, + sequenceBeforeRespawn + ) + ).toBe(sequenceBeforeRespawn) + runtime.onPtyData('pty-restarted', 'new generation', 101) + + await expect(runtime.serializeMainTerminalBuffer('pty-restarted')).resolves.toMatchObject({ + data: expect.not.stringContaining('old generation'), + seq: sequenceBeforeRespawn + 'new generation'.length + }) + }) + + it('keeps active listener sequences monotonic across a daemon reset', () => { + const { runtime } = createSideEffectRuntime() + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-reset', + { value: 0, generation: 'reset' }, + 0 + ) + runtime.onPtyData('pty-reset', 'old', 100) + const sequenceBeforeRespawn = runtime.getPtyOutputSequence('pty-reset') + const observedSequences: number[] = [] + runtime.subscribeToTerminalData('pty-reset', (_data, meta) => { + if (typeof meta?.seq === 'number') { + observedSequences.push(meta.seq) + } + }) + + runtime.onPtyData('pty-reset', 'early', 101) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-reset', + { value: 0, generation: 'reset' }, + sequenceBeforeRespawn + ) + runtime.onPtyData('pty-reset', 'later', 102) + + expect(observedSequences).toEqual([ + sequenceBeforeRespawn + 'early'.length, + sequenceBeforeRespawn + 'early'.length + 'later'.length + ]) }) it('carries the synthetic permission BEL as a bell fact', () => { @@ -7275,6 +7404,214 @@ describe('OrcaRuntimeService', () => { }) }) + it('does not let pre-response bytes hide restored provider history', async () => { + const { runtime } = createSideEffectRuntime() + const serializeProviderBuffer = vi.fn().mockResolvedValue({ + data: 'restored history\r\nqueued', + cols: 80, + rows: 24, + seq: 906, + source: 'headless' + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer, + hasRendererSerializer: () => false + }) + runtime.onPtyData('pty-restored', 'queued', 100) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-restored', + { value: 900, generation: 'continued' }, + 0 + ) + + const snapshot = await runtime.serializeTerminalBuffer('pty-restored', { + scrollbackRows: 5000 + }) + + expect(snapshot?.data).toContain('restored history') + expect(serializeProviderBuffer).toHaveBeenCalledOnce() + }) + + it('keeps restored provider history authoritative after later live output', async () => { + const { runtime } = createSideEffectRuntime() + const serializeProviderBuffer = vi.fn().mockResolvedValue({ + data: 'restored history\r\nlater output', + cols: 80, + rows: 24, + seq: 912, + source: 'headless' + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer, + hasRendererSerializer: () => false + }) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-restored', + { value: 900, generation: 'continued' }, + 0 + ) + runtime.onPtyData('pty-restored', 'later output', 100) + + const snapshot = await runtime.serializeTerminalBuffer('pty-restored') + + expect(snapshot?.data).toContain('restored history') + expect(serializeProviderBuffer).toHaveBeenCalledOnce() + }) + + it('uses provider alternate-screen state while a partial model is unsafe', async () => { + const { runtime } = createSideEffectRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer: vi.fn().mockResolvedValue({ + data: 'restored tui', + cols: 80, + rows: 24, + seq: 903, + source: 'headless', + alternateScreen: true + }), + hasRendererSerializer: () => false + }) + runtime.onPtyData('pty-tui', 'tui', 100) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-tui', + { value: 900, generation: 'continued' }, + 0 + ) + + await runtime.serializeTerminalBuffer('pty-tui') + + expect(runtime.isTerminalAlternateScreen('pty-tui')).toBe(true) + }) + + it('tracks live alternate-screen transitions after a provider snapshot', async () => { + const { runtime } = createSideEffectRuntime() + const serializeProviderBuffer = vi.fn().mockResolvedValue({ + data: 'restored tui', + cols: 80, + rows: 24, + seq: 900, + source: 'headless', + alternateScreen: true + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer, + hasRendererSerializer: () => false + }) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-tui', + { value: 900, generation: 'continued' }, + 0 + ) + await runtime.serializeTerminalBuffer('pty-tui') + expect(runtime.isTerminalAlternateScreen('pty-tui')).toBe(true) + + runtime.onPtyData('pty-tui', '\x1b[?1049l', 100) + expect(runtime.isTerminalAlternateScreen('pty-tui')).toBe(false) + runtime.onPtyData('pty-tui', '\x1b[?1049h', 101) + expect(runtime.isTerminalAlternateScreen('pty-tui')).toBe(true) + }) + + it('keeps mode transitions that race a provider snapshot response', async () => { + const { runtime } = createSideEffectRuntime() + let resolveProviderSnapshot: + | ((snapshot: { + data: string + cols: number + rows: number + seq: number + source: 'headless' + alternateScreen: boolean + }) => void) + | undefined + const serializeProviderBuffer = vi.fn( + () => + new Promise<{ + data: string + cols: number + rows: number + seq: number + source: 'headless' + alternateScreen: boolean + }>((resolve) => { + resolveProviderSnapshot = resolve + }) + ) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer, + hasRendererSerializer: () => false + }) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-tui-race', + { value: 900, generation: 'continued' }, + 0 + ) + + const snapshotPromise = runtime.serializeTerminalBuffer('pty-tui-race') + await vi.waitFor(() => expect(resolveProviderSnapshot).toBeDefined()) + runtime.onPtyData('pty-tui-race', '\x1b[?1049l', 100) + resolveProviderSnapshot?.({ + data: 'captured alt screen', + cols: 80, + rows: 24, + seq: 900, + source: 'headless', + alternateScreen: true + }) + await snapshotPromise + + expect(runtime.isTerminalAlternateScreen('pty-tui-race')).toBe(false) + }) + + it('translates reset provider snapshots without retaining the old title', async () => { + const { runtime } = createSideEffectRuntime() + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-replaced', + { value: 0, generation: 'reset' }, + 0 + ) + runtime.onPtyData('pty-replaced', '\x1b]0;Old process\x07old', 100) + const sequenceBeforeRespawn = runtime.getPtyOutputSequence('pty-replaced') + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer: vi.fn().mockResolvedValue({ + data: 'new process', + cols: 80, + rows: 24, + seq: 'new process'.length, + source: 'headless', + lastTitle: 'New process' + }), + hasRendererSerializer: () => false + }) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-replaced', + { value: 0, generation: 'reset' }, + sequenceBeforeRespawn + ) + + await expect(runtime.serializeTerminalBuffer('pty-replaced')).resolves.toMatchObject({ + lastTitle: 'New process', + seq: sequenceBeforeRespawn + 'new process'.length + }) + }) + it('prefers the tracked title over the headless emulator lastTitle', async () => { const { runtime } = createSideEffectRuntime() syncSinglePty(runtime) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 910acf41c59..0fb9d38ba6a 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -25,6 +25,7 @@ import type { TerminalSideEffectFact } from '../../shared/terminal-side-effect-facts' import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' +import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker' import { AGENT_STATUS_STALE_AFTER_MS, isFreshNonDoneAgentStatus, @@ -1180,6 +1181,8 @@ type RuntimeHeadlessTerminal = { type HeadlessSeedMetadata = { cwd?: string | null oscLinks?: TerminalOscLinkRange[] + /** Cold restore history must outrank a model that only saw new-generation bytes. */ + preferProviderIfExisting?: boolean /** Persisted kitty flags from the daemon snapshot, re-applied to the fresh * emulator so hidden `CSI ? u` answers the real flags instead of ?0u * (terminal-query-authority.md §kitty). */ @@ -2249,7 +2252,14 @@ export class OrcaRuntimeService { private titleObservationSequence = 0 private headlessTerminals = new Map() private ptyOutputSequenceById = new Map() - private providerSequenceAnchoredPtys = new Set() + private providerSequenceInitializedPtys = new Set() + private providerSequenceOffsetByPtyId = new Map() + private providerSnapshotPreferredPtys = new Set() + private providerModeTrackersByPtyId = new Map() + private providerModeSnapshotScansByPtyId = new Map< + string, + Set + >() private recentPtyPathCandidatesById = new Map() // Why: OSC 9999 status can span PTY chunks. Keeping parser state in the // runtime lets hidden/model-owned terminals observe agent state without a @@ -5686,6 +5696,10 @@ export class OrcaRuntimeService { onPtyData(ptyId: string, data: string, at: number, sequenceChars = data.length): number { const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + sequenceChars this.ptyOutputSequenceById.set(ptyId, outputSequence) + this.providerModeTrackersByPtyId.get(ptyId)?.scan(data) + for (const tracker of this.providerModeSnapshotScansByPtyId.get(ptyId) ?? []) { + tracker.scan(data) + } const osc7Metadata = this.recordOsc7MetadataForPty(ptyId, data) const cwd = osc7Metadata.cwd const cwdChanged = osc7Metadata.cwdChanged @@ -6410,6 +6424,29 @@ export class OrcaRuntimeService { this.ptyTitleTrackersByPtyId.delete(ptyId) } + private resetTrackedTerminalStateForProviderGeneration(ptyId: string): void { + // Why: a replacement daemon session can reuse the PTY id, but title/parser + // state from the prior process must not bleed into its snapshots or chunks. + this.disposePtyTitleTracker(ptyId) + this.oscTitleScanTailByPtyId.delete(ptyId) + this.osc7ScanTailByPtyId.delete(ptyId) + this.agentStatusOscProcessorsByPtyId.delete(ptyId) + const pty = this.ptysById.get(ptyId) + if (pty) { + pty.lastOscTitle = null + pty.lastOscTitleAt = null + pty.lastAgentStatus = null + pty.managementTitle = null + pty.managementTitleAt = null + } + for (const leaf of this.getLeavesForPty(ptyId)) { + leaf.lastOscTitle = null + leaf.lastOscTitleAt = null + leaf.lastAgentStatus = null + } + this.clearAgentRowSnapshotsForPty(ptyId) + } + private setTerminalSideEffectConsumerAvailable(available: boolean): void { const nextAvailable = available && this.onTerminalSideEffects !== null if (nextAvailable === this.terminalSideEffectConsumerAvailable) { @@ -6600,27 +6637,78 @@ export class OrcaRuntimeService { return this.ptyOutputSequenceById.get(ptyId) ?? 0 } - anchorPtyOutputSequenceFromProviderSnapshot(ptyId: string, providerSequence: number): number { + synchronizePtyOutputSequenceFromProvider( + ptyId: string, + providerSequence: { value: number; generation: 'continued' | 'reset' }, + runtimeSequenceAtSpawnStart = 0 + ): number { if ( - this.providerSequenceAnchoredPtys.has(ptyId) || - !Number.isFinite(providerSequence) || - providerSequence < 0 + !Number.isFinite(providerSequence.value) || + providerSequence.value < 0 || + !Number.isFinite(runtimeSequenceAtSpawnStart) || + runtimeSequenceAtSpawnStart < 0 ) { return this.getPtyOutputSequence(ptyId) } - const baseline = Math.floor(providerSequence) - const anchoredSequence = baseline + this.getPtyOutputSequence(ptyId) - this.ptyOutputSequenceById.set(ptyId, anchoredSequence) - this.providerSequenceAnchoredPtys.add(ptyId) + const baseline = Math.floor(providerSequence.value) + const currentSequence = this.getPtyOutputSequence(ptyId) + const sequenceAtSpawnStart = Math.min(currentSequence, Math.floor(runtimeSequenceAtSpawnStart)) + const postSpawnSequence = currentSequence - sequenceAtSpawnStart + const wasInitialized = this.providerSequenceInitializedPtys.has(ptyId) + const replacesExistingRuntimeGeneration = wasInitialized || sequenceAtSpawnStart > 0 + const providerOffset = + providerSequence.generation === 'reset' + ? sequenceAtSpawnStart + : (this.providerSequenceOffsetByPtyId.get(ptyId) ?? 0) + const providerBaseline = providerOffset + baseline + + if (providerSequence.generation === 'reset') { + // Why: daemon respawn/cold restore starts a new absolute domain. Old + // emulator state cannot remain authoritative over the replacement. + if (replacesExistingRuntimeGeneration) { + this.disposeHeadlessTerminal(ptyId) + } + this.providerModeTrackersByPtyId.delete(ptyId) + if (replacesExistingRuntimeGeneration && postSpawnSequence === 0) { + this.resetTrackedTerminalStateForProviderGeneration(ptyId) + } + } + + const synchronizedSequence = + providerSequence.generation === 'reset' + ? currentSequence + : wasInitialized + ? currentSequence + : providerBaseline + postSpawnSequence + this.ptyOutputSequenceById.set(ptyId, synchronizedSequence) + this.providerSequenceInitializedPtys.add(ptyId) + this.providerSequenceOffsetByPtyId.set(ptyId, providerOffset) + + const snapshotMayCoverMissingState = + (providerSequence.generation === 'continued' && !wasInitialized) || + (postSpawnSequence > 0 && + providerSequence.generation === 'reset' && + replacesExistingRuntimeGeneration) || + (providerSequence.generation === 'continued' && + wasInitialized && + providerBaseline > currentSequence) + if (snapshotMayCoverMissingState) { + // Why: bytes can cross the control/stream sockets around attach. Until a + // full renderer/provider snapshot is available, a partial model is unsafe. + this.providerSnapshotPreferredPtys.add(ptyId) + } else if (providerSequence.generation === 'reset') { + this.providerSnapshotPreferredPtys.delete(ptyId) + } + const headless = this.headlessTerminals.get(ptyId) - if (headless) { + if (headless && !wasInitialized && providerSequence.generation === 'continued') { // Why: daemon bytes can reach main just before spawn resolves. Queue the // baseline behind those writes so their emulator sequence is rebased too. headless.writeChain = headless.writeChain.then(() => { - headless.outputSequence += baseline + headless.outputSequence = synchronizedSequence }) } - return anchoredSequence + return synchronizedSequence } subscribeToTerminalData( @@ -6821,10 +6909,17 @@ export class OrcaRuntimeService { // scrollback to mobile so it rewraps at the new cols, but alternate-screen // TUIs (vim, Claude Code) own their repaint and have no scrollback — for // those the mobile client just resizes xterm geometry and consumes the - // TUI's own redraw, so the resize re-stream must be skipped. Returns false - // when there is no headless emulator (resize falls back to geometry-only). + // TUI's own redraw, so the resize re-stream must be skipped. Provider state + // covers restored PTYs whose main-side emulator is only a partial suffix. isTerminalAlternateScreen(ptyId: string): boolean { - return this.headlessTerminals.get(ptyId)?.emulator.isAlternateScreen ?? false + if (this.providerSnapshotPreferredPtys.has(ptyId)) { + return this.providerModeTrackersByPtyId.get(ptyId)?.isAlternateScreen ?? false + } + return ( + this.headlessTerminals.get(ptyId)?.emulator.isAlternateScreen ?? + this.providerModeTrackersByPtyId.get(ptyId)?.isAlternateScreen ?? + false + ) } // Why: daemon-backed PTYs that the runtime adopted after an Orca relaunch @@ -6848,10 +6943,14 @@ export class OrcaRuntimeService { if (existing) { // Why: emulator already has live data — re-seeding would duplicate // every byte. The seed is only valid when the emulator is fresh. + if (metadata.preferProviderIfExisting) { + this.providerSnapshotPreferredPtys.add(ptyId) + } return } const dims = size ?? this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } const state = this.createPtyHeadlessTerminalState(ptyId, dims) + state.outputSequence = this.getPtyOutputSequence(ptyId) this.headlessTerminals.set(ptyId, state) this.recordOsc7MetadataForPty(ptyId, data) this.recordRecentPtyOutputForPathProvenance(ptyId, data) @@ -6873,6 +6972,7 @@ export class OrcaRuntimeService { if (metadata.oscLinks !== undefined) { state.emulator.setRestoredOscLinks(metadata.oscLinks) } + this.providerSnapshotPreferredPtys.delete(ptyId) }) .catch(() => { // Seeding is best-effort; live data will continue to populate the @@ -6890,7 +6990,8 @@ export class OrcaRuntimeService { if (this.headlessHydrationState.has(ptyId)) { return } - if (this.headlessTerminals.has(ptyId)) { + const providerSnapshotPreferred = this.providerSnapshotPreferredPtys.has(ptyId) + if (this.headlessTerminals.has(ptyId) && !providerSnapshotPreferred) { // Daemon-snapshot seed already populated the emulator — skip hydration. this.headlessHydrationState.set(ptyId, 'done') return @@ -6905,11 +7006,18 @@ export class OrcaRuntimeService { return } + if (providerSnapshotPreferred) { + // Why: a stream byte can create a partial model before restored history + // arrives. A mounted renderer snapshot can safely replace that model. + this.disposeHeadlessTerminal(ptyId) + } + this.headlessHydrationState.set(ptyId, 'pending') const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } // Why: hydration writes below never set forwardQueryReplies (main-side // replay guard) — renderer-buffer snapshots can embed stale queries. const state = this.createPtyHeadlessTerminalState(ptyId, dims) + state.outputSequence = this.getPtyOutputSequence(ptyId) this.headlessTerminals.set(ptyId, state) // Why: append the seed work to writeChain so live writes queued by @@ -6948,6 +7056,7 @@ export class OrcaRuntimeService { state.emulator.setLastTitle(seedTitle) this.applySeededAgentStatus(ptyId, seedTitle) } + this.providerSnapshotPreferredPtys.delete(ptyId) } catch { // Hydration is best-effort. Live writes continue via the same // writeChain that this catch-arm leaves intact. @@ -7140,6 +7249,19 @@ export class OrcaRuntimeService { alternateScreen?: boolean pendingEscapeTailAnsi?: string } | null> { + if (this.providerSnapshotPreferredPtys.has(ptyId)) { + // Why: pre-attach stream bytes only form a suffix of restored state. A + // sequenced provider snapshot safely reconciles live bytes; renderer is + // the fallback when an older provider cannot expose that boundary. + const providerSnapshot = await this.serializeProviderTerminalBuffer(ptyId, opts) + if (providerSnapshot) { + return providerSnapshot + } + const rendererSnapshot = await this.serializeRendererTerminalBuffer(ptyId, opts) + if (rendererSnapshot) { + return rendererSnapshot + } + } const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, opts) if (headlessSnapshot) { return headlessSnapshot @@ -7198,13 +7320,42 @@ export class OrcaRuntimeService { ptyId: string, opts: { scrollbackRows?: number } = {} ): Promise { + const liveModeTracker = new TerminalKittyKeyboardModeTracker() + let liveModeTrackers = this.providerModeSnapshotScansByPtyId.get(ptyId) + if (!liveModeTrackers) { + liveModeTrackers = new Set() + this.providerModeSnapshotScansByPtyId.set(ptyId, liveModeTrackers) + } + liveModeTrackers.add(liveModeTracker) try { // Why: daemon PTYs survive an app relaunch before any renderer mounts. // Mobile still needs their retained history without navigating desktop. const snapshot = await this.ptyController?.serializeProviderBuffer?.(ptyId, opts) - return snapshot ? this.preferTrackedLastTitle(ptyId, snapshot) : null + if (typeof snapshot?.alternateScreen === 'boolean') { + const modeTracker = new TerminalKittyKeyboardModeTracker() + if (snapshot.alternateScreen) { + modeTracker.scan('\x1b[?1049h') + } + if (liveModeTracker.hasObservedAlternateScreenSwitch) { + modeTracker.scan(liveModeTracker.isAlternateScreen ? '\x1b[?1049h' : '\x1b[?1049l') + } + this.providerModeTrackersByPtyId.set(ptyId, modeTracker) + } + if (!snapshot) { + return null + } + const providerOffset = this.providerSequenceOffsetByPtyId.get(ptyId) ?? 0 + return this.preferTrackedLastTitle(ptyId, { + ...snapshot, + seq: providerOffset + snapshot.seq + }) } catch { return null + } finally { + liveModeTrackers.delete(liveModeTracker) + if (liveModeTrackers.size === 0) { + this.providerModeSnapshotScansByPtyId.delete(ptyId) + } } } @@ -8293,7 +8444,11 @@ export class OrcaRuntimeService { this.clearWaitBlockedCheckState(ptyId) this.recentPtyPathCandidatesById.delete(ptyId) this.ptyOutputSequenceById.delete(ptyId) - this.providerSequenceAnchoredPtys.delete(ptyId) + this.providerSequenceInitializedPtys.delete(ptyId) + this.providerSequenceOffsetByPtyId.delete(ptyId) + this.providerSnapshotPreferredPtys.delete(ptyId) + this.providerModeTrackersByPtyId.delete(ptyId) + this.providerModeSnapshotScansByPtyId.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) this.terminalSpawnCommandsByPtyId.delete(ptyId) this.disposePtyTitleTracker(ptyId) @@ -20511,7 +20666,11 @@ export class OrcaRuntimeService { this.clearWaitBlockedCheckState(ptyId) this.recentPtyPathCandidatesById.delete(ptyId) this.ptyOutputSequenceById.delete(ptyId) - this.providerSequenceAnchoredPtys.delete(ptyId) + this.providerSequenceInitializedPtys.delete(ptyId) + this.providerSequenceOffsetByPtyId.delete(ptyId) + this.providerSnapshotPreferredPtys.delete(ptyId) + this.providerModeTrackersByPtyId.delete(ptyId) + this.providerModeSnapshotScansByPtyId.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) this.terminalSpawnCommandsByPtyId.delete(ptyId) this.disposePtyTitleTracker(ptyId) diff --git a/src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts b/src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts index b6e265c7f80..5d04ef24389 100644 --- a/src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts +++ b/src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts @@ -92,3 +92,96 @@ it('replays post-capture output in the provider snapshot sequence domain', async runtime.cleanupSubscription('terminal-restored:phone-1') await dispatchPromise }) + +it('keeps provider-backed alternate-screen resizes geometry-only', async () => { + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let resizeListener: + | ((event: { + cols: number + rows: number + displayMode: string + reason: string + seq: number + }) => void) + | undefined + const serializeTerminalBuffer = vi + .fn() + .mockResolvedValue({ data: 'restored tui', cols: 80, rows: 24 }) + const runtime = { + getRuntimeId: () => 'test-runtime', + registerRemoteTerminalViewSubscriber: () => () => {}, + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-tui' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer, + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(true), + handleMobileSubscribe: vi.fn().mockResolvedValue(undefined), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn((_ptyId, listener) => { + resizeListener = listener + return vi.fn() + }), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateMobileViewport: vi.fn().mockResolvedValue({ updated: true, applied: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatchPromise = dispatcher.dispatchStreaming( + { + ...request, + id: 'req-provider-alt-screen', + params: { + terminal: 'terminal-tui', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + } + }, + vi.fn(), + { + connectionId: 'conn-tui-resize', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + } + } + ) + + await vi.waitFor(() => expect(resizeListener).toBeDefined()) + binaryFrames.splice(0) + resizeListener?.({ + cols: 100, + rows: 30, + displayMode: 'auto', + reason: 'apply-layout', + seq: 2 + }) + await vi.waitFor(() => + expect( + binaryFrames.some( + (frame) => decodeTerminalStreamFrame(frame)?.opcode === TerminalStreamOpcode.Resized + ) + ).toBe(true) + ) + + expect(serializeTerminalBuffer).toHaveBeenCalledOnce() + expect( + binaryFrames.some( + (frame) => decodeTerminalStreamFrame(frame)?.opcode === TerminalStreamOpcode.SnapshotStart + ) + ).toBe(false) + + runtime.cleanupSubscription('terminal-tui:phone-1') + await dispatchPromise +}) diff --git a/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts b/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts index 6d88f56a070..9ad2cb4d77a 100644 --- a/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts +++ b/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts @@ -60,10 +60,13 @@ describe('TerminalKittyKeyboardModeTracker', () => { tracker.scan('\x1b[>1u') expect(tracker.flags).toBe(1) tracker.scan('\x1b[?1049h') + expect(tracker.hasObservedAlternateScreenSwitch).toBe(true) + expect(tracker.isAlternateScreen).toBe(true) expect(tracker.flags).toBe(0) tracker.scan('\x1b[>2u') expect(tracker.flags).toBe(2) tracker.scan('\x1b[?1049l') + expect(tracker.isAlternateScreen).toBe(false) expect(tracker.flags).toBe(1) }) diff --git a/src/shared/terminal-kitty-keyboard-mode-tracker.ts b/src/shared/terminal-kitty-keyboard-mode-tracker.ts index a0b7b0d1f2b..2b501a2c9ff 100644 --- a/src/shared/terminal-kitty-keyboard-mode-tracker.ts +++ b/src/shared/terminal-kitty-keyboard-mode-tracker.ts @@ -30,12 +30,21 @@ export class TerminalKittyKeyboardModeTracker { private mainStack: number[] = [] private altStack: number[] = [] private alternateScreenActive = false + private alternateScreenSwitchObserved = false /** Current effective kitty keyboard flags (0 = protocol inactive). */ get flags(): number { return this.currentFlags } + get isAlternateScreen(): boolean { + return this.alternateScreenActive + } + + get hasObservedAlternateScreenSwitch(): boolean { + return this.alternateScreenSwitchObserved + } + reset(): void { this.scanTail = '' this.currentFlags = 0 @@ -44,6 +53,7 @@ export class TerminalKittyKeyboardModeTracker { this.mainStack = [] this.altStack = [] this.alternateScreenActive = false + this.alternateScreenSwitchObserved = false } scan(data: string): void { @@ -77,6 +87,7 @@ export class TerminalKittyKeyboardModeTracker { const tail = this.scanTail this.reset() this.scanTail = tail + this.alternateScreenSwitchObserved = true continue } if (match[0].endsWith('!p')) { @@ -108,6 +119,7 @@ export class TerminalKittyKeyboardModeTracker { if (param !== 47 && param !== 1047 && param !== 1049) { continue } + this.alternateScreenSwitchObserved = true // Why: xterm swaps the current flags with the inactive screen's slot on // every 47/1047/1049 transition, without an already-active guard — // mirror it exactly so this state matches what the renderer encodes.