Simplify terminal streaming protocol

This commit is contained in:
Mohamed Boudra
2026-03-22 22:31:23 +07:00
parent 54282409bb
commit 50ea5a76fb
35 changed files with 1809 additions and 4121 deletions
+106 -160
View File
@@ -44,10 +44,8 @@ import type {
ListTerminalsResponse,
CreateTerminalResponse,
SubscribeTerminalResponse,
TerminalOutput,
TerminalState,
KillTerminalResponse,
AttachTerminalStreamResponse,
DetachTerminalStreamResponse,
TerminalInput,
SessionInboundMessage,
SessionOutboundMessage,
@@ -63,18 +61,14 @@ import { getAgentProviderDefinition } from "../server/agent/provider-manifest.js
import { isRelayClientWebSocketUrl } from "../shared/daemon-endpoints.js";
import {
asUint8Array,
decodeBinaryMuxFrame,
encodeBinaryMuxFrame,
BinaryMuxChannel,
TerminalBinaryFlags,
TerminalBinaryMessageType,
type BinaryMuxFrame,
} from "../shared/binary-mux.js";
decodeTerminalSnapshotPayload,
decodeTerminalStreamFrame,
encodeTerminalResizePayload,
encodeTerminalStreamFrame,
TerminalStreamOpcode,
type TerminalStreamFrame,
} from "../shared/terminal-stream-protocol.js";
import { encodeTerminalKeyInput, type TerminalKeyInput } from "../shared/terminal-key-input.js";
import {
TerminalStreamManager,
type TerminalStreamChunk,
} from "./daemon-client-terminal-stream-manager.js";
import {
createRelayE2eeTransportFactory,
createWebSocketTransportFactory,
@@ -108,7 +102,10 @@ export type {
WebSocketFactory,
WebSocketLike,
} from "./daemon-client-transport.js";
export type { TerminalStreamChunk } from "./daemon-client-terminal-stream-manager.js";
export type TerminalStreamEvent =
| { terminalId: string; type: "output"; data: Uint8Array }
| { terminalId: string; type: "snapshot"; state: TerminalState };
export type ConnectionState =
| { status: "idle" }
@@ -244,10 +241,7 @@ type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"];
type ListTerminalsPayload = ListTerminalsResponse["payload"];
type CreateTerminalPayload = CreateTerminalResponse["payload"];
type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"];
type TerminalOutputPayload = TerminalOutput["payload"];
type KillTerminalPayload = KillTerminalResponse["payload"];
type AttachTerminalStreamPayload = AttachTerminalStreamResponse["payload"];
type DetachTerminalStreamPayload = DetachTerminalStreamResponse["payload"];
export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"];
export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"];
@@ -426,9 +420,10 @@ export class DaemonClient {
{ cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } }
>();
private terminalDirectorySubscriptions = new Set<string>();
private activeTerminalId: string | null = null;
private readonly terminalStreamListeners = new Set<(event: TerminalStreamEvent) => void>();
private logger: Logger;
private pendingSendQueue: PendingSend[] = [];
private terminalStreams: TerminalStreamManager;
private readonly logConnectionPath: "direct" | "relay";
private readonly logServerId: string | null;
private readonly logClientIdHash: string;
@@ -457,17 +452,6 @@ export class DaemonClient {
Number.isFinite(this.config.runtimeGeneration)
? this.config.runtimeGeneration
: null;
this.terminalStreams = new TerminalStreamManager({
sendAck: (ack) => {
this.sendBinaryFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.Ack,
streamId: ack.streamId,
offset: ack.offset,
payload: new Uint8Array(0),
});
},
});
}
// ============================================================================
@@ -676,7 +660,7 @@ export class DaemonClient {
this.disposeTransport(1000, "Client closed");
this.clearWaiters(new Error("Daemon client closed"));
this.rejectPendingSendQueue(new Error("Daemon client closed"));
this.terminalStreams.clearAll();
this.activeTerminalId = null;
this.lastServerInfoMessage = null;
this.updateConnectionState(
{ status: "disposed" },
@@ -800,7 +784,7 @@ export class DaemonClient {
}
}
private sendBinaryFrame(frame: BinaryMuxFrame): void {
private sendBinaryFrame(frame: Uint8Array): void {
if (!this.transport || this.connectionState.status !== "connected") {
if (this.config.suppressSendErrors) {
return;
@@ -808,7 +792,7 @@ export class DaemonClient {
throw new Error(`Transport not connected (status: ${this.connectionState.status})`);
}
try {
this.transport.send(encodeBinaryMuxFrame(frame));
this.transport.send(frame);
} catch (error) {
if (this.config.suppressSendErrors) {
return;
@@ -2622,16 +2606,23 @@ export class DaemonClient {
terminalId,
requestId: resolvedRequestId,
});
return this.sendCorrelatedRequest({
const payload = await this.sendCorrelatedRequest({
requestId: resolvedRequestId,
message,
responseType: "subscribe_terminal_response",
timeout: 10000,
options: { skipQueue: true },
});
if (!payload.error) {
this.activeTerminalId = terminalId;
}
return payload;
}
unsubscribeTerminal(terminalId: string): void {
if (this.activeTerminalId === terminalId) {
this.activeTerminalId = null;
}
this.sendSessionMessage({
type: "unsubscribe_terminal_request",
terminalId,
@@ -2639,6 +2630,29 @@ export class DaemonClient {
}
sendTerminalInput(terminalId: string, message: TerminalInput["message"]): void {
if (this.activeTerminalId === terminalId) {
if (message.type === "input") {
this.sendBinaryFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Input,
payload: encodeUtf8String(message.data),
}),
);
return;
}
if (message.type === "resize") {
this.sendBinaryFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Resize,
payload: encodeTerminalResizePayload({
rows: message.rows,
cols: message.cols,
}),
}),
);
return;
}
}
this.sendSessionMessage({
type: "terminal_input",
terminalId,
@@ -2662,127 +2676,44 @@ export class DaemonClient {
});
}
async attachTerminalStream(
terminalId: string,
options?: {
resumeOffset?: number;
rows?: number;
cols?: number;
},
requestId?: string,
): Promise<AttachTerminalStreamPayload> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
type: "attach_terminal_stream_request",
terminalId,
requestId: resolvedRequestId,
...(options?.resumeOffset !== undefined ? { resumeOffset: options.resumeOffset } : {}),
...(options?.rows !== undefined ? { rows: options.rows } : {}),
...(options?.cols !== undefined ? { cols: options.cols } : {}),
});
return this.sendCorrelatedRequest({
requestId: resolvedRequestId,
message,
responseType: "attach_terminal_stream_response",
timeout: 10000,
options: { skipQueue: true },
});
onTerminalStreamEvent(handler: (event: TerminalStreamEvent) => void): () => void {
this.terminalStreamListeners.add(handler);
return () => {
this.terminalStreamListeners.delete(handler);
};
}
async detachTerminalStream(
streamId: number,
requestId?: string,
): Promise<DetachTerminalStreamPayload> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
type: "detach_terminal_stream_request",
streamId,
requestId: resolvedRequestId,
});
const payload = await this.sendCorrelatedRequest({
requestId: resolvedRequestId,
message,
responseType: "detach_terminal_stream_response",
timeout: 10000,
options: { skipQueue: true },
});
this.terminalStreams.clearStream({ streamId });
return payload;
}
onTerminalStreamData(
streamId: number,
handler: (chunk: TerminalStreamChunk) => void,
): () => void {
return this.terminalStreams.subscribe({ streamId, handler });
}
async waitForTerminalStreamData(
streamId: number,
predicate: (chunk: TerminalStreamChunk) => boolean,
async waitForTerminalStreamEvent(
predicate: (event: TerminalStreamEvent) => boolean,
timeout = 5000,
): Promise<TerminalStreamChunk> {
return new Promise<TerminalStreamChunk>((resolve, reject) => {
): Promise<TerminalStreamEvent> {
return new Promise<TerminalStreamEvent>((resolve, reject) => {
const timeoutHandle = setTimeout(() => {
unsubscribe();
reject(new Error(`Timeout waiting for terminal stream data (${timeout}ms)`));
reject(new Error(`Timeout waiting for terminal stream event (${timeout}ms)`));
}, timeout);
const unsubscribe = this.onTerminalStreamData(streamId, (chunk) => {
if (!predicate(chunk)) {
const unsubscribe = this.onTerminalStreamEvent((event) => {
if (!predicate(event)) {
return;
}
clearTimeout(timeoutHandle);
unsubscribe();
resolve(chunk);
resolve(event);
});
});
}
sendTerminalStreamInput(streamId: number, data: string | Uint8Array): void {
const payload = typeof data === "string" ? encodeUtf8String(data) : data;
this.sendBinaryFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.InputUtf8,
streamId,
offset: 0,
payload,
});
}
sendTerminalStreamKey(streamId: number, input: TerminalKeyInput): void {
sendTerminalStreamKey(input: TerminalKeyInput): void {
const encoded = encodeTerminalKeyInput(input);
if (!encoded) {
return;
}
this.sendTerminalStreamInput(streamId, encoded);
}
sendTerminalStreamAck(streamId: number, offset: number): void {
const normalizedOffset = Math.max(0, Math.floor(offset));
this.terminalStreams.noteAck({ streamId, offset: normalizedOffset });
this.sendBinaryFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.Ack,
streamId,
offset: normalizedOffset,
payload: new Uint8Array(0),
});
}
async waitForTerminalOutput(terminalId: string, timeout = 5000): Promise<TerminalOutputPayload> {
return this.waitFor(
(msg) => {
if (msg.type !== "terminal_output") {
return null;
}
if (msg.payload.terminalId !== terminalId) {
return null;
}
return msg.payload;
},
timeout,
{ skipQueue: true },
this.sendBinaryFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Input,
payload: encodeUtf8String(encoded),
}),
);
}
@@ -2890,7 +2821,7 @@ export class DaemonClient {
const rawBytes = asUint8Array(rawData);
if (rawBytes) {
const frame = decodeBinaryMuxFrame(rawBytes);
const frame = decodeTerminalStreamFrame(rawBytes);
if (frame) {
this.handleBinaryFrame(frame);
return;
@@ -2922,21 +2853,42 @@ export class DaemonClient {
this.handleSessionMessage(parsed.data.message);
}
private handleBinaryFrame(frame: BinaryMuxFrame): void {
if (
frame.channel === BinaryMuxChannel.Terminal &&
frame.messageType === TerminalBinaryMessageType.OutputUtf8
) {
const chunk: TerminalStreamChunk = {
streamId: frame.streamId,
offset: frame.offset,
endOffset: frame.offset + (frame.payload?.byteLength ?? 0),
replay: Boolean((frame.flags ?? 0) & TerminalBinaryFlags.Replay),
data: frame.payload ?? new Uint8Array(0),
};
this.terminalStreams.receiveChunk({ chunk });
private handleBinaryFrame(frame: TerminalStreamFrame): void {
const terminalId = this.activeTerminalId;
if (!terminalId) {
return;
}
if (frame.opcode === TerminalStreamOpcode.Output) {
this.emitTerminalStreamEvent({
terminalId,
type: "output",
data: frame.payload,
});
return;
}
if (frame.opcode === TerminalStreamOpcode.Snapshot) {
const state = decodeTerminalSnapshotPayload(frame.payload);
if (!state) {
return;
}
this.emitTerminalStreamEvent({
terminalId,
type: "snapshot",
state,
});
}
}
private emitTerminalStreamEvent(event: TerminalStreamEvent): void {
for (const listener of this.terminalStreamListeners) {
try {
listener(event);
} catch {
// no-op
}
}
}
private updateConnectionState(
@@ -2996,7 +2948,7 @@ export class DaemonClient {
// and responses from the previous connection will never arrive.
this.clearWaiters(new Error(reason ?? "Connection lost"));
this.rejectPendingSendQueue(new Error(reason ?? "Connection lost"));
this.terminalStreams.clearAll();
this.activeTerminalId = null;
this.lastServerInfoMessage = null;
if (wasDisposed) {
@@ -3051,7 +3003,9 @@ export class DaemonClient {
}
if (msg.type === "terminal_stream_exit") {
this.terminalStreams.clearStream({ streamId: msg.payload.streamId });
if (this.activeTerminalId === msg.payload.terminalId) {
this.activeTerminalId = null;
}
}
if (this.rawMessageListeners.size > 0) {
@@ -3153,14 +3107,6 @@ export class DaemonClient {
}
}
private async waitFor<T>(
predicate: (msg: SessionOutboundMessage) => T | null,
timeout = 30000,
_options?: { skipQueue?: boolean },
): Promise<T> {
return this.waitForWithCancel(predicate, timeout, _options).promise;
}
private waitForWithCancel<T>(
predicate: (msg: SessionOutboundMessage) => T | null,
timeout = 30000,