From 0286e7bafe840be046a1475d3e4316e906a2bbd6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:37:42 -0700 Subject: [PATCH] refactor(rpc): split terminal methods into cohesive modules --- config/max-lines-baseline.txt | 1 - ...-legacy-stream-id-characterization.test.ts | 91 + ...terminal-manifest-characterization.test.ts | 207 + ...stream-extraction-characterization.test.ts | 130 + src/main/runtime/rpc/methods/terminal.ts | 3840 +---------------- .../rpc/methods/terminal/stream-schemas.ts | 78 + .../terminal/terminal-input-delivery.ts | 188 + .../terminal-legacy-binary-control-frames.ts | 141 + .../terminal-legacy-simple-subscriptions.ts | 184 + .../terminal-legacy-subscribe-binary.ts | 286 ++ .../terminal-legacy-subscribe-live.ts | 158 + .../terminal-legacy-subscribe-snapshot.ts | 265 ++ .../terminal-legacy-subscription-types.ts | 58 + .../terminal/terminal-lifecycle-methods.ts | 178 + .../terminal/terminal-multiplex-cleanup.ts | 100 + .../terminal/terminal-multiplex-connection.ts | 106 + .../terminal-multiplex-flow-control.ts | 264 ++ .../terminal-multiplex-frame-delivery.ts | 153 + .../terminal-multiplex-initial-snapshot.ts | 137 + .../terminal-multiplex-live-stream.ts | 139 + .../terminal/terminal-multiplex-method.ts | 72 + .../terminal-multiplex-slot-frames.ts | 297 ++ ...erminal-multiplex-stream-initialization.ts | 151 + .../terminal-multiplex-subscribe-frame.ts | 73 + ...terminal-multiplex-subscribe-resolution.ts | 96 + .../terminal/terminal-output-batcher.ts | 120 + .../terminal/terminal-query-methods.ts | 101 + .../methods/terminal/terminal-send-method.ts | 227 + .../terminal/terminal-snapshot-publication.ts | 209 + .../terminal/terminal-stream-replay.ts | 203 + .../methods/terminal/terminal-stream-types.ts | 100 + .../terminal/terminal-subscribe-method.ts | 95 + .../terminal-subscription-stream-id.ts | 5 + .../terminal/terminal-viewport-methods.ts | 120 + .../terminal/terminal-viewport-update.ts | 38 + .../rpc/methods/terminal/unary-schemas.ts | 203 + .../rpc/methods/terminal/viewport-schemas.ts | 51 + 37 files changed, 5042 insertions(+), 3823 deletions(-) create mode 100644 src/main/runtime/rpc/methods/terminal-legacy-stream-id-characterization.test.ts create mode 100644 src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts create mode 100644 src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts create mode 100644 src/main/runtime/rpc/methods/terminal/stream-schemas.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-legacy-binary-control-frames.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-binary.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-live.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-legacy-subscription-types.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-multiplex-cleanup.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-multiplex-connection.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-multiplex-frame-delivery.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-multiplex-live-stream.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-multiplex-stream-initialization.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-frame.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-resolution.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-output-batcher.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-send-method.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-stream-replay.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-subscription-stream-id.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-viewport-update.ts create mode 100644 src/main/runtime/rpc/methods/terminal/unary-schemas.ts create mode 100644 src/main/runtime/rpc/methods/terminal/viewport-schemas.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 940129be633..2aed4f0ecd8 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -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 diff --git a/src/main/runtime/rpc/methods/terminal-legacy-stream-id-characterization.test.ts b/src/main/runtime/rpc/methods/terminal-legacy-stream-id-characterization.test.ts new file mode 100644 index 00000000000..3f3630b0ffa --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal-legacy-stream-id-characterization.test.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(() => {})) + } as unknown as Partial + 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) + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts b/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts new file mode 100644 index 00000000000..daa25165d3d --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts @@ -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) { + 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 + + 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 + ) => create(selector, 'term-preallocated') + ), + createTerminal + } as unknown as Partial + 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' + }) + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts b/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts new file mode 100644 index 00000000000..db05c6b3fe5 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts @@ -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 + }[] = [] + 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) + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 5dee92a7676..e80d07773dc 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -1,3826 +1,22 @@ -/* oxlint-disable max-lines -- Why: terminal RPC methods are co-located for discoverability; splitting would scatter related handlers across files. */ -import { z } from 'zod' -import { randomUUID } from 'node:crypto' +import type { RpcAnyMethod } from '../core' +import { TERMINAL_LIFECYCLE_METHODS } from './terminal/terminal-lifecycle-methods' +import { TERMINAL_MULTIPLEX_METHODS } from './terminal/terminal-multiplex-method' +import { TERMINAL_QUERY_METHODS } from './terminal/terminal-query-methods' +import { TERMINAL_SEND_METHODS } from './terminal/terminal-send-method' +import { TERMINAL_SUBSCRIBE_METHODS } from './terminal/terminal-subscribe-method' import { - InvalidArgumentError, - defineMethod, - defineStreamingMethod, - type RpcAnyMethod -} from '../core' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' -import type { DriverState, OrcaRuntimeService, SubscriptionRegistration } from '../../orca-runtime' -import { - TerminalStreamOpcode, - decodeTerminalStreamJson, - decodeTerminalStreamText, - encodeTerminalStreamFrame, - encodeTerminalStreamJson, - encodeTerminalStreamText, - type TerminalStreamFrame -} from '../../../../shared/terminal-stream-protocol' -import { - iterateTerminalOutputFrameChunks, - sliceTerminalOutputSourceRanges, - type TerminalOutputFrameChunk, - type TerminalOutputMeta -} from '../terminal-output-frame-chunks' -import { TERMINAL_PANE_SPLIT_SOURCES } from '../../../../shared/feature-education-telemetry' -import type { TerminalOscLinkRange } from '../../../../shared/terminal-osc-link-ranges' -import { - TERMINAL_INPUT_MAX_BYTES, - TERMINAL_INPUT_TOO_LARGE_ERROR, - isTerminalInputTooLargeWithYield -} from '../../../../shared/terminal-input' -import { - measureTerminalStreamByteLength, - terminalStreamByteLength, - terminalStreamByteLengthExceeds -} from '../terminal-stream-byte-length' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply' -import { - EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE, - scanTerminalReplyQuerySequences, - type TerminalReplyQuerySequence, - type TerminalReplyQueryScanState -} from '../../../../shared/terminal-reply-query-scan' -import { - MOBILE_SNAPSHOT_BYTE_BUDGET, - MOBILE_SUBSCRIBE_SCROLLBACK_ROWS -} from '../../scrollback-limits' -import { assertTerminalAgentSendable } from '../terminal-agent-send-guard' -import { - navigationTargetsHost, - resolveRuntimeNavigationTarget -} from '../../../../shared/runtime-navigation' -import { - TERMINAL_MULTIPLEX_ACK_STREAM_INITIAL_WINDOW_BYTES, - TERMINAL_MULTIPLEX_ACK_STREAM_MAX_WINDOW_BYTES, - TERMINAL_MULTIPLEX_ACK_TOTAL_INITIAL_WINDOW_BYTES, - TERMINAL_MULTIPLEX_ACK_TOTAL_MAX_WINDOW_BYTES, - TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION, - TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION, - TERMINAL_MULTIPLEX_PENDING_MAX_BYTES, - TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR, - TERMINAL_OUTPUT_BATCH_MAX_BYTES -} from '../../../../shared/terminal-multiplex-flow-control' -import { drainTerminalMultiplexRoundRobin } from '../terminal-multiplex-round-robin' -import type { TerminalSourceRangeLedger } from '../terminal-source-range-ledger' -import { TerminalSourceRangeRegistry } from '../terminal-source-range-registry' -import { - sameTerminalOutputSourceIdentity, - type TerminalOutputSourceRange -} from '../../../../shared/terminal-output-source-range' -import type { TerminalSnapshotUnavailableReason } from '../../../../shared/terminal-snapshot-unavailability' -import type { RemoteTerminalSourceRangeReplacementReservation } from '../../remote-terminal-source-range-consumer' -import { withTerminalCloseAttribution } from '../terminal-close-attribution' - -const REQUESTED_SNAPSHOT_BYTE_BUDGET = 2 * 1024 * 1024 -const TERMINAL_OUTPUT_FLUSH_MS = 5 -const TERMINAL_QUERY_REPLAY_MAX_CHARS = 16 * 1024 -// Why: bound initial subscribe latency; readiness after this deadline triggers an in-stream recovery snapshot. -const MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS = 3_000 -let nextTerminalStreamId = 1 - -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 -} - -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 - -type TerminalViewportClient = { - id: string - type?: 'mobile' | 'desktop' -} - -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 - // 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: ReturnType - 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 -} - -type TerminalOutputChunk = { - data: string - bytes: number - meta?: TerminalOutputMeta -} - -function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutputMeta) => void): { - push: (data: string, meta?: TerminalOutputMeta) => void - flush: () => void - dispose: () => void -} { - let chunks: string[] = [] - let bytes = 0 - let lastSeq: number | undefined - let pendingCwd: string | undefined - let pendingRawLength = 0 - let pendingSourceRanges: TerminalOutputSourceRange[] = [] - let timer: ReturnType | 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 = [] - } - } -} - -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' -} - -async function assertTerminalSendTextWithinLimit(text: string | undefined): Promise { - 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) - } -} - -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 -} - -type TerminalStreamInputOutcome = 'delivered' | 'rejected' | 'failed' - -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 -} - -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') -} - -async function sendTerminalStreamInput( - runtime: OrcaRuntimeService, - args: { - terminal: string - text: string - client: TerminalViewportClient | undefined - isMobile: boolean - } -): Promise { - 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' - } -} - -type MobileInputFloorClaimHolder = { - current: ReturnType -} - -async function commitMobileInputFloorClaim(claim: MobileInputFloorClaimHolder): Promise { - 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 - } - } -} - -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 -} - -function isTerminalSendGuardNotWritable(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - return message.includes('terminal_guard_not_writable') -} - -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') -} - -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 -} - -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 - ) - } - } -} - -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) -} - -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 - } -} - -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 } -} - -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 } -} - -function* iterateTerminalStreamTextPayloads(data: string): Generator> { - if (!data) { - return - } - for (const chunk of iterateTerminalOutputFrameChunks(data)) { - yield chunk.bytes - } -} - -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 -} - -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))) -} - -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)] -} - -async function serializeBudgetedRequestedSnapshot( - runtime: OrcaRuntimeService, - ptyId: string, - scrollbackRows: number | undefined -): Promise { - 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 -} - -function sendSnapshotFrames( - sendFrame: ( - opcode: TerminalStreamOpcode, - payload?: Uint8Array - ) => 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 } -} - -async function serializeBudgetedMobileSnapshot( - runtime: OrcaRuntimeService, - ptyId: string, - isMobile: boolean -): Promise { - 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 -} - -async function serializeStableMobileRendererSnapshot( - runtime: OrcaRuntimeService, - ptyId: string -): Promise { - 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. -async function sendMobileResizeRestream( - runtime: OrcaRuntimeService, - ptyId: string, - sendFrame: (opcode: TerminalStreamOpcode, payload?: Uint8Array) => void, - event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number }, - shouldSend?: () => boolean -): Promise { - // 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 -} - -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 } -} - -const TerminalHandle = z.object({ - terminal: requiredString('Missing terminal handle') -}) - -const TerminalFocus = TerminalHandle.extend({ - navigation: z.enum(['caller', 'host']).optional() -}) - -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() -}) - -const TerminalResolveActive = z.object({ - worktree: OptionalString -}) - -const TerminalResolvePane = z.object({ - paneKey: requiredString('Missing pane key'), - worktreeId: OptionalString -}) - -const TerminalRecoverPane = z.object({ - paneKey: requiredString('Missing pane key'), - worktreeId: requiredString('Missing worktree ID'), - expectedTerminal: requiredString('Missing expected terminal handle').optional() -}) - -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. -const TerminalRename = TerminalHandle.extend({ - title: z.custom((value) => value === null || typeof value === 'string', { - message: 'Missing --title (pass empty string or null to reset)' - }) -}) - -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() -}) - -const TerminalViewport = z.object({ - cols: z.number().int().min(1).max(1000), - rows: z.number().int().min(1).max(500) -}) - -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 -}) - -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 -}) - -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() -}) - -const TerminalStop = z.object({ - worktree: requiredString('Missing worktree selector') -}) - -const TerminalSleep = TerminalStop - -const TerminalStopExact = TerminalStop.extend({ - expectedPtyIds: z.array(requiredString('Missing PTY ID')).min(1), - keepHistory: z.boolean().optional(), - targetOnly: z.boolean().optional() -}) - -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()) -}) - -const AgentTeamsPrepareLaunch = z.object({ - paneKey: requiredString('Missing pane key'), - env: z.record(z.string(), z.string()).optional() -}) - -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') - }) -]) - -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() -}) - -const TerminalMultiplex = z.object({}) - -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() -}) - -const TerminalMultiplexLegacyAckFrame = z - .object({ - bytes: z.number().int().nonnegative() - }) - .strict() - -const TerminalMultiplexSourceRangeAckFrame = z - .object({ - streamGeneration: z.string().min(1), - ackedEndByte: z.number().int().nonnegative() - }) - .strict() - -const TerminalMultiplexSnapshotRequestFrame = z.object({ - requestId: z.number().int().positive().optional(), - scrollbackRows: z.number().finite().optional() -}) - -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() -}) - -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). -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. -const TerminalSetAutoRestoreFit = z.object({ - ms: z.number().nullable() -}) + TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS, + TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS +} from './terminal/terminal-viewport-methods' +// The manifest order is part of the released RPC contract. Keep composition here so the +// public entry point owns registration rather than forwarding an aggregated child export. export const TERMINAL_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) - }) - }), - 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 => { - 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 } - } - }), - 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 - }) - }) - }), - 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 } - } - }), - // Why: one streaming RPC owns the binary socket and routes many panes by streamId; legacy subscribe stays as fallback. - 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 closed = false - let cursor = 0 - const streams = new Map() - const sourceRangeRegistry = new TerminalSourceRangeRegistry() - const pendingPtyWaitControllers = new Map>() - let ackTotalInFlightBytes = 0 - let ackTotalWindowBytes = TERMINAL_MULTIPLEX_ACK_TOTAL_INITIAL_WINDOW_BYTES - let ackFlushCursorStreamId: number | null = null - let resolveMultiplex = (): void => {} - const multiplexClosed = new Promise((resolve) => { - resolveMultiplex = resolve - }) - const sendFrame = ( - streamId: number, - opcode: TerminalStreamOpcode, - payload: Uint8Array = new Uint8Array(), - seq?: number, - onRejected?: () => void - ): boolean => { - if (closed) { - onRejected?.() - return false - } - // Why: a seq-less Output chunk must carry sentinel 0, not the control-frame cursor, or it poisons the client's frame-drop tracker. - const resolvedSeq = - typeof seq === 'number' ? seq : opcode === TerminalStreamOpcode.Output ? 0 : cursor++ - let sent: boolean | void - try { - sent = sendBinary( - encodeTerminalStreamFrame({ opcode, streamId, seq: resolvedSeq, payload }) - ) - } catch { - onRejected?.() - 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. - closeMultiplex() - return false - } - return true - } - const sendStreamError = (streamId: number, message: string): void => { - sendFrame(streamId, TerminalStreamOpcode.Error, encodeTerminalStreamText(message)) - emit({ type: 'error', streamId, message }) - } - const notifyStreamWriteUnavailable = ( - stream: TerminalMultiplexStream, - outcome: TerminalStreamInputOutcome - ): void => { - if ( - closed || - streams.get(stream.streamId) !== stream || - outcome !== 'rejected' || - !stream.supportsWriteUnavailable - ) { - return - } - sendFrame(stream.streamId, TerminalStreamOpcode.WriteUnavailable) - } - const sendResizedFrame = ( - stream: TerminalMultiplexStream, - event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number } - ): void => { - stream.lastResizeCols = event.cols - sendFrame( - stream.streamId, - TerminalStreamOpcode.Resized, - encodeTerminalStreamJson({ - cols: event.cols, - rows: event.rows, - displayMode: event.displayMode, - reason: event.reason, - seq: event.seq - }) - ) - } - const canSendAckGatedOutput = (stream: TerminalMultiplexStream, bytes: number): boolean => { - if (!stream.ackOutput) { - return true - } - return ( - stream.ackInFlightBytes + bytes <= stream.ackWindowBytes && - ackTotalInFlightBytes + bytes <= ackTotalWindowBytes && - (!stream.ackOutputSourceRanges || stream.sourceRangeLedger?.canAccept(bytes) === true) - ) - } - const 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') { - detachStream(stream.streamId, true) - } - return false - } - const admission = prepared?.status === 'ready' ? prepared.admission : undefined - const sent = sendFrame( - stream.streamId, - chunk.opcode ?? TerminalStreamOpcode.Output, - chunk.bytes, - chunk.seq, - admission?.rollback - ) - if (!sent) { - return false - } - if (admission && !admission.commit()) { - detachStream(stream.streamId, true) - return false - } - if (stream.ackOutput) { - stream.ackInFlightBytes += chunk.bytes.byteLength - ackTotalInFlightBytes += chunk.bytes.byteLength - } - return true - } - const queueOrSendOutput = ( - stream: TerminalMultiplexStream, - chunk: TerminalOutputFrameChunk - ): void => { - if (closed || streams.get(stream.streamId) !== stream || stream.outputPaused) { - return - } - if ( - stream.ackPendingOutputOverflowed || - stream.ackPendingOutput.length > 0 || - !canSendAckGatedOutput(stream, chunk.bytes.byteLength) - ) { - appendAckPendingOutput(stream, chunk) - return - } - sendAckGatedOutput(stream, chunk) - } - const sendAckRecoverySnapshot = async (stream: TerminalMultiplexStream): Promise => { - if ( - 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 (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) => - !closed && - streams.get(stream.streamId) === stream && - 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 (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 (closed || streams.get(stream.streamId) !== stream) { - return - } - sendStreamError( - stream.streamId, - error instanceof Error ? error.message : 'Remote terminal recovery snapshot failed.' - ) - detachStream(stream.streamId, true) - } finally { - if (streams.get(stream.streamId) === stream) { - stream.ackRecoverySnapshotInFlight = false - flushAllAckPendingOutput() - } - } - } - const flushAckPendingOutput = ( - stream: TerminalMultiplexStream, - maxChunks = Number.POSITIVE_INFINITY - ): number => { - if (stream.outputPaused) { - return 0 - } - if (stream.ackPendingOutputOverflowed) { - void sendAckRecoverySnapshot(stream) - return 0 - } - let flushed = 0 - while ( - flushed < stream.ackPendingOutput.length && - flushed < maxChunks && - canSendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!.bytes.byteLength) - ) { - if (!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 - } - const flushAllAckPendingOutput = (): void => { - const ordered = Array.from(streams.values()) - ackFlushCursorStreamId = drainTerminalMultiplexRoundRobin({ - streams: ordered, - cursorStreamId: ackFlushCursorStreamId, - canContinue: () => !closed, - drainOne: (stream) => { - if (streams.get(stream.streamId) !== stream) { - return false - } - if (flushAckPendingOutput(stream, 1) > 0) { - return true - } - return false - } - }) - } - const 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 - ) - ackTotalWindowBytes = Math.min( - TERMINAL_MULTIPLEX_ACK_TOTAL_MAX_WINDOW_BYTES, - ackTotalWindowBytes + acknowledged - ) - stream.ackInFlightBytes -= acknowledged - ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - acknowledged) - flushAllAckPendingOutput() - } - const 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 - ) - } - acknowledgeOutput(stream, result.acknowledgedBytes) - } - const 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() - } - } - const 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() - detachSourceRangeConsumer(stream, 'stream-detached') - ackTotalInFlightBytes = Math.max(0, 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) - 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 }) - } - } - const cancelPendingPtyWaits = (streamId: number): void => { - const controllers = pendingPtyWaitControllers.get(streamId) - if (!controllers) { - return - } - pendingPtyWaitControllers.delete(streamId) - for (const controller of controllers) { - controller.abort() - } - } - const cancelAllPendingPtyWaits = (): void => { - for (const streamId of Array.from(pendingPtyWaitControllers.keys())) { - cancelPendingPtyWaits(streamId) - } - } - const closeMultiplex = (): void => { - if (closed) { - return - } - closed = true - signal?.removeEventListener('abort', cancelAllPendingPtyWaits) - cancelAllPendingPtyWaits() - const remoteDesktopKeysByPty = new Map() - 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) - } - 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) - } - unregisterControlHandler() - resolveMultiplex() - } - const handleSlotFrame = ( - stream: TerminalMultiplexStream, - frame: TerminalStreamFrame - ): void => { - if (closed || streams.get(stream.streamId) !== stream) { - return - } - if (frame.opcode === TerminalStreamOpcode.Unsubscribe) { - cancelPendingPtyWaits(stream.streamId) - detachStream(stream.streamId, false) - return - } - if (frame.opcode === TerminalStreamOpcode.Ack) { - const payload = decodeTerminalStreamJson(frame.payload) ?? {} - if (stream.ackOutputSourceRanges) { - const parsed = TerminalMultiplexSourceRangeAckFrame.safeParse(payload) - if (parsed.success) { - acknowledgeSourceRanges( - stream, - parsed.data.streamGeneration, - parsed.data.ackedEndByte - ) - } - } else { - const parsed = TerminalMultiplexLegacyAckFrame.safeParse(payload) - if (parsed.success) { - 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 - }) - 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(frame.payload) ?? {} - ) - void sendRequestedSnapshot(stream, payload.success ? payload.data : {}) - } - } - const sendRequestedSnapshot = async ( - stream: TerminalMultiplexStream, - request: z.infer - ): Promise => { - if (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 (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 (closed || streams.get(stream.streamId) !== stream) { - return - } - size = runtime.getTerminalSize(stream.ptyId) - displayMode = runtime.getMobileDisplayMode(stream.ptyId) - if (stream.pendingOutputOverflowed) { - sendSnapshotFrames((opcode, payload) => 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) => 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) { - 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(() => {}) - } - } - } - } - const handleSubscribeFrame = async (payload: Uint8Array): Promise => { - const raw = decodeTerminalStreamJson(payload) - const parsed = TerminalMultiplexSubscribeFrame.safeParse(raw) - if (!parsed.success) { - return - } - const request = parsed.data - detachStream(request.streamId, false) - cancelPendingPtyWaits(request.streamId) - - const isMobile = request.client?.type === 'mobile' - 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 { - sendStreamError(request.streamId, 'terminal_handle_stale') - emit({ type: 'end', streamId: request.streamId }) - return - } - if (!leaf?.ptyId && request.client) { - if ( - pendingPtyWaitControllers.size >= - TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION - ) { - sendStreamError(request.streamId, TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR) - emit({ type: 'end', streamId: request.streamId }) - return - } - // 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) { - cancelPendingPtyWaits(request.streamId) - detachStream(request.streamId, false) - } - } - ) - try { - const ptyId = await runtime.waitForLeafPtyId( - request.terminal, - 10_000, - waitController.signal - ) - leaf = { ptyId } - } catch { - if (closed || signal?.aborted || waitController.signal.aborted) { - return - } - // 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() - } - } - if (!leaf?.ptyId) { - sendStreamError(request.streamId, 'no_connected_pty') - emit({ type: 'end', streamId: request.streamId }) - return - } - if (closed) { - return - } - // 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). - detachStream(request.streamId, false) - if (streams.size >= TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION) { - sendStreamError(request.streamId, TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR) - emit({ type: 'end', streamId: request.streamId }) - return - } - - const ptyId = leaf.ptyId - 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) { - sendFrame( - request.streamId, - TerminalStreamOpcode.Metadata, - encodeTerminalStreamJson({ cwd: meta.cwd }), - meta.seq - ) - } - for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) { - queueOrSendOutput(stream, chunk) - } - }), - unsubscribeData: () => {}, - unsubscribeResize: () => {}, - unsubscribeFit: () => {}, - unsubscribeDriver: () => {}, - unregisterBinaryHandler: () => {}, - exitWaiterAbort: new AbortController() - } - streams.set(request.streamId, stream) - stream.unregisterBinaryHandler = registerBinaryStreamHandler(request.streamId, (frame) => - handleSlotFrame(stream, frame) - ) - - try { - const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { - if (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 (closed || streams.get(request.streamId) !== stream) { - return - } - - let read = await runtime.readTerminal(request.terminal) - let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) - if (closed || streams.get(request.streamId) !== stream) { - return - } - 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 (closed || streams.get(request.streamId) !== stream) { - return - } - 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) => 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() - 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) => sendFrame(request.streamId, opcode, payload), - event, - () => - !closed && - streams.get(request.streamId) === stream && - stream.resizeGeneration === resizeGeneration - ) - .then((restreamed) => { - if ( - closed || - streams.get(request.streamId) !== stream || - stream.resizeGeneration !== resizeGeneration - ) { - return - } - if (!restreamed) { - sendResizedFrame(stream, event) - } - }) - // Why: on re-stream failure, still emit the geometry-only Resized frame so the client never misses the resize. - .catch(() => { - if ( - closed || - streams.get(request.streamId) !== stream || - stream.resizeGeneration !== resizeGeneration - ) { - return - } - sendResizedFrame(stream, event) - }) - return - } - 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) { - detachStream(request.streamId, true) - } - }) - .catch(() => { - if (streams.get(request.streamId) === stream) { - detachStream(request.streamId, true) - } - }) - } catch (error) { - // Why the ownership check: a newer subscribe may already own this streamId; tearing down the slot here would kill the successor's live registrations. - if (streams.get(request.streamId) !== stream) { - return - } - detachStream(request.streamId, false) - sendStreamError(request.streamId, error instanceof Error ? error.message : String(error)) - emit({ type: 'end', streamId: request.streamId }) - } - } - const unregisterControlHandler = registerBinaryStreamHandler(0, (frame) => { - if (frame.opcode === TerminalStreamOpcode.Subscribe) { - void handleSubscribeFrame(frame.payload) - } - }) - - signal?.addEventListener('abort', cancelAllPendingPtyWaits, { once: true }) - - runtime.registerSubscriptionCleanup( - `terminal-multiplex:${connectionId}`, - closeMultiplex, - connectionId - ) - emit({ type: 'ready' }) - await multiplexClosed - } - }), - // terminal.subscribe: 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) - // Why: a closed stream must not allocate listeners, mobile-fit state, or a hidden renderer surface no client will consume. - if (signal?.aborted) { - return - } - - // Why: the PTY spawns asynchronously after tab creation; wait for it so an early subscribe gets a live stream instead of a bare scrollback+end. - if (!leaf?.ptyId && params.client) { - // Why: a never-mounted tab has no graph leaf to await; mounting the exact tab attaches its PTY without activating the worktree. - rendererMountRequestedBeforePty = runtime.requestRendererTerminalTabMount(params.terminal) - try { - const ptyId = await runtime.waitForLeafPtyId(params.terminal, 10_000, signal) - leaf = { ptyId } - } catch { - if (signal?.aborted) { - return - } - // PTY wait timed out — fall through to scrollback-only path below - } - } - - 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 mobileInputLeaseOnly = - isMobile && params.capabilities?.mobileInputLeaseOnly === 1 && Boolean(clientId) - // Why: mount/PTY wait and phone-fit can each emit a redraw creating suffix-only state, so capture the pre-mount absence signal first. - const missingHeadlessStateBeforeMobileFit = - isMobile && - (rendererMountRequestedBeforePty || runtime.hasHeadlessTerminalState?.(ptyId) === false) - const serializerGenerationBeforeMobileFit = missingHeadlessStateBeforeMobileFit - ? rendererMountRequestedBeforePty - ? serializerGenerationBeforeAnyMount - : runtime.getRendererTerminalSerializerGeneration(ptyId) - : 0 - const supportsDesktopViewportClaims = params.capabilities?.desktopViewportClaims === 1 - const supportsWriteUnavailable = params.capabilities?.writeUnavailable === 1 - if (mobileInputLeaseOnly && clientId) { - let closed = false - let stopWatchingLifetime = (): void => {} - let resolveStream = (): void => {} - const streamClosed = new Promise((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 - } - return - } - // Why: only unregister the width floor this subscription took (see the multiplex stream's registeredRemoteDesktopDriver note). - let registeredRemoteDesktopDriver = false - if (!useBinaryStream) { - // 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:${nextTerminalStreamId++}` - let closed = false - let outputBatcher: ReturnType | null = null - let unsubscribeData = (): void => {} - let unsubscribeFit = (): void => {} - let stopWatchingLifetime = (): void => {} - let resolveStream = (): void => {} - const streamClosed = new Promise((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 - } - return - } - - const streamId = nextTerminalStreamId++ - const remoteDesktopSubscriptionKey = `stream:${streamId}` - let cursor = 0 - let closed = false - let buffering = true - let pendingRemoteDesktopViewport: { cols: number; rows: number } | null = null - // Why: cols the mobile client last rewrapped to; gates the resize re-stream to fire only on an actual width change. - let lastResizeCols: number | undefined - let resizeGeneration = 0 - let pendingOutput: TerminalOutputChunk[] = [] - let desktopClaimTail: Promise = 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 | null = null - let outputBatcher: ReturnType | null = null - let resolveStream = (): void => {} - const streamClosed = new Promise((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 = 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 = - registerBinaryStreamHandler?.(streamId, (frame) => { - if (closed) { - return - } - if (frame.opcode === TerminalStreamOpcode.Input) { - const text = decodeTerminalStreamText(frame.payload) - if (!text) { - return - } - if (isTerminalInputLockedForClient(runtime, ptyId, params.client)) { - return - } - void desktopClaimTail.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 (!closed && outcome === 'rejected' && supportsWriteUnavailable) { - 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) { - registeredRemoteDesktopDriver = true - if (buffering) { - pendingRemoteDesktopViewport = { cols: viewport.cols, rows: viewport.rows } - return - } - } - desktopClaimTail = desktopClaimTail - .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 - registeredRemoteDesktopDriver = true - desktopClaimTail = desktopClaimTail - .then( - () => - runtime.updateRemoteDesktopViewer( - ptyId, - remoteDesktopSubscriptionKey, - clientId, - cols, - rows, - true - ), - () => - runtime.updateRemoteDesktopViewer( - ptyId, - remoteDesktopSubscriptionKey, - clientId, - cols, - rows, - true - ) - ) - .catch(() => false) - } - }) ?? (() => {}) - 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() - } - // Server-side auto-fit: resize PTY to phone dims before serializing scrollback - try { - 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. - registeredRemoteDesktopDriver = true - pendingRemoteDesktopViewport = params.viewport - } - if (closed) { - return - } - - let read = await runtime.readTerminal(params.terminal) - let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) - if (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() - 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 (abortRendererMountWait === abortMountWait) { - abortRendererMountWait = () => {} - } - } - void rendererReadyPromise.then(finishMountWait, finishMountWait) - let deadlineTimer: ReturnType | null = null - const initialDeadline = new Promise((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 (closed || signal?.aborted) { - return - } - if (rendererReady) { - read = await runtime.readTerminal(params.terminal) - const stableRendererSnapshot = await serializeStableMobileRendererSnapshot( - runtime, - ptyId - ) - if (closed) { - return - } - if (stableRendererSnapshot?.data.length) { - serialized = stableRendererSnapshot - const trailingOutput = 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. - lateRendererReadyPromise = rendererReadyPromise - } - } - let initialOutputOverflowed = false - if (pendingOutputOverflowed) { - pendingOutput.splice(0) - pendingOutputBytes = 0 - pendingOutputOverflowed = false - read = await runtime.readTerminal(params.terminal) - serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) - if (closed) { - return - } - if (pendingOutputOverflowed) { - initialOutputOverflowed = true - pendingOutput.splice(0) - pendingOutputBytes = 0 - pendingOutputOverflowed = false - } - } - const size = runtime.getTerminalSize(ptyId) - const 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, - lines: read.tail, - truncated: - initialOutputOverflowed || (!sendBinary && isTerminalReadPayloadIncomplete(read)), - cols: serialized?.cols ?? size?.cols, - rows: serialized?.rows ?? size?.rows, - displayMode, - seq: layoutSeq - }) - const snapshotStats = sendSnapshotFrames(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, - 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, - 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. - 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 (pendingOutputOverflowed && recoveryAttempts < 2) { - pendingOutputOverflowed = false - recoveryAttempts += 1 - const recovery = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) - if (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(sendFrame, { - kind: 'resized', - cols: recovery.cols, - rows: recovery.rows, - 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, - reason: 'pending-output-overflow', - bytes: recoveryStats.bytes, - chunks: recoveryStats.chunks, - scrollbackRows: recovery.scrollbackRows, - truncatedByByteBudget: recovery.truncatedByByteBudget === true - }) - const trimmed = trimPendingOutputCoveredBySnapshot(pendingOutput, recovery.seq) - pendingOutput = trimmed.chunks - pendingOutputBytes = trimmed.bytes - snapshotOutputSeq = recovery.seq - } - buffering = false - const bufferedOutput = pendingOutput.splice(0) - const queryReplayData = pendingQueryOverflowed - ? '' - : 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. - 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, - pendingQuerySequences - ) - } - if (uncoveredData) { - outputBatcher.push(uncoveredData, uncoveredMeta) - } - } - } - pendingOutputBytes = 0 - outputBatcher.flush() - const lateRendererReady = lateRendererReadyPromise - lateRendererReadyPromise = null - if (lateRendererReady) { - void lateRendererReady - .then(async (rendererReady) => { - if (!rendererReady || closed) { - return - } - outputBatcher?.flush() - const recovery = await serializeStableMobileRendererSnapshot(runtime, ptyId) - if (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(sendFrame, { - kind: 'resized', - cols: recovery.cols, - rows: recovery.rows, - displayMode, - reason: 'renderer-mount-ready', - source: recovery.source, - truncated: false, - truncatedByByteBudget: recovery.truncatedByByteBudget, - data: recovery.data - }) - lastResizeCols = recovery.cols - console.log('[mobile-terminal-stream] recovery snapshot', { - terminal: params.terminal, - 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 => { - lastResizeCols = event.cols - sendFrame( - TerminalStreamOpcode.Resized, - encodeTerminalStreamJson({ - cols: event.cols, - rows: event.rows, - displayMode: event.displayMode, - reason: event.reason, - seq: event.seq - }) - ) - } - unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => { - outputBatcher?.flush() - const eventGeneration = resizeGeneration + 1 - 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 !== lastResizeCols - if (widthChanged) { - lastResizeCols = event.cols - void sendMobileResizeRestream( - runtime, - ptyId, - sendFrame, - event, - () => !closed && resizeGeneration === eventGeneration - ) - .then((restreamed) => { - if (closed || 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 (closed || 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 && - registeredRemoteDesktopDriver && - pendingRemoteDesktopViewport - ) { - const viewport = pendingRemoteDesktopViewport - pendingRemoteDesktopViewport = null - void updateViewportForClient( - runtime, - ptyId, - remoteDesktopSubscriptionKey, - params.client, - viewport, - 'desktop', - 'register', - !supportsDesktopViewportClaims - ).catch(() => {}) - } - - // Legacy fit-override-changed for non-mobile (desktop) subscribers - unsubscribeFit = !isMobile - ? runtime.subscribeToFitOverrideChanges(ptyId, (event) => { - 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 - }) - }) - : () => {} - } catch (error) { - registration.releaseIfCurrent() - throw error - } - - await streamClosed - } - }), - 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) - }) - }) + ...TERMINAL_QUERY_METHODS, + ...TERMINAL_SEND_METHODS, + ...TERMINAL_LIFECYCLE_METHODS, + ...TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS, + ...TERMINAL_MULTIPLEX_METHODS, + ...TERMINAL_SUBSCRIBE_METHODS, + ...TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS ] diff --git a/src/main/runtime/rpc/methods/terminal/stream-schemas.ts b/src/main/runtime/rpc/methods/terminal/stream-schemas.ts new file mode 100644 index 00000000000..04a6990fe51 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/stream-schemas.ts @@ -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() +}) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts new file mode 100644 index 00000000000..7313eb82b94 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts @@ -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 { + 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 { + 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 +} + +export async function commitMobileInputFloorClaim( + claim: MobileInputFloorClaimHolder +): Promise { + 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') +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-binary-control-frames.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-binary-control-frames.ts new file mode 100644 index 00000000000..1033464071c --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-binary-control-frames.ts @@ -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 + setDesktopClaimTail: (tail: Promise) => void + sendFrame: (opcode: TerminalStreamOpcode, payload?: Uint8Array) => 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) + ) + } + }) +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts new file mode 100644 index 00000000000..ad4c7dd05da --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts @@ -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 { + 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((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 { + 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((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 + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-binary.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-binary.ts new file mode 100644 index 00000000000..08639f16f48 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-binary.ts @@ -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 { + 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 = 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 | null = null + let outputBatcher: TerminalOutputBatcher | null = null + let resolveStream = (): void => {} + const streamClosed = new Promise((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 = 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 +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-live.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-live.ts new file mode 100644 index 00000000000..73a359d73af --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-live.ts @@ -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 + }) + }) + : () => {} +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts new file mode 100644 index 00000000000..fcae3d02f6f --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts @@ -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 { + 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 | null = null + const initialDeadline = new Promise((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() +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscription-types.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscription-types.ts new file mode 100644 index 00000000000..d9eb624f0e1 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscription-types.ts @@ -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 +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 | null + abortRendererMountWait: () => void + outputBatcher: TerminalOutputBatcher + unsubscribeResize: () => void + unsubscribeFit: () => void + registeredRemoteDesktopDriver: boolean + displayMode: string + readonly registration: SubscriptionRegistration + readonly streamClosed: Promise + readonly sendFrame: ( + opcode: TerminalStreamOpcode, + payload?: Uint8Array, + frameSeq?: number + ) => void +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts new file mode 100644 index 00000000000..37c4cda51b4 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -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 + }) + }) + }) +] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-cleanup.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-cleanup.ts new file mode 100644 index 00000000000..924975d34b6 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-cleanup.ts @@ -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() + 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() + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-connection.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-connection.ts new file mode 100644 index 00000000000..67d68602b62 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-connection.ts @@ -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 +export type MultiplexSnapshotRequest = z.infer +export type MultiplexEmit = (result: unknown) => void + +export type TerminalMultiplexConnectionBase = { + runtime: RpcContext['runtime'] + connectionId: string + sendBinary: NonNullable + registerBinaryStreamHandler: NonNullable + signal: RpcContext['signal'] + emit: MultiplexEmit + closed: boolean + cursor: number + streams: Map + sourceRangeRegistry: TerminalSourceRangeRegistry + pendingPtyWaitControllers: Map> + ackTotalInFlightBytes: number + ackTotalWindowBytes: number + ackFlushCursorStreamId: number | null + resolveMultiplex: () => void + multiplexClosed: Promise + unregisterControlHandler: () => void +} + +export type TerminalMultiplexFrameDelivery = { + sendFrame: ( + streamId: number, + opcode: TerminalStreamOpcode, + payload?: Uint8Array, + 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 + 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 +} + +export type TerminalMultiplexSubscribeFrame = { + handleSubscribeFrame: (payload: Uint8Array) => Promise +} + +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 diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts new file mode 100644 index 00000000000..a469b2d7a00 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts @@ -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 => { + 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() + } + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-frame-delivery.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-frame-delivery.ts new file mode 100644 index 00000000000..6e9c0e38231 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-frame-delivery.ts @@ -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 = 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) + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts new file mode 100644 index 00000000000..724e14f2cc5 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts @@ -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 { + 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 } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-live-stream.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-live-stream.ts new file mode 100644 index 00000000000..45604dc4a01 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-live-stream.ts @@ -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) + } + }) +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts new file mode 100644 index 00000000000..11fd0c4d039 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts @@ -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((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(), + sourceRangeRegistry: new TerminalSourceRangeRegistry(), + pendingPtyWaitControllers: new Map>(), + 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 + } + }) +] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts new file mode 100644 index 00000000000..7a4ae50e701 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts @@ -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(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(frame.payload) ?? {} + ) + void state.sendRequestedSnapshot(stream, payload.success ? payload.data : {}) + } + } + state.sendRequestedSnapshot = async ( + stream: TerminalMultiplexStream, + request: MultiplexSnapshotRequest + ): Promise => { + 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(() => {}) + } + } + } + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-stream-initialization.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-stream-initialization.ts new file mode 100644 index 00000000000..cc3ff90b2d4 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-stream-initialization.ts @@ -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 { + 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 +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-frame.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-frame.ts new file mode 100644 index 00000000000..dcb7b64ece4 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-frame.ts @@ -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(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 }) + } + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-resolution.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-resolution.ts new file mode 100644 index 00000000000..6197850a47a --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-resolution.ts @@ -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 { + 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) + })() +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-output-batcher.ts b/src/main/runtime/rpc/methods/terminal/terminal-output-batcher.ts new file mode 100644 index 00000000000..4a5056946d6 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-output-batcher.ts @@ -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 | 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 = [] + } + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts new file mode 100644 index 00000000000..91ce5498506 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts @@ -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) + }) + }) +] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts new file mode 100644 index 00000000000..a4c58fbab68 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -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 => { + 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 } + } + }) +] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts new file mode 100644 index 00000000000..33b09a0ead9 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts @@ -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 { + 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 + ) => 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 { + 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 { + 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) => void, + event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number }, + shouldSend?: () => boolean +): Promise { + // 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 +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-stream-replay.ts b/src/main/runtime/rpc/methods/terminal/terminal-stream-replay.ts new file mode 100644 index 00000000000..5ffd1079a3c --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-stream-replay.ts @@ -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> { + 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)] +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts b/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts new file mode 100644 index 00000000000..484a9f0f933 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts @@ -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 + // 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 +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts new file mode 100644 index 00000000000..bd16382d747 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts @@ -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) + } + }) +] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-subscription-stream-id.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscription-stream-id.ts new file mode 100644 index 00000000000..66c50b3f8c3 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscription-stream-id.ts @@ -0,0 +1,5 @@ +let nextTerminalStreamId = 1 + +export function allocateTerminalSubscriptionStreamId(): number { + return nextTerminalStreamId++ +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts new file mode 100644 index 00000000000..71feee4f24d --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts @@ -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) + }) + }) +] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-viewport-update.ts b/src/main/runtime/rpc/methods/terminal/terminal-viewport-update.ts new file mode 100644 index 00000000000..cfcaa3a0896 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-viewport-update.ts @@ -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 } +} diff --git a/src/main/runtime/rpc/methods/terminal/unary-schemas.ts b/src/main/runtime/rpc/methods/terminal/unary-schemas.ts new file mode 100644 index 00000000000..f60b418ca05 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/unary-schemas.ts @@ -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((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() +}) diff --git a/src/main/runtime/rpc/methods/terminal/viewport-schemas.ts b/src/main/runtime/rpc/methods/terminal/viewport-schemas.ts new file mode 100644 index 00000000000..d9b76d66ea1 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/viewport-schemas.ts @@ -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() +})