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