Harden daemon PTY generation handoff

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-14 18:19:43 -04:00
co-authored by Orca
parent 49303fec80
commit 02de3c565c
10 changed files with 686 additions and 44 deletions
+6 -1
View File
@@ -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' })
})
})
+21 -4
View File
@@ -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 }
: {}),
+8 -6
View File
@@ -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 }
)
})
+18 -7
View File
@@ -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
})
}
}
+6 -3
View File
@@ -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
+341 -4
View File
@@ -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)
+178 -19
View File
@@ -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<string, RuntimeHeadlessTerminal>()
private ptyOutputSequenceById = new Map<string, number>()
private providerSequenceAnchoredPtys = new Set<string>()
private providerSequenceInitializedPtys = new Set<string>()
private providerSequenceOffsetByPtyId = new Map<string, number>()
private providerSnapshotPreferredPtys = new Set<string>()
private providerModeTrackersByPtyId = new Map<string, TerminalKittyKeyboardModeTracker>()
private providerModeSnapshotScansByPtyId = new Map<
string,
Set<TerminalKittyKeyboardModeTracker>
>()
private recentPtyPathCandidatesById = new Map<string, string[]>()
// 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<PtyProviderBufferSnapshot | null> {
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)
@@ -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<ArrayBufferLike>[] = []
const cleanups = new Map<string, () => 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<RuntimeTerminalWait>(() => {})),
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
})
@@ -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)
})
@@ -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.