refactor(rpc): split terminal methods into cohesive modules

This commit is contained in:
Neil
2026-08-23 17:37:42 -07:00
parent 0a613d5fed
commit 0286e7bafe
37 changed files with 5042 additions and 3823 deletions
-1
View File
@@ -65,7 +65,6 @@ inline src/main/runtime/orca-runtime-git.ts
inline src/main/runtime/orca-runtime.test.ts
inline src/main/runtime/orca-runtime.ts
inline src/main/runtime/rpc/methods/orchestration.ts
inline src/main/runtime/rpc/methods/terminal.ts
inline src/main/runtime/runtime-rpc.ts
inline src/main/source-control/hosted-review-creation.ts
inline src/main/speech/model-manager.ts
@@ -0,0 +1,91 @@
import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import type { RuntimeTerminalWait } from '../../../../shared/runtime-types'
import { createSubscriptionRegistryDouble } from '../subscription-registry-test-double'
import { TERMINAL_METHODS } from './terminal'
function request(params: unknown): RpcRequest {
return {
id: 'request',
authToken: 'token',
method: 'terminal.subscribe',
params
}
}
describe('legacy terminal subscription stream IDs', () => {
it('allocates binary and JSON subscriptions from one monotonically increasing owner', async () => {
const registry = createSubscriptionRegistryDouble()
const runtime = {
getRuntimeId: () => 'runtime',
resolveLeafForHandle: vi.fn(() => ({ ptyId: 'pty' })),
readTerminal: vi.fn(async () => ({ tail: [], truncated: false })),
serializeTerminalBuffer: vi.fn(async () => ({ data: '', cols: 80, rows: 24 })),
getTerminalSize: vi.fn(() => ({ cols: 80, rows: 24 })),
getMobileDisplayMode: vi.fn(() => 'auto'),
getLayout: vi.fn(() => ({ seq: 1 })),
subscribeToTerminalData: vi.fn(() => () => {}),
registerRemoteTerminalViewSubscriber: vi.fn(() => () => {}),
subscribeToTerminalResize: vi.fn(() => () => {}),
subscribeToFitOverrideChanges: vi.fn(() => () => {}),
subscribeToPtyExit: vi.fn(() => () => {}),
registerSubscriptionCleanup: vi.fn(registry.registerSubscriptionCleanup),
registerOwnedSubscriptionCleanup: vi.fn(registry.registerOwnedSubscriptionCleanup),
cleanupSubscription: vi.fn(registry.cleanupSubscription),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {}))
} as unknown as Partial<OrcaRuntimeService>
const dispatcher = new RpcDispatcher({
runtime: runtime as OrcaRuntimeService,
methods: TERMINAL_METHODS
})
const subscribeBinary = async (terminal: string, clientId: string) => {
const messages: string[] = []
const pending = dispatcher.dispatchStreaming(
request({
terminal,
client: { id: clientId, type: 'desktop' },
capabilities: { terminalBinaryStream: 1 }
}),
(message) => messages.push(message),
{ connectionId: `connection-${clientId}`, sendBinary: () => {} }
)
await vi.waitFor(() =>
expect(messages.some((message) => JSON.parse(message).result?.type === 'subscribed')).toBe(
true
)
)
const subscribed = messages
.map((message) => JSON.parse(message))
.find((message) => message.result?.type === 'subscribed')
const streamId = subscribed?.result?.streamId
if (typeof streamId !== 'number') {
throw new Error('Missing legacy binary stream ID')
}
runtime.cleanupSubscription?.(`${terminal}:${clientId}`)
await pending
return streamId
}
const first = await subscribeBinary('terminal-a', 'client-a')
const jsonMessages: string[] = []
const jsonPending = dispatcher.dispatchStreaming(
request({ terminal: 'terminal-json', client: { id: 'client-json', type: 'desktop' } }),
(message) => jsonMessages.push(message),
{ connectionId: 'connection-json' }
)
await vi.waitFor(() =>
expect(
jsonMessages.some((message) => JSON.parse(message).result?.type === 'scrollback')
).toBe(true)
)
runtime.cleanupSubscription?.('terminal-json:client-json')
await jsonPending
const second = await subscribeBinary('terminal-b', 'client-b')
expect(second).toBe(first + 2)
})
})
@@ -0,0 +1,207 @@
import { describe, expect, it, vi } from 'vitest'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { TERMINAL_METHODS } from './terminal'
import {
TerminalMultiplexLegacyAckFrame,
TerminalMultiplexSourceRangeAckFrame,
TerminalMultiplexSubscribeFrame
} from './terminal/stream-schemas'
const METHOD_CASES: readonly (readonly [string, unknown, boolean])[] = [
['terminal.list', {}, false],
['terminal.resolveActive', {}, false],
['terminal.resolvePane', { paneKey: 'pane' }, false],
['terminal.recoverPane', { paneKey: 'pane', worktreeId: 'worktree' }, false],
['terminal.show', { terminal: 'term' }, false],
['terminal.read', { terminal: 'term' }, false],
['terminal.inspectProcess', { terminal: 'term' }, false],
['terminal.isRunningAgent', { terminal: 'term' }, false],
['terminal.agentStatus', { terminal: 'term' }, false],
['terminal.rename', { terminal: 'term', title: null }, false],
['terminal.clearBuffer', { terminal: 'term' }, false],
['terminal.send', { terminal: 'term', text: 'x' }, false],
['terminal.wait', { terminal: 'term', for: 'exit' }, false],
['terminal.create', {}, false],
['terminal.split', { terminal: 'term' }, false],
['terminal.stop', { worktree: 'worktree' }, false],
['terminal.sleep', { worktree: 'worktree' }, false],
['terminal.stopExact', { worktree: 'worktree', expectedPtyIds: ['pty'] }, false],
['terminal.resizeForClient', { terminal: 'term', mode: 'restore', clientId: 'client' }, false],
['terminal.focus', { terminal: 'term' }, false],
['terminal.close', { terminal: 'term' }, false],
['terminal.closeTab', { terminal: 'term' }, false],
['agentTeams.tmuxCompat', { teamId: 'team', token: 'token', envPane: 'pane', argv: [] }, false],
['agentTeams.prepareLaunch', { paneKey: 'pane' }, false],
['terminal.setDisplayMode', { terminal: 'term', mode: 'auto' }, false],
['terminal.restoreFit', { terminal: 'term' }, false],
['terminal.getDisplayMode', { terminal: 'term' }, false],
[
'terminal.updateViewport',
{ terminal: 'term', client: { id: 'client' }, viewport: { cols: 80, rows: 24 } },
false
],
['terminal.multiplex', {}, true],
['terminal.subscribe', { terminal: 'term' }, true],
['terminal.unsubscribe', { subscriptionId: 'term' }, false],
['terminal.getAutoRestoreFit', {}, false],
['terminal.setAutoRestoreFit', { ms: null }, false]
]
function schemaFor(name: string) {
const method = TERMINAL_METHODS.find((candidate) => candidate.name === name)
if (!method?.params) {
throw new Error(`Missing terminal schema: ${name}`)
}
return method.params
}
async function invoke(name: string, params: unknown, runtime: Partial<OrcaRuntimeService>) {
const method = TERMINAL_METHODS.find((candidate) => candidate.name === name)
if (!method?.params || 'stream' in method) {
throw new Error(`Missing unary terminal method: ${name}`)
}
return method.handler(method.params.parse(params), { runtime: runtime as OrcaRuntimeService })
}
describe('terminal RPC manifest characterization', () => {
it('preserves all method names, order, streaming flags, and parseable minimum inputs', () => {
expect(TERMINAL_METHODS).toHaveLength(33)
expect(TERMINAL_METHODS.map((method) => [method.name, 'stream' in method])).toEqual(
METHOD_CASES.map(([name, _params, stream]) => [name, stream])
)
expect(new Set(TERMINAL_METHODS.map((method) => method.name)).size).toBe(33)
for (const [name, params] of METHOD_CASES) {
expect(() => schemaFor(name).parse(params), name).not.toThrow()
}
})
it('keeps legacy coercion and viewport-boundary differences', () => {
expect(schemaFor('terminal.list').parse({ limit: 'invalid' })).toEqual({})
expect(schemaFor('terminal.split').parse({ terminal: 'term', direction: 'diagonal' })).toEqual({
terminal: 'term'
})
expect(() => schemaFor('terminal.rename').parse({ terminal: 'term' })).toThrow()
expect(schemaFor('terminal.rename').parse({ terminal: 'term', title: '' })).toEqual({
terminal: 'term',
title: ''
})
expect(() =>
schemaFor('terminal.updateViewport').parse({
terminal: 'term',
client: { id: 'client' },
viewport: { cols: 241, rows: 120 }
})
).toThrow()
expect(() =>
schemaFor('terminal.subscribe').parse({
terminal: 'term',
viewport: { cols: 1000, rows: 500 }
})
).not.toThrow()
expect(() =>
schemaFor('terminal.setDisplayMode').parse({
terminal: 'term',
mode: 'auto',
viewport: { cols: 1001, rows: 501 }
})
).not.toThrow()
})
it('keeps multiplex control objects strict while subscribe remains skew-tolerant', () => {
expect(TerminalMultiplexLegacyAckFrame.safeParse({ bytes: 1, future: true }).success).toBe(
false
)
expect(
TerminalMultiplexSourceRangeAckFrame.safeParse({
streamGeneration: 'generation',
ackedEndByte: 1,
future: true
}).success
).toBe(false)
expect(
TerminalMultiplexSubscribeFrame.parse({
streamId: 1,
terminal: 'term',
futureCapability: 1
})
).toEqual({ streamId: 1, terminal: 'term' })
})
it('returns execution-host authority from lifecycle handlers without reinterpretation', async () => {
const pane = {
handle: 'term-pane',
executionHostId: 'ssh-host',
hostPlatform: 'win32'
}
const recovered = {
handle: 'term-recovered',
executionHostId: 'folder-host',
hostPlatform: 'linux'
}
const shown = {
handle: 'term-shown',
executionHostId: 'ssh-host',
hostPlatform: 'win32'
}
const split = {
handle: 'term-split',
executionHostId: 'folder-host',
hostPlatform: 'linux'
}
const runtime = {
resolveTerminalPane: vi.fn(() => pane),
recoverTerminalPane: vi.fn(async () => recovered),
showTerminal: vi.fn(async () => shown),
splitTerminal: vi.fn(async () => split)
} as unknown as Partial<OrcaRuntimeService>
await expect(invoke('terminal.resolvePane', { paneKey: 'pane' }, runtime)).resolves.toEqual({
terminal: pane
})
await expect(
invoke('terminal.recoverPane', { paneKey: 'pane', worktreeId: 'folder-worktree' }, runtime)
).resolves.toEqual({ terminal: recovered })
await expect(invoke('terminal.show', { terminal: 'term' }, runtime)).resolves.toEqual({
terminal: shown
})
await expect(invoke('terminal.split', { terminal: 'term' }, runtime)).resolves.toEqual({
split
})
})
it('keeps remote and folder selectors opaque through terminal.create', async () => {
const created = {
handle: 'term-created',
executionHostId: 'ssh-host',
hostPlatform: 'win32'
}
const createTerminal = vi.fn(async () => created)
const runtime = {
dedupeTerminalCreate: vi.fn(
async (
_owner: string,
selector: string | undefined,
_mutationId: string | undefined,
_reconcile: boolean,
create: (selector: string | undefined, preAllocatedHandle?: string) => Promise<unknown>
) => create(selector, 'term-preallocated')
),
createTerminal
} as unknown as Partial<OrcaRuntimeService>
const selector = 'ssh://windows-host/C:/Users/dev/repo'
await expect(
invoke(
'terminal.create',
{ worktree: selector, clientMutationId: 'mutation', command: 'provider resume-token' },
runtime
)
).resolves.toEqual({ terminal: created })
expect(createTerminal).toHaveBeenCalledWith(
selector,
expect.objectContaining({
command: 'provider resume-token',
preAllocatedHandle: 'term-preallocated'
})
)
})
})
@@ -0,0 +1,130 @@
import { describe, expect, it, vi } from 'vitest'
import {
TerminalStreamOpcode,
decodeTerminalStreamJson,
encodeTerminalStreamFrame
} from '../../../../shared/terminal-stream-protocol'
import { sendSnapshotFrames } from './terminal/terminal-snapshot-publication'
import { TerminalSourceRangeRegistry } from '../terminal-source-range-registry'
import { initializeMultiplexStream } from './terminal/terminal-multiplex-stream-initialization'
import type { TerminalMultiplexConnection } from './terminal/terminal-multiplex-connection'
import type { OrcaRuntimeService } from '../../orca-runtime'
// These assertions protect the permanent binary publication seam while its state machine
// lives in concrete terminal domain modules.
describe('terminal stream extraction characterization', () => {
it('publishes an empty snapshot as Start then End without a synthetic chunk', () => {
const frames: {
opcode: TerminalStreamOpcode
payload?: Uint8Array<ArrayBufferLike>
}[] = []
const stats = sendSnapshotFrames(
(opcode, payload) => {
frames.push({ opcode, payload })
},
{
kind: 'scrollback',
cols: 80,
rows: 24,
data: '',
seq: 41,
truncated: false,
truncatedByByteBudget: false
}
)
expect(frames.map((frame) => frame.opcode)).toEqual([
TerminalStreamOpcode.SnapshotStart,
TerminalStreamOpcode.SnapshotEnd
])
expect(decodeTerminalStreamJson(frames[0]!.payload ?? new Uint8Array())).toEqual({
kind: 'scrollback',
cols: 80,
rows: 24,
seq: 41,
truncated: false,
truncatedByByteBudget: false
})
expect(stats).toEqual({ bytes: 0, chunks: 0, published: true })
})
it('stops publication immediately when SnapshotStart is rejected', () => {
const opcodes: TerminalStreamOpcode[] = []
const stats = sendSnapshotFrames(
(opcode) => {
opcodes.push(opcode)
return false
},
{
kind: 'scrollback',
cols: 80,
rows: 24,
data: 'unpublished'
}
)
expect(opcodes).toEqual([TerminalStreamOpcode.SnapshotStart])
expect(stats).toEqual({ bytes: 0, chunks: 0, published: false })
})
it('keeps the permanent v1 header bytes and opcode assignments', () => {
const seq = 2 ** 32 + 0x89abcdef
const bytes = encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.WriteUnavailable,
streamId: 0x78563412,
seq,
payload: new Uint8Array([0xaa])
})
const header = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
expect(Array.from(bytes.slice(0, 4))).toEqual([0x74, 1, 17, 0])
expect(header.getUint32(4, true)).toBe(0x78563412)
expect(header.getUint32(8, true)).toBe(1)
expect(header.getUint32(12, true)).toBe(0x89abcdef)
expect(bytes[16]).toBe(0xaa)
expect([
TerminalStreamOpcode.Output,
TerminalStreamOpcode.SnapshotStart,
TerminalStreamOpcode.SnapshotChunk,
TerminalStreamOpcode.SnapshotEnd,
TerminalStreamOpcode.Resized,
TerminalStreamOpcode.Error,
TerminalStreamOpcode.Input,
TerminalStreamOpcode.Resize,
TerminalStreamOpcode.Subscribe,
TerminalStreamOpcode.Unsubscribe,
TerminalStreamOpcode.SnapshotRequest,
TerminalStreamOpcode.Metadata,
TerminalStreamOpcode.Ack,
TerminalStreamOpcode.ClaimViewport,
TerminalStreamOpcode.OutputSpan,
TerminalStreamOpcode.SetOutputPaused,
TerminalStreamOpcode.WriteUnavailable
]).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17])
})
it('lets a slot-handler registration throw escape before per-stream catch ownership begins', async () => {
const streams = new Map()
const runtime = {
attachRemoteTerminalSourceRangeConsumer: vi.fn(() => false)
} as unknown as OrcaRuntimeService
const state = {
runtime,
connectionId: 'connection',
streams,
sourceRangeRegistry: new TerminalSourceRangeRegistry(),
registerBinaryStreamHandler: vi.fn(() => {
throw new Error('registrar-failed')
}),
sendFrame: vi.fn(),
queueOrSendOutput: vi.fn()
} as unknown as TerminalMultiplexConnection
const installed = vi.fn()
await expect(
initializeMultiplexStream(state, { streamId: 7, terminal: 'terminal' }, 'pty', installed)
).rejects.toThrow('registrar-failed')
expect(installed).not.toHaveBeenCalled()
expect(streams.has(7)).toBe(true)
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,78 @@
import { z } from 'zod'
import { requiredString } from '../../schemas'
import { TerminalViewport } from './unary-schemas'
const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') })
export const TerminalResizeForClient = z.discriminatedUnion('mode', [
z.object({
terminal: requiredString('Missing terminal handle'),
mode: z.literal('mobile-fit'),
cols: z.number().finite().positive(),
rows: z.number().finite().positive(),
clientId: requiredString('Missing client ID')
}),
z.object({
terminal: requiredString('Missing terminal handle'),
mode: z.literal('restore'),
clientId: requiredString('Missing client ID')
})
])
export const TerminalSubscribe = TerminalHandle.extend({
client: z
.object({
id: requiredString('Missing client ID'),
type: z.enum(['mobile', 'desktop']).default('desktop')
})
.optional(),
viewport: TerminalViewport.optional(),
capabilities: z
.object({
terminalBinaryStream: z.literal(1).optional(),
desktopViewportClaims: z.literal(1).optional(),
mobileInputLeaseOnly: z.literal(1).optional(),
writeUnavailable: z.literal(1).optional()
})
.optional()
})
export const TerminalMultiplex = z.object({})
export const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({
streamId: z.number().int().min(1),
client: z
.object({
id: requiredString('Missing client ID'),
type: z.enum(['mobile', 'desktop']).default('desktop')
})
.optional(),
viewport: TerminalViewport.optional(),
capabilities: z
.object({
ackOutput: z.literal(1).optional(),
ackOutputSourceRanges: z.literal(1).optional(),
desktopViewportClaims: z.literal(1).optional(),
outputPause: z.literal(1).optional(),
writeUnavailable: z.literal(1).optional()
})
.optional()
})
export const TerminalMultiplexLegacyAckFrame = z
.object({
bytes: z.number().int().nonnegative()
})
.strict()
export const TerminalMultiplexSourceRangeAckFrame = z
.object({
streamGeneration: z.string().min(1),
ackedEndByte: z.number().int().nonnegative()
})
.strict()
export const TerminalMultiplexSnapshotRequestFrame = z.object({
requestId: z.number().int().positive().optional(),
scrollbackRows: z.number().finite().optional()
})
@@ -0,0 +1,188 @@
import { InvalidArgumentError } from '../../core'
import type {
DriverState,
OrcaRuntimeService,
SubscriptionRegistration
} from '../../../orca-runtime'
import {
TERMINAL_INPUT_MAX_BYTES,
TERMINAL_INPUT_TOO_LARGE_ERROR,
isTerminalInputTooLargeWithYield
} from '../../../../../shared/terminal-input'
import type { TerminalViewportClient } from './terminal-stream-types'
export function isTerminalInputLockedForClient(
runtime: OrcaRuntimeService,
ptyId: string,
client: TerminalViewportClient | undefined
): boolean {
if (client?.type === 'mobile') {
return false
}
// Why: pre-refactor mobile builds sent no client metadata, so treat a missing client as legacy mobile (unlocked).
if (!client) {
return false
}
return runtime.getDriver(ptyId).kind === 'mobile'
}
export async function assertTerminalSendTextWithinLimit(text: string | undefined): Promise<void> {
if (!text) {
return
}
// Why: sends can be paste-sized; validate outside Zod so large input yields before runtime dispatch.
if (await isTerminalInputTooLargeWithYield(text, TERMINAL_INPUT_MAX_BYTES)) {
throw new InvalidArgumentError(TERMINAL_INPUT_TOO_LARGE_ERROR)
}
}
export function resolveMobileFloorClientId(
driver: DriverState | null,
client: TerminalViewportClient | undefined
): string | null {
if (client?.type === 'mobile') {
return client.id
}
if (!client && driver?.kind === 'mobile') {
return driver.clientId
}
return null
}
export type TerminalStreamInputOutcome = 'delivered' | 'rejected' | 'failed'
export function watchSubscriptionLifetime(
runtime: OrcaRuntimeService,
ptyId: string,
signal: AbortSignal | undefined,
registration: SubscriptionRegistration
): () => void {
let unsubscribeExit: (() => void) | null = null
let removeAbort: (() => void) | null = null
let stopped = false
const stop = (): void => {
stopped = true
unsubscribeExit?.()
removeAbort?.()
}
const release = (): void => {
registration.releaseIfCurrent()
stop()
}
unsubscribeExit = runtime.subscribeToPtyExit(ptyId, release)
if (stopped) {
unsubscribeExit()
return stop
}
if (!signal) {
return stop
}
if (signal.aborted) {
release()
return stop
}
const onAbort = (): void => release()
removeAbort = () => signal.removeEventListener('abort', onAbort)
signal.addEventListener('abort', onAbort, { once: true })
if (stopped) {
removeAbort()
}
return stop
}
export function isTerminalStreamInputRejection(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return message.includes('terminal_not_writable') || message.includes('terminal_handle_stale')
}
export async function sendTerminalStreamInput(
runtime: OrcaRuntimeService,
args: {
terminal: string
text: string
client: TerminalViewportClient | undefined
isMobile: boolean
}
): Promise<TerminalStreamInputOutcome> {
const action = { text: args.text, enter: false, interrupt: false }
const clientId = args.isMobile ? args.client?.id : undefined
const floorClaim: MobileInputFloorClaimHolder = { current: null }
try {
if (!clientId) {
const result = await runtime.sendTerminal(args.terminal, action)
return result.accepted ? 'delivered' : 'rejected'
}
const result = await runtime.sendTerminal(args.terminal, action, {
reserveWrite: (writePtyId) => {
const claim = runtime.beginMobileInputFloor(writePtyId, clientId)
if (!claim) {
throw new Error('mobile_input_floor_unavailable')
}
floorClaim.current = claim
},
afterWrite: () => commitMobileInputFloorClaim(floorClaim)
})
if (!result.accepted) {
floorClaim.current?.rollback()
return 'rejected'
}
return 'delivered'
} catch (error) {
floorClaim.current?.rollback()
return isTerminalStreamInputRejection(error) ? 'rejected' : 'failed'
}
}
export type MobileInputFloorClaimHolder = {
current: ReturnType<OrcaRuntimeService['beginMobileInputFloor']>
}
export async function commitMobileInputFloorClaim(
claim: MobileInputFloorClaimHolder
): Promise<void> {
const current = claim.current
if (!current) {
return
}
try {
await current.commit()
} finally {
// Why: the runtime may yield before the next write, which then needs a fresh reservation if desktop reclaimed the floor.
if (claim.current === current) {
claim.current = null
}
}
}
export function getTerminalSendGuardRefusedReason(
error: unknown
): 'no-agent' | 'permission' | undefined {
const message = error instanceof Error ? error.message : String(error)
if (message.includes('terminal_guard_permission')) {
return 'permission'
}
if (message.includes('terminal_guard_no_agent')) {
return 'no-agent'
}
return undefined
}
export function isTerminalSendGuardNotWritable(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return message.includes('terminal_guard_not_writable')
}
export function assertTerminalSendExactPtyBinding(
runtime: OrcaRuntimeService,
handle: string,
expectedPtyId: string | undefined
): void {
try {
if (expectedPtyId && runtime.resolveLiveLeafForHandle(handle)?.ptyId === expectedPtyId) {
return
}
} catch {
// Fall through to the stable guarded-send result below.
}
throw new Error('terminal_guard_not_writable')
}
@@ -0,0 +1,141 @@
import {
TerminalStreamOpcode,
decodeTerminalStreamJson,
decodeTerminalStreamText
} from '../../../../../shared/terminal-stream-protocol'
import { isTerminalInputLockedForClient, sendTerminalStreamInput } from './terminal-input-delivery'
import type { TerminalSubscriptionArgs } from './terminal-legacy-subscription-types'
import { updateViewportForClient } from './terminal-viewport-update'
type LegacyBinaryControlState = {
isClosed: () => boolean
isBuffering: () => boolean
setRegisteredRemoteDesktopDriver: () => void
setPendingRemoteDesktopViewport: (viewport: { cols: number; rows: number }) => void
getDesktopClaimTail: () => Promise<boolean>
setDesktopClaimTail: (tail: Promise<boolean>) => void
sendFrame: (opcode: TerminalStreamOpcode, payload?: Uint8Array<ArrayBufferLike>) => void
}
export function registerLegacyBinaryControlFrames(
args: TerminalSubscriptionArgs,
streamId: number,
remoteDesktopSubscriptionKey: string,
controls: LegacyBinaryControlState
): () => void {
const {
params,
runtime,
registerBinaryStreamHandler,
ptyId,
clientId,
isMobile,
supportsDesktopViewportClaims,
supportsWriteUnavailable
} = args
if (!registerBinaryStreamHandler) {
return () => {}
}
return registerBinaryStreamHandler(streamId, (frame) => {
if (controls.isClosed()) {
return
}
if (frame.opcode === TerminalStreamOpcode.Input) {
const text = decodeTerminalStreamText(frame.payload)
if (!text) {
return
}
if (isTerminalInputLockedForClient(runtime, ptyId, params.client)) {
return
}
void controls.getDesktopClaimTail().then(async (claimed) => {
if (!claimed || isTerminalInputLockedForClient(runtime, ptyId, params.client)) {
return
}
const outcome = await sendTerminalStreamInput(runtime, {
terminal: params.terminal,
text,
client: params.client,
isMobile
})
if (!controls.isClosed() && outcome === 'rejected' && supportsWriteUnavailable) {
controls.sendFrame(TerminalStreamOpcode.WriteUnavailable)
}
})
return
}
if (frame.opcode === TerminalStreamOpcode.Resize && params.client) {
const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>(frame.payload)
if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') {
return
}
const cols = viewport.cols
const rows = viewport.rows
if (clientId) {
controls.setRegisteredRemoteDesktopDriver()
if (controls.isBuffering()) {
controls.setPendingRemoteDesktopViewport({ cols: viewport.cols, rows: viewport.rows })
return
}
}
controls.setDesktopClaimTail(
controls
.getDesktopClaimTail()
.then(async (priorClaimed) => {
const result = await updateViewportForClient(
runtime,
ptyId,
remoteDesktopSubscriptionKey,
params.client!,
{ cols, rows },
'desktop',
'register',
!supportsDesktopViewportClaims
)
return supportsDesktopViewportClaims ? priorClaimed && result.applied : result.applied
})
.catch(() => false)
)
return
}
if (
frame.opcode === TerminalStreamOpcode.ClaimViewport &&
params.client &&
clientId &&
!isMobile
) {
const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>(frame.payload)
if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') {
return
}
const cols = viewport.cols
const rows = viewport.rows
controls.setRegisteredRemoteDesktopDriver()
controls.setDesktopClaimTail(
controls
.getDesktopClaimTail()
.then(
() =>
runtime.updateRemoteDesktopViewer(
ptyId,
remoteDesktopSubscriptionKey,
clientId,
cols,
rows,
true
),
() =>
runtime.updateRemoteDesktopViewer(
ptyId,
remoteDesktopSubscriptionKey,
clientId,
cols,
rows,
true
)
)
.catch(() => false)
)
}
})
}
@@ -0,0 +1,184 @@
import { createTerminalOutputBatcher } from './terminal-output-batcher'
import type { TerminalOutputBatcher } from './terminal-output-batcher'
import { isTerminalReadPayloadIncomplete } from './terminal-stream-replay'
import { serializeBudgetedMobileSnapshot } from './terminal-snapshot-publication'
import { updateViewportForClient } from './terminal-viewport-update'
import { watchSubscriptionLifetime } from './terminal-input-delivery'
import type { TerminalSubscriptionArgs } from './terminal-legacy-subscription-types'
import { allocateTerminalSubscriptionStreamId } from './terminal-subscription-stream-id'
export async function runTerminalLeaseSubscription(args: TerminalSubscriptionArgs): Promise<void> {
const { params, runtime, connectionId, signal, emit, ptyId, clientId } = args
if (!clientId) {
return
}
let closed = false
let stopWatchingLifetime = (): void => {}
let resolveStream = (): void => {}
const streamClosed = new Promise<void>((resolve) => {
resolveStream = resolve
})
const subscriptionId = `${params.terminal}:${clientId}`
// Why: chat needs the input-floor ack without registering a view subscriber or transporting duplicate PTY output.
const registration = runtime.registerOwnedSubscriptionCleanup(
subscriptionId,
() => {
stopWatchingLifetime()
closed = true
runtime.handleMobileUnsubscribe(ptyId, clientId)
emit({ type: 'end' })
resolveStream()
},
connectionId
)
stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration)
if (closed) {
// Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything.
return
}
try {
// Why: a lease-only subscriber has no terminal view, so its cached viewport must never phone-fit the PTY.
await runtime.handleMobileSubscribe(ptyId, clientId, undefined)
if (closed || signal?.aborted) {
// Why: a disconnect can win the awaited subscribe and resurrect mobile presence after cleanup already released it.
// Unguarded on purpose: this must still fire when our own cleanup already ran.
// Safe only because lease-only passes no viewport and both !viewport paths in
// handleMobileSubscribeInternal return with no await, so no rebind can land
// first. Adding an await there — or passing a viewport here — makes a
// superseded handler delete the replacement's (ptyId, clientId) presence.
runtime.handleMobileUnsubscribe(ptyId, clientId)
if (!closed) {
registration.releaseIfCurrent()
}
return
}
emit({ type: 'subscribed', streamId: null, lines: [], truncated: false })
await streamClosed
} catch (error) {
registration.releaseIfCurrent()
throw error
}
}
export async function runTerminalJsonSubscription(args: TerminalSubscriptionArgs): Promise<void> {
const {
params,
runtime,
connectionId,
signal,
emit,
ptyId,
clientId,
supportsDesktopViewportClaims
} = args
// Why: only unregister the width floor this subscription took (see the multiplex stream's registeredRemoteDesktopDriver note).
let registeredRemoteDesktopDriver = false
// Why: a hidden watcher and a visible pane can subscribe to one terminal, so key by client so neither stream evicts the other.
const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal
const remoteDesktopSubscriptionKey = `json:${allocateTerminalSubscriptionStreamId()}`
let closed = false
let outputBatcher: TerminalOutputBatcher | null = null
let unsubscribeData = (): void => {}
let unsubscribeFit = (): void => {}
let stopWatchingLifetime = (): void => {}
let resolveStream = (): void => {}
const streamClosed = new Promise<void>((resolve) => {
resolveStream = resolve
})
// Why: register before viewport/snapshot awaits so a socket close can't orphan the stream listeners or its remote-desktop width floor.
const registration = runtime.registerOwnedSubscriptionCleanup(
subscriptionId,
() => {
stopWatchingLifetime()
closed = true
outputBatcher?.flush()
outputBatcher?.dispose()
unsubscribeData()
unsubscribeFit()
if (registeredRemoteDesktopDriver && clientId) {
runtime.unregisterRemoteDesktopViewer(ptyId, remoteDesktopSubscriptionKey)
}
emit({ type: 'end' })
resolveStream()
},
connectionId
)
stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration)
if (closed) {
// Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything.
return
}
try {
if (clientId && params.client && params.viewport) {
registeredRemoteDesktopDriver = true
await updateViewportForClient(
runtime,
ptyId,
remoteDesktopSubscriptionKey,
params.client,
params.viewport,
'desktop',
'register',
!supportsDesktopViewportClaims
)
}
if (closed || signal?.aborted) {
registration.releaseIfCurrent()
return
}
const read = await runtime.readTerminal(params.terminal)
const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, false)
if (closed || signal?.aborted) {
registration.releaseIfCurrent()
return
}
const size = runtime.getTerminalSize(ptyId)
const displayMode = runtime.getMobileDisplayMode(ptyId)
const seq = runtime.getLayout(ptyId)?.seq
emit({
type: 'scrollback',
lines: read.tail,
truncated: isTerminalReadPayloadIncomplete(read),
serialized: serialized?.data,
oscLinks: serialized?.oscLinks,
cwd: serialized?.cwd,
// Why: an empty snapshot with no PTY size must still report the dims the fit
// will produce — dimless frames re-armed the mobile fit loop (STA-3337).
cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols,
rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows,
displayMode,
seq
})
outputBatcher = createTerminalOutputBatcher((chunk) => {
emit({ type: 'data', chunk })
})
const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data) => {
outputBatcher?.push(data)
})
// Why: the legacy JSON stream can feed a live xterm view, so register as a view subscriber; worst case is a withheld model reply, safer than a double reply.
const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId)
unsubscribeData = () => {
releaseViewSubscriber()
unsubscribeStreamData()
}
unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => {
outputBatcher?.flush()
const mode =
event.mode === 'mobile-fit'
? event.mode
: (runtime.getRemoteDesktopFitHold?.(ptyId, remoteDesktopSubscriptionKey).mode ??
'desktop-fit')
emit({
type: 'fit-override-changed',
mode,
cols: event.cols,
rows: event.rows
})
})
await streamClosed
} catch (error) {
registration.releaseIfCurrent()
throw error
}
}
@@ -0,0 +1,286 @@
import {
TerminalStreamOpcode,
encodeTerminalStreamFrame,
encodeTerminalStreamJson
} from '../../../../../shared/terminal-stream-protocol'
import { iterateTerminalOutputFrameChunks } from '../../terminal-output-frame-chunks'
import {
EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE,
scanTerminalReplyQuerySequences,
type TerminalReplyQueryScanState,
type TerminalReplyQuerySequence
} from '../../../../../shared/terminal-reply-query-scan'
import { TERMINAL_MULTIPLEX_PENDING_MAX_BYTES } from '../../../../../shared/terminal-multiplex-flow-control'
import { measureTerminalStreamByteLength } from '../../terminal-stream-byte-length'
import { createTerminalOutputBatcher, type TerminalOutputBatcher } from './terminal-output-batcher'
import { watchSubscriptionLifetime } from './terminal-input-delivery'
import { trimPendingOutputToBudget } from './terminal-stream-replay'
import { allocateTerminalSubscriptionStreamId } from './terminal-subscription-stream-id'
import type {
LegacyBinarySubscriptionState,
TerminalSubscriptionArgs
} from './terminal-legacy-subscription-types'
import type { TerminalOutputChunk } from './terminal-stream-types'
import { publishLegacyBinaryInitialSnapshot } from './terminal-legacy-subscribe-snapshot'
import { activateLegacyBinarySubscription } from './terminal-legacy-subscribe-live'
import { registerLegacyBinaryControlFrames } from './terminal-legacy-binary-control-frames'
const TERMINAL_QUERY_REPLAY_MAX_CHARS = 16 * 1024
export async function runTerminalBinarySubscription(args: TerminalSubscriptionArgs): Promise<void> {
const { params, runtime, connectionId, sendBinary, signal, emit, ptyId, clientId, isMobile } =
args
if (!sendBinary) {
throw new Error('binary_terminal_stream_required')
}
let registeredRemoteDesktopDriver = false
const streamId = allocateTerminalSubscriptionStreamId()
const remoteDesktopSubscriptionKey = `stream:${streamId}`
let cursor = 0
let closed = false
let buffering = true
let pendingRemoteDesktopViewport: { cols: number; rows: number } | null = null
let lastResizeCols: number | undefined
let resizeGeneration = 0
let pendingOutput: TerminalOutputChunk[] = []
let desktopClaimTail: Promise<boolean> = Promise.resolve(true)
let pendingOutputBytes = 0
let pendingOutputOverflowed = false
let pendingQueryScanState: TerminalReplyQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE
const pendingQuerySequences: TerminalReplyQuerySequence[] = []
let pendingQueryChars = 0
let pendingQueryOverflowed = false
let unsubscribeData = (): void => {}
let unsubscribeResize = (): void => {}
let unsubscribeFit = (): void => {}
let unregisterBinaryHandler = (): void => {}
let abortRendererMountWait = (): void => {}
let stopWatchingLifetime = (): void => {}
let lateRendererReadyPromise: Promise<boolean> | null = null
let outputBatcher: TerminalOutputBatcher | null = null
let resolveStream = (): void => {}
const streamClosed = new Promise<void>((resolve) => {
resolveStream = resolve
})
// Why: register cleanup before any await so a mid-subscribe disconnect still removes mobile presence; client-scoped ids also allow parallel desktop subscribers.
const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal
const registration = runtime.registerOwnedSubscriptionCleanup(
subscriptionId,
() => {
stopWatchingLifetime()
outputBatcher?.flush()
outputBatcher?.dispose()
closed = true
unsubscribeData()
unsubscribeResize()
unsubscribeFit()
unregisterBinaryHandler()
abortRendererMountWait()
if (isMobile && clientId) {
runtime.handleMobileUnsubscribe(ptyId, clientId)
} else if (registeredRemoteDesktopDriver && clientId) {
runtime.unregisterRemoteDesktopViewer(ptyId, remoteDesktopSubscriptionKey)
}
emit({ type: 'end' })
resolveStream()
},
connectionId
)
stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration)
if (closed) {
// Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything.
return
}
const sendFrame = (
opcode: TerminalStreamOpcode,
payload: Uint8Array<ArrayBufferLike> = new Uint8Array(),
frameSeq = cursor++
): void => {
if (closed || !sendBinary) {
return
}
sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: frameSeq, payload }))
}
outputBatcher = createTerminalOutputBatcher((data, meta) => {
if (meta?.cwd !== undefined) {
sendFrame(
TerminalStreamOpcode.Metadata,
encodeTerminalStreamJson({ cwd: meta.cwd }),
meta.seq
)
}
for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) {
sendFrame(chunk.opcode ?? TerminalStreamOpcode.Output, chunk.bytes, chunk.seq)
}
})
unregisterBinaryHandler = registerLegacyBinaryControlFrames(
args,
streamId,
remoteDesktopSubscriptionKey,
{
isClosed: () => closed,
isBuffering: () => buffering,
setRegisteredRemoteDesktopDriver: () => {
registeredRemoteDesktopDriver = true
},
setPendingRemoteDesktopViewport: (viewport) => {
pendingRemoteDesktopViewport = viewport
},
getDesktopClaimTail: () => desktopClaimTail,
setDesktopClaimTail: (tail) => {
desktopClaimTail = tail
},
sendFrame
}
)
const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => {
if (closed) {
return
}
if (buffering) {
const rawLength = meta?.rawLength
if (
typeof meta?.seq === 'number' &&
typeof rawLength === 'number' &&
rawLength === data.length
) {
const scan = scanTerminalReplyQuerySequences(
data,
meta.seq - rawLength,
pendingQueryScanState
)
pendingQueryScanState = scan.state
for (const query of scan.queries) {
if (pendingQueryChars + query.data.length > TERMINAL_QUERY_REPLAY_MAX_CHARS) {
pendingQueryOverflowed = true
break
}
pendingQuerySequences.push(query)
pendingQueryChars += query.data.length
}
} else {
pendingQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE
}
const remainingBudget = Math.max(1, TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - pendingOutputBytes)
const measurement = measureTerminalStreamByteLength(data, {
stopAfterBytes: remainingBudget
})
pendingOutput.push({ data, bytes: measurement.byteLength, meta })
pendingOutputBytes += measurement.byteLength
const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes)
pendingOutputBytes = trimmed.bytes
pendingOutputOverflowed ||= trimmed.overflowed
return
}
outputBatcher?.push(data, meta)
})
// Why: capture live bytes before mobile-fit awaits; registering presence first would suppress main while no view held the query.
const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId)
unsubscribeData = () => {
releaseViewSubscriber()
unsubscribeStreamData()
}
const state: LegacyBinarySubscriptionState = {
streamId,
remoteDesktopSubscriptionKey,
get closed() {
return closed
},
set closed(value) {
closed = value
},
get buffering() {
return buffering
},
set buffering(value) {
buffering = value
},
get pendingRemoteDesktopViewport() {
return pendingRemoteDesktopViewport
},
set pendingRemoteDesktopViewport(value) {
pendingRemoteDesktopViewport = value
},
get lastResizeCols() {
return lastResizeCols
},
set lastResizeCols(value) {
lastResizeCols = value
},
get resizeGeneration() {
return resizeGeneration
},
set resizeGeneration(value) {
resizeGeneration = value
},
get pendingOutput() {
return pendingOutput
},
set pendingOutput(value) {
pendingOutput = value
},
get pendingOutputBytes() {
return pendingOutputBytes
},
set pendingOutputBytes(value) {
pendingOutputBytes = value
},
get pendingOutputOverflowed() {
return pendingOutputOverflowed
},
set pendingOutputOverflowed(value) {
pendingOutputOverflowed = value
},
pendingQuerySequences,
get pendingQueryOverflowed() {
return pendingQueryOverflowed
},
set pendingQueryOverflowed(value) {
pendingQueryOverflowed = value
},
get lateRendererReadyPromise() {
return lateRendererReadyPromise
},
set lateRendererReadyPromise(value) {
lateRendererReadyPromise = value
},
get abortRendererMountWait() {
return abortRendererMountWait
},
set abortRendererMountWait(value) {
abortRendererMountWait = value
},
outputBatcher: outputBatcher!,
get unsubscribeResize() {
return unsubscribeResize
},
set unsubscribeResize(value) {
unsubscribeResize = value
},
get unsubscribeFit() {
return unsubscribeFit
},
set unsubscribeFit(value) {
unsubscribeFit = value
},
get registeredRemoteDesktopDriver() {
return registeredRemoteDesktopDriver
},
set registeredRemoteDesktopDriver(value) {
registeredRemoteDesktopDriver = value
},
displayMode: 'auto',
registration,
streamClosed,
sendFrame
}
try {
await publishLegacyBinaryInitialSnapshot(args, state)
if (state.closed || signal?.aborted) {
return
}
activateLegacyBinarySubscription(args, state)
} catch (error) {
registration.releaseIfCurrent()
throw error
}
await streamClosed
}
@@ -0,0 +1,158 @@
import {
TerminalStreamOpcode,
encodeTerminalStreamJson
} from '../../../../../shared/terminal-stream-protocol'
import {
sendMobileResizeRestream,
sendSnapshotFrames,
serializeStableMobileRendererSnapshot
} from './terminal-snapshot-publication'
import { updateViewportForClient } from './terminal-viewport-update'
import type {
LegacyBinarySubscriptionState,
TerminalSubscriptionArgs
} from './terminal-legacy-subscription-types'
export function activateLegacyBinarySubscription(
args: TerminalSubscriptionArgs,
state: LegacyBinarySubscriptionState
): void {
const { params, runtime, emit, ptyId, clientId, isMobile, supportsDesktopViewportClaims } = args
const lateRendererReady = state.lateRendererReadyPromise
state.lateRendererReadyPromise = null
if (lateRendererReady) {
void lateRendererReady
.then(async (rendererReady) => {
if (!rendererReady || state.closed) {
return
}
state.outputBatcher?.flush()
const recovery = await serializeStableMobileRendererSnapshot(runtime, ptyId)
if (state.closed) {
return
}
if (!recovery?.data.length) {
return
}
// Why: late recovery has no buffered-output gate, so only an exact renderer high-water may reset mobile without erasing live bytes.
if (recovery.seq !== runtime.getPtyOutputSequence(ptyId)) {
return
}
runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery(ptyId, recovery)
// Why: shipped mobile clients apply resized snapshots in place, so a blank xterm recovers without resubscribe.
const recoveryStats = sendSnapshotFrames(state.sendFrame, {
kind: 'resized',
cols: recovery.cols,
rows: recovery.rows,
displayMode: state.displayMode,
reason: 'renderer-mount-ready',
source: recovery.source,
truncated: false,
truncatedByByteBudget: recovery.truncatedByByteBudget,
data: recovery.data
})
state.lastResizeCols = recovery.cols
console.log('[mobile-terminal-stream] recovery snapshot', {
terminal: params.terminal,
streamId: state.streamId,
reason: 'renderer-mount-ready',
bytes: recoveryStats.bytes,
chunks: recoveryStats.chunks,
scrollbackRows: recovery.scrollbackRows,
truncatedByByteBudget: recovery.truncatedByByteBudget === true
})
})
.catch(() => {})
}
const sendResizedFrame = (event: {
cols: number
rows: number
displayMode: string
reason: string
seq?: number
}): void => {
state.lastResizeCols = event.cols
state.sendFrame(
TerminalStreamOpcode.Resized,
encodeTerminalStreamJson({
cols: event.cols,
rows: event.rows,
displayMode: event.displayMode,
reason: event.reason,
seq: event.seq
})
)
}
state.unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => {
state.outputBatcher?.flush()
const eventGeneration = state.resizeGeneration + 1
state.resizeGeneration = eventGeneration
// Why: xterm only re-wraps soft-wrapped lines, so a width change needs a full re-serialize+replay to rewrap restored hard-wrapped scrollback.
const widthChanged = isMobile && event.cols !== state.lastResizeCols
if (widthChanged) {
state.lastResizeCols = event.cols
void sendMobileResizeRestream(
runtime,
ptyId,
state.sendFrame,
event,
() => !state.closed && state.resizeGeneration === eventGeneration
)
.then((restreamed) => {
if (state.closed || state.resizeGeneration !== eventGeneration) {
return
}
if (!restreamed) {
sendResizedFrame(event)
}
})
// Why: on re-stream failure, still emit the geometry-only Resized frame so the client never misses the resize.
.catch(() => {
if (state.closed || state.resizeGeneration !== eventGeneration) {
return
}
sendResizedFrame(event)
})
return
}
sendResizedFrame(event)
})
// Install the resize listener before draining the parked viewport, since applyLayout emits synchronously.
if (
clientId &&
params.client &&
state.registeredRemoteDesktopDriver &&
state.pendingRemoteDesktopViewport
) {
const viewport = state.pendingRemoteDesktopViewport
state.pendingRemoteDesktopViewport = null
void updateViewportForClient(
runtime,
ptyId,
state.remoteDesktopSubscriptionKey,
params.client,
viewport,
'desktop',
'register',
!supportsDesktopViewportClaims
).catch(() => {})
}
// Legacy fit-override-changed for non-mobile (desktop) subscribers
state.unsubscribeFit = !isMobile
? runtime.subscribeToFitOverrideChanges(ptyId, (event) => {
const mode =
event.mode === 'mobile-fit'
? event.mode
: (runtime.getRemoteDesktopFitHold?.(ptyId, state.remoteDesktopSubscriptionKey).mode ??
'desktop-fit')
emit({
type: 'fit-override-changed',
mode,
cols: event.cols,
rows: event.rows
})
})
: () => {}
}
@@ -0,0 +1,265 @@
import {
sendSnapshotFrames,
serializeBudgetedMobileSnapshot,
serializeStableMobileRendererSnapshot
} from './terminal-snapshot-publication'
import {
getOutputAfterSnapshotSeq,
isTerminalReadPayloadIncomplete,
stripSnapshotBoundaryQuerySuffixes,
trimPendingOutputCoveredBySnapshot
} from './terminal-stream-replay'
import type {
LegacyBinarySubscriptionState,
TerminalSubscriptionArgs
} from './terminal-legacy-subscription-types'
const MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS = 3_000
export async function publishLegacyBinaryInitialSnapshot(
args: TerminalSubscriptionArgs,
state: LegacyBinarySubscriptionState
): Promise<void> {
const {
params,
runtime,
signal,
sendBinary,
emit,
ptyId,
clientId,
isMobile,
missingHeadlessStateBeforeMobileFit,
rendererMountRequestedBeforePty,
serializerGenerationBeforeMobileFit
} = args
if (isMobile && clientId) {
await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport)
} else if (clientId && params.viewport) {
// Why: legacy subscribe records geometry without taking ownership; only an explicit activity/claim frame may suppress the host.
state.registeredRemoteDesktopDriver = true
state.pendingRemoteDesktopViewport = params.viewport
}
if (state.closed) {
return
}
let read = await runtime.readTerminal(params.terminal)
let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile)
if (state.closed) {
return
}
// Why: missing model state (not blank snapshot text) signals a never-attached PTY; a renderer-sourced snapshot already proves attachment, so skip the remount.
const mountRequested =
missingHeadlessStateBeforeMobileFit &&
serialized?.source !== 'renderer' &&
(rendererMountRequestedBeforePty || runtime.requestRendererTerminalTabMount(params.terminal))
if (missingHeadlessStateBeforeMobileFit && mountRequested) {
// Why: an idle legacy PTY emits no later byte, so wait for a settle proving this remount completed before replaying its screen.
const mountWaitController = new AbortController()
const abortMountWait = (): void => mountWaitController.abort()
state.abortRendererMountWait = abortMountWait
if (signal?.aborted) {
abortMountWait()
} else {
signal?.addEventListener('abort', abortMountWait, { once: true })
}
const rendererReadyPromise = runtime
.waitForRendererTerminalSerializer(
ptyId,
serializerGenerationBeforeMobileFit,
undefined,
mountWaitController.signal
)
.catch(() => false)
const finishMountWait = (): void => {
signal?.removeEventListener('abort', abortMountWait)
if (state.abortRendererMountWait === abortMountWait) {
state.abortRendererMountWait = () => {}
}
}
void rendererReadyPromise.then(finishMountWait, finishMountWait)
let deadlineTimer: ReturnType<typeof setTimeout> | null = null
const initialDeadline = new Promise<boolean>((resolve) => {
deadlineTimer = setTimeout(() => resolve(false), MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS)
if (typeof deadlineTimer.unref === 'function') {
deadlineTimer.unref()
}
})
const rendererReady = await Promise.race([rendererReadyPromise, initialDeadline])
if (deadlineTimer) {
clearTimeout(deadlineTimer)
}
if (state.closed || signal?.aborted) {
return
}
if (rendererReady) {
read = await runtime.readTerminal(params.terminal)
const stableRendererSnapshot = await serializeStableMobileRendererSnapshot(runtime, ptyId)
if (state.closed) {
return
}
if (stableRendererSnapshot?.data.length) {
serialized = stableRendererSnapshot
const trailingOutput = state.pendingOutput.flatMap((item) => {
const output = getOutputAfterSnapshotSeq(item, stableRendererSnapshot.seq)
const seq = item.meta?.seq
return output && typeof seq === 'number' ? [{ data: output.data, seq }] : []
})
runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery(
ptyId,
stableRendererSnapshot,
trailingOutput
)
}
} else {
// Why: a renderer can settle after the bounded initial response; keep observing so an idle PTY self-heals without bytes.
state.lateRendererReadyPromise = rendererReadyPromise
}
}
let initialOutputOverflowed = false
if (state.pendingOutputOverflowed) {
state.pendingOutput.splice(0)
state.pendingOutputBytes = 0
state.pendingOutputOverflowed = false
read = await runtime.readTerminal(params.terminal)
serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile)
if (state.closed) {
return
}
if (state.pendingOutputOverflowed) {
initialOutputOverflowed = true
state.pendingOutput.splice(0)
state.pendingOutputBytes = 0
state.pendingOutputOverflowed = false
}
}
const size = runtime.getTerminalSize(ptyId)
state.displayMode = runtime.getMobileDisplayMode(ptyId)
// Why: layout seq is the mobile stale-event filter's high-water mark (undefined pre-transition is fail-open). See docs/mobile-terminal-layout-state-machine.md.
const layoutSeq = runtime.getLayout(ptyId)?.seq
// Why: only an output offset can cover buffered chunks; layout versions are a separate sequence domain.
let snapshotOutputSeq = serialized?.seq
emit({
type: 'subscribed',
streamId: state.streamId,
lines: read.tail,
truncated: initialOutputOverflowed || (!sendBinary && isTerminalReadPayloadIncomplete(read)),
cols: serialized?.cols ?? size?.cols,
rows: serialized?.rows ?? size?.rows,
displayMode: state.displayMode,
seq: layoutSeq
})
const snapshotStats = sendSnapshotFrames(state.sendFrame, {
kind: 'scrollback',
// Why: prefer the subscriber's viewport over the 80x24 stopgap when the PTY has
// no size yet — the mismatch made mobile burn its resubscribe budget (STA-3337).
cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols ?? 80,
rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows ?? 24,
displayMode: state.displayMode,
seq: snapshotOutputSeq,
cwd: serialized?.cwd,
truncated: initialOutputOverflowed,
truncatedByByteBudget: serialized?.truncatedByByteBudget,
oscLinks: serialized?.oscLinks,
data: serialized?.data ?? ''
})
console.log('[mobile-terminal-stream] snapshot', {
terminal: params.terminal,
streamId: state.streamId,
kind: 'scrollback',
bytes: snapshotStats.bytes,
chunks: snapshotStats.chunks,
scrollbackRows: serialized?.scrollbackRows,
truncatedByByteBudget: serialized?.truncatedByByteBudget === true
})
// Why: baseline for resize re-stream gating; the client already rewrapped to these cols via the initial snapshot replay.
state.lastResizeCols = serialized?.cols ?? size?.cols
let recoveryAttempts = 0
// Why: if the bounded pre-subscribe tail overflowed, only a fresh model snapshot covers the dropped middle without replay gaps.
while (state.pendingOutputOverflowed && recoveryAttempts < 2) {
state.pendingOutputOverflowed = false
recoveryAttempts += 1
const recovery = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile)
if (state.closed) {
return
}
if (!recovery) {
break
}
// Why: without an output seq (renderer fallback) covered chunks can't be trimmed exactly, so keep the bounded replay over an unverifiable snapshot.
if (typeof recovery.seq !== 'number') {
break
}
// Why: clients drop a repeat scrollback snapshot but apply 'resized' inline; omit seq so output-byte seqs don't pollute the layout-seq filter.
const recoveryStats = sendSnapshotFrames(state.sendFrame, {
kind: 'resized',
cols: recovery.cols,
rows: recovery.rows,
displayMode: state.displayMode,
reason: 'pending-output-overflow',
source: recovery.source,
truncated: false,
truncatedByByteBudget: recovery.truncatedByByteBudget,
data: recovery.data
})
console.log('[mobile-terminal-stream] recovery snapshot', {
terminal: params.terminal,
streamId: state.streamId,
reason: 'pending-output-overflow',
bytes: recoveryStats.bytes,
chunks: recoveryStats.chunks,
scrollbackRows: recovery.scrollbackRows,
truncatedByByteBudget: recovery.truncatedByByteBudget === true
})
const trimmed = trimPendingOutputCoveredBySnapshot(state.pendingOutput, recovery.seq)
state.pendingOutput = trimmed.chunks
state.pendingOutputBytes = trimmed.bytes
snapshotOutputSeq = recovery.seq
}
state.buffering = false
const bufferedOutput = state.pendingOutput.splice(0)
const queryReplayData = state.pendingQueryOverflowed
? ''
: state.pendingQuerySequences
.filter(
(query) =>
initialOutputOverflowed ||
(typeof snapshotOutputSeq === 'number' && query.startSeq < snapshotOutputSeq)
)
.map((query) => query.data)
.join('')
if (queryReplayData) {
// Why: snapshots omit control queries but their seq trims the live chunk; replay the post-snapshot query so the mobile xterm answers once.
state.outputBatcher.push(queryReplayData)
}
if (!initialOutputOverflowed) {
for (const item of bufferedOutput) {
const uncovered = getOutputAfterSnapshotSeq(item, snapshotOutputSeq)
let uncoveredData = uncovered?.data ?? null
let uncoveredMeta = uncovered?.meta
if (
uncoveredData &&
uncoveredData !== item.data &&
typeof snapshotOutputSeq === 'number' &&
typeof item.meta?.seq === 'number' &&
typeof item.meta.rawLength === 'number'
) {
if (item.meta.rawLength === item.data.length) {
uncoveredMeta = { ...item.meta, rawLength: uncoveredData.length }
}
uncoveredData = stripSnapshotBoundaryQuerySuffixes(
uncoveredData,
snapshotOutputSeq,
snapshotOutputSeq,
state.pendingQuerySequences
)
}
if (uncoveredData) {
state.outputBatcher.push(uncoveredData, uncoveredMeta)
}
}
}
state.pendingOutputBytes = 0
state.outputBatcher.flush()
}
@@ -0,0 +1,58 @@
import type { z } from 'zod'
import type { RpcContext } from '../../core'
import type { SubscriptionRegistration } from '../../../orca-runtime'
import type { TerminalReplyQuerySequence } from '../../../../../shared/terminal-reply-query-scan'
import type { TerminalOutputChunk } from './terminal-stream-types'
import type { TerminalSubscribe } from './stream-schemas'
import type { TerminalOutputBatcher } from './terminal-output-batcher'
import type { TerminalStreamOpcode } from '../../../../../shared/terminal-stream-protocol'
export type TerminalSubscribeParams = z.infer<typeof TerminalSubscribe>
export type TerminalSubscriptionEmit = (result: unknown) => void
export type TerminalSubscriptionArgs = {
params: TerminalSubscribeParams
runtime: RpcContext['runtime']
connectionId: RpcContext['connectionId']
sendBinary: RpcContext['sendBinary']
registerBinaryStreamHandler: RpcContext['registerBinaryStreamHandler']
signal: RpcContext['signal']
emit: TerminalSubscriptionEmit
ptyId: string
clientId: string | undefined
isMobile: boolean
supportsDesktopViewportClaims: boolean
supportsWriteUnavailable: boolean
missingHeadlessStateBeforeMobileFit: boolean
rendererMountRequestedBeforePty: boolean
serializerGenerationBeforeMobileFit: number
}
export type LegacyBinarySubscriptionState = {
readonly streamId: number
readonly remoteDesktopSubscriptionKey: string
closed: boolean
buffering: boolean
pendingRemoteDesktopViewport: { cols: number; rows: number } | null
lastResizeCols: number | undefined
resizeGeneration: number
pendingOutput: TerminalOutputChunk[]
pendingOutputBytes: number
pendingOutputOverflowed: boolean
readonly pendingQuerySequences: TerminalReplyQuerySequence[]
pendingQueryOverflowed: boolean
lateRendererReadyPromise: Promise<boolean> | null
abortRendererMountWait: () => void
outputBatcher: TerminalOutputBatcher
unsubscribeResize: () => void
unsubscribeFit: () => void
registeredRemoteDesktopDriver: boolean
displayMode: string
readonly registration: SubscriptionRegistration
readonly streamClosed: Promise<void>
readonly sendFrame: (
opcode: TerminalStreamOpcode,
payload?: Uint8Array<ArrayBufferLike>,
frameSeq?: number
) => void
}
@@ -0,0 +1,178 @@
import { defineMethod, type RpcAnyMethod } from '../../core'
import {
navigationTargetsHost,
resolveRuntimeNavigationTarget
} from '../../../../../shared/runtime-navigation'
import { withTerminalCloseAttribution } from '../../terminal-close-attribution'
import {
AgentTeamsPrepareLaunch,
AgentTeamsTmuxCompat,
TerminalCreateParams,
TerminalFocus,
TerminalHandle,
TerminalSleep,
TerminalSplit,
TerminalStop,
TerminalStopExact,
TerminalWait
} from './unary-schemas'
import { TerminalResizeForClient } from './stream-schemas'
export const TERMINAL_LIFECYCLE_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'terminal.wait',
params: TerminalWait,
handler: async (params, { runtime, signal }) => ({
wait: await runtime.waitForTerminal(params.terminal, {
condition: params.for,
timeoutMs: params.timeoutMs,
signal
})
})
}),
defineMethod({
name: 'terminal.create',
params: TerminalCreateParams,
handler: async (params, { runtime, pairedDeviceId, clientId }) => ({
terminal: await runtime.dedupeTerminalCreate(
pairedDeviceId ?? clientId ?? 'local',
params.worktree,
params.clientMutationId,
params.reconcileExisting === true,
(canonicalWorktreeSelector, preAllocatedHandle) =>
runtime.createTerminal(canonicalWorktreeSelector, {
command: params.command,
startupCommandDelivery: params.startupCommandDelivery,
env: params.env,
envToDelete: params.envToDelete,
...(params.launchConfig ? { launchConfig: params.launchConfig } : {}),
...(params.resumeProviderSession
? { resumeProviderSession: params.resumeProviderSession }
: {}),
...(params.launchToken ? { launchToken: params.launchToken } : {}),
...(params.launchAgent ? { launchAgent: params.launchAgent } : {}),
...(params.terminalColorQueryReplies
? { terminalColorQueryReplies: params.terminalColorQueryReplies }
: {}),
title: params.title,
focus: params.focus === true,
rendererBacked: params.rendererBacked === true,
activate: params.activate === true,
presentation: params.presentation,
tabId: params.tabId,
leafId: params.leafId,
...(preAllocatedHandle ? { preAllocatedHandle } : {})
})
)
})
}),
defineMethod({
name: 'terminal.split',
params: TerminalSplit,
handler: async (params, { runtime }) => ({
split: await runtime.splitTerminal(params.terminal, {
direction: params.direction,
command: params.command,
env: params.env,
telemetrySource: params.telemetrySource
})
})
}),
defineMethod({
name: 'terminal.stop',
params: TerminalStop,
handler: async (params, { runtime }) => runtime.stopTerminalsForWorktree(params.worktree)
}),
defineMethod({
name: 'terminal.sleep',
params: TerminalSleep,
handler: async (params, { runtime }) => runtime.sleepTerminalsForWorktree(params.worktree)
}),
defineMethod({
name: 'terminal.stopExact',
params: TerminalStopExact,
handler: async (params, { runtime }) =>
runtime.stopExactTerminalsForWorktree(params.worktree, params.expectedPtyIds, {
keepHistory: params.keepHistory,
targetOnly: params.targetOnly
})
}),
defineMethod({
name: 'terminal.resizeForClient',
params: TerminalResizeForClient,
handler: async (params, { runtime }) => {
// Why: a stale handle must fail with terminal_handle_stale, not resize the wrong PTY (#7718).
const leaf = runtime.resolveLiveLeafForHandle(params.terminal)
if (!leaf?.ptyId) {
throw new Error('no_connected_pty')
}
const result = await runtime.resizeForClient(
leaf.ptyId,
params.mode,
params.clientId,
params.mode === 'mobile-fit' ? params.cols : undefined,
params.mode === 'mobile-fit' ? params.rows : undefined
)
return {
terminal: {
handle: params.terminal,
...result
}
}
}
}),
defineMethod({
name: 'terminal.focus',
params: TerminalFocus,
handler: async (params, { runtime, clientKind }) => ({
focus: await runtime.focusTerminal(params.terminal, {
navigateHost: navigationTargetsHost(
resolveRuntimeNavigationTarget({ navigation: params.navigation, clientKind })
)
})
})
}),
defineMethod({
name: 'terminal.close',
params: TerminalHandle,
handler: async (params, context) => ({
close: await withTerminalCloseAttribution(
'terminal.close',
context,
'terminal',
params.terminal,
() => context.runtime.closeTerminal(params.terminal)
)
})
}),
defineMethod({
name: 'terminal.closeTab',
params: TerminalHandle,
handler: async (params, context) => ({
close: await withTerminalCloseAttribution(
'terminal.closeTab',
context,
'terminal-tab',
params.terminal,
() => context.runtime.closeTerminalTab(params.terminal)
)
})
}),
defineMethod({
name: 'agentTeams.tmuxCompat',
params: AgentTeamsTmuxCompat,
handler: async (params, { runtime }) => ({
tmux: await runtime.handleAgentTeamsTmuxCompat(params)
})
}),
defineMethod({
name: 'agentTeams.prepareLaunch',
params: AgentTeamsPrepareLaunch,
handler: async (params, { runtime }) => ({
launch: await runtime.prepareClaudeAgentTeamsLeader({
paneKey: params.paneKey,
baseEnv: params.env
})
})
})
]
@@ -0,0 +1,100 @@
import type {
TerminalMultiplexCleanupStage,
TerminalMultiplexConnection,
TerminalMultiplexFlowControlStage
} from './terminal-multiplex-connection'
export function installMultiplexCleanup(
build: TerminalMultiplexFlowControlStage
): asserts build is TerminalMultiplexCleanupStage {
const state = build as TerminalMultiplexConnection
const { runtime, streams, pendingPtyWaitControllers, emit, signal } = state
state.detachStream = (
streamId: number,
emitEnd: boolean,
releaseRemoteDesktopDriver = true
): void => {
const stream = streams.get(streamId)
if (!stream) {
return
}
const replacement = stream.sourceRangeReplacement
stream.sourceRangeReplacement = null
if (replacement) {
runtime.rollbackRemoteTerminalSourceRangeReplacement(
replacement,
'stream-detached-replacement-aborted'
)
}
stream.outputBatcher.flush()
stream.outputBatcher.dispose()
state.detachSourceRangeConsumer(stream, 'stream-detached')
state.ackTotalInFlightBytes = Math.max(0, state.ackTotalInFlightBytes - stream.ackInFlightBytes)
stream.ackInFlightBytes = 0
stream.ackPendingOutput = []
stream.ackPendingOutputBytes = 0
stream.ackPendingOutputOverflowed = false
stream.ackRecoverySnapshotInFlight = false
stream.unsubscribeData()
stream.unsubscribeResize()
stream.unsubscribeFit()
stream.unsubscribeDriver()
stream.unregisterBinaryHandler()
streams.delete(streamId)
state.flushAllAckPendingOutput()
// Why: release the runtime exit-waiter for this slot (see the field's note); delete before abort so its .catch no-ops instead of re-detaching.
stream.exitWaiterAbort.abort()
if (stream.isMobile && stream.client?.id) {
runtime.handleMobileUnsubscribe(stream.ptyId, stream.client.id)
} else if (
releaseRemoteDesktopDriver &&
stream.registeredRemoteDesktopDriver &&
stream.client?.id
) {
// Why: release the width floor only if THIS stream took it, so a passive stream can't release a peer's floor.
runtime.unregisterRemoteDesktopViewer(stream.ptyId, stream.remoteDesktopSubscriptionKey)
}
if (emitEnd) {
emit({ type: 'end', streamId })
}
}
state.cancelPendingPtyWaits = (streamId: number): void => {
const controllers = pendingPtyWaitControllers.get(streamId)
if (!controllers) {
return
}
pendingPtyWaitControllers.delete(streamId)
for (const controller of controllers) {
controller.abort()
}
}
state.cancelAllPendingPtyWaits = (): void => {
for (const streamId of Array.from(pendingPtyWaitControllers.keys())) {
state.cancelPendingPtyWaits(streamId)
}
}
state.closeMultiplex = (): void => {
if (state.closed) {
return
}
state.closed = true
signal?.removeEventListener('abort', state.cancelAllPendingPtyWaits)
state.cancelAllPendingPtyWaits()
const remoteDesktopKeysByPty = new Map<string, string[]>()
for (const streamId of Array.from(streams.keys())) {
const stream = streams.get(streamId)
if (stream?.registeredRemoteDesktopDriver && !stream.isMobile && stream.client?.id) {
const keys = remoteDesktopKeysByPty.get(stream.ptyId) ?? []
keys.push(stream.remoteDesktopSubscriptionKey)
remoteDesktopKeysByPty.set(stream.ptyId, keys)
}
state.detachStream(streamId, false, false)
}
// Why: one connection can own many panes on the same PTY; remove floors together so close scans each registry once.
for (const [ptyId, subscriptionKeys] of remoteDesktopKeysByPty) {
void runtime.unregisterRemoteDesktopViewers(ptyId, subscriptionKeys)
}
state.unregisterControlHandler()
state.resolveMultiplex()
}
}
@@ -0,0 +1,106 @@
import type { z } from 'zod'
import type { RpcContext } from '../../core'
import type {
TerminalStreamFrame,
TerminalStreamOpcode
} from '../../../../../shared/terminal-stream-protocol'
import type { TerminalOutputFrameChunk } from '../../terminal-output-frame-chunks'
import type {
TerminalMultiplexSnapshotRequestFrame,
TerminalMultiplexSubscribeFrame
} from './stream-schemas'
import type { TerminalMultiplexStream } from './terminal-stream-types'
import type { TerminalSourceRangeRegistry } from '../../terminal-source-range-registry'
export type MultiplexSubscribeRequest = z.infer<typeof TerminalMultiplexSubscribeFrame>
export type MultiplexSnapshotRequest = z.infer<typeof TerminalMultiplexSnapshotRequestFrame>
export type MultiplexEmit = (result: unknown) => void
export type TerminalMultiplexConnectionBase = {
runtime: RpcContext['runtime']
connectionId: string
sendBinary: NonNullable<RpcContext['sendBinary']>
registerBinaryStreamHandler: NonNullable<RpcContext['registerBinaryStreamHandler']>
signal: RpcContext['signal']
emit: MultiplexEmit
closed: boolean
cursor: number
streams: Map<number, TerminalMultiplexStream>
sourceRangeRegistry: TerminalSourceRangeRegistry
pendingPtyWaitControllers: Map<number, Set<AbortController>>
ackTotalInFlightBytes: number
ackTotalWindowBytes: number
ackFlushCursorStreamId: number | null
resolveMultiplex: () => void
multiplexClosed: Promise<void>
unregisterControlHandler: () => void
}
export type TerminalMultiplexFrameDelivery = {
sendFrame: (
streamId: number,
opcode: TerminalStreamOpcode,
payload?: Uint8Array<ArrayBufferLike>,
frameSeq?: number,
onRejected?: () => void
) => boolean
sendStreamError: (streamId: number, message: string) => void
notifyStreamWriteUnavailable: (
stream: TerminalMultiplexStream,
outcome: 'delivered' | 'rejected' | 'failed'
) => void
sendResizedFrame: (
stream: TerminalMultiplexStream,
event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number }
) => void
canSendAckGatedOutput: (stream: TerminalMultiplexStream, bytes: number) => boolean
sendAckGatedOutput: (stream: TerminalMultiplexStream, chunk: TerminalOutputFrameChunk) => boolean
queueOrSendOutput: (stream: TerminalMultiplexStream, chunk: TerminalOutputFrameChunk) => void
}
export type TerminalMultiplexFlowControl = {
sendAckRecoverySnapshot: (stream: TerminalMultiplexStream) => Promise<void>
flushAckPendingOutput: (stream: TerminalMultiplexStream, maxChunks?: number) => number
flushAllAckPendingOutput: () => void
acknowledgeOutput: (stream: TerminalMultiplexStream, bytes: number) => void
acknowledgeSourceRanges: (
stream: TerminalMultiplexStream,
streamGeneration: string,
ackedEndByte: number
) => void
detachSourceRangeConsumer: (stream: TerminalMultiplexStream, reason: string) => void
}
export type TerminalMultiplexCleanup = {
detachStream: (streamId: number, emitEnd: boolean, releaseRemoteDesktopDriver?: boolean) => void
cancelPendingPtyWaits: (streamId: number) => void
cancelAllPendingPtyWaits: () => void
closeMultiplex: () => void
}
export type TerminalMultiplexSlotFrames = {
handleSlotFrame: (stream: TerminalMultiplexStream, frame: TerminalStreamFrame) => void
sendRequestedSnapshot: (
stream: TerminalMultiplexStream,
request: MultiplexSnapshotRequest
) => Promise<void>
}
export type TerminalMultiplexSubscribeFrame = {
handleSubscribeFrame: (payload: Uint8Array<ArrayBufferLike>) => Promise<void>
}
export type TerminalMultiplexFrameDeliveryStage = TerminalMultiplexConnectionBase &
TerminalMultiplexFrameDelivery
export type TerminalMultiplexFlowControlStage = TerminalMultiplexFrameDeliveryStage &
TerminalMultiplexFlowControl
export type TerminalMultiplexCleanupStage = TerminalMultiplexFlowControlStage &
TerminalMultiplexCleanup
export type TerminalMultiplexSlotFramesStage = TerminalMultiplexCleanupStage &
TerminalMultiplexSlotFrames
export type TerminalMultiplexConnection = TerminalMultiplexSlotFramesStage &
TerminalMultiplexSubscribeFrame
@@ -0,0 +1,264 @@
import {
TERMINAL_MULTIPLEX_ACK_STREAM_MAX_WINDOW_BYTES,
TERMINAL_MULTIPLEX_ACK_TOTAL_MAX_WINDOW_BYTES
} from '../../../../../shared/terminal-multiplex-flow-control'
import { drainTerminalMultiplexRoundRobin } from '../../terminal-multiplex-round-robin'
import {
sendSnapshotFrames,
serializeBudgetedRequestedSnapshot
} from './terminal-snapshot-publication'
import type {
TerminalMultiplexConnection,
TerminalMultiplexFlowControlStage,
TerminalMultiplexFrameDeliveryStage
} from './terminal-multiplex-connection'
import type { TerminalMultiplexStream } from './terminal-stream-types'
import type { RemoteTerminalSourceRangeReplacementReservation } from '../../../remote-terminal-source-range-consumer'
export function installMultiplexFlowControl(
build: TerminalMultiplexFrameDeliveryStage
): asserts build is TerminalMultiplexFlowControlStage {
const state = build as TerminalMultiplexConnection
const { runtime, streams } = state
state.sendAckRecoverySnapshot = async (stream: TerminalMultiplexStream): Promise<void> => {
if (
state.closed ||
streams.get(stream.streamId) !== stream ||
stream.outputPaused ||
stream.ackRecoverySnapshotInFlight
) {
return
}
stream.ackRecoverySnapshotInFlight = true
let replacement: RemoteTerminalSourceRangeReplacementReservation | null = null
try {
const serialized = await serializeBudgetedRequestedSnapshot(runtime, stream.ptyId, 0)
if (state.closed || streams.get(stream.streamId) !== stream || stream.outputPaused) {
return
}
if (!serialized) {
throw new Error('Remote terminal recovery snapshot unavailable.')
}
if (
stream.ackOutputSourceRanges &&
(serialized.source === undefined || typeof serialized.seq !== 'number')
) {
throw new Error('Remote terminal recovery snapshot source identity unavailable.')
}
if (
stream.ackOutputSourceRanges &&
serialized.source !== undefined &&
typeof serialized.seq === 'number'
) {
replacement = runtime.reserveRemoteTerminalSourceRangeReplacement(
{
ptyId: stream.ptyId,
consumerId: stream.remoteDesktopSubscriptionKey,
streamGeneration: stream.streamGeneration
},
serialized.seq,
'ack-pending-overflow'
)
stream.sourceRangeReplacement = replacement
}
const displayMode = runtime.getMobileDisplayMode(stream.ptyId)
const publication = sendSnapshotFrames(
(opcode, payload) =>
!state.closed &&
streams.get(stream.streamId) === stream &&
state.sendFrame(stream.streamId, opcode, payload),
{
kind: 'scrollback',
cols: serialized.cols,
rows: serialized.rows,
displayMode,
reason: 'ack-pending-overflow',
seq: serialized.seq,
source: serialized.source,
kittyKeyboardFlags: serialized.kittyKeyboardFlags,
truncatedByByteBudget: serialized.truncatedByByteBudget,
data: serialized.data
}
)
if (!publication.published) {
throw new Error('Remote terminal recovery snapshot was not published.')
}
if (state.closed || streams.get(stream.streamId) !== stream) {
throw new Error('Remote terminal recovery snapshot stream detached.')
}
const localReplacement = replacement
? typeof serialized.seq === 'number'
? stream.sourceRangeLedger?.planSourceRangeReplacement(serialized.seq)
: null
: null
if (replacement && !localReplacement) {
throw new Error('Remote terminal recovery source ledger replacement unavailable.')
}
if (
replacement &&
(!serialized.source ||
typeof serialized.seq !== 'number' ||
!runtime.commitRemoteTerminalSourceRangeReplacement(replacement, {
source: serialized.source,
seq: serialized.seq
}))
) {
throw new Error('Remote terminal recovery snapshot replacement was not accepted.')
}
localReplacement?.commit()
stream.sourceRangeReplacement = null
replacement = null
if (typeof serialized.seq === 'number') {
const snapshotSeq = serialized.seq
const retained = stream.ackPendingOutput.filter(
(chunk) => !(typeof chunk.seq === 'number' && chunk.seq <= snapshotSeq)
)
stream.ackPendingOutput = retained
stream.ackPendingOutputBytes = retained.reduce(
(total, chunk) => total + chunk.bytes.byteLength,
0
)
}
stream.ackPendingOutputOverflowed = false
} catch (error) {
if (replacement) {
if (stream.sourceRangeReplacement === replacement) {
stream.sourceRangeReplacement = null
runtime.rollbackRemoteTerminalSourceRangeReplacement(
replacement,
'ack-pending-overflow-unpublished'
)
}
replacement = null
}
if (state.closed || streams.get(stream.streamId) !== stream) {
return
}
state.sendStreamError(
stream.streamId,
error instanceof Error ? error.message : 'Remote terminal recovery snapshot failed.'
)
state.detachStream(stream.streamId, true)
} finally {
if (streams.get(stream.streamId) === stream) {
stream.ackRecoverySnapshotInFlight = false
state.flushAllAckPendingOutput()
}
}
}
state.flushAckPendingOutput = (
stream: TerminalMultiplexStream,
maxChunks = Number.POSITIVE_INFINITY
): number => {
if (stream.outputPaused) {
return 0
}
if (stream.ackPendingOutputOverflowed) {
void state.sendAckRecoverySnapshot(stream)
return 0
}
let flushed = 0
while (
flushed < stream.ackPendingOutput.length &&
flushed < maxChunks &&
state.canSendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!.bytes.byteLength)
) {
if (!state.sendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!)) {
return flushed
}
flushed += 1
}
if (flushed > 0) {
stream.ackPendingOutput.splice(0, flushed)
stream.ackPendingOutputBytes = stream.ackPendingOutput.reduce(
(total, pending) => total + pending.bytes.byteLength,
0
)
}
return flushed
}
state.flushAllAckPendingOutput = (): void => {
const ordered = Array.from(streams.values())
state.ackFlushCursorStreamId = drainTerminalMultiplexRoundRobin({
streams: ordered,
cursorStreamId: state.ackFlushCursorStreamId,
canContinue: () => !state.closed,
drainOne: (stream) => {
if (streams.get(stream.streamId) !== stream) {
return false
}
if (state.flushAckPendingOutput(stream, 1) > 0) {
return true
}
return false
}
})
}
state.acknowledgeOutput = (stream: TerminalMultiplexStream, bytes: number): void => {
if (!stream.ackOutput || bytes <= 0) {
return
}
const acknowledged = Math.min(stream.ackInFlightBytes, bytes)
stream.ackWindowBytes = Math.min(
TERMINAL_MULTIPLEX_ACK_STREAM_MAX_WINDOW_BYTES,
stream.ackWindowBytes + acknowledged
)
state.ackTotalWindowBytes = Math.min(
TERMINAL_MULTIPLEX_ACK_TOTAL_MAX_WINDOW_BYTES,
state.ackTotalWindowBytes + acknowledged
)
stream.ackInFlightBytes -= acknowledged
state.ackTotalInFlightBytes = Math.max(0, state.ackTotalInFlightBytes - acknowledged)
state.flushAllAckPendingOutput()
}
state.acknowledgeSourceRanges = (
stream: TerminalMultiplexStream,
streamGeneration: string,
ackedEndByte: number
): void => {
if (!stream.ackOutputSourceRanges) {
return
}
const result = stream.sourceRangeLedger?.acknowledge(streamGeneration, ackedEndByte)
if (!result) {
return
}
if (result.status !== 'accepted') {
return
}
if (result.settled.length > 0) {
runtime.settleRemoteTerminalSourceRanges(
{
ptyId: stream.ptyId,
consumerId: stream.remoteDesktopSubscriptionKey,
streamGeneration: stream.streamGeneration
},
result.settled
)
}
state.acknowledgeOutput(stream, result.acknowledgedBytes)
}
state.detachSourceRangeConsumer = (stream: TerminalMultiplexStream, reason: string): void => {
if (!stream.sourceRangeConsumerAttached) {
return
}
stream.sourceRangeConsumerAttached = false
const ledger = stream.sourceRangeLedger
stream.sourceRangeLedger = null
if (!ledger) {
return
}
const identity = {
ptyId: stream.ptyId,
consumerId: stream.remoteDesktopSubscriptionKey,
streamGeneration: stream.streamGeneration
}
const transfer = ledger.beginTransfer()
const ranges = transfer.frames.flatMap((frame) => frame.sourceRanges)
try {
runtime.cancelRemoteTerminalSourceRanges(identity, ranges, reason)
} finally {
transfer.commit()
}
}
}
@@ -0,0 +1,153 @@
import {
TerminalStreamOpcode,
encodeTerminalStreamFrame,
encodeTerminalStreamJson,
encodeTerminalStreamText
} from '../../../../../shared/terminal-stream-protocol'
import { appendAckPendingOutput } from './terminal-stream-replay'
import type {
TerminalMultiplexConnection,
TerminalMultiplexConnectionBase,
TerminalMultiplexFrameDeliveryStage
} from './terminal-multiplex-connection'
import type { TerminalOutputFrameChunk } from '../../terminal-output-frame-chunks'
import type { TerminalStreamInputOutcome } from './terminal-input-delivery'
import type { TerminalMultiplexStream } from './terminal-stream-types'
export function installMultiplexFrameDelivery(
build: TerminalMultiplexConnectionBase
): asserts build is TerminalMultiplexFrameDeliveryStage {
const state = build as TerminalMultiplexConnection
const { sendBinary, emit, streams } = state
state.sendFrame = (
streamId: number,
opcode: TerminalStreamOpcode,
payload: Uint8Array<ArrayBufferLike> = new Uint8Array(),
seq?: number,
onRejected?: () => void
): boolean => {
if (state.closed) {
onRejected?.()
return false
}
// Why: a seq-less Output chunk must carry sentinel 0, not the control-frame state.cursor, or it poisons the client's frame-drop tracker.
const resolvedSeq =
typeof seq === 'number' ? seq : opcode === TerminalStreamOpcode.Output ? 0 : state.cursor++
let sent: boolean | void
try {
sent = sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: resolvedSeq, payload }))
} catch {
onRejected?.()
state.closeMultiplex()
return false
}
if (sent === false) {
onRejected?.()
// Why: false means the transport discarded this frame; reconnect is the only available retry boundary with an authoritative snapshot.
state.closeMultiplex()
return false
}
return true
}
state.sendStreamError = (streamId: number, message: string): void => {
state.sendFrame(streamId, TerminalStreamOpcode.Error, encodeTerminalStreamText(message))
emit({ type: 'error', streamId, message })
}
state.notifyStreamWriteUnavailable = (
stream: TerminalMultiplexStream,
outcome: TerminalStreamInputOutcome
): void => {
if (
state.closed ||
streams.get(stream.streamId) !== stream ||
outcome !== 'rejected' ||
!stream.supportsWriteUnavailable
) {
return
}
state.sendFrame(stream.streamId, TerminalStreamOpcode.WriteUnavailable)
}
state.sendResizedFrame = (
stream: TerminalMultiplexStream,
event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number }
): void => {
stream.lastResizeCols = event.cols
state.sendFrame(
stream.streamId,
TerminalStreamOpcode.Resized,
encodeTerminalStreamJson({
cols: event.cols,
rows: event.rows,
displayMode: event.displayMode,
reason: event.reason,
seq: event.seq
})
)
}
state.canSendAckGatedOutput = (stream: TerminalMultiplexStream, bytes: number): boolean => {
if (!stream.ackOutput) {
return true
}
return (
stream.ackInFlightBytes + bytes <= stream.ackWindowBytes &&
state.ackTotalInFlightBytes + bytes <= state.ackTotalWindowBytes &&
(!stream.ackOutputSourceRanges || stream.sourceRangeLedger?.canAccept(bytes) === true)
)
}
state.sendAckGatedOutput = (
stream: TerminalMultiplexStream,
chunk: TerminalOutputFrameChunk
): boolean => {
const prepared = stream.ackOutputSourceRanges
? stream.sourceRangeLedger?.prepareAccept(
chunk.bytes.byteLength,
chunk.displayLength,
chunk.sourceRanges ?? [],
chunk.seq
)
: undefined
if (stream.ackOutputSourceRanges && prepared?.status !== 'ready') {
if (prepared?.status !== 'capacity') {
state.detachStream(stream.streamId, true)
}
return false
}
const admission = prepared?.status === 'ready' ? prepared.admission : undefined
const sent = state.sendFrame(
stream.streamId,
chunk.opcode ?? TerminalStreamOpcode.Output,
chunk.bytes,
chunk.seq,
admission?.rollback
)
if (!sent) {
return false
}
if (admission && !admission.commit()) {
state.detachStream(stream.streamId, true)
return false
}
if (stream.ackOutput) {
stream.ackInFlightBytes += chunk.bytes.byteLength
state.ackTotalInFlightBytes += chunk.bytes.byteLength
}
return true
}
state.queueOrSendOutput = (
stream: TerminalMultiplexStream,
chunk: TerminalOutputFrameChunk
): void => {
if (state.closed || streams.get(stream.streamId) !== stream || stream.outputPaused) {
return
}
if (
stream.ackPendingOutputOverflowed ||
stream.ackPendingOutput.length > 0 ||
!state.canSendAckGatedOutput(stream, chunk.bytes.byteLength)
) {
appendAckPendingOutput(stream, chunk)
return
}
state.sendAckGatedOutput(stream, chunk)
}
}
@@ -0,0 +1,137 @@
import {
sendSnapshotFrames,
serializeBudgetedMobileSnapshot
} from './terminal-snapshot-publication'
import { getOutputAfterSnapshotSeq } from './terminal-stream-replay'
import type {
MultiplexSubscribeRequest,
TerminalMultiplexConnection
} from './terminal-multiplex-connection'
import type { TerminalMultiplexStream } from './terminal-stream-types'
export type MultiplexPublishedInitialState = {
isMobile: boolean
size: { cols: number; rows: number } | null
displayMode: string
}
export async function publishMultiplexInitialSnapshot(
state: TerminalMultiplexConnection,
request: MultiplexSubscribeRequest,
stream: TerminalMultiplexStream
): Promise<MultiplexPublishedInitialState | null> {
const { runtime, streams, emit } = state
const { ptyId } = stream
const isMobile = stream.isMobile
let read = await runtime.readTerminal(request.terminal)
let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile)
if (state.closed || streams.get(request.streamId) !== stream) {
return null
}
let initialOutputOverflowed = false
if (stream.pendingOutputOverflowed) {
stream.pendingOutput.splice(0)
stream.pendingOutputBytes = 0
stream.pendingOutputOverflowed = false
read = await runtime.readTerminal(request.terminal)
serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile)
if (state.closed || streams.get(request.streamId) !== stream) {
return null
}
if (stream.pendingOutputOverflowed) {
initialOutputOverflowed = true
stream.pendingOutput.splice(0)
stream.pendingOutputBytes = 0
stream.pendingOutputOverflowed = false
}
}
const size = runtime.getTerminalSize(ptyId)
const displayMode = runtime.getMobileDisplayMode(ptyId)
const layoutSeq = runtime.getLayout(ptyId)?.seq
// Why: layout versions and output offsets are different sequence domains.
const snapshotOutputSeq = serialized?.seq
emit({
type: 'subscribed',
streamId: request.streamId,
terminal: request.terminal,
cols: serialized?.cols ?? size?.cols,
rows: serialized?.rows ?? size?.rows,
displayMode,
seq: layoutSeq,
...((stream.ackOutputSourceRanges || stream.supportsOutputPause) && {
capabilities: {
...(stream.ackOutputSourceRanges ? { ackOutputSourceRanges: 1 as const } : {}),
...(stream.supportsOutputPause ? { outputPause: 1 as const } : {})
}
}),
...(stream.ackOutputSourceRanges ? { streamGeneration: stream.streamGeneration } : {}),
// Why: retained-tail truncation loses history, not the authoritative latest-screen fallback.
truncated: initialOutputOverflowed
})
stream.sourceRangeReplacement =
stream.ackOutputSourceRanges &&
serialized?.source !== undefined &&
typeof serialized.seq === 'number'
? runtime.reserveRemoteTerminalSourceRangeReplacement(
{
ptyId,
consumerId: stream.remoteDesktopSubscriptionKey,
streamGeneration: stream.streamGeneration
},
serialized.seq,
'initial-snapshot'
)
: null
const snapshotPublication = sendSnapshotFrames(
(opcode, payload) => state.sendFrame(request.streamId, opcode, payload),
{
kind: 'scrollback',
cols: serialized?.cols ?? size?.cols ?? 80,
rows: serialized?.rows ?? size?.rows ?? 24,
displayMode,
seq: snapshotOutputSeq,
cwd: serialized?.cwd,
truncated: initialOutputOverflowed,
truncatedByByteBudget: serialized?.truncatedByByteBudget,
source: serialized?.source,
kittyKeyboardFlags: serialized?.kittyKeyboardFlags,
oscLinks: serialized?.oscLinks,
pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi,
data: serialized?.data ?? (read.tail.length > 0 ? `${read.tail.join('\r\n')}\r\n` : '')
}
)
const replacement = stream.sourceRangeReplacement
stream.sourceRangeReplacement = null
if (replacement) {
const committed =
snapshotPublication.published &&
serialized?.source !== undefined &&
typeof serialized.seq === 'number' &&
runtime.commitRemoteTerminalSourceRangeReplacement(replacement, {
source: serialized.source,
seq: serialized.seq
})
if (!committed) {
runtime.rollbackRemoteTerminalSourceRangeReplacement(
replacement,
'initial-snapshot-unpublished'
)
}
}
// Why: baseline for resize re-stream gating; the client already rewrapped to these cols via the initial snapshot replay.
stream.lastResizeCols = serialized?.cols ?? size?.cols
stream.buffering = false
const pendingOutput = stream.pendingOutput.splice(0)
if (!initialOutputOverflowed) {
for (const chunk of pendingOutput) {
const uncovered = getOutputAfterSnapshotSeq(chunk, snapshotOutputSeq)
if (uncovered) {
stream.outputBatcher.push(uncovered.data, uncovered.meta)
}
}
}
stream.pendingOutputBytes = 0
stream.pendingOutputOverflowed = false
stream.outputBatcher.flush()
return { isMobile, size, displayMode }
}
@@ -0,0 +1,139 @@
import { sendMobileResizeRestream } from './terminal-snapshot-publication'
import { updateViewportForClient } from './terminal-viewport-update'
import type {
MultiplexSubscribeRequest,
TerminalMultiplexConnection
} from './terminal-multiplex-connection'
import type { MultiplexPublishedInitialState } from './terminal-multiplex-initial-snapshot'
import type { TerminalMultiplexStream } from './terminal-stream-types'
export function activateMultiplexStream(
state: TerminalMultiplexConnection,
request: MultiplexSubscribeRequest,
stream: TerminalMultiplexStream,
published: MultiplexPublishedInitialState
): void {
const { runtime, streams, emit } = state
const { ptyId } = stream
const { isMobile, size } = published
if (!isMobile) {
stream.unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => {
const mode =
event.mode === 'mobile-fit'
? event.mode
: (runtime.getRemoteDesktopFitHold?.(ptyId, stream.remoteDesktopSubscriptionKey).mode ??
'desktop-fit')
emit({
type: 'fit-override-changed',
streamId: request.streamId,
mode,
cols: event.cols,
rows: event.rows
})
})
stream.unsubscribeDriver = runtime.subscribeToDriverChanges(ptyId, (driver) => {
emit({
type: 'driver-changed',
streamId: request.streamId,
driver
})
})
const fitOverride = runtime.getTerminalFitOverride(ptyId)
const desktopHold = runtime.getRemoteDesktopFitHold?.(
ptyId,
stream.remoteDesktopSubscriptionKey
) ?? { mode: 'desktop-fit' as const, cols: size?.cols ?? 0, rows: size?.rows ?? 0 }
emit({
type: 'fit-override-changed',
streamId: request.streamId,
mode: fitOverride?.mode ?? desktopHold.mode,
cols: fitOverride?.cols ?? desktopHold.cols,
rows: fitOverride?.rows ?? desktopHold.rows
})
emit({
type: 'driver-changed',
streamId: request.streamId,
driver: runtime.getDriver(ptyId)
})
}
stream.unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => {
stream.outputBatcher.flush()
const resizeGeneration = stream.resizeGeneration + 1
stream.resizeGeneration = resizeGeneration
const widthChanged = stream.isMobile && event.cols !== stream.lastResizeCols
if (widthChanged) {
stream.lastResizeCols = event.cols
// Why: re-serialize+replay the full scrollback at the new cols so restored hard-wrapped lines rewrap; live output resumes after the snapshot lands.
void sendMobileResizeRestream(
runtime,
ptyId,
(opcode, payload) => state.sendFrame(request.streamId, opcode, payload),
event,
() =>
!state.closed &&
streams.get(request.streamId) === stream &&
stream.resizeGeneration === resizeGeneration
)
.then((restreamed) => {
if (
state.closed ||
streams.get(request.streamId) !== stream ||
stream.resizeGeneration !== resizeGeneration
) {
return
}
if (!restreamed) {
state.sendResizedFrame(stream, event)
}
})
// Why: on re-stream failure, still emit the geometry-only Resized frame so the client never misses the resize.
.catch(() => {
if (
state.closed ||
streams.get(request.streamId) !== stream ||
stream.resizeGeneration !== resizeGeneration
) {
return
}
state.sendResizedFrame(stream, event)
})
return
}
state.sendResizedFrame(stream, event)
})
// Install the resize listener before draining the parked viewport, since applyLayout emits synchronously.
if (
!stream.isMobile &&
stream.client?.id &&
stream.registeredRemoteDesktopDriver &&
stream.pendingRemoteDesktopViewport
) {
const viewport = stream.pendingRemoteDesktopViewport
stream.pendingRemoteDesktopViewport = null
void updateViewportForClient(
runtime,
ptyId,
stream.remoteDesktopSubscriptionKey,
stream.client,
viewport,
'desktop',
'register',
!stream.supportsDesktopViewportClaims
).catch(() => {})
}
void runtime
.waitForTerminal(request.terminal, {
condition: 'exit',
signal: stream.exitWaiterAbort.signal
})
.then(() => {
if (streams.get(request.streamId) === stream) {
state.detachStream(request.streamId, true)
}
})
.catch(() => {
if (streams.get(request.streamId) === stream) {
state.detachStream(request.streamId, true)
}
})
}
@@ -0,0 +1,72 @@
import { defineStreamingMethod, type RpcAnyMethod } from '../../core'
import { TerminalStreamOpcode } from '../../../../../shared/terminal-stream-protocol'
import { TERMINAL_MULTIPLEX_ACK_TOTAL_INITIAL_WINDOW_BYTES } from '../../../../../shared/terminal-multiplex-flow-control'
import { TerminalSourceRangeRegistry } from '../../terminal-source-range-registry'
import { TerminalMultiplex } from './stream-schemas'
import type { TerminalMultiplexConnectionBase } from './terminal-multiplex-connection'
import type { TerminalMultiplexStream } from './terminal-stream-types'
import { installMultiplexFrameDelivery } from './terminal-multiplex-frame-delivery'
import { installMultiplexFlowControl } from './terminal-multiplex-flow-control'
import { installMultiplexCleanup } from './terminal-multiplex-cleanup'
import { installMultiplexSlotFrames } from './terminal-multiplex-slot-frames'
import { installMultiplexSubscribeFrame } from './terminal-multiplex-subscribe-frame'
export const TERMINAL_MULTIPLEX_METHODS: RpcAnyMethod[] = [
defineStreamingMethod({
name: 'terminal.multiplex',
params: TerminalMultiplex,
handler: async (
_params,
{ runtime, connectionId, sendBinary, registerBinaryStreamHandler, signal },
emit
) => {
if (!sendBinary || !registerBinaryStreamHandler || !connectionId) {
throw new Error('binary_terminal_stream_required')
}
let resolveMultiplex = (): void => {}
const multiplexClosed = new Promise<void>((resolve) => {
resolveMultiplex = resolve
})
// Installers only close over this per-connection state; none of it is module-global.
const state: TerminalMultiplexConnectionBase = {
runtime,
connectionId,
sendBinary,
registerBinaryStreamHandler,
signal,
emit,
closed: false,
cursor: 0,
streams: new Map<number, TerminalMultiplexStream>(),
sourceRangeRegistry: new TerminalSourceRangeRegistry(),
pendingPtyWaitControllers: new Map<number, Set<AbortController>>(),
ackTotalInFlightBytes: 0,
ackTotalWindowBytes: TERMINAL_MULTIPLEX_ACK_TOTAL_INITIAL_WINDOW_BYTES,
ackFlushCursorStreamId: null,
resolveMultiplex,
multiplexClosed,
unregisterControlHandler: () => {}
}
installMultiplexFrameDelivery(state)
installMultiplexFlowControl(state)
installMultiplexCleanup(state)
installMultiplexSlotFrames(state)
installMultiplexSubscribeFrame(state)
state.unregisterControlHandler = registerBinaryStreamHandler(0, (frame) => {
if (frame.opcode === TerminalStreamOpcode.Subscribe) {
void state.handleSubscribeFrame(frame.payload)
}
})
signal?.addEventListener('abort', state.cancelAllPendingPtyWaits, { once: true })
runtime.registerSubscriptionCleanup(
`terminal-multiplex:${connectionId}`,
state.closeMultiplex,
connectionId
)
emit({ type: 'ready' })
await multiplexClosed
}
})
]
@@ -0,0 +1,297 @@
import {
TerminalStreamOpcode,
decodeTerminalStreamJson,
decodeTerminalStreamText,
type TerminalStreamFrame
} from '../../../../../shared/terminal-stream-protocol'
import {
TerminalMultiplexLegacyAckFrame,
TerminalMultiplexSnapshotRequestFrame,
TerminalMultiplexSourceRangeAckFrame
} from './stream-schemas'
import { isTerminalInputLockedForClient, sendTerminalStreamInput } from './terminal-input-delivery'
import {
getOutputAfterSnapshotSeq,
normalizeMultiplexSnapshotScrollbackRows
} from './terminal-stream-replay'
import {
sendSnapshotFrames,
serializeBudgetedRequestedSnapshot
} from './terminal-snapshot-publication'
import { updateViewportForClient } from './terminal-viewport-update'
import type {
MultiplexSnapshotRequest,
TerminalMultiplexCleanupStage,
TerminalMultiplexConnection,
TerminalMultiplexSlotFramesStage
} from './terminal-multiplex-connection'
import type { TerminalMultiplexStream } from './terminal-stream-types'
export function installMultiplexSlotFrames(
build: TerminalMultiplexCleanupStage
): asserts build is TerminalMultiplexSlotFramesStage {
const state = build as TerminalMultiplexConnection
const { runtime, streams } = state
state.handleSlotFrame = (stream: TerminalMultiplexStream, frame: TerminalStreamFrame): void => {
if (state.closed || streams.get(stream.streamId) !== stream) {
return
}
if (frame.opcode === TerminalStreamOpcode.Unsubscribe) {
state.cancelPendingPtyWaits(stream.streamId)
state.detachStream(stream.streamId, false)
return
}
if (frame.opcode === TerminalStreamOpcode.Ack) {
const payload = decodeTerminalStreamJson<unknown>(frame.payload) ?? {}
if (stream.ackOutputSourceRanges) {
const parsed = TerminalMultiplexSourceRangeAckFrame.safeParse(payload)
if (parsed.success) {
state.acknowledgeSourceRanges(
stream,
parsed.data.streamGeneration,
parsed.data.ackedEndByte
)
}
} else {
const parsed = TerminalMultiplexLegacyAckFrame.safeParse(payload)
if (parsed.success) {
state.acknowledgeOutput(stream, parsed.data.bytes)
}
}
return
}
if (frame.opcode === TerminalStreamOpcode.Input) {
const text = decodeTerminalStreamText(frame.payload)
if (!text) {
return
}
if (isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) {
return
}
// Mobile already has the higher-priority floor, so a rejected desktop claim must not suppress later phone input.
const inputClaimTail = stream.isMobile ? Promise.resolve(true) : stream.desktopClaimTail
void inputClaimTail.then(async (claimed) => {
if (!claimed || isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) {
return
}
const outcome = await sendTerminalStreamInput(runtime, {
terminal: stream.terminal,
text,
client: stream.client,
isMobile: stream.isMobile
})
state.notifyStreamWriteUnavailable(stream, outcome)
})
return
}
if (frame.opcode === TerminalStreamOpcode.SetOutputPaused && stream.supportsOutputPause) {
const payload = decodeTerminalStreamJson<{ paused?: unknown }>(frame.payload)
if (typeof payload?.paused !== 'boolean' || stream.outputPaused === payload.paused) {
return
}
stream.outputPaused = payload.paused
if (stream.outputPaused) {
stream.outputBatcher.flush()
stream.ackPendingOutput = []
stream.ackPendingOutputBytes = 0
stream.ackPendingOutputOverflowed = false
}
return
}
if (frame.opcode === TerminalStreamOpcode.Resize && stream.client) {
const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>(frame.payload)
if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') {
return
}
const cols = viewport.cols
const rows = viewport.rows
// Why: resize registers stream-scoped geometry so detach can release it; older clients lack explicit claims.
if (!stream.isMobile && stream.client?.id) {
stream.registeredRemoteDesktopDriver = true
if (stream.buffering) {
stream.pendingRemoteDesktopViewport = { cols: viewport.cols, rows: viewport.rows }
return
}
}
stream.desktopClaimTail = stream.desktopClaimTail
.then(async (priorClaimed) => {
const result = await updateViewportForClient(
runtime,
stream.ptyId,
stream.remoteDesktopSubscriptionKey,
stream.client!,
{ cols, rows },
stream.isMobile ? 'mobile' : 'desktop',
'register',
!stream.supportsDesktopViewportClaims
)
return stream.supportsDesktopViewportClaims
? priorClaimed && result.applied
: result.applied
})
.catch(() => false)
return
}
if (frame.opcode === TerminalStreamOpcode.ClaimViewport && stream.client && !stream.isMobile) {
const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>(frame.payload)
if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') {
return
}
const cols = viewport.cols
const rows = viewport.rows
stream.registeredRemoteDesktopDriver = true
stream.desktopClaimTail = stream.desktopClaimTail
.then(
() =>
runtime.updateRemoteDesktopViewer(
stream.ptyId,
stream.remoteDesktopSubscriptionKey,
stream.client!.id,
cols,
rows,
true
),
() =>
runtime.updateRemoteDesktopViewer(
stream.ptyId,
stream.remoteDesktopSubscriptionKey,
stream.client!.id,
cols,
rows,
true
)
)
.catch(() => false)
return
}
if (frame.opcode === TerminalStreamOpcode.SnapshotRequest) {
const payload = TerminalMultiplexSnapshotRequestFrame.safeParse(
decodeTerminalStreamJson<unknown>(frame.payload) ?? {}
)
void state.sendRequestedSnapshot(stream, payload.success ? payload.data : {})
}
}
state.sendRequestedSnapshot = async (
stream: TerminalMultiplexStream,
request: MultiplexSnapshotRequest
): Promise<void> => {
if (state.closed || streams.get(stream.streamId) !== stream) {
return
}
stream.outputBatcher.flush()
stream.pendingOutputOverflowed = false
stream.buffering = true
const requestId = request.requestId
let sentSnapshotOutputSeq: number | undefined
try {
const scrollbackRows = normalizeMultiplexSnapshotScrollbackRows(request.scrollbackRows)
let serialized = await serializeBudgetedRequestedSnapshot(
runtime,
stream.ptyId,
scrollbackRows
)
if (state.closed || streams.get(stream.streamId) !== stream) {
return
}
let size = runtime.getTerminalSize(stream.ptyId)
let displayMode = runtime.getMobileDisplayMode(stream.ptyId)
if (stream.pendingOutputOverflowed) {
// Why: the overflowed tail is newer than the first snapshot, so retry for a current image instead of null.
stream.pendingOutput.splice(0)
stream.pendingOutputBytes = 0
stream.pendingOutputOverflowed = false
serialized = await serializeBudgetedRequestedSnapshot(runtime, stream.ptyId, scrollbackRows)
if (state.closed || streams.get(stream.streamId) !== stream) {
return
}
size = runtime.getTerminalSize(stream.ptyId)
displayMode = runtime.getMobileDisplayMode(stream.ptyId)
if (stream.pendingOutputOverflowed) {
sendSnapshotFrames(
(opcode, payload) => state.sendFrame(stream.streamId, opcode, payload),
{
kind: 'scrollback',
cols: size?.cols ?? 80,
rows: size?.rows ?? 24,
requestId,
displayMode,
truncated: true,
truncatedByByteBudget: false,
unavailable: 'pending-output-overflowed',
data: ''
}
)
return
}
}
sentSnapshotOutputSeq = serialized?.seq
sendSnapshotFrames((opcode, payload) => state.sendFrame(stream.streamId, opcode, payload), {
kind: 'scrollback',
cols: serialized?.cols ?? size?.cols ?? 80,
rows: serialized?.rows ?? size?.rows ?? 24,
requestId,
displayMode,
seq: serialized?.seq,
cwd: serialized?.cwd,
source: serialized?.source,
kittyKeyboardFlags: serialized?.kittyKeyboardFlags,
oscLinks: serialized?.oscLinks,
pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi,
truncated: false,
truncatedByByteBudget: serialized?.truncatedByByteBudget,
// Why: no serializer answered, which is not proof the pane is empty — say so instead of passing off '' as the buffer.
unavailable: serialized ? undefined : 'no-serializable-buffer',
data: serialized?.data ?? ''
})
} catch (error) {
state.sendStreamError(
stream.streamId,
error instanceof Error ? error.message : 'Remote terminal snapshot failed.'
)
} finally {
if (streams.get(stream.streamId) === stream) {
const shouldFlushPendingOutput = !stream.pendingOutputOverflowed
stream.buffering = false
const pendingOutput = stream.pendingOutput.splice(0)
if (shouldFlushPendingOutput) {
for (const chunk of pendingOutput) {
// Why: an untagged reply resets the client to the snapshot's
// high-water, so covered bytes would render twice; tagged
// snapshots feed a side consumer and the live view still
// needs every buffered chunk.
const uncovered =
typeof requestId === 'number'
? chunk
: getOutputAfterSnapshotSeq(chunk, sentSnapshotOutputSeq)
if (uncovered) {
stream.outputBatcher.push(uncovered.data, uncovered.meta)
}
}
}
stream.pendingOutputBytes = 0
stream.pendingOutputOverflowed = false
stream.outputBatcher.flush()
// Why: a resize parked during snapshot buffering must be applied now, or it is dropped until the viewer's next resize.
if (
!stream.isMobile &&
stream.client?.id &&
stream.registeredRemoteDesktopDriver &&
stream.pendingRemoteDesktopViewport
) {
const viewport = stream.pendingRemoteDesktopViewport
stream.pendingRemoteDesktopViewport = null
void updateViewportForClient(
runtime,
stream.ptyId,
stream.remoteDesktopSubscriptionKey,
stream.client,
viewport,
'desktop',
'register',
!stream.supportsDesktopViewportClaims
).catch(() => {})
}
}
}
}
}
@@ -0,0 +1,151 @@
import { randomUUID } from 'node:crypto'
import {
TerminalStreamOpcode,
encodeTerminalStreamJson
} from '../../../../../shared/terminal-stream-protocol'
import { iterateTerminalOutputFrameChunks } from '../../terminal-output-frame-chunks'
import { TERMINAL_MULTIPLEX_ACK_STREAM_INITIAL_WINDOW_BYTES } from '../../../../../shared/terminal-multiplex-flow-control'
import { createTerminalOutputBatcher } from './terminal-output-batcher'
import { appendPendingMultiplexOutput } from './terminal-stream-replay'
import { updateViewportForClient } from './terminal-viewport-update'
import type {
MultiplexSubscribeRequest,
TerminalMultiplexConnection
} from './terminal-multiplex-connection'
import type { TerminalMultiplexStream } from './terminal-stream-types'
export async function initializeMultiplexStream(
state: TerminalMultiplexConnection,
request: MultiplexSubscribeRequest,
ptyId: string,
onInstalled: (stream: TerminalMultiplexStream) => void
): Promise<TerminalMultiplexStream | null> {
const { runtime, connectionId, streams, sourceRangeRegistry, registerBinaryStreamHandler } = state
const isMobile = request.client?.type === 'mobile'
const remoteDesktopSubscriptionKey = `multiplex:${connectionId}:${request.streamId}`
const streamGeneration = randomUUID()
const requestedSourceRangeConsumer =
request.capabilities?.ackOutput === 1 && request.capabilities?.ackOutputSourceRanges === 1
const sourceRangeLedger = requestedSourceRangeConsumer
? sourceRangeRegistry.open(streamGeneration)
: null
const sourceRangeConsumerAttached =
sourceRangeLedger !== null &&
runtime.attachRemoteTerminalSourceRangeConsumer({
ptyId,
consumerId: remoteDesktopSubscriptionKey,
streamGeneration
})
if (!sourceRangeConsumerAttached) {
sourceRangeLedger?.close()
}
const stream: TerminalMultiplexStream = {
streamId: request.streamId,
terminal: request.terminal,
ptyId,
client: request.client,
isMobile,
ackOutput: request.capabilities?.ackOutput === 1,
ackOutputSourceRanges: sourceRangeConsumerAttached,
streamGeneration,
sourceRangeLedger: sourceRangeConsumerAttached ? sourceRangeLedger : null,
sourceRangeConsumerAttached,
sourceRangeReplacement: null,
ackInFlightBytes: 0,
ackWindowBytes: TERMINAL_MULTIPLEX_ACK_STREAM_INITIAL_WINDOW_BYTES,
supportsOutputPause: request.capabilities?.outputPause === 1,
supportsWriteUnavailable: request.capabilities?.writeUnavailable === 1,
outputPaused: false,
supportsDesktopViewportClaims: request.capabilities?.desktopViewportClaims === 1,
desktopClaimTail: Promise.resolve(true),
registeredRemoteDesktopDriver: false,
// Why: streamId is client-local, so key the width floor by connectionId or two connections sharing stream 1 for one PTY clobber each other's floor.
remoteDesktopSubscriptionKey,
pendingRemoteDesktopViewport: null,
buffering: true,
ackPendingOutput: [],
ackPendingOutputBytes: 0,
ackPendingOutputOverflowed: false,
ackRecoverySnapshotInFlight: false,
pendingOutput: [],
pendingOutputBytes: 0,
pendingOutputOverflowed: false,
lastResizeCols: undefined,
resizeGeneration: 0,
outputBatcher: createTerminalOutputBatcher((data, meta) => {
if (meta?.cwd !== undefined) {
state.sendFrame(
request.streamId,
TerminalStreamOpcode.Metadata,
encodeTerminalStreamJson({ cwd: meta.cwd }),
meta.seq
)
}
for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) {
state.queueOrSendOutput(stream, chunk)
}
}),
unsubscribeData: () => {},
unsubscribeResize: () => {},
unsubscribeFit: () => {},
unsubscribeDriver: () => {},
unregisterBinaryHandler: () => {},
exitWaiterAbort: new AbortController()
}
streams.set(request.streamId, stream)
stream.unregisterBinaryHandler = registerBinaryStreamHandler(request.streamId, (frame) =>
state.handleSlotFrame(stream, frame)
)
onInstalled(stream)
const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => {
if (state.closed || streams.get(request.streamId) !== stream) {
return
}
if (stream.outputPaused) {
return
}
if (stream.buffering) {
appendPendingMultiplexOutput(stream, data, meta)
return
}
stream.outputBatcher.push(data, meta)
})
// Why: a multiplexed stream feeds a remote xterm view with query authority, so the main model responder yields while attached (terminal-query-authority.md).
const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId)
stream.unsubscribeData = () => {
releaseViewSubscriber()
unsubscribeStreamData()
}
if (isMobile && request.client?.id) {
await runtime.handleMobileSubscribe(ptyId, request.client.id, request.viewport)
} else if (request.client?.id && request.viewport) {
// Why: subscribe records this stream's geometry and cleanup key but doesn't claim ownership; activity frames claim later.
stream.registeredRemoteDesktopDriver = true
stream.pendingRemoteDesktopViewport = request.viewport
}
if (
!isMobile &&
request.client?.id &&
stream.registeredRemoteDesktopDriver &&
stream.pendingRemoteDesktopViewport
) {
const viewport = stream.pendingRemoteDesktopViewport
stream.pendingRemoteDesktopViewport = null
await updateViewportForClient(
runtime,
ptyId,
stream.remoteDesktopSubscriptionKey,
request.client,
viewport,
'desktop',
'register',
!stream.supportsDesktopViewportClaims
)
}
if (state.closed || streams.get(request.streamId) !== stream) {
return null
}
return stream
}
@@ -0,0 +1,73 @@
import { decodeTerminalStreamJson } from '../../../../../shared/terminal-stream-protocol'
import { TerminalMultiplexSubscribeFrame } from './stream-schemas'
import type {
TerminalMultiplexConnection,
TerminalMultiplexSlotFramesStage
} from './terminal-multiplex-connection'
import { resolveMultiplexSubscribePty } from './terminal-multiplex-subscribe-resolution'
import { initializeMultiplexStream } from './terminal-multiplex-stream-initialization'
import { publishMultiplexInitialSnapshot } from './terminal-multiplex-initial-snapshot'
import { activateMultiplexStream } from './terminal-multiplex-live-stream'
import type { TerminalMultiplexStream } from './terminal-stream-types'
export function installMultiplexSubscribeFrame(
build: TerminalMultiplexSlotFramesStage
): asserts build is TerminalMultiplexConnection {
const state = build as TerminalMultiplexConnection
state.handleSubscribeFrame = async (payload) => {
const raw = decodeTerminalStreamJson<unknown>(payload)
const parsed = TerminalMultiplexSubscribeFrame.safeParse(raw)
if (!parsed.success) {
return
}
const request = parsed.data
const resolution = resolveMultiplexSubscribePty(state, request)
const ptyId =
typeof resolution === 'string' || resolution === null ? resolution : await resolution
if (!ptyId) {
return
}
let stream: TerminalMultiplexStream | null = null
let installedStream: TerminalMultiplexStream | null = null
try {
stream = await initializeMultiplexStream(state, request, ptyId, (installed) => {
installedStream = installed
})
} catch (error) {
if (!installedStream) {
throw error
}
if (state.streams.get(request.streamId) !== installedStream) {
return
}
state.detachStream(request.streamId, false)
state.sendStreamError(
request.streamId,
error instanceof Error ? error.message : String(error)
)
state.emit({ type: 'end', streamId: request.streamId })
return
}
if (!stream) {
return
}
try {
const published = await publishMultiplexInitialSnapshot(state, request, stream)
if (!published) {
return
}
activateMultiplexStream(state, request, stream, published)
} catch (error) {
// A successor may already own the reused stream ID; never tear it down here.
if (state.streams.get(request.streamId) !== stream) {
return
}
state.detachStream(request.streamId, false)
state.sendStreamError(
request.streamId,
error instanceof Error ? error.message : String(error)
)
state.emit({ type: 'end', streamId: request.streamId })
}
}
}
@@ -0,0 +1,96 @@
import { TerminalStreamOpcode } from '../../../../../shared/terminal-stream-protocol'
import {
TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION,
TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION,
TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR
} from '../../../../../shared/terminal-multiplex-flow-control'
import type {
MultiplexSubscribeRequest,
TerminalMultiplexConnection
} from './terminal-multiplex-connection'
function finalizeResolvedMultiplexPty(
state: TerminalMultiplexConnection,
request: MultiplexSubscribeRequest,
leaf: { ptyId: string | null } | null
): string | null {
if (!leaf?.ptyId) {
state.sendStreamError(request.streamId, 'no_connected_pty')
state.emit({ type: 'end', streamId: request.streamId })
return null
}
if (state.closed) {
return null
}
// Why: a competing subscribe may own this streamId after the PTY await; detach it so an orphaned view subscriber can't silence the model responder (terminal-query-authority.md).
state.detachStream(request.streamId, false)
if (state.streams.size >= TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION) {
state.sendStreamError(request.streamId, TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR)
state.emit({ type: 'end', streamId: request.streamId })
return null
}
return leaf.ptyId
}
export function resolveMultiplexSubscribePty(
state: TerminalMultiplexConnection,
request: MultiplexSubscribeRequest
): string | null | Promise<string | null> {
const { runtime, pendingPtyWaitControllers, registerBinaryStreamHandler, signal, emit } = state
state.detachStream(request.streamId, false)
state.cancelPendingPtyWaits(request.streamId)
let leaf: { ptyId: string | null } | null
try {
// Why: binding the stream to whatever PTY now occupies a stale handle's pane would mirror the wrong terminal (#7718).
leaf = runtime.resolveLiveLeafForHandle(request.terminal)
} catch {
state.sendStreamError(request.streamId, 'terminal_handle_stale')
emit({ type: 'end', streamId: request.streamId })
return null
}
if (leaf?.ptyId || !request.client) {
return finalizeResolvedMultiplexPty(state, request, leaf)
}
if (pendingPtyWaitControllers.size >= TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION) {
state.sendStreamError(request.streamId, TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR)
emit({ type: 'end', streamId: request.streamId })
return null
}
// Why: a never-mounted tab has no graph leaf to await; mounting the exact tab attaches its PTY without activating the worktree.
runtime.requestRendererTerminalTabMount(request.terminal)
const waitController = new AbortController()
const pendingControllers = pendingPtyWaitControllers.get(request.streamId) ?? new Set()
pendingControllers.add(waitController)
pendingPtyWaitControllers.set(request.streamId, pendingControllers)
if (signal?.aborted) {
waitController.abort()
}
// Why: the live slot handler does not exist until the PTY attaches; retain cancellation ownership while the pane is still pending.
const unregisterPendingHandler = registerBinaryStreamHandler(request.streamId, (frame) => {
if (frame.opcode === TerminalStreamOpcode.Unsubscribe) {
state.cancelPendingPtyWaits(request.streamId)
state.detachStream(request.streamId, false)
}
})
return (async () => {
try {
const ptyId = await runtime.waitForLeafPtyId(request.terminal, 10_000, waitController.signal)
leaf = { ptyId }
} catch {
if (state.closed || signal?.aborted || waitController.signal.aborted) {
return null
}
// Fall through to the explicit no_connected_pty error below.
} finally {
const currentControllers = pendingPtyWaitControllers.get(request.streamId)
currentControllers?.delete(waitController)
if (currentControllers?.size === 0) {
pendingPtyWaitControllers.delete(request.streamId)
}
unregisterPendingHandler()
}
return finalizeResolvedMultiplexPty(state, request, leaf)
})()
}
@@ -0,0 +1,120 @@
import type { TerminalOutputMeta } from '../../terminal-output-frame-chunks'
import { TERMINAL_OUTPUT_BATCH_MAX_BYTES } from '../../../../../shared/terminal-multiplex-flow-control'
import { measureTerminalStreamByteLength } from '../../terminal-stream-byte-length'
import {
sameTerminalOutputSourceIdentity,
type TerminalOutputSourceRange
} from '../../../../../shared/terminal-output-source-range'
const TERMINAL_OUTPUT_FLUSH_MS = 5
export type TerminalOutputBatcher = {
push: (data: string, meta?: TerminalOutputMeta) => void
flush: () => void
dispose: () => void
}
export function createTerminalOutputBatcher(
onFlush: (data: string, meta?: TerminalOutputMeta) => void
): TerminalOutputBatcher {
let chunks: string[] = []
let bytes = 0
let lastSeq: number | undefined
let pendingCwd: string | undefined
let pendingRawLength = 0
let pendingSourceRanges: TerminalOutputSourceRange[] = []
let timer: ReturnType<typeof setTimeout> | null = null
const clearTimer = (): void => {
if (!timer) {
return
}
clearTimeout(timer)
timer = null
}
const flush = (): void => {
clearTimer()
if (chunks.length === 0 && pendingRawLength === 0) {
return
}
const data = chunks.length === 1 ? chunks[0]! : chunks.join('')
const meta =
typeof lastSeq === 'number' || pendingCwd !== undefined || pendingSourceRanges.length > 0
? {
...(typeof lastSeq === 'number' ? { seq: lastSeq, rawLength: pendingRawLength } : {}),
...(pendingCwd !== undefined ? { cwd: pendingCwd } : {}),
...(pendingSourceRanges.length > 0
? { sourceRanges: Object.freeze(pendingSourceRanges.slice()) }
: {})
}
: undefined
chunks = []
bytes = 0
lastSeq = undefined
pendingCwd = undefined
pendingRawLength = 0
pendingSourceRanges = []
onFlush(data, meta)
}
return {
push(data: string, meta?: TerminalOutputMeta): void {
const rawLength = meta?.rawLength ?? data.length
if (!data && rawLength === 0) {
return
}
if (meta?.transformed || rawLength !== data.length) {
flush()
onFlush(data, { ...meta, rawLength, transformed: true })
return
}
const nextSourceRanges = meta?.sourceRanges ?? []
const lastSourceRange = pendingSourceRanges.at(-1)
const firstNextSourceRange = nextSourceRanges[0]
if (
chunks.length > 0 &&
(pendingSourceRanges.length > 0 !== nextSourceRanges.length > 0 ||
(lastSourceRange &&
firstNextSourceRange &&
(!sameTerminalOutputSourceIdentity(lastSourceRange, firstNextSourceRange) ||
lastSourceRange.displayEnd !== firstNextSourceRange.displayStart)))
) {
flush()
}
if (meta?.cwd !== undefined) {
flush()
pendingCwd = meta.cwd
}
chunks.push(data)
pendingRawLength += rawLength
pendingSourceRanges.push(...nextSourceRanges)
const remainingBudget = Math.max(1, TERMINAL_OUTPUT_BATCH_MAX_BYTES - bytes)
const measurement = measureTerminalStreamByteLength(data, {
stopAfterBytes: remainingBudget
})
bytes += measurement.byteLength
if (typeof meta?.seq === 'number') {
lastSeq = meta.seq
}
if (measurement.exceededLimit || bytes >= TERMINAL_OUTPUT_BATCH_MAX_BYTES) {
flush()
return
}
if (!timer) {
// Why: coalesce stream output before it crosses the network; desktop subscribers share the same burst boundary.
timer = setTimeout(flush, TERMINAL_OUTPUT_FLUSH_MS)
if (typeof timer.unref === 'function') {
timer.unref()
}
}
},
flush,
dispose(): void {
clearTimer()
chunks = []
bytes = 0
pendingRawLength = 0
pendingSourceRanges = []
}
}
}
@@ -0,0 +1,101 @@
import { defineMethod, type RpcAnyMethod } from '../../core'
import {
TerminalHandle,
TerminalListParams,
TerminalRead,
TerminalRecoverPane,
TerminalRename,
TerminalResolveActive,
TerminalResolvePane
} from './unary-schemas'
export const TERMINAL_QUERY_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'terminal.list',
params: TerminalListParams,
handler: async (params, { runtime }) =>
runtime.listTerminals(params.worktree, params.limit, {
handles: params.handles,
requireFreshPtyLiveness: params.requireFreshPtyLiveness,
includeVisualLayouts: params.includeVisualLayouts
})
}),
defineMethod({
name: 'terminal.resolveActive',
params: TerminalResolveActive,
handler: async (params, { runtime }) => ({
handle: await runtime.resolveActiveTerminal(params.worktree)
})
}),
defineMethod({
name: 'terminal.resolvePane',
params: TerminalResolvePane,
handler: async (params, { runtime }) => ({
terminal: runtime.resolveTerminalPane(params.paneKey, params.worktreeId)
})
}),
defineMethod({
name: 'terminal.recoverPane',
params: TerminalRecoverPane,
handler: async (params, { runtime }) => ({
terminal: await runtime.recoverTerminalPane(
params.paneKey,
params.worktreeId,
params.expectedTerminal
)
})
}),
defineMethod({
name: 'terminal.show',
params: TerminalHandle,
handler: async (params, { runtime }) => ({
terminal: await runtime.showTerminal(params.terminal)
})
}),
defineMethod({
name: 'terminal.read',
params: TerminalRead,
handler: async (params, { runtime }) => ({
terminal: await runtime.readTerminal(params.terminal, {
cursor: params.cursor,
limit: params.limit,
screen: params.screen
})
})
}),
defineMethod({
name: 'terminal.inspectProcess',
params: TerminalHandle,
handler: async (params, { runtime }) => ({
process: await runtime.inspectTerminalProcess(params.terminal)
})
}),
defineMethod({
name: 'terminal.isRunningAgent',
params: TerminalHandle,
handler: async (params, { runtime }) => ({
isRunningAgent: await runtime.isTerminalRunningAgent(params.terminal)
})
}),
defineMethod({
name: 'terminal.agentStatus',
params: TerminalHandle,
handler: async (params, { runtime }) => ({
agentStatus: await runtime.getTerminalAgentStatus(params.terminal)
})
}),
defineMethod({
name: 'terminal.rename',
params: TerminalRename,
handler: async (params, { runtime }) => ({
rename: await runtime.renameTerminal(params.terminal, params.title || null)
})
}),
defineMethod({
name: 'terminal.clearBuffer',
params: TerminalHandle,
handler: async (params, { runtime }) => ({
clear: await runtime.clearTerminalBuffer(params.terminal)
})
})
]
@@ -0,0 +1,227 @@
import { InvalidArgumentError, defineMethod, type RpcAnyMethod } from '../../core'
import { isTerminalQueryReply } from '../../../../../shared/terminal-query-reply'
import { assertTerminalAgentSendable } from '../../terminal-agent-send-guard'
import { TerminalSend } from './unary-schemas'
import {
assertTerminalSendExactPtyBinding,
assertTerminalSendTextWithinLimit,
commitMobileInputFloorClaim,
getTerminalSendGuardRefusedReason,
isTerminalInputLockedForClient,
isTerminalSendGuardNotWritable,
resolveMobileFloorClientId,
type MobileInputFloorClaimHolder
} from './terminal-input-delivery'
import { updateViewportForClient } from './terminal-viewport-update'
export const TERMINAL_SEND_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'terminal.send',
params: TerminalSend,
handler: async (params, { runtime, clientId, signal }) => {
await assertTerminalSendTextWithinLimit(params.text)
await assertTerminalSendTextWithinLimit(params.resolvedLaunchDraft?.text)
const queryReplyClientId = clientId ?? params.client?.id
if (
params.inputKind === 'query-reply' &&
(!params.text ||
!isTerminalQueryReply(params.text) ||
params.enter === true ||
params.interrupt === true ||
params.agentPrompt === true ||
params.requireAgentStatus !== undefined ||
params.client?.type !== 'mobile' ||
!queryReplyClientId ||
(clientId !== undefined && params.client.id !== clientId))
) {
throw new InvalidArgumentError('Invalid terminal query reply')
}
// Why: a stale handle must fail with terminal_handle_stale, not evaluate driver/lock state against the wrong PTY (#7718).
const leaf = runtime.resolveLiveLeafForHandle(params.terminal)
const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null
if (
params.inputKind === 'query-reply' &&
leaf?.ptyId &&
!runtime.isMobileTerminalQueryReplyAuthority(leaf.ptyId, queryReplyClientId!)
) {
return {
send: {
handle: params.terminal,
accepted: false,
bytesWritten: 0
}
}
}
if (leaf?.ptyId && isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) {
return {
send: {
handle: params.terminal,
accepted: false,
bytesWritten: 0
}
}
}
if (
leaf?.ptyId &&
params.client?.type === 'desktop' &&
params.claimViewport === true &&
params.viewport
) {
const claim = await updateViewportForClient(
runtime,
leaf.ptyId,
`send:${params.client.id}`,
params.client,
params.viewport,
'desktop',
'refresh',
true
)
// Why: a stream-less request can't safely create ownership, so never write at stale geometry.
if (!claim.updated || isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) {
return {
send: {
handle: params.terminal,
accepted: false,
bytesWritten: 0
}
}
}
}
const hasText = typeof params.text === 'string' && params.text.length > 0
const hasSuffix = params.enter === true || params.interrupt === true
if (params.requireAgentStatus === 'sendable' && hasText && hasSuffix) {
// Why: guarded sends are two-phase; reject combined payload + submit so a guard flip can't cause partial delivery.
return {
send: {
handle: params.terminal,
accepted: false,
bytesWritten: 0
}
}
}
// Why: recheck permission/no-agent state immediately before accepting the PTY write.
const assertSendPreconditions =
params.requireAgentStatus === 'sendable'
? async (ptyId?: string): Promise<void> => {
await assertTerminalAgentSendable({
runtime,
handle: params.terminal,
assertWritable: () => {
assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId)
if (ptyId && isTerminalInputLockedForClient(runtime, ptyId, params.client)) {
throw new Error('terminal_guard_not_writable')
}
}
})
}
: undefined
if (params.requireAgentStatus === 'sendable') {
try {
await assertSendPreconditions?.(leaf?.ptyId ?? undefined)
} catch (error) {
if (isTerminalSendGuardNotWritable(error)) {
return {
send: {
handle: params.terminal,
accepted: false,
bytesWritten: 0
}
}
}
const refusedReason = getTerminalSendGuardRefusedReason(error)
if (!refusedReason) {
throw error
}
return {
send: {
handle: params.terminal,
accepted: false,
bytesWritten: 0,
refusedReason
}
}
}
}
const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client)
const mobileFloorClaim: MobileInputFloorClaimHolder = { current: null }
const beforeWrite = assertSendPreconditions
const useSettledAgentPrompt =
params.agentPrompt === true &&
hasText &&
params.enter === true &&
params.interrupt !== true &&
params.client?.type === 'desktop' &&
(await runtime.isTerminalRunningSettledPromptAgent(params.terminal))
const reserveWrite =
params.inputKind !== 'query-reply' && leaf?.ptyId && mobileFloorClientId
? (ptyId: string): void => {
const claim = runtime.beginMobileInputFloor(ptyId, mobileFloorClientId)
if (!claim) {
throw new Error('mobile_input_floor_unavailable')
}
mobileFloorClaim.current = claim
}
: undefined
let result
try {
result = useSettledAgentPrompt
? await runtime.sendTerminalAgentPrompt(params.terminal, params.text!, {
beforeWrite,
signal
})
: await runtime.sendTerminal(
params.terminal,
{
text: params.text,
enter: params.enter === true,
interrupt: params.interrupt === true
},
{
beforeWrite,
...(reserveWrite ? { reserveWrite } : {}),
...(params.inputKind !== 'query-reply' && mobileFloorClientId
? { afterWrite: () => commitMobileInputFloorClaim(mobileFloorClaim) }
: {})
}
)
} catch (error) {
mobileFloorClaim.current?.rollback()
const refusedReason = getTerminalSendGuardRefusedReason(error)
if (refusedReason) {
return {
send: {
handle: params.terminal,
accepted: false,
bytesWritten: 0,
refusedReason
}
}
}
if (isTerminalSendGuardNotWritable(error)) {
return {
send: {
handle: params.terminal,
accepted: false,
bytesWritten: 0
}
}
}
throw error
}
if (result.accepted !== true) {
mobileFloorClaim.current?.rollback()
}
if (
result.accepted === true &&
params.enter === true &&
params.client?.type === 'mobile' &&
params.resolvedLaunchDraft
) {
runtime.notifyNativeChatLaunchDraftResolved(params.terminal, params.resolvedLaunchDraft)
}
// Why: deliberate mobile input takes the floor (drives `* → mobile{clientId}`); clientless sends fall back to the current mobile driver.
return { send: result }
}
})
]
@@ -0,0 +1,209 @@
import type { OrcaRuntimeService } from '../../../orca-runtime'
import {
TerminalStreamOpcode,
encodeTerminalStreamJson
} from '../../../../../shared/terminal-stream-protocol'
import {
MOBILE_SNAPSHOT_BYTE_BUDGET,
MOBILE_SUBSCRIBE_SCROLLBACK_ROWS
} from '../../../scrollback-limits'
import { terminalStreamByteLengthExceeds } from '../../terminal-stream-byte-length'
import {
iterateTerminalStreamTextPayloads,
requestedSnapshotScrollbackCandidates
} from './terminal-stream-replay'
import type { SerializedSnapshot, SnapshotFrameOptions } from './terminal-stream-types'
const REQUESTED_SNAPSHOT_BYTE_BUDGET = 2 * 1024 * 1024
export async function serializeBudgetedRequestedSnapshot(
runtime: OrcaRuntimeService,
ptyId: string,
scrollbackRows: number | undefined
): Promise<SerializedSnapshot> {
const requestedRows = scrollbackRows ?? 0
for (const rows of requestedSnapshotScrollbackCandidates(scrollbackRows)) {
const serialized = await runtime.serializeAuthoritativeTerminalBuffer(ptyId, {
scrollbackRows: rows
})
if (!serialized) {
return null
}
const scrollbackAnsi =
'scrollbackAnsi' in serialized && typeof serialized.scrollbackAnsi === 'string'
? serialized.scrollbackAnsi
: ''
const data = scrollbackAnsi + serialized.data
const overByteBudget = terminalStreamByteLengthExceeds(data, REQUESTED_SNAPSHOT_BYTE_BUDGET)
if (!overByteBudget || rows === 0) {
return {
...serialized,
data,
scrollbackRows: rows,
truncatedByByteBudget: rows < requestedRows || overByteBudget
}
}
}
return null
}
export function sendSnapshotFrames(
sendFrame: (
opcode: TerminalStreamOpcode,
payload?: Uint8Array<ArrayBufferLike>
) => boolean | void,
options: SnapshotFrameOptions
): { bytes: number; chunks: number; published: boolean } {
if (
sendFrame(
TerminalStreamOpcode.SnapshotStart,
encodeTerminalStreamJson({
kind: options.kind,
cols: options.cols,
rows: options.rows,
requestId: options.requestId,
displayMode: options.displayMode,
reason: options.reason,
unavailable: options.unavailable,
seq: options.seq,
cwd: options.cwd,
source: options.source,
oscLinks: options.oscLinks,
pendingEscapeTailAnsi: options.pendingEscapeTailAnsi,
// Why conditional and additive: old clients ignore the unknown field,
// and a new client must read absence as unknown rather than zero, so
// no opcode or capability negotiation is involved (Rule 1 of
// docs/reference/remote-wire-compatibility.md).
// Why `seq` is required: the flags are only proven at this frame's own
// seq, so without a replay boundary the client cannot order them.
...(typeof options.seq === 'number' && options.kittyKeyboardFlags !== undefined
? { kittyKeyboardFlags: options.kittyKeyboardFlags }
: {}),
truncated: options.truncated === true,
truncatedByByteBudget: options.truncatedByByteBudget === true
})
) === false
) {
return { bytes: 0, chunks: 0, published: false }
}
let chunks = 0
let bytes = 0
for (const chunk of iterateTerminalStreamTextPayloads(options.data)) {
if (sendFrame(TerminalStreamOpcode.SnapshotChunk, chunk) === false) {
return { bytes, chunks, published: false }
}
chunks++
bytes += chunk.byteLength
}
const published = sendFrame(TerminalStreamOpcode.SnapshotEnd) !== false
return { bytes, chunks, published }
}
export async function serializeBudgetedMobileSnapshot(
runtime: OrcaRuntimeService,
ptyId: string,
isMobile: boolean
): Promise<SerializedSnapshot> {
if (!isMobile) {
const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: 0 })
return serialized
? {
...serialized,
data: (serialized.scrollbackAnsi ?? '') + serialized.data,
scrollbackRows: 0,
truncatedByByteBudget: false
}
: null
}
const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0]
for (const rows of candidates) {
const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: rows })
if (!serialized) {
return null
}
const data = (serialized.scrollbackAnsi ?? '') + serialized.data
const overByteBudget = terminalStreamByteLengthExceeds(data, MOBILE_SNAPSHOT_BYTE_BUDGET)
if (!overByteBudget || rows === 0) {
return {
...serialized,
data,
scrollbackRows: rows,
truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget
}
}
}
return null
}
export async function serializeStableMobileRendererSnapshot(
runtime: OrcaRuntimeService,
ptyId: string
): Promise<SerializedSnapshot> {
const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0]
let candidateIndex = 0
for (let attempt = 0; attempt < candidates.length; attempt += 1) {
// Why: advance toward zero scrollback each retry so the final attempt always has a bounded payload.
candidateIndex = Math.max(candidateIndex, attempt)
const rows = candidates[candidateIndex]
const outputSequenceBefore = runtime.getPtyOutputSequence(ptyId)
const serialized = await runtime.serializeRendererTerminalBuffer(ptyId, {
scrollbackRows: rows
})
const outputSequenceAfter = runtime.getPtyOutputSequence(ptyId)
if (outputSequenceBefore !== outputSequenceAfter) {
continue
}
if (!serialized) {
return null
}
const overByteBudget = terminalStreamByteLengthExceeds(
serialized.data,
MOBILE_SNAPSHOT_BYTE_BUDGET
)
if (!overByteBudget || rows === 0) {
return {
...serialized,
scrollbackRows: rows,
truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget
}
}
candidateIndex += 1
}
return null
}
// Why: mobile xterm can't rewrap the HARD newlines baked into a restored snapshot, so a real reflow re-serializes and replays the FULL buffer at the new cols.
export async function sendMobileResizeRestream(
runtime: OrcaRuntimeService,
ptyId: string,
sendFrame: (opcode: TerminalStreamOpcode, payload?: Uint8Array<ArrayBufferLike>) => void,
event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number },
shouldSend?: () => boolean
): Promise<boolean> {
// Why: only a true geometry reflow rewraps scrollback; a dimensionless mode-change would re-send the whole buffer for nothing.
if (event.reason !== 'apply-layout' || runtime.isTerminalAlternateScreen(ptyId)) {
return false
}
const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, true)
if (!serialized) {
return false
}
if (shouldSend && !shouldSend()) {
return true
}
sendSnapshotFrames(sendFrame, {
kind: 'resized',
cols: serialized.cols,
rows: serialized.rows,
displayMode: event.displayMode,
reason: event.reason,
seq: event.seq ?? serialized.seq,
source: serialized.source,
cwd: serialized.cwd,
oscLinks: serialized.oscLinks,
truncated: false,
truncatedByByteBudget: serialized.truncatedByByteBudget,
data: serialized.data
})
return true
}
@@ -0,0 +1,203 @@
import type { TerminalReplyQuerySequence } from '../../../../../shared/terminal-reply-query-scan'
import {
iterateTerminalOutputFrameChunks,
sliceTerminalOutputSourceRanges,
type TerminalOutputFrameChunk,
type TerminalOutputMeta
} from '../../terminal-output-frame-chunks'
import {
measureTerminalStreamByteLength,
terminalStreamByteLength
} from '../../terminal-stream-byte-length'
import { TERMINAL_MULTIPLEX_PENDING_MAX_BYTES } from '../../../../../shared/terminal-multiplex-flow-control'
import type { TerminalMultiplexStream, TerminalOutputChunk } from './terminal-stream-types'
export function appendPendingMultiplexOutput(
stream: TerminalMultiplexStream,
data: string,
meta?: TerminalOutputMeta
): void {
const remainingBudget = Math.max(
1,
TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - stream.pendingOutputBytes
)
const measurement = measureTerminalStreamByteLength(data, {
stopAfterBytes: remainingBudget
})
stream.pendingOutput.push({ data, bytes: measurement.byteLength, meta })
stream.pendingOutputBytes += measurement.byteLength
const trimmed = trimPendingOutputToBudget(stream.pendingOutput, stream.pendingOutputBytes)
stream.pendingOutputBytes = trimmed.bytes
stream.pendingOutputOverflowed ||= trimmed.overflowed
}
export function getOutputAfterSnapshotSeq(
chunk: TerminalOutputChunk,
snapshotSeq: number | undefined
): TerminalOutputChunk | null {
if (
typeof snapshotSeq !== 'number' ||
typeof chunk.meta?.seq !== 'number' ||
typeof chunk.meta.rawLength !== 'number'
) {
return chunk
}
if (chunk.meta.seq <= snapshotSeq) {
return null
}
const chunkStartSeq = chunk.meta.seq - chunk.meta.rawLength
if (chunkStartSeq >= snapshotSeq) {
return chunk
}
if (chunk.meta.transformed) {
return null
}
const offset = snapshotSeq - chunkStartSeq
return {
data: chunk.data.slice(offset),
bytes: chunk.bytes,
meta: {
...chunk.meta,
rawLength: chunk.meta.rawLength - offset,
sourceRanges: sliceTerminalOutputSourceRanges(
chunk.meta.sourceRanges,
offset,
chunk.data.length
)
}
}
}
export function stripSnapshotBoundaryQuerySuffixes(
data: string,
dataStartSeq: number,
snapshotSeq: number,
queries: TerminalReplyQuerySequence[]
): string {
let output = ''
let offset = 0
for (const query of queries) {
if (query.startSeq >= snapshotSeq || query.endSeq <= snapshotSeq) {
continue
}
const removeStart = Math.max(0, query.startSeq - dataStartSeq)
const removeEnd = Math.min(data.length, query.endSeq - dataStartSeq)
if (removeEnd <= offset || removeStart >= data.length) {
continue
}
output += data.slice(offset, removeStart)
offset = removeEnd
}
return output + data.slice(offset)
}
export function appendAckPendingOutput(
stream: TerminalMultiplexStream,
chunk: TerminalOutputFrameChunk
): void {
stream.ackPendingOutput.push(chunk)
stream.ackPendingOutputBytes += chunk.bytes.byteLength
let omittedChunkCount = 0
while (
stream.ackPendingOutputBytes > TERMINAL_MULTIPLEX_PENDING_MAX_BYTES &&
omittedChunkCount < stream.ackPendingOutput.length
) {
stream.ackPendingOutputBytes -= stream.ackPendingOutput[omittedChunkCount]!.bytes.byteLength
omittedChunkCount += 1
}
if (omittedChunkCount > 0) {
stream.ackPendingOutput.splice(0, omittedChunkCount)
stream.ackPendingOutputOverflowed = true
}
}
export function trimPendingOutputToBudget(
pendingOutput: TerminalOutputChunk[],
pendingOutputBytes: number
): { bytes: number; overflowed: boolean } {
let omittedChunkCount = 0
while (
pendingOutputBytes > TERMINAL_MULTIPLEX_PENDING_MAX_BYTES &&
omittedChunkCount < pendingOutput.length
) {
const chunk = pendingOutput[omittedChunkCount]
pendingOutputBytes -= chunk.bytes
omittedChunkCount += 1
}
if (omittedChunkCount > 0) {
pendingOutput.splice(0, omittedChunkCount)
}
return { bytes: pendingOutputBytes, overflowed: omittedChunkCount > 0 }
}
export function trimPendingOutputCoveredBySnapshot(
pendingOutput: TerminalOutputChunk[],
snapshotSeq: number | undefined
): { chunks: TerminalOutputChunk[]; bytes: number } {
if (typeof snapshotSeq !== 'number') {
return {
chunks: pendingOutput,
bytes: pendingOutput.reduce((sum, chunk) => sum + chunk.bytes, 0)
}
}
const chunks: TerminalOutputChunk[] = []
let bytes = 0
for (const chunk of pendingOutput) {
const chunkSeq = chunk.meta?.seq
const rawLength = chunk.meta?.rawLength ?? chunk.data.length
if (typeof chunkSeq !== 'number' || rawLength !== chunk.data.length) {
chunks.push(chunk)
bytes += chunk.bytes
continue
}
const startSeq = chunkSeq - rawLength
if (snapshotSeq >= chunkSeq) {
continue
}
if (snapshotSeq <= startSeq) {
chunks.push(chunk)
bytes += chunk.bytes
continue
}
const data = chunk.data.slice(snapshotSeq - startSeq)
const slicedBytes = terminalStreamByteLength(data)
chunks.push({ data, bytes: slicedBytes, meta: undefined })
bytes += slicedBytes
}
return { chunks, bytes }
}
export function* iterateTerminalStreamTextPayloads(
data: string
): Generator<Uint8Array<ArrayBufferLike>> {
if (!data) {
return
}
for (const chunk of iterateTerminalOutputFrameChunks(data)) {
yield chunk.bytes
}
}
export function isTerminalReadPayloadIncomplete(read: {
truncated: boolean
limited?: boolean
}): boolean {
// Why: a limited preview is an incomplete payload even when the retained buffer wasn't truncated.
return read.truncated || read.limited === true
}
export function normalizeMultiplexSnapshotScrollbackRows(
value: number | undefined
): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return undefined
}
return Math.max(0, Math.min(50_000, Math.floor(value)))
}
export function requestedSnapshotScrollbackCandidates(requestedRows: number | undefined): number[] {
const candidates = [requestedRows ?? 0, 1000, 500, 250, 100, 25, 0]
.filter((rows): rows is number => typeof rows === 'number')
.map((rows) => Math.max(0, Math.min(50_000, Math.floor(rows))))
return [...new Set(candidates)]
}
@@ -0,0 +1,100 @@
import type { TerminalOscLinkRange } from '../../../../../shared/terminal-osc-link-ranges'
import type { TerminalSnapshotUnavailableReason } from '../../../../../shared/terminal-snapshot-unavailability'
import type { TerminalSourceRangeLedger } from '../../terminal-source-range-ledger'
import type { RemoteTerminalSourceRangeReplacementReservation } from '../../../remote-terminal-source-range-consumer'
import type {
TerminalOutputFrameChunk,
TerminalOutputMeta
} from '../../terminal-output-frame-chunks'
import type { TerminalOutputBatcher } from './terminal-output-batcher'
export type SnapshotFrameOptions = {
kind: 'scrollback' | 'resized'
cols: number
rows: number
data: string
requestId?: number
displayMode?: string
reason?: string
seq?: number
cwd?: string | null
truncated?: boolean
truncatedByByteBudget?: boolean
// Why: distinguishes "I could not answer right now" from a genuinely empty buffer; omitted on success.
unavailable?: TerminalSnapshotUnavailableReason
source?: 'headless' | 'renderer'
oscLinks?: TerminalOscLinkRange[]
pendingEscapeTailAnsi?: string
/** Effective kitty flags proven at this frame's own `seq`. */
kittyKeyboardFlags?: number
}
export type SerializedSnapshot = {
data: string
scrollbackAnsi?: string
cols: number
rows: number
seq?: number
cwd?: string | null
source?: 'headless' | 'renderer'
oscLinks?: TerminalOscLinkRange[]
scrollbackRows: number
truncatedByByteBudget: boolean
pendingEscapeTailAnsi?: string
kittyKeyboardFlags?: number
} | null
export type TerminalViewportClient = {
id: string
type?: 'mobile' | 'desktop'
}
export type TerminalMultiplexStream = {
streamId: number
terminal: string
ptyId: string
client: TerminalViewportClient | undefined
isMobile: boolean
ackOutput: boolean
ackOutputSourceRanges: boolean
streamGeneration: string
sourceRangeLedger: TerminalSourceRangeLedger | null
sourceRangeConsumerAttached: boolean
sourceRangeReplacement: RemoteTerminalSourceRangeReplacementReservation | null
ackInFlightBytes: number
ackWindowBytes: number
supportsOutputPause: boolean
supportsWriteUnavailable: boolean
outputPaused: boolean
supportsDesktopViewportClaims: boolean
desktopClaimTail: Promise<boolean>
// Whether THIS stream registered the width driver, so detach won't release a peer stream's floor.
registeredRemoteDesktopDriver: boolean
remoteDesktopSubscriptionKey: string
pendingRemoteDesktopViewport: { cols: number; rows: number } | null
buffering: boolean
ackPendingOutput: TerminalOutputFrameChunk[]
ackPendingOutputBytes: number
ackPendingOutputOverflowed: boolean
ackRecoverySnapshotInFlight: boolean
pendingOutput: TerminalOutputChunk[]
pendingOutputBytes: number
pendingOutputOverflowed: boolean
// Cols the mobile client last rewrapped to; re-stream full scrollback only when width actually changes.
lastResizeCols: number | undefined
resizeGeneration: number
outputBatcher: TerminalOutputBatcher
unsubscribeData: () => void
unsubscribeResize: () => void
unsubscribeFit: () => void
unsubscribeDriver: () => void
unregisterBinaryHandler: () => void
// Why: the runtime drops the exit-waiter only on real PTY exit; abort on detach so a never-exiting agent terminal doesn't leak the waiter.
exitWaiterAbort: AbortController
}
export type TerminalOutputChunk = {
data: string
bytes: number
meta?: TerminalOutputMeta
}
@@ -0,0 +1,95 @@
import { defineStreamingMethod, type RpcAnyMethod } from '../../core'
import { TerminalSubscribe } from './stream-schemas'
import { isTerminalReadPayloadIncomplete } from './terminal-stream-replay'
import { runTerminalBinarySubscription } from './terminal-legacy-subscribe-binary'
import {
runTerminalJsonSubscription,
runTerminalLeaseSubscription
} from './terminal-legacy-simple-subscriptions'
import type { TerminalSubscriptionArgs } from './terminal-legacy-subscription-types'
export const TERMINAL_SUBSCRIBE_METHODS: RpcAnyMethod[] = [
// Streams live terminal output over WebSocket; mobile clients pass client+viewport for server-side auto-fit.
defineStreamingMethod({
name: 'terminal.subscribe',
params: TerminalSubscribe,
handler: async (
params,
{ runtime, connectionId, sendBinary, registerBinaryStreamHandler, signal },
emit
) => {
let leaf = runtime.resolveLeafForHandle(params.terminal)
const isMobile = params.client?.type === 'mobile'
const serializerGenerationBeforeAnyMount = isMobile
? (runtime.getRendererTerminalSerializerGenerationForHandle?.(params.terminal) ?? 0)
: 0
let rendererMountRequestedBeforePty = false
const useBinaryStream = params.capabilities?.terminalBinaryStream === 1 && Boolean(sendBinary)
if (signal?.aborted) {
return
}
if (!leaf?.ptyId && params.client) {
rendererMountRequestedBeforePty = runtime.requestRendererTerminalTabMount(params.terminal)
try {
const ptyId = await runtime.waitForLeafPtyId(params.terminal, 10_000, signal)
leaf = { ptyId }
} catch {
if (signal?.aborted) {
return
}
}
}
if (!leaf?.ptyId) {
const read = await runtime.readTerminal(params.terminal)
emit({
type: 'subscribed',
streamId: null,
lines: read.tail,
truncated: isTerminalReadPayloadIncomplete(read)
})
emit({ type: 'end' })
return
}
if (isMobile && (!useBinaryStream || !sendBinary)) {
throw new Error('binary_terminal_stream_required')
}
const ptyId = leaf.ptyId
const clientId = params.client?.id
const missingHeadlessStateBeforeMobileFit =
isMobile &&
(rendererMountRequestedBeforePty || runtime.hasHeadlessTerminalState?.(ptyId) === false)
const args: TerminalSubscriptionArgs = {
params,
runtime,
connectionId,
sendBinary,
registerBinaryStreamHandler,
signal,
emit,
ptyId,
clientId,
isMobile,
supportsDesktopViewportClaims: params.capabilities?.desktopViewportClaims === 1,
supportsWriteUnavailable: params.capabilities?.writeUnavailable === 1,
rendererMountRequestedBeforePty,
missingHeadlessStateBeforeMobileFit,
serializerGenerationBeforeMobileFit: missingHeadlessStateBeforeMobileFit
? rendererMountRequestedBeforePty
? serializerGenerationBeforeAnyMount
: runtime.getRendererTerminalSerializerGeneration(ptyId)
: 0
}
if (isMobile && params.capabilities?.mobileInputLeaseOnly === 1 && Boolean(clientId)) {
await runTerminalLeaseSubscription(args)
return
}
if (!useBinaryStream) {
await runTerminalJsonSubscription(args)
return
}
await runTerminalBinarySubscription(args)
}
})
]
@@ -0,0 +1,5 @@
let nextTerminalStreamId = 1
export function allocateTerminalSubscriptionStreamId(): number {
return nextTerminalStreamId++
}
@@ -0,0 +1,120 @@
import { z } from 'zod'
import { defineMethod, type RpcAnyMethod } from '../../core'
import { TerminalHandle } from './unary-schemas'
import {
TerminalSetAutoRestoreFit,
TerminalSetDisplayMode,
TerminalUnsubscribe,
TerminalUpdateViewport
} from './viewport-schemas'
import { updateViewportForClient } from './terminal-viewport-update'
export const TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS: RpcAnyMethod[] = [
defineMethod({
name: 'terminal.setDisplayMode',
params: TerminalSetDisplayMode,
handler: async (params, { runtime }) => {
// Why: a stale handle must fail with terminal_handle_stale, not mutate the wrong PTY's display mode/viewport (#7718).
const leaf = runtime.resolveLiveLeafForHandle(params.terminal)
if (!leaf?.ptyId) {
throw new Error('no_connected_pty')
}
// Why: late-bind viewport for desktop-subscribed callers; otherwise an 'auto' toggle skips phone-fit and nothing resizes.
if (params.viewport && params.client?.id) {
runtime.updateMobileSubscriberViewport(leaf.ptyId, params.client.id, params.viewport)
}
if (params.client && params.client.type === 'mobile' && params.mode !== 'desktop') {
runtime.markMobileActor(leaf.ptyId, params.client.id)
}
runtime.setMobileDisplayMode(leaf.ptyId, params.mode)
await runtime.applyMobileDisplayMode(leaf.ptyId)
return { mode: params.mode, seq: runtime.getLayout(leaf.ptyId)?.seq }
}
}),
defineMethod({
name: 'terminal.restoreFit',
params: TerminalHandle,
handler: async (params, { runtime }) => {
// Why: a stale handle must fail with terminal_handle_stale, not reclaim the wrong PTY to desktop dims (#7718).
const leaf = runtime.resolveLiveLeafForHandle(params.terminal)
if (!leaf?.ptyId) {
throw new Error('no_connected_pty')
}
return { restored: await runtime.reclaimTerminalForDesktop(leaf.ptyId) }
}
}),
defineMethod({
name: 'terminal.getDisplayMode',
params: TerminalHandle,
handler: async (params, { runtime }) => {
const leaf = runtime.resolveLeafForHandle(params.terminal)
const mode = leaf?.ptyId ? runtime.getMobileDisplayMode(leaf.ptyId) : 'auto'
const isPhoneFitted = leaf?.ptyId ? runtime.isMobileSubscriberActive(leaf.ptyId) : false
return { mode, isPhoneFitted }
}
}),
defineMethod({
name: 'terminal.updateViewport',
params: TerminalUpdateViewport,
handler: async (params, { runtime }) => {
// Why: a stale handle must fail with terminal_handle_stale, not write viewport state to the wrong PTY (#7718).
const leaf = runtime.resolveLiveLeafForHandle(params.terminal)
if (!leaf?.ptyId) {
throw new Error('no_connected_pty')
}
const viewportUpdate = await updateViewportForClient(
runtime,
leaf.ptyId,
`viewport:${params.client.id}`,
params.client,
params.viewport,
'mobile',
// Why: one-shot RPC with no disconnect hook — refresh the existing stream-owned floor, never create a leak-prone one.
'refresh',
params.claim === true
)
return { ...viewportUpdate, seq: runtime.getLayout(leaf.ptyId)?.seq }
}
})
]
export const TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS: RpcAnyMethod[] = [
defineMethod({
name: 'terminal.unsubscribe',
params: TerminalUnsubscribe,
handler: async (params, { runtime, connectionId }) => {
// Why: only the connection that owns the subscription may retire it — a stale
// unsubscribe from the pre-reconnect socket names an id the replacement now owns.
let unsubscribed = runtime.cleanupSubscriptionIfOwnedByConnection(
params.subscriptionId,
connectionId
)
// Why: older builds send a bare-handle subscriptionId, so also try the reconstructed `${terminal}:${clientId}` composite key.
// Why AND over the calls that ran: a clientless stream registers under the bare id
// and a client-scoped one under the composite, so either call can be the real
// teardown. Reporting false needs one genuine refusal, not merely a missing id.
if (params.client && !params.subscriptionId.includes(':')) {
unsubscribed =
runtime.cleanupSubscriptionIfOwnedByConnection(
`${params.subscriptionId}:${params.client.id}`,
connectionId
) && unsubscribed
}
return { unsubscribed }
}
}),
defineMethod({
name: 'terminal.getAutoRestoreFit',
params: z.object({}),
handler: async (_params, { runtime }) => ({
ms: runtime.getMobileAutoRestoreFitMs()
})
}),
defineMethod({
name: 'terminal.setAutoRestoreFit',
params: TerminalSetAutoRestoreFit,
handler: async (params, { runtime }) => ({
ms: runtime.setMobileAutoRestoreFitMs(params.ms)
})
})
]
@@ -0,0 +1,38 @@
import type { OrcaRuntimeService } from '../../../orca-runtime'
import type { TerminalViewportClient } from './terminal-stream-types'
export async function updateViewportForClient(
runtime: OrcaRuntimeService,
ptyId: string,
subscriptionKey: string,
client: TerminalViewportClient,
viewport: { cols: number; rows: number },
defaultType: 'mobile' | 'desktop',
// Why: the one-shot RPC has no disconnect hook, so 'refresh' only updates a stream-owned floor; stream paths that own cleanup 'register'.
registration: 'register' | 'refresh' = 'register',
claim = false
): Promise<{ updated: boolean; applied: boolean }> {
const type = client.type ?? defaultType
if (type === 'mobile') {
return runtime.updateMobileViewport(ptyId, client.id, viewport)
}
// Why: stream attachment observes geometry without taking control; a later claim frame makes it authoritative.
const updated =
registration === 'refresh'
? await runtime.refreshRemoteDesktopViewer(
ptyId,
client.id,
viewport.cols,
viewport.rows,
claim
)
: await runtime.updateRemoteDesktopViewer(
ptyId,
subscriptionKey,
client.id,
viewport.cols,
viewport.rows,
claim
)
return { updated, applied: updated }
}
@@ -0,0 +1,203 @@
import { z } from 'zod'
import { OptionalFiniteNumber, OptionalString, requiredString } from '../../schemas'
import { TERMINAL_PANE_SPLIT_SOURCES } from '../../../../../shared/feature-education-telemetry'
import { isTuiAgent } from '../../../../../shared/tui-agent-config'
export const TerminalHandle = z.object({
terminal: requiredString('Missing terminal handle')
})
export const TerminalFocus = TerminalHandle.extend({
navigation: z.enum(['caller', 'host']).optional()
})
export const TerminalListParams = z.object({
worktree: OptionalString,
limit: OptionalFiniteNumber,
handles: z
.array(requiredString('Missing terminal handle').pipe(z.string().max(256)))
.max(64)
.optional(),
requireFreshPtyLiveness: z.boolean().optional(),
// Why: layouts are ~31% of a large listing and only the human CLI formatter
// reads them. Absent means "include" so pre-flag clients keep rendering them.
includeVisualLayouts: z.boolean().optional()
})
export const TerminalResolveActive = z.object({
worktree: OptionalString
})
export const TerminalResolvePane = z.object({
paneKey: requiredString('Missing pane key'),
worktreeId: OptionalString
})
export const TerminalRecoverPane = z.object({
paneKey: requiredString('Missing pane key'),
worktreeId: requiredString('Missing worktree ID'),
expectedTerminal: requiredString('Missing expected terminal handle').optional()
})
export const TerminalRead = TerminalHandle.extend({
cursor: z
.unknown()
.transform((value) => {
if (value === undefined) {
return undefined
}
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
return Number.NaN
}
return value
})
.pipe(
z
.number()
.optional()
.refine((v) => v === undefined || Number.isFinite(v), {
message: 'Cursor must be a non-negative integer'
})
)
.optional(),
limit: OptionalFiniteNumber,
// Why: optional so an older host that does not understand it simply drops the key and answers
// with its usual stream read; the response's `source` is what tells the caller which it got.
screen: z.literal(true).optional()
}).refine((params) => !(params.screen === true && params.cursor !== undefined), {
// Why: a cursor pages through accumulated output; a screen is the current frame with nothing
// behind it. Honoring both would answer with rendered lines carrying the stream's pagination
// metadata — two frames of reference in one payload, which is the confusion `source` exists to
// remove. The CLI already refuses the pair, but the RPC is reachable without it.
message: 'Cursor cannot be combined with a screen read'
})
// Why: preserve the legacy contract — `title: string | null` only, `undefined` rejected, so the CLI's "reset" signal stays distinct.
export const TerminalRename = TerminalHandle.extend({
title: z.custom<string | null>((value) => value === null || typeof value === 'string', {
message: 'Missing --title (pass empty string or null to reset)'
})
})
export const TerminalSend = TerminalHandle.extend({
text: OptionalString,
enter: z.unknown().optional(),
interrupt: z.unknown().optional(),
// Why: older hosts strip this optional intent and retain their direct-send behavior.
agentPrompt: z.literal(true).optional(),
resolvedLaunchDraft: z
.object({
text: z.string(),
createdAt: z.number().finite()
})
.optional(),
requireAgentStatus: z.enum(['sendable']).optional(),
// Why: terminal-generated replies are valid input but must not transfer the shared terminal floor.
inputKind: z.enum(['query-reply']).optional(),
// Why: identifies the caller for the driver state machine; when absent (older clients) the server falls back to the most recent mobile actor (docs/mobile-presence-lock.md).
client: z
.object({
id: requiredString('Missing client ID'),
type: z.enum(['mobile', 'desktop']).default('desktop').optional()
})
.optional(),
viewport: z
.object({
cols: z.number().int().min(1).max(1000),
rows: z.number().int().min(1).max(500)
})
.optional(),
claimViewport: z.literal(true).optional()
})
export const TerminalViewport = z.object({
cols: z.number().int().min(1).max(1000),
rows: z.number().int().min(1).max(500)
})
export const TerminalWait = TerminalHandle.extend({
for: z.custom<'exit' | 'tui-idle'>((value) => value === 'exit' || value === 'tui-idle', {
message: 'Invalid --for value. Supported: exit, tui-idle'
}),
timeoutMs: OptionalFiniteNumber
})
export const TerminalCreateParams = z.object({
worktree: OptionalString,
clientMutationId: z.string().min(1).max(128).optional(),
reconcileExisting: z.boolean().optional(),
command: OptionalString,
startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(),
env: z.record(z.string(), z.string()).optional(),
envToDelete: z.array(z.string().min(1).max(256)).max(32).optional(),
launchConfig: z
.object({
agentCommand: z.string().optional(),
agentArgs: z.string(),
agentEnv: z.record(z.string(), z.string()),
ompResumeFilePath: z
.string()
.min(1)
.max(32 * 1024)
.optional()
})
.optional(),
resumeProviderSession: z
.object({
key: z.enum(['session_id', 'conversation_id']),
id: z.string().min(1).max(512),
transcriptPath: z.string().min(1).max(32_768).optional()
})
.optional(),
launchToken: OptionalString,
launchAgent: z.string().refine(isTuiAgent).optional(),
terminalColorQueryReplies: z
.object({
foreground: z.string().max(128).optional(),
background: z.string().max(128).optional()
})
.optional(),
title: OptionalString,
focus: z.unknown().optional(),
rendererBacked: z.unknown().optional(),
activate: z.unknown().optional(),
presentation: z.enum(['background', 'focused']).optional(),
tabId: OptionalString,
leafId: OptionalString
})
export const TerminalSplit = TerminalHandle.extend({
direction: z
.unknown()
.transform((v) => (v === 'vertical' || v === 'horizontal' ? v : undefined))
.pipe(z.union([z.enum(['vertical', 'horizontal']), z.undefined()]))
.optional(),
command: OptionalString,
env: z.record(z.string(), z.string()).optional(),
telemetrySource: z.enum(TERMINAL_PANE_SPLIT_SOURCES).optional()
})
export const TerminalStop = z.object({
worktree: requiredString('Missing worktree selector')
})
export const TerminalSleep = TerminalStop
export const TerminalStopExact = TerminalStop.extend({
expectedPtyIds: z.array(requiredString('Missing PTY ID')).min(1),
keepHistory: z.boolean().optional(),
targetOnly: z.boolean().optional()
})
export const AgentTeamsTmuxCompat = z.object({
teamId: requiredString('Missing agent team ID'),
token: requiredString('Missing agent team token'),
envPane: requiredString('Missing tmux pane identity'),
cwd: OptionalString,
argv: z.array(z.string())
})
export const AgentTeamsPrepareLaunch = z.object({
paneKey: requiredString('Missing pane key'),
env: z.record(z.string(), z.string()).optional()
})
@@ -0,0 +1,51 @@
import { z } from 'zod'
import { requiredString } from '../../schemas'
const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') })
export const TerminalSetDisplayMode = TerminalHandle.extend({
// Why: 'auto' = mobile drives dims while subscribed (desktop restores on last-leave); 'desktop' = no resize, mobile scales to fit.
mode: z.enum(['auto', 'desktop']),
// Why: identifies the caller for the driver state machine; optional for older mobile clients.
client: z
.object({
id: requiredString('Missing client ID'),
type: z.enum(['mobile', 'desktop']).default('desktop').optional()
})
.optional(),
// Why: carries the measured viewport so an 'auto' toggle on a viewport-less record can phone-fit instead of no-op'ing.
viewport: z
.object({
cols: z.number().int().positive(),
rows: z.number().int().positive()
})
.optional()
})
export const TerminalUnsubscribe = z.object({
subscriptionId: requiredString('Missing subscription ID'),
// Why: lets the server rebuild the composite `${terminal}:${clientId}` cleanup key when older clients pass a bare subscriptionId (docs/mobile-presence-lock.md).
client: z
.object({
id: requiredString('Missing client ID')
})
.optional()
})
// Why: in-place update avoids an unsubscribe→resubscribe that flashed the lock banner and stranded the PTY at phone dims (docs/mobile-presence-lock.md).
export const TerminalUpdateViewport = TerminalHandle.extend({
client: z.object({
id: requiredString('Missing client ID'),
type: z.enum(['mobile', 'desktop']).default('mobile').optional()
}),
viewport: z.object({
cols: z.number().int().min(20).max(240),
rows: z.number().int().min(8).max(120)
}),
claim: z.boolean().optional()
})
// Why: phone-fit auto-restore preference (docs/mobile-fit-hold.md); `null` = Indefinite, finite ms clamped to [5_000, 60min] server-side.
export const TerminalSetAutoRestoreFit = z.object({
ms: z.number().nullable()
})