Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/main/daemon/daemon-pty-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -506,6 +507,10 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(second.launchAgent).toBe('droid')
expect(second.snapshot).toBeDefined()
expect(second.snapshot).toContain('hello from shell')
expect(second.providerSequence).toEqual({
value: 'hello from shell\r\n'.length,
generation: 'continued'
})
})

it('includes rehydrateSequences in snapshot when terminal modes are active', async () => {
Expand All @@ -528,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' })
})
})

Expand Down
22 changes: 21 additions & 1 deletion src/main/daemon/daemon-pty-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -389,6 +407,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
id: sessionId,
pid,
...launchIdentity(),
...(providerSequence ? { providerSequence } : {}),
...(isReattach ? { isReattach: true } : {})
}
}
Expand All @@ -410,6 +429,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
snapshot: snapshotPayload,
snapshotCols: result.snapshot.cols,
snapshotRows: result.snapshot.rows,
...(providerSequence ? { providerSequence } : {}),
...(typeof kittyKeyboardFlags === 'number' && kittyKeyboardFlags > 0
? { snapshotKittyKeyboardFlags: kittyKeyboardFlags }
: {}),
Expand Down
76 changes: 75 additions & 1 deletion src/main/ipc/pty.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10437,6 +10437,44 @@ describe('registerPtyHandlers', () => {
expect(mockProc.proc.write).not.toHaveBeenCalled()
})

it('synchronizes runtime output sequencing from a provider reattach snapshot', async () => {
setLocalPtyProvider({
spawn: vi.fn(async () => ({
id: 'pty-restored',
isReattach: true,
providerSequence: { value: 900, generation: 'continued' as const }
})),
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(),
getPtyOutputSequence: vi.fn().mockReturnValue(7),
synchronizePtyOutputSequenceFromProvider: 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.synchronizePtyOutputSequenceFromProvider).toHaveBeenCalledWith(
'pty-restored',
{ value: 900, generation: 'continued' },
7
)
})

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 = {
Expand Down Expand Up @@ -10474,7 +10512,7 @@ describe('registerPtyHandlers', () => {
'pty-cold-restore',
'restored history\r\n',
undefined,
{ cwd: '/projects/restored', oscLinks }
{ cwd: '/projects/restored', oscLinks, preferProviderIfExisting: true }
)
})

Expand Down Expand Up @@ -11291,6 +11329,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<unknown>
}

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 = {
Expand Down
34 changes: 33 additions & 1 deletion src/main/ipc/pty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3125,7 +3125,18 @@ 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 (result.providerSequence) {
runtime?.synchronizePtyOutputSequenceFromProvider?.(
result.id,
result.providerSequence,
sequenceBeforeProviderSpawn
)
}
} catch (err) {
const rawMessage = err instanceof Error ? err.message : String(err)
const spawnError = normalizeNodePtySpawnError(err)
Expand Down Expand Up @@ -3466,6 +3477,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
Expand Down Expand Up @@ -4034,7 +4054,18 @@ export function registerPtyHandlers(
)
}
spawnTiming.mark('options')
const expectedPtyId = effectiveSessionAppId ?? effectiveSessionId
const sequenceBeforeProviderSpawn = expectedPtyId
? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0)
: 0
result = await provider.spawn(spawnOptions)
if (result.providerSequence) {
runtime?.synchronizePtyOutputSequenceFromProvider?.(
result.id,
result.providerSequence,
sequenceBeforeProviderSpawn
)
}
spawnTiming.mark('provider_spawn')
} catch (err) {
// Why: a failed spawn must not leave a stale hidden mark on a session
Expand Down Expand Up @@ -4269,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
})
}
}
Expand Down
6 changes: 6 additions & 0 deletions src/main/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ export type PtySpawnResult = {
* writing the snapshot so ANSI cursor positions land correctly. */
snapshotCols?: number
snapshotRows?: 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
Expand Down
Loading
Loading