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
@@ -1,131 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import {
TerminalStreamManager,
type TerminalStreamChunk,
} from "./daemon-client-terminal-stream-manager.js";
function createChunk(input: {
streamId: number;
offset: number;
data: string;
}): TerminalStreamChunk {
const bytes = new TextEncoder().encode(input.data);
return {
streamId: input.streamId,
offset: input.offset,
endOffset: input.offset + bytes.byteLength,
replay: false,
data: bytes,
};
}
describe("TerminalStreamManager", () => {
test("buffers chunks and flushes with ack when handler subscribes", () => {
const sendAck = vi.fn();
const manager = new TerminalStreamManager({ sendAck });
const seen: string[] = [];
manager.receiveChunk({
chunk: createChunk({ streamId: 7, offset: 4, data: "hello" }),
});
expect(sendAck).toHaveBeenCalledTimes(1);
expect(sendAck).toHaveBeenCalledWith({ streamId: 7, offset: 9 });
manager.subscribe({
streamId: 7,
handler: (chunk) => {
seen.push(new TextDecoder().decode(chunk.data));
},
});
expect(seen).toEqual(["hello"]);
expect(sendAck).toHaveBeenCalledTimes(1);
});
test("acks buffered chunks even while no subscriber is attached", () => {
const sendAck = vi.fn();
const manager = new TerminalStreamManager({ sendAck });
manager.receiveChunk({
chunk: createChunk({ streamId: 2, offset: 0, data: "abc" }),
});
expect(sendAck).toHaveBeenCalledTimes(1);
expect(sendAck).toHaveBeenCalledWith({ streamId: 2, offset: 3 });
});
test("does not ack when every handler throws", () => {
const sendAck = vi.fn();
const manager = new TerminalStreamManager({ sendAck });
manager.subscribe({
streamId: 11,
handler: () => {
throw new Error("boom");
},
});
manager.receiveChunk({
chunk: createChunk({ streamId: 11, offset: 0, data: "x" }),
});
expect(sendAck).not.toHaveBeenCalled();
});
test("evicts oldest buffered chunks when max buffered chunk count is exceeded", () => {
const sendAck = vi.fn();
const manager = new TerminalStreamManager({
sendAck,
maxBufferedChunks: 2,
maxBufferedBytes: 1024,
});
manager.receiveChunk({
chunk: createChunk({ streamId: 5, offset: 0, data: "A" }),
});
manager.receiveChunk({
chunk: createChunk({ streamId: 5, offset: 1, data: "B" }),
});
manager.receiveChunk({
chunk: createChunk({ streamId: 5, offset: 2, data: "C" }),
});
expect(sendAck).toHaveBeenNthCalledWith(1, { streamId: 5, offset: 1 });
expect(sendAck).toHaveBeenNthCalledWith(2, { streamId: 5, offset: 2 });
expect(sendAck).toHaveBeenNthCalledWith(3, { streamId: 5, offset: 3 });
const seen: string[] = [];
manager.subscribe({
streamId: 5,
handler: (chunk) => {
seen.push(new TextDecoder().decode(chunk.data));
},
});
expect(seen).toEqual(["B", "C"]);
expect(sendAck).toHaveBeenCalledTimes(3);
});
test("tracks explicit ack offsets and skips stale auto-acks", () => {
const sendAck = vi.fn();
const manager = new TerminalStreamManager({ sendAck });
manager.subscribe({
streamId: 13,
handler: () => {
// no-op
},
});
manager.noteAck({ streamId: 13, offset: 10 });
manager.receiveChunk({
chunk: createChunk({ streamId: 13, offset: 0, data: "abc" }),
});
expect(sendAck).not.toHaveBeenCalled();
manager.receiveChunk({
chunk: createChunk({ streamId: 13, offset: 11, data: "z" }),
});
expect(sendAck).toHaveBeenCalledTimes(1);
expect(sendAck).toHaveBeenCalledWith({ streamId: 13, offset: 12 });
});
});
@@ -1,173 +0,0 @@
export type TerminalStreamChunk = {
streamId: number;
offset: number;
endOffset: number;
replay: boolean;
data: Uint8Array;
};
type TerminalChunkHandler = (chunk: TerminalStreamChunk) => void;
type BufferedTerminalStreamQueue = {
chunks: TerminalStreamChunk[];
bytes: number;
};
type TerminalStreamAck = {
streamId: number;
offset: number;
};
export type TerminalStreamManagerConfig = {
sendAck: (ack: TerminalStreamAck) => void;
maxBufferedChunks?: number;
maxBufferedBytes?: number;
};
const DEFAULT_MAX_BUFFERED_CHUNKS = 2048;
const DEFAULT_MAX_BUFFERED_BYTES = 2 * 1024 * 1024;
export class TerminalStreamManager {
private readonly handlers: Map<number, Set<TerminalChunkHandler>> = new Map();
private readonly bufferedChunks: Map<number, BufferedTerminalStreamQueue> = new Map();
private readonly ackOffsets: Map<number, number> = new Map();
private readonly maxBufferedChunks: number;
private readonly maxBufferedBytes: number;
constructor(private readonly config: TerminalStreamManagerConfig) {
this.maxBufferedChunks = config.maxBufferedChunks ?? DEFAULT_MAX_BUFFERED_CHUNKS;
this.maxBufferedBytes = config.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
}
clearAll(): void {
this.handlers.clear();
this.bufferedChunks.clear();
this.ackOffsets.clear();
}
clearStream(input: { streamId: number }): void {
this.handlers.delete(input.streamId);
this.bufferedChunks.delete(input.streamId);
this.ackOffsets.delete(input.streamId);
}
subscribe(input: { streamId: number; handler: TerminalChunkHandler }): () => void {
const { streamId, handler } = input;
if (!this.handlers.has(streamId)) {
this.handlers.set(streamId, new Set());
}
const streamHandlers = this.handlers.get(streamId)!;
streamHandlers.add(handler);
this.flushBufferedChunks({ streamId, handler });
return () => {
streamHandlers.delete(handler);
if (streamHandlers.size === 0) {
this.handlers.delete(streamId);
}
};
}
receiveChunk(input: { chunk: TerminalStreamChunk }): void {
const { chunk } = input;
const streamHandlers = this.handlers.get(chunk.streamId);
if (!streamHandlers || streamHandlers.size === 0) {
this.bufferChunk({ chunk });
this.maybeAckChunk({
streamId: chunk.streamId,
endOffset: chunk.endOffset,
});
return;
}
let delivered = false;
for (const handler of streamHandlers) {
try {
handler(chunk);
delivered = true;
} catch {
// no-op
}
}
if (delivered) {
this.maybeAckChunk({
streamId: chunk.streamId,
endOffset: chunk.endOffset,
});
}
}
noteAck(input: TerminalStreamAck): void {
const normalizedOffset = Math.max(0, Math.floor(input.offset));
const previousAck = this.ackOffsets.get(input.streamId) ?? -1;
if (normalizedOffset > previousAck) {
this.ackOffsets.set(input.streamId, normalizedOffset);
}
}
private flushBufferedChunks(input: { streamId: number; handler: TerminalChunkHandler }): void {
const buffered = this.bufferedChunks.get(input.streamId);
if (!buffered || buffered.chunks.length === 0) {
return;
}
for (const chunk of buffered.chunks) {
try {
input.handler(chunk);
this.maybeAckChunk({
streamId: input.streamId,
endOffset: chunk.endOffset,
});
} catch {
// no-op
}
}
this.bufferedChunks.delete(input.streamId);
}
private bufferChunk(input: { chunk: TerminalStreamChunk }): void {
const queue = this.bufferedChunks.get(input.chunk.streamId) ?? {
chunks: [],
bytes: 0,
};
queue.chunks.push(input.chunk);
queue.bytes += input.chunk.data.byteLength;
while (queue.chunks.length > this.maxBufferedChunks || queue.bytes > this.maxBufferedBytes) {
const removed = queue.chunks.shift();
if (!removed) {
break;
}
queue.bytes -= removed.data.byteLength;
if (queue.bytes < 0) {
queue.bytes = 0;
}
}
this.bufferedChunks.set(input.chunk.streamId, queue);
}
private maybeAckChunk(input: { streamId: number; endOffset: number }): void {
if (!Number.isFinite(input.endOffset) || input.endOffset < 0) {
return;
}
const normalizedEndOffset = Math.floor(input.endOffset);
const previousAck = this.ackOffsets.get(input.streamId) ?? -1;
if (normalizedEndOffset <= previousAck) {
return;
}
this.ackOffsets.set(input.streamId, normalizedEndOffset);
try {
this.config.sendAck({
streamId: input.streamId,
offset: normalizedEndOffset,
});
} catch {
// no-op
}
}
}
+141 -170
View File
@@ -1,12 +1,14 @@
import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest";
import { DaemonClient, type DaemonTransport } from "./daemon-client";
import {
BinaryMuxChannel,
TerminalBinaryMessageType,
asUint8Array,
decodeBinaryMuxFrame,
encodeBinaryMuxFrame,
} from "../shared/binary-mux.js";
decodeTerminalResizePayload,
decodeTerminalSnapshotPayload,
decodeTerminalStreamFrame,
encodeTerminalSnapshotPayload,
encodeTerminalStreamFrame,
TerminalStreamOpcode,
} from "../shared/terminal-stream-protocol.js";
expectTypeOf<"getGitDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf<false>();
expectTypeOf<
@@ -1224,7 +1226,7 @@ describe("DaemonClient", () => {
});
});
test("auto-acks terminal stream chunks after delivery", async () => {
test("emits output events for the active terminal stream", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -1242,36 +1244,40 @@ describe("DaemonClient", () => {
await connectPromise;
const seen: string[] = [];
const decoder = new TextDecoder();
const unsubscribe = client.onTerminalStreamData(7, (chunk) => {
seen.push(decoder.decode(chunk.data));
const unsubscribe = client.onTerminalStreamEvent((event) => {
if (event.type !== "output") {
return;
}
seen.push(new TextDecoder().decode(event.data));
});
const payload = new TextEncoder().encode("hello");
const subscribePromise = client.subscribeTerminal("term-1", "sub-1");
mock.triggerMessage(
encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 7,
offset: 10,
payload,
wrapSessionMessage({
type: "subscribe_terminal_response",
payload: {
terminalId: "term-1",
state: null,
error: null,
requestId: "sub-1",
},
}),
);
await subscribePromise;
mock.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
payload: new TextEncoder().encode("hello"),
}),
);
expect(seen).toEqual(["hello"]);
expect(mock.sent).toHaveLength(1);
const ackBytes = asUint8Array(mock.sent[0]);
expect(ackBytes).not.toBeNull();
const ackFrame = decodeBinaryMuxFrame(ackBytes!);
expect(ackFrame).not.toBeNull();
expect(ackFrame!.channel).toBe(BinaryMuxChannel.Terminal);
expect(ackFrame!.messageType).toBe(TerminalBinaryMessageType.Ack);
expect(ackFrame!.streamId).toBe(7);
expect(ackFrame!.offset).toBe(15);
unsubscribe();
});
test("handles terminal binary frames delivered as UTF-8 strings", async () => {
test("emits snapshot events for the active terminal stream", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -1288,76 +1294,100 @@ describe("DaemonClient", () => {
mock.triggerOpen();
await connectPromise;
const seen: string[] = [];
client.onTerminalStreamData(11, (chunk) => {
seen.push(new TextDecoder().decode(chunk.data));
const snapshots: unknown[] = [];
client.onTerminalStreamEvent((event) => {
if (event.type !== "snapshot") {
return;
}
snapshots.push(event.state);
});
const payload = new TextEncoder().encode("ls\r\n");
const frame = encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 11,
offset: 0,
payload,
});
const frameAsString = new TextDecoder("utf-8", { fatal: true }).decode(frame);
mock.triggerMessage(frameAsString);
expect(seen).toEqual(["ls\r\n"]);
expect(mock.sent).toHaveLength(1);
const ackFrame = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!);
expect(ackFrame?.channel).toBe(BinaryMuxChannel.Terminal);
expect(ackFrame?.messageType).toBe(TerminalBinaryMessageType.Ack);
expect(ackFrame?.streamId).toBe(11);
expect(ackFrame?.offset).toBe(4);
});
test("acks buffered terminal chunks when handler is attached", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const subscribePromise = client.subscribeTerminal("term-1", "sub-2");
mock.triggerMessage(
encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 19,
offset: 0,
payload: new TextEncoder().encode("buffered"),
wrapSessionMessage({
type: "subscribe_terminal_response",
payload: {
terminalId: "term-1",
state: null,
error: null,
requestId: "sub-2",
},
}),
);
await subscribePromise;
const state = {
rows: 1,
cols: 5,
grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]],
scrollback: [],
cursor: { row: 0, col: 5 },
};
mock.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Snapshot,
payload: encodeTerminalSnapshotPayload(state),
}),
);
expect(mock.sent).toHaveLength(1);
const bufferedAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!);
expect(bufferedAck?.messageType).toBe(TerminalBinaryMessageType.Ack);
expect(bufferedAck?.streamId).toBe(19);
expect(bufferedAck?.offset).toBe(8);
mock.sent.length = 0;
const seen: string[] = [];
client.onTerminalStreamData(19, (chunk) => {
seen.push(new TextDecoder().decode(chunk.data));
});
expect(seen.join("")).toContain("buffered");
expect(mock.sent).toHaveLength(0);
expect(snapshots).toEqual([state]);
});
test("stops delivering and acking after terminal_stream_exit", async () => {
test("sends input and resize frames for the active terminal", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const subscribePromise = client.subscribeTerminal("term-1", "sub-3");
mock.triggerMessage(
wrapSessionMessage({
type: "subscribe_terminal_response",
payload: {
terminalId: "term-1",
state: null,
error: null,
requestId: "sub-3",
},
}),
);
await subscribePromise;
mock.sent.length = 0;
client.sendTerminalInput("term-1", {
type: "input",
data: "echo hello\r",
});
client.sendTerminalInput("term-1", {
type: "resize",
rows: 24,
cols: 80,
});
const inputFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[0])!);
const resizeFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[1])!);
expect(inputFrame?.opcode).toBe(TerminalStreamOpcode.Input);
expect(new TextDecoder().decode(inputFrame?.payload ?? new Uint8Array())).toBe("echo hello\r");
expect(resizeFrame?.opcode).toBe(TerminalStreamOpcode.Resize);
expect(decodeTerminalResizePayload(resizeFrame?.payload ?? new Uint8Array())).toEqual({
rows: 24,
cols: 80,
});
});
test("ignores terminal stream frames after terminal_stream_exit", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -1375,114 +1405,55 @@ describe("DaemonClient", () => {
await connectPromise;
const seen: string[] = [];
const unsubscribe = client.onTerminalStreamData(23, (chunk) => {
seen.push(new TextDecoder().decode(chunk.data));
const unsubscribe = client.onTerminalStreamEvent((event) => {
if (event.type !== "output") {
return;
}
seen.push(new TextDecoder().decode(event.data));
});
const subscribePromise = client.subscribeTerminal("term-1", "sub-4");
mock.triggerMessage(
encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 23,
offset: 0,
wrapSessionMessage({
type: "subscribe_terminal_response",
payload: {
terminalId: "term-1",
state: null,
error: null,
requestId: "sub-4",
},
}),
);
await subscribePromise;
mock.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
payload: new TextEncoder().encode("before-exit"),
}),
);
expect(seen).toEqual(["before-exit"]);
expect(mock.sent).toHaveLength(1);
mock.sent.length = 0;
mock.triggerMessage(
wrapSessionMessage({
type: "terminal_stream_exit",
payload: {
streamId: 23,
terminalId: "term-1",
},
}),
);
mock.triggerMessage(
encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 23,
offset: 12,
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
payload: new TextEncoder().encode("after-exit"),
}),
);
expect(seen).toEqual(["before-exit"]);
expect(mock.sent).toHaveLength(1);
const postExitAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!);
expect(postExitAck?.messageType).toBe(TerminalBinaryMessageType.Ack);
expect(postExitAck?.streamId).toBe(23);
expect(postExitAck?.offset).toBe(22);
unsubscribe();
});
test("cleans local stream state even when detach reports success=false", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const seen: string[] = [];
client.onTerminalStreamData(31, (chunk) => {
seen.push(new TextDecoder().decode(chunk.data));
});
const detachPromise = client.detachTerminalStream(31, "detach-31");
const detachRequest = JSON.parse(String(mock.sent[0])) as {
type: "session";
message: { type: string; streamId: number; requestId: string };
};
expect(detachRequest.message.type).toBe("detach_terminal_stream_request");
expect(detachRequest.message.streamId).toBe(31);
mock.triggerMessage(
wrapSessionMessage({
type: "detach_terminal_stream_response",
payload: {
streamId: 31,
success: false,
requestId: "detach-31",
},
}),
);
const payload = await detachPromise;
expect(payload.success).toBe(false);
mock.sent.length = 0;
mock.triggerMessage(
encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 31,
offset: 0,
payload: new TextEncoder().encode("after-detach"),
}),
);
expect(seen).toEqual([]);
expect(mock.sent).toHaveLength(1);
const detachedAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!);
expect(detachedAck?.messageType).toBe(TerminalBinaryMessageType.Ack);
expect(detachedAck?.streamId).toBe(31);
expect(detachedAck?.offset).toBe(12);
});
test("parses canonical agent_stream tool_call payloads without crashing", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
+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,
File diff suppressed because it is too large Load Diff
+198 -392
View File
@@ -23,8 +23,6 @@ import {
type UnsubscribeTerminalRequest,
type TerminalInput,
type KillTerminalRequest,
type AttachTerminalStreamRequest,
type DetachTerminalStreamRequest,
type SubscribeCheckoutDiffRequest,
type UnsubscribeCheckoutDiffRequest,
type DirectorySuggestionsRequest,
@@ -35,11 +33,12 @@ import {
import type { TerminalManager, TerminalsChangedEvent } from "../terminal/terminal-manager.js";
import type { TerminalSession } from "../terminal/terminal.js";
import {
BinaryMuxChannel,
TerminalBinaryFlags,
TerminalBinaryMessageType,
type BinaryMuxFrame,
} from "../shared/binary-mux.js";
TerminalStreamOpcode,
encodeTerminalSnapshotPayload,
encodeTerminalStreamFrame,
decodeTerminalResizePayload,
type TerminalStreamFrame,
} from "../shared/terminal-stream-protocol.js";
import { TTSManager } from "./agent/tts-manager.js";
import { STTManager } from "./agent/stt-manager.js";
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
@@ -191,9 +190,8 @@ const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150;
const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000;
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__";
const TERMINAL_STREAM_WINDOW_BYTES = 256 * 1024;
const TERMINAL_STREAM_MAX_PENDING_BYTES = 2 * 1024 * 1024;
const TERMINAL_STREAM_MAX_PENDING_CHUNKS = 2048;
const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024;
const TERMINAL_STREAM_LOW_WATER_BYTES = 16 * 1024;
function deriveInitialAgentTitle(prompt: string): string | null {
const firstContentLine = prompt
@@ -277,11 +275,12 @@ type CheckoutErrorPayload = {
message: string;
};
type TerminalStreamPendingChunk = {
data: string;
startOffset: number;
endOffset: number;
replay: boolean;
type ActiveTerminalStream = {
terminalId: string;
unsubscribe: () => void;
needsSnapshot: boolean;
primed: boolean;
snapshotTimer: ReturnType<typeof setTimeout> | null;
};
export type SessionRuntimeMetrics = {
@@ -395,7 +394,8 @@ type VoiceTranscriptionResultPayload = {
export type SessionOptions = {
clientId: string;
onMessage: (msg: SessionOutboundMessage) => void;
onBinaryMessage?: (frame: BinaryMuxFrame) => void;
onBinaryMessage?: (frame: Uint8Array) => void;
getBinaryBufferedAmount?: () => number;
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void;
logger: pino.Logger;
downloadTokenStore: DownloadTokenStore;
@@ -546,7 +546,8 @@ export class Session {
private readonly clientId: string;
private readonly sessionId: string;
private readonly onMessage: (msg: SessionOutboundMessage) => void;
private readonly onBinaryMessage: ((frame: BinaryMuxFrame) => void) | null;
private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null;
private readonly getBinaryBufferedAmount: (() => number) | null;
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
private readonly sessionLogger: pino.Logger;
private readonly paseoHome: string;
@@ -605,21 +606,8 @@ export class Session {
private readonly terminalManager: TerminalManager | null;
private readonly subscribedTerminalDirectories = new Set<string>();
private unsubscribeTerminalsChanged: (() => void) | null = null;
private terminalSubscriptions: Map<string, () => void> = new Map();
private terminalExitSubscriptions: Map<string, () => void> = new Map();
private readonly terminalStreams = new Map<
number,
{
terminalId: string;
unsubscribe: () => void;
lastOutputOffset: number;
lastAckOffset: number;
pendingChunks: TerminalStreamPendingChunk[];
pendingBytes: number;
}
>();
private readonly terminalStreamByTerminalId = new Map<string, number>();
private nextTerminalStreamId = 1;
private activeTerminalStream: ActiveTerminalStream | null = null;
private readonly checkoutDiffSubscriptions = new Map<string, { targetKey: string }>();
private readonly checkoutDiffTargets = new Map<string, CheckoutDiffWatchTarget>();
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
@@ -648,6 +636,7 @@ export class Session {
clientId,
onMessage,
onBinaryMessage,
getBinaryBufferedAmount,
onLifecycleIntent,
logger,
downloadTokenStore,
@@ -670,6 +659,7 @@ export class Session {
this.sessionId = uuidv4();
this.onMessage = onMessage;
this.onBinaryMessage = onBinaryMessage ?? null;
this.getBinaryBufferedAmount = getBinaryBufferedAmount ?? null;
this.onLifecycleIntent = onLifecycleIntent ?? null;
this.downloadTokenStore = downloadTokenStore;
this.pushTokenStore = pushTokenStore;
@@ -761,8 +751,8 @@ export class Session {
checkoutDiffWatcherCount,
checkoutDiffFallbackRefreshTargetCount,
terminalDirectorySubscriptionCount: this.subscribedTerminalDirectories.size,
terminalSubscriptionCount: this.terminalSubscriptions.size,
terminalStreamCount: this.terminalStreams.size,
terminalSubscriptionCount: this.activeTerminalStream ? 1 : 0,
terminalStreamCount: this.activeTerminalStream ? 1 : 0,
};
}
@@ -1764,13 +1754,6 @@ export class Session {
await this.handleKillTerminalRequest(msg);
break;
case "attach_terminal_stream_request":
await this.handleAttachTerminalStreamRequest(msg);
break;
case "detach_terminal_stream_request":
this.handleDetachTerminalStreamRequest(msg);
break;
}
} catch (error: any) {
const err = error instanceof Error ? error : new Error(String(error));
@@ -1805,73 +1788,43 @@ export class Session {
}
}
public handleBinaryFrame(frame: BinaryMuxFrame): void {
switch (frame.channel) {
case BinaryMuxChannel.Terminal:
this.handleTerminalBinaryFrame(frame);
break;
default:
this.sessionLogger.warn(
{ channel: frame.channel, messageType: frame.messageType },
"Unhandled binary mux channel",
);
break;
public handleBinaryFrame(frame: TerminalStreamFrame): void {
const activeStream = this.activeTerminalStream;
if (!activeStream || !this.terminalManager) {
return;
}
}
private handleTerminalBinaryFrame(frame: BinaryMuxFrame): void {
if (frame.messageType === TerminalBinaryMessageType.InputUtf8) {
const binding = this.terminalStreams.get(frame.streamId);
if (!binding) {
this.sessionLogger.warn(
{ streamId: frame.streamId },
"Terminal stream not found for input",
);
return;
}
if (!this.terminalManager) {
return;
}
const session = this.terminalManager.getTerminal(binding.terminalId);
if (!session) {
this.detachTerminalStream(frame.streamId, { emitExit: true });
return;
}
const payload = frame.payload ?? new Uint8Array(0);
if (payload.byteLength === 0) {
return;
}
const text = Buffer.from(payload).toString("utf8");
if (!text) {
return;
}
session.send({ type: "input", data: text });
const terminal = this.terminalManager.getTerminal(activeStream.terminalId);
if (!terminal) {
this.detachActiveTerminalStream({ emitExit: true });
return;
}
if (frame.messageType === TerminalBinaryMessageType.Ack) {
const binding = this.terminalStreams.get(frame.streamId);
if (binding) {
if (!Number.isFinite(frame.offset) || frame.offset < 0) {
switch (frame.opcode) {
case TerminalStreamOpcode.Input: {
if (frame.payload.byteLength === 0) {
return;
}
const nextAckOffset = Math.max(
binding.lastAckOffset,
Math.min(Math.floor(frame.offset), binding.lastOutputOffset),
);
if (nextAckOffset > binding.lastAckOffset) {
binding.lastAckOffset = nextAckOffset;
this.flushPendingTerminalStreamChunks(frame.streamId, binding);
const text = Buffer.from(frame.payload).toString("utf8");
if (!text) {
return;
}
terminal.send({ type: "input", data: text });
return;
}
return;
}
this.sessionLogger.warn(
{ streamId: frame.streamId, messageType: frame.messageType },
"Unhandled terminal binary frame",
);
case TerminalStreamOpcode.Resize: {
const resize = decodeTerminalResizePayload(frame.payload);
if (!resize) {
return;
}
terminal.send({ type: "resize", rows: resize.rows, cols: resize.cols });
this.queueTerminalSnapshot(activeStream, terminal);
return;
}
default:
return;
}
}
private async handleRestartServerRequest(requestId: string, reason?: string): Promise<void> {
@@ -2032,7 +1985,21 @@ export class Session {
attentionTimestamp: null,
};
await this.agentStorage.upsert(nextRecord);
this.agentManager.notifyAgentState(agentId);
// Unload the agent from memory — the storage record is the source of truth now.
// This tears down the provider session and drops the hydrated timeline,
// freeing memory. ensureAgentLoaded will re-initialize if needed later.
if (this.agentManager.getAgent(agentId)) {
try {
await this.agentManager.closeAgent(agentId);
} catch (error) {
this.sessionLogger.warn(
{ err: error, agentId },
"Failed to close agent during archive",
);
}
}
return { archivedAt, archivedRecord: nextRecord };
}
@@ -7447,7 +7414,7 @@ export class Session {
this.onMessage(msg);
}
private emitBinary(frame: BinaryMuxFrame): void {
private emitBinary(frame: Uint8Array): void {
if (!this.onBinaryMessage) {
return;
}
@@ -7506,15 +7473,11 @@ export class Session {
}
this.subscribedTerminalDirectories.clear();
for (const unsubscribe of this.terminalSubscriptions.values()) {
unsubscribe();
}
this.terminalSubscriptions.clear();
for (const unsubscribeExit of this.terminalExitSubscriptions.values()) {
unsubscribeExit();
}
this.terminalExitSubscriptions.clear();
this.detachAllTerminalStreams({ emitExit: false });
this.detachActiveTerminalStream({ emitExit: false });
for (const target of this.checkoutDiffTargets.values()) {
this.closeCheckoutDiffWatchTarget(target);
@@ -7550,22 +7513,8 @@ export class Session {
this.terminalExitSubscriptions.delete(terminalId);
}
const unsubscribe = this.terminalSubscriptions.get(terminalId);
if (unsubscribe) {
try {
unsubscribe();
} catch (error) {
this.sessionLogger.warn(
{ err: error, terminalId },
"Failed to unsubscribe terminal after process exit",
);
}
this.terminalSubscriptions.delete(terminalId);
}
const streamId = this.terminalStreamByTerminalId.get(terminalId);
if (typeof streamId === "number") {
this.detachTerminalStream(streamId, { emitExit: true });
if (this.activeTerminalStream?.terminalId === terminalId) {
this.detachActiveTerminalStream({ emitExit: true });
}
}
@@ -7740,25 +7689,7 @@ export class Session {
}
this.ensureTerminalExitSubscription(session);
// Unsubscribe from previous subscription if any
const existing = this.terminalSubscriptions.get(msg.terminalId);
if (existing) {
existing();
}
// Subscribe to terminal updates
const unsubscribe = session.subscribe((serverMsg) => {
if (serverMsg.type === "full") {
this.emit({
type: "terminal_output",
payload: {
terminalId: msg.terminalId,
state: serverMsg.state,
},
});
}
});
this.terminalSubscriptions.set(msg.terminalId, unsubscribe);
const activeStream = this.bindActiveTerminalStream(session);
// Send initial state
this.emit({
@@ -7770,13 +7701,16 @@ export class Session {
requestId: msg.requestId,
},
});
if (activeStream) {
this.sendTerminalSnapshot(activeStream, session);
activeStream.primed = true;
}
}
private handleUnsubscribeTerminalRequest(msg: UnsubscribeTerminalRequest): void {
const unsubscribe = this.terminalSubscriptions.get(msg.terminalId);
if (unsubscribe) {
unsubscribe();
this.terminalSubscriptions.delete(msg.terminalId);
if (this.activeTerminalStream?.terminalId === msg.terminalId) {
this.detachActiveTerminalStream({ emitExit: false });
}
}
@@ -7796,15 +7730,8 @@ export class Session {
}
private killTrackedTerminal(terminalId: string, options?: { emitExit: boolean }): void {
const unsubscribe = this.terminalSubscriptions.get(terminalId);
if (unsubscribe) {
unsubscribe();
this.terminalSubscriptions.delete(terminalId);
}
const streamId = this.terminalStreamByTerminalId.get(terminalId);
if (typeof streamId === "number") {
this.detachTerminalStream(streamId, { emitExit: options?.emitExit ?? true });
if (this.activeTerminalStream?.terminalId === terminalId) {
this.detachActiveTerminalStream({ emitExit: options?.emitExit ?? true });
}
this.terminalManager?.killTerminal(terminalId);
@@ -7867,277 +7794,156 @@ export class Session {
});
}
private async handleAttachTerminalStreamRequest(msg: AttachTerminalStreamRequest): Promise<void> {
if (!this.terminalManager || !this.onBinaryMessage) {
this.emit({
type: "attach_terminal_stream_response",
payload: {
terminalId: msg.terminalId,
streamId: null,
replayedFrom: 0,
currentOffset: 0,
earliestAvailableOffset: 0,
reset: true,
error: "Terminal streaming not available",
requestId: msg.requestId,
},
});
return;
private bindActiveTerminalStream(terminal: TerminalSession): ActiveTerminalStream | null {
this.detachActiveTerminalStream({ emitExit: false });
if (!this.onBinaryMessage) {
return null;
}
const session = this.terminalManager.getTerminal(msg.terminalId);
if (!session) {
this.emit({
type: "attach_terminal_stream_response",
payload: {
terminalId: msg.terminalId,
streamId: null,
replayedFrom: 0,
currentOffset: 0,
earliestAvailableOffset: 0,
reset: true,
error: "Terminal not found",
requestId: msg.requestId,
},
});
return;
}
if (msg.rows || msg.cols) {
const state = session.getState();
session.send({
type: "resize",
rows: msg.rows ?? state.rows,
cols: msg.cols ?? state.cols,
});
}
const existingStreamId = this.terminalStreamByTerminalId.get(msg.terminalId);
if (typeof existingStreamId === "number") {
// Replacing an active stream can happen when multiple UI surfaces attach to the
// same terminal. Emit exit for the replaced stream so stale listeners reconnect
// instead of continuing to send input to an invalid stream id.
this.detachTerminalStream(existingStreamId, { emitExit: true });
}
const streamId = this.allocateTerminalStreamId();
const requestedResumeOffset = typeof msg.resumeOffset === "number" ? msg.resumeOffset : 0;
const initialOffset = Math.max(0, Math.floor(requestedResumeOffset));
const binding: {
terminalId: string;
unsubscribe: () => void;
lastOutputOffset: number;
lastAckOffset: number;
pendingChunks: TerminalStreamPendingChunk[];
pendingBytes: number;
} = {
terminalId: msg.terminalId,
const activeStream: ActiveTerminalStream = {
terminalId: terminal.id,
unsubscribe: () => {},
lastOutputOffset: initialOffset,
lastAckOffset: initialOffset,
pendingChunks: [],
pendingBytes: 0,
needsSnapshot: false,
primed: false,
snapshotTimer: null,
};
this.terminalStreams.set(streamId, binding);
this.terminalStreamByTerminalId.set(msg.terminalId, streamId);
let rawSub;
try {
rawSub = session.subscribeRaw(
(chunk) => {
const currentBinding = this.terminalStreams.get(streamId);
if (!currentBinding) {
return;
}
this.enqueueOrEmitTerminalStreamChunk(streamId, currentBinding, {
data: chunk.data,
startOffset: chunk.startOffset,
endOffset: chunk.endOffset,
replay: chunk.replay,
});
},
{ fromOffset: requestedResumeOffset },
);
} catch (error) {
this.terminalStreams.delete(streamId);
this.terminalStreamByTerminalId.delete(msg.terminalId);
throw error;
}
binding.unsubscribe = rawSub.unsubscribe;
binding.lastAckOffset = rawSub.replayedFrom;
if (binding.lastOutputOffset < rawSub.replayedFrom) {
binding.lastOutputOffset = rawSub.replayedFrom;
}
this.flushPendingTerminalStreamChunks(streamId, binding);
this.emit({
type: "attach_terminal_stream_response",
payload: {
terminalId: msg.terminalId,
streamId,
replayedFrom: rawSub.replayedFrom,
currentOffset: rawSub.currentOffset,
earliestAvailableOffset: rawSub.earliestAvailableOffset,
reset: rawSub.reset,
error: null,
requestId: msg.requestId,
},
});
}
private getTerminalStreamChunkByteLength(chunk: TerminalStreamPendingChunk): number {
return Math.max(0, chunk.endOffset - chunk.startOffset);
}
private canEmitTerminalStreamChunk(
binding: {
lastAckOffset: number;
},
chunk: TerminalStreamPendingChunk,
): boolean {
return chunk.startOffset < binding.lastAckOffset + TERMINAL_STREAM_WINDOW_BYTES;
}
private emitTerminalStreamChunk(
streamId: number,
binding: {
lastOutputOffset: number;
},
chunk: TerminalStreamPendingChunk,
): void {
const payload = new Uint8Array(Buffer.from(chunk.data, "utf8"));
this.emitBinary({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId,
offset: chunk.startOffset,
flags: chunk.replay ? TerminalBinaryFlags.Replay : 0,
payload,
});
binding.lastOutputOffset = chunk.endOffset;
}
private enqueueOrEmitTerminalStreamChunk(
streamId: number,
binding: {
lastAckOffset: number;
lastOutputOffset: number;
pendingChunks: TerminalStreamPendingChunk[];
pendingBytes: number;
},
chunk: TerminalStreamPendingChunk,
): void {
const chunkBytes = this.getTerminalStreamChunkByteLength(chunk);
if (binding.pendingChunks.length > 0 || !this.canEmitTerminalStreamChunk(binding, chunk)) {
if (
binding.pendingChunks.length >= TERMINAL_STREAM_MAX_PENDING_CHUNKS ||
binding.pendingBytes + chunkBytes > TERMINAL_STREAM_MAX_PENDING_BYTES
) {
this.sessionLogger.warn(
{
streamId,
pendingChunks: binding.pendingChunks.length,
pendingBytes: binding.pendingBytes,
chunkBytes,
},
"Terminal stream pending buffer overflow; closing stream",
);
this.detachTerminalStream(streamId, { emitExit: true });
activeStream.unsubscribe = terminal.subscribe((message) => {
if (this.activeTerminalStream !== activeStream) {
return;
}
binding.pendingChunks.push(chunk);
binding.pendingBytes += chunkBytes;
if (message.type === "output") {
this.handleActiveTerminalOutput(activeStream, terminal, message.data);
return;
}
if (message.type === "snapshot") {
this.maybeSendQueuedTerminalSnapshot(activeStream, terminal);
}
});
this.activeTerminalStream = activeStream;
return activeStream;
}
private handleActiveTerminalOutput(
activeStream: ActiveTerminalStream,
terminal: TerminalSession,
data: string,
): void {
if (!activeStream.primed || data.length === 0) {
return;
}
this.emitTerminalStreamChunk(streamId, binding, chunk);
if (activeStream.needsSnapshot) {
this.maybeSendQueuedTerminalSnapshot(activeStream, terminal);
return;
}
if (this.getCurrentBinaryBufferedAmount() > TERMINAL_STREAM_HIGH_WATER_BYTES) {
activeStream.needsSnapshot = true;
this.scheduleTerminalSnapshotRetry(activeStream, terminal);
return;
}
this.emitBinary(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
payload: new Uint8Array(Buffer.from(data, "utf8")),
}),
);
if (this.getCurrentBinaryBufferedAmount() > TERMINAL_STREAM_HIGH_WATER_BYTES) {
activeStream.needsSnapshot = true;
this.scheduleTerminalSnapshotRetry(activeStream, terminal);
}
}
private flushPendingTerminalStreamChunks(
streamId: number,
binding: {
lastAckOffset: number;
lastOutputOffset: number;
pendingChunks: TerminalStreamPendingChunk[];
pendingBytes: number;
},
private queueTerminalSnapshot(
activeStream: ActiveTerminalStream,
terminal: TerminalSession,
): void {
while (binding.pendingChunks.length > 0) {
const next = binding.pendingChunks[0];
if (!next || !this.canEmitTerminalStreamChunk(binding, next)) {
break;
}
binding.pendingChunks.shift();
binding.pendingBytes -= this.getTerminalStreamChunkByteLength(next);
if (binding.pendingBytes < 0) {
binding.pendingBytes = 0;
}
this.emitTerminalStreamChunk(streamId, binding, next);
if (this.getCurrentBinaryBufferedAmount() > TERMINAL_STREAM_HIGH_WATER_BYTES) {
activeStream.needsSnapshot = true;
this.scheduleTerminalSnapshotRetry(activeStream, terminal);
return;
}
this.sendTerminalSnapshot(activeStream, terminal);
}
private handleDetachTerminalStreamRequest(msg: DetachTerminalStreamRequest): void {
const success = this.detachTerminalStream(msg.streamId, { emitExit: false });
this.emit({
type: "detach_terminal_stream_response",
payload: {
streamId: msg.streamId,
success,
requestId: msg.requestId,
},
});
}
private detachAllTerminalStreams(options?: { emitExit: boolean }): void {
for (const streamId of Array.from(this.terminalStreams.keys())) {
this.detachTerminalStream(streamId, options);
private maybeSendQueuedTerminalSnapshot(
activeStream: ActiveTerminalStream,
terminal: TerminalSession,
): void {
if (!activeStream.needsSnapshot) {
return;
}
if (this.getCurrentBinaryBufferedAmount() >= TERMINAL_STREAM_LOW_WATER_BYTES) {
this.scheduleTerminalSnapshotRetry(activeStream, terminal);
return;
}
this.sendTerminalSnapshot(activeStream, terminal);
}
private detachTerminalStream(streamId: number, options?: { emitExit: boolean }): boolean {
const binding = this.terminalStreams.get(streamId);
if (!binding) {
private sendTerminalSnapshot(
activeStream: ActiveTerminalStream,
terminal: TerminalSession,
): void {
this.clearTerminalSnapshotRetry(activeStream);
activeStream.needsSnapshot = false;
this.emitBinary(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Snapshot,
payload: encodeTerminalSnapshotPayload(terminal.getState()),
}),
);
}
private scheduleTerminalSnapshotRetry(
activeStream: ActiveTerminalStream,
terminal: TerminalSession,
): void {
if (activeStream.snapshotTimer) {
return;
}
activeStream.snapshotTimer = setTimeout(() => {
activeStream.snapshotTimer = null;
if (this.activeTerminalStream !== activeStream || !activeStream.needsSnapshot) {
return;
}
this.maybeSendQueuedTerminalSnapshot(activeStream, terminal);
}, 33);
}
private clearTerminalSnapshotRetry(activeStream: ActiveTerminalStream): void {
if (!activeStream.snapshotTimer) {
return;
}
clearTimeout(activeStream.snapshotTimer);
activeStream.snapshotTimer = null;
}
private detachActiveTerminalStream(options?: { emitExit: boolean }): boolean {
const activeStream = this.activeTerminalStream;
if (!activeStream) {
return false;
}
this.activeTerminalStream = null;
this.clearTerminalSnapshotRetry(activeStream);
try {
binding.unsubscribe();
activeStream.unsubscribe();
} catch (error) {
this.sessionLogger.warn({ err: error, streamId }, "Failed to unsubscribe terminal stream");
this.sessionLogger.warn({ err: error }, "Failed to unsubscribe terminal stream");
}
this.terminalStreams.delete(streamId);
if (this.terminalStreamByTerminalId.get(binding.terminalId) === streamId) {
this.terminalStreamByTerminalId.delete(binding.terminalId);
}
if (options?.emitExit) {
this.emit({
type: "terminal_stream_exit",
payload: {
streamId,
terminalId: binding.terminalId,
terminalId: activeStream.terminalId,
},
});
}
return true;
}
private allocateTerminalStreamId(): number {
let attempts = 0;
while (attempts < 0xffffffff) {
const candidate = this.nextTerminalStreamId >>> 0;
this.nextTerminalStreamId = ((this.nextTerminalStreamId + 1) & 0xffffffff) >>> 0;
if (candidate === 0) {
attempts += 1;
continue;
}
if (!this.terminalStreams.has(candidate)) {
return candidate;
}
attempts += 1;
private getCurrentBinaryBufferedAmount(): number {
const bufferedAmount = this.getBinaryBufferedAmount?.() ?? 0;
if (!Number.isFinite(bufferedAmount) || bufferedAmount < 0) {
return 0;
}
throw new Error("Unable to allocate terminal stream id");
return Math.floor(bufferedAmount);
}
}
@@ -1,11 +1,10 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
BinaryMuxChannel,
TerminalBinaryMessageType,
asUint8Array,
decodeBinaryMuxFrame,
encodeBinaryMuxFrame,
} from "../shared/binary-mux.js";
decodeTerminalStreamFrame,
encodeTerminalStreamFrame,
TerminalStreamOpcode,
} from "../shared/terminal-stream-protocol.js";
const wsModuleMock = vi.hoisted(() => {
class MockWebSocketServer {
@@ -89,6 +88,7 @@ const TEST_DAEMON_VERSION = "1.2.3-test";
class MockSocket {
readyState = 1;
bufferedAmount = 0;
sent: unknown[] = [];
private listeners = new Map<string, Array<(...args: any[]) => void>>();
@@ -623,7 +623,7 @@ describe("relay external socket reconnect behavior", () => {
await server.close();
});
test("routes inbound binary mux frames to session.handleBinaryFrame", async () => {
test("routes inbound terminal frames to session.handleBinaryFrame", async () => {
const server = createServer();
const socket = new MockSocket();
@@ -638,11 +638,8 @@ describe("relay external socket reconnect behavior", () => {
socket.emit(
"message",
Buffer.from(
encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.InputUtf8,
streamId: 9,
offset: 0,
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Input,
payload: new TextEncoder().encode("ls\r"),
}),
),
@@ -651,20 +648,16 @@ describe("relay external socket reconnect behavior", () => {
expect(session.handleBinaryFrame).toHaveBeenCalledTimes(1);
const frame = session.handleBinaryFrame.mock.calls[0]?.[0] as {
channel: number;
messageType: number;
streamId: number;
offset: number;
opcode: number;
payload: Uint8Array;
};
expect(frame.channel).toBe(BinaryMuxChannel.Terminal);
expect(frame.messageType).toBe(TerminalBinaryMessageType.InputUtf8);
expect(frame.streamId).toBe(9);
expect(frame.offset).toBe(0);
expect(frame.opcode).toBe(TerminalStreamOpcode.Input);
expect(new TextDecoder().decode(frame.payload)).toBe("ls\r");
await server.close();
});
test("sends outbound binary mux frames from session over websocket", async () => {
test("sends outbound terminal frames from session over websocket", async () => {
const server = createServer();
const socket = new MockSocket();
@@ -677,33 +670,18 @@ describe("relay external socket reconnect behavior", () => {
const session = sessionMock.instances[0]!;
const onBinaryMessage = session.args.onBinaryMessage as
| ((frame: {
channel: number;
messageType: number;
streamId: number;
offset: number;
payload?: Uint8Array;
}) => void)
| ((frame: Uint8Array) => void)
| undefined;
expect(onBinaryMessage).toBeTypeOf("function");
onBinaryMessage?.({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 11,
offset: 42,
payload: new TextEncoder().encode("ok"),
});
onBinaryMessage?.(new Uint8Array([TerminalStreamOpcode.Output, 0x6f, 0x6b]));
expect(socket.sent).toHaveLength(2);
const binaryPayload = asUint8Array(socket.sent[1]);
expect(binaryPayload).not.toBeNull();
const frame = decodeBinaryMuxFrame(binaryPayload!);
const frame = decodeTerminalStreamFrame(binaryPayload!);
expect(frame).not.toBeNull();
expect(frame!.channel).toBe(BinaryMuxChannel.Terminal);
expect(frame!.messageType).toBe(TerminalBinaryMessageType.OutputUtf8);
expect(frame!.streamId).toBe(11);
expect(frame!.offset).toBe(42);
expect(frame!.opcode).toBe(TerminalStreamOpcode.Output);
expect(new TextDecoder().decode(frame!.payload ?? new Uint8Array())).toBe("ok");
await server.close();
+19 -5
View File
@@ -18,7 +18,10 @@ import {
type WSOutboundMessage,
wrapSessionMessage,
} from "./messages.js";
import { asUint8Array, decodeBinaryMuxFrame, encodeBinaryMuxFrame } from "../shared/binary-mux.js";
import {
asUint8Array,
decodeTerminalStreamFrame,
} from "../shared/terminal-stream-protocol.js";
import type { AllowedHostsConfig } from "./allowed-hosts.js";
import { isHostAllowed } from "./allowed-hosts.js";
import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js";
@@ -161,6 +164,7 @@ function bufferFromWsData(data: Buffer | ArrayBuffer | Buffer[] | string): Buffe
type WebSocketLike = {
readyState: number;
bufferedAmount?: number;
send: (data: string | Uint8Array | ArrayBuffer) => void;
close: (code?: number, reason?: string) => void;
on: (event: "message" | "close" | "error", listener: (...args: any[]) => void) => void;
@@ -492,12 +496,12 @@ export class VoiceAssistantWebSocketServer {
private sendBinaryToClient(
ws: WebSocketLike,
frame: Parameters<typeof encodeBinaryMuxFrame>[0],
frame: Uint8Array,
): void {
if (ws.readyState !== 1) {
return;
}
ws.send(encodeBinaryMuxFrame(frame));
ws.send(frame);
}
private sendToConnection(connection: SessionConnection, message: WSOutboundMessage): void {
@@ -508,7 +512,7 @@ export class VoiceAssistantWebSocketServer {
private sendBinaryToConnection(
connection: SessionConnection,
frame: Parameters<typeof encodeBinaryMuxFrame>[0],
frame: Uint8Array,
): void {
for (const ws of connection.sockets) {
this.sendBinaryToClient(ws, frame);
@@ -595,6 +599,16 @@ export class VoiceAssistantWebSocketServer {
}
this.sendBinaryToConnection(connection, frame);
},
getBinaryBufferedAmount: () => {
if (!connection) {
return 0;
}
let bufferedAmount = 0;
for (const socket of connection.sockets) {
bufferedAmount = Math.max(bufferedAmount, socket.bufferedAmount ?? 0);
}
return bufferedAmount;
},
onLifecycleIntent: (intent) => {
this.onLifecycleIntent?.(intent);
},
@@ -900,7 +914,7 @@ export class VoiceAssistantWebSocketServer {
const buffer = bufferFromWsData(data);
const asBytes = asUint8Array(buffer);
if (asBytes) {
const frame = decodeBinaryMuxFrame(asBytes);
const frame = decodeTerminalStreamFrame(asBytes);
if (frame) {
if (!activeConnection) {
this.incrementRuntimeCounter("binaryBeforeHelloRejected");
@@ -297,27 +297,13 @@ describe("runAsyncWorktreeBootstrap", () => {
send: () => {
sendAt = Date.now();
},
subscribe: () => () => {},
onExit: () => () => {},
subscribeRaw: (listener) => {
outputListener = (chunk) =>
listener({
data: chunk.data,
startOffset: 0,
endOffset: chunk.data.length,
replay: false,
});
return {
unsubscribe: () => {
outputListener = null;
},
replayedFrom: 0,
currentOffset: 0,
earliestAvailableOffset: 0,
reset: false,
subscribe: (listener) => {
outputListener = (chunk) => listener({ type: "output", data: chunk.data });
return () => {
outputListener = null;
};
},
getOutputOffset: () => 0,
onExit: () => () => {},
getState: () => ({
rows: 0,
cols: 0,
@@ -398,18 +384,10 @@ describe("runAsyncWorktreeBootstrap", () => {
send: () => {},
subscribe: () => () => {},
onExit: () => () => {},
subscribeRaw: () => ({
unsubscribe: () => {},
replayedFrom: 0,
currentOffset: 0,
earliestAvailableOffset: 0,
reset: false,
}),
getOutputOffset: () => 1,
getState: () => ({
rows: 0,
cols: 0,
grid: [],
rows: 1,
cols: 1,
grid: [[{ char: "$" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
}),
@@ -383,9 +383,9 @@ function buildTerminalTimelineItem(input: {
}
async function waitForTerminalBootstrapReadiness(
terminal: Pick<TerminalSession, "getOutputOffset" | "subscribeRaw">,
terminal: Pick<TerminalSession, "getState" | "subscribe">,
): Promise<void> {
if (terminal.getOutputOffset() > 0) {
if (terminalHasOutput(terminal.getState())) {
return;
}
@@ -410,12 +410,14 @@ async function waitForTerminalBootstrapReadiness(
resolve();
};
const rawSubscription = terminal.subscribeRaw(() => {
unsubscribe = terminal.subscribe((message) => {
if (message.type !== "output") {
return;
}
finish();
});
unsubscribe = rawSubscription.unsubscribe;
if (terminal.getOutputOffset() > 0) {
if (terminalHasOutput(terminal.getState())) {
finish();
return;
}
@@ -424,6 +426,17 @@ async function waitForTerminalBootstrapReadiness(
});
}
function terminalHasOutput(state: ReturnType<TerminalSession["getState"]>): boolean {
for (const row of [...state.scrollback, ...state.grid]) {
for (const cell of row) {
if (cell.char.trim().length > 0) {
return true;
}
}
}
return false;
}
async function runWorktreeTerminalBootstrap(
options: RunAsyncWorktreeBootstrapOptions,
runtimeEnv: WorktreeRuntimeEnv,
@@ -1,50 +0,0 @@
import { describe, expect, it } from "vitest";
import {
BinaryMuxChannel,
TerminalBinaryFlags,
TerminalBinaryMessageType,
asUint8Array,
decodeBinaryMuxFrame,
encodeBinaryMuxFrame,
} from "./binary-mux.js";
describe("binary mux frame codec", () => {
it("encodes and decodes round trip", () => {
const payload = new TextEncoder().encode("hello");
const encoded = encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 42,
offset: 1234,
flags: TerminalBinaryFlags.Replay,
payload,
});
const decoded = decodeBinaryMuxFrame(encoded);
expect(decoded).not.toBeNull();
expect(decoded!.channel).toBe(BinaryMuxChannel.Terminal);
expect(decoded!.messageType).toBe(TerminalBinaryMessageType.OutputUtf8);
expect(decoded!.streamId).toBe(42);
expect(decoded!.offset).toBe(1234);
expect(decoded!.flags).toBe(TerminalBinaryFlags.Replay);
expect(Array.from(decoded!.payload ?? [])).toEqual(Array.from(payload));
});
it("rejects malformed frame payload length", () => {
const encoded = encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.InputUtf8,
streamId: 1,
offset: 0,
payload: new Uint8Array([1, 2, 3]),
});
const tampered = encoded.slice(0, encoded.byteLength - 1);
expect(decodeBinaryMuxFrame(tampered)).toBeNull();
});
it("converts UTF-8 string payloads to bytes", () => {
const bytes = asUint8Array("hello");
expect(bytes).not.toBeNull();
expect(Array.from(bytes ?? [])).toEqual(Array.from(new TextEncoder().encode("hello")));
});
});
-133
View File
@@ -1,133 +0,0 @@
const MUX_MAGIC_1 = 0x50; // 'P'
const MUX_MAGIC_2 = 0x58; // 'X'
const MUX_VERSION = 1;
const HEADER_SIZE = 24;
const UINT32_MAX = 0xffffffff;
const TWO_POW_32 = 0x1_0000_0000;
export const BinaryMuxConstants = {
magic1: MUX_MAGIC_1,
magic2: MUX_MAGIC_2,
version: MUX_VERSION,
headerSize: HEADER_SIZE,
} as const;
export const enum BinaryMuxChannel {
Terminal = 1,
FileTransfer = 2,
}
export const enum TerminalBinaryMessageType {
InputUtf8 = 1,
OutputUtf8 = 2,
Ack = 3,
}
export const enum TerminalBinaryFlags {
Replay = 1,
}
export interface BinaryMuxFrame {
channel: number;
messageType: number;
streamId: number;
offset: number;
flags?: number;
payload?: Uint8Array;
}
export function asUint8Array(data: unknown): Uint8Array | null {
if (typeof data === "string") {
if (typeof TextEncoder !== "undefined") {
return new TextEncoder().encode(data);
}
if (typeof Buffer !== "undefined") {
return new Uint8Array(Buffer.from(data, "utf8"));
}
const out = new Uint8Array(data.length);
for (let i = 0; i < data.length; i += 1) {
out[i] = data.charCodeAt(i) & 0xff;
}
return out;
}
if (data instanceof Uint8Array) {
return data;
}
if (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) {
return new Uint8Array(data);
}
if (ArrayBuffer.isView(data)) {
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
}
if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) {
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
}
return null;
}
export function isLikelyBinaryMuxFrame(data: Uint8Array): boolean {
if (data.byteLength < HEADER_SIZE) {
return false;
}
return data[0] === MUX_MAGIC_1 && data[1] === MUX_MAGIC_2 && data[2] === MUX_VERSION;
}
export function encodeBinaryMuxFrame(frame: BinaryMuxFrame): Uint8Array {
const payload = frame.payload ?? new Uint8Array(0);
const out = new Uint8Array(HEADER_SIZE + payload.byteLength);
const view = new DataView(out.buffer, out.byteOffset, out.byteLength);
const flags = frame.flags ?? 0;
if (!Number.isFinite(frame.streamId) || frame.streamId < 0 || frame.streamId > UINT32_MAX) {
throw new Error(`Invalid streamId: ${frame.streamId}`);
}
if (!Number.isFinite(frame.offset) || frame.offset < 0) {
throw new Error(`Invalid offset: ${frame.offset}`);
}
const offsetHi = Math.floor(frame.offset / TWO_POW_32);
const offsetLo = frame.offset >>> 0;
if (offsetHi > UINT32_MAX) {
throw new Error(`Offset too large: ${frame.offset}`);
}
view.setUint8(0, MUX_MAGIC_1);
view.setUint8(1, MUX_MAGIC_2);
view.setUint8(2, MUX_VERSION);
view.setUint8(3, frame.channel & 0xff);
view.setUint8(4, frame.messageType & 0xff);
view.setUint8(5, flags & 0xff);
view.setUint8(6, 0);
view.setUint8(7, 0);
view.setUint32(8, frame.streamId >>> 0);
view.setUint32(12, offsetHi >>> 0);
view.setUint32(16, offsetLo);
view.setUint32(20, payload.byteLength >>> 0);
out.set(payload, HEADER_SIZE);
return out;
}
export function decodeBinaryMuxFrame(data: Uint8Array): BinaryMuxFrame | null {
if (!isLikelyBinaryMuxFrame(data)) {
return null;
}
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
const payloadLength = view.getUint32(20);
if (payloadLength !== data.byteLength - HEADER_SIZE) {
return null;
}
const offsetHi = view.getUint32(12);
const offsetLo = view.getUint32(16);
const offset = offsetHi * TWO_POW_32 + offsetLo;
return {
channel: view.getUint8(3),
messageType: view.getUint8(4),
flags: view.getUint8(5),
streamId: view.getUint32(8),
offset,
payload: data.subarray(HEADER_SIZE),
};
}
+4 -59
View File
@@ -1139,21 +1139,6 @@ export const KillTerminalRequestSchema = z.object({
requestId: z.string(),
});
export const AttachTerminalStreamRequestSchema = z.object({
type: z.literal("attach_terminal_stream_request"),
terminalId: z.string(),
resumeOffset: z.number().int().nonnegative().optional(),
rows: z.number().int().positive().optional(),
cols: z.number().int().positive().optional(),
requestId: z.string(),
});
export const DetachTerminalStreamRequestSchema = z.object({
type: z.literal("detach_terminal_stream_request"),
streamId: z.number().int().nonnegative(),
requestId: z.string(),
});
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
VoiceAudioChunkMessageSchema,
AbortRequestMessageSchema,
@@ -1219,8 +1204,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
UnsubscribeTerminalRequestSchema,
TerminalInputSchema,
KillTerminalRequestSchema,
AttachTerminalStreamRequestSchema,
DetachTerminalStreamRequestSchema,
]);
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
@@ -2112,7 +2095,7 @@ const TerminalInfoSchema = z.object({
cwd: z.string(),
});
const TerminalCellSchema = z.object({
export const TerminalCellSchema = z.object({
char: z.string(),
fg: z.number().optional(),
bg: z.number().optional(),
@@ -2123,7 +2106,7 @@ const TerminalCellSchema = z.object({
underline: z.boolean().optional(),
});
const TerminalStateSchema = z.object({
export const TerminalStateSchema = z.object({
rows: z.number(),
cols: z.number(),
grid: z.array(z.array(TerminalCellSchema)),
@@ -2167,14 +2150,6 @@ export const SubscribeTerminalResponseSchema = z.object({
}),
});
export const TerminalOutputSchema = z.object({
type: z.literal("terminal_output"),
payload: z.object({
terminalId: z.string(),
state: TerminalStateSchema,
}),
});
export const KillTerminalResponseSchema = z.object({
type: z.literal("kill_terminal_response"),
payload: z.object({
@@ -2184,33 +2159,9 @@ export const KillTerminalResponseSchema = z.object({
}),
});
export const AttachTerminalStreamResponseSchema = z.object({
type: z.literal("attach_terminal_stream_response"),
payload: z.object({
terminalId: z.string(),
streamId: z.number().int().nonnegative().nullable(),
replayedFrom: z.number().int().nonnegative(),
currentOffset: z.number().int().nonnegative(),
earliestAvailableOffset: z.number().int().nonnegative(),
reset: z.boolean(),
error: z.string().nullable(),
requestId: z.string(),
}),
});
export const DetachTerminalStreamResponseSchema = z.object({
type: z.literal("detach_terminal_stream_response"),
payload: z.object({
streamId: z.number().int().nonnegative(),
success: z.boolean(),
requestId: z.string(),
}),
});
export const TerminalStreamExitSchema = z.object({
type: z.literal("terminal_stream_exit"),
payload: z.object({
streamId: z.number().int().nonnegative(),
terminalId: z.string(),
}),
});
@@ -2278,10 +2229,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
TerminalsChangedSchema,
CreateTerminalResponseSchema,
SubscribeTerminalResponseSchema,
TerminalOutputSchema,
KillTerminalResponseSchema,
AttachTerminalStreamResponseSchema,
DetachTerminalStreamResponseSchema,
TerminalStreamExitSchema,
]);
@@ -2415,13 +2363,10 @@ export type SubscribeTerminalRequest = z.infer<typeof SubscribeTerminalRequestSc
export type SubscribeTerminalResponse = z.infer<typeof SubscribeTerminalResponseSchema>;
export type UnsubscribeTerminalRequest = z.infer<typeof UnsubscribeTerminalRequestSchema>;
export type TerminalInput = z.infer<typeof TerminalInputSchema>;
export type TerminalOutput = z.infer<typeof TerminalOutputSchema>;
export type TerminalCell = z.infer<typeof TerminalCellSchema>;
export type TerminalState = z.infer<typeof TerminalStateSchema>;
export type KillTerminalRequest = z.infer<typeof KillTerminalRequestSchema>;
export type KillTerminalResponse = z.infer<typeof KillTerminalResponseSchema>;
export type AttachTerminalStreamRequest = z.infer<typeof AttachTerminalStreamRequestSchema>;
export type AttachTerminalStreamResponse = z.infer<typeof AttachTerminalStreamResponseSchema>;
export type DetachTerminalStreamRequest = z.infer<typeof DetachTerminalStreamRequestSchema>;
export type DetachTerminalStreamResponse = z.infer<typeof DetachTerminalStreamResponseSchema>;
export type TerminalStreamExit = z.infer<typeof TerminalStreamExitSchema>;
// ============================================================================
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import {
TerminalStreamOpcode,
decodeTerminalResizePayload,
decodeTerminalSnapshotPayload,
decodeTerminalStreamFrame,
encodeTerminalResizePayload,
encodeTerminalSnapshotPayload,
encodeTerminalStreamFrame,
} from "./terminal-stream-protocol.js";
describe("terminal stream protocol", () => {
it("encodes output frames as a 1-byte opcode prefix plus payload", () => {
const payload = new TextEncoder().encode("hello");
const encoded = encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
payload,
});
expect(encoded[0]).toBe(TerminalStreamOpcode.Output);
expect(Array.from(encoded.subarray(1))).toEqual(Array.from(payload));
const decoded = decodeTerminalStreamFrame(encoded);
expect(decoded).toEqual({
opcode: TerminalStreamOpcode.Output,
payload,
});
});
it("round-trips resize payloads", () => {
const payload = encodeTerminalResizePayload({
rows: 24,
cols: 80,
});
expect(decodeTerminalResizePayload(payload)).toEqual({
rows: 24,
cols: 80,
});
});
it("round-trips snapshot payloads", () => {
const state = {
rows: 1,
cols: 2,
grid: [[{ char: "A" }, { char: "B" }]],
scrollback: [],
cursor: { row: 0, col: 2 },
};
const payload = encodeTerminalSnapshotPayload(state);
expect(decodeTerminalSnapshotPayload(payload)).toEqual(state);
});
it("rejects unknown opcodes", () => {
expect(decodeTerminalStreamFrame(new Uint8Array([0xff, 0x01]))).toBeNull();
});
});
@@ -0,0 +1,120 @@
import { z } from "zod";
import { TerminalStateSchema } from "./messages.js";
export const TerminalStreamResizeSchema = z.object({
rows: z.number().int().positive(),
cols: z.number().int().positive(),
});
export const TerminalStreamOpcode = {
Output: 0x01,
Input: 0x02,
Resize: 0x03,
Snapshot: 0x04,
} as const;
export type TerminalStreamOpcode =
(typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode];
export type TerminalStreamFrame = {
opcode: TerminalStreamOpcode;
payload: Uint8Array;
};
export function asUint8Array(data: unknown): Uint8Array | null {
if (typeof data === "string") {
if (typeof TextEncoder !== "undefined") {
return new TextEncoder().encode(data);
}
if (typeof Buffer !== "undefined") {
return new Uint8Array(Buffer.from(data, "utf8"));
}
const out = new Uint8Array(data.length);
for (let index = 0; index < data.length; index += 1) {
out[index] = data.charCodeAt(index) & 0xff;
}
return out;
}
if (data instanceof Uint8Array) {
return data;
}
if (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) {
return new Uint8Array(data);
}
if (ArrayBuffer.isView(data)) {
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
}
if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) {
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
}
return null;
}
function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
return (
value === TerminalStreamOpcode.Output ||
value === TerminalStreamOpcode.Input ||
value === TerminalStreamOpcode.Resize ||
value === TerminalStreamOpcode.Snapshot
);
}
export function encodeTerminalStreamFrame(input: {
opcode: TerminalStreamOpcode;
payload?: Uint8Array | ArrayBuffer | string;
}): Uint8Array {
const payload = asUint8Array(input.payload ?? new Uint8Array(0)) ?? new Uint8Array(0);
const bytes = new Uint8Array(1 + payload.byteLength);
bytes[0] = input.opcode;
bytes.set(payload, 1);
return bytes;
}
export function decodeTerminalStreamFrame(bytes: Uint8Array): TerminalStreamFrame | null {
if (bytes.byteLength < 1) {
return null;
}
const opcode = bytes[0];
if (!isTerminalStreamOpcode(opcode)) {
return null;
}
return {
opcode,
payload: bytes.subarray(1),
};
}
export function encodeTerminalSnapshotPayload(state: z.infer<typeof TerminalStateSchema>): Uint8Array {
return encodeJsonPayload(state);
}
export function decodeTerminalSnapshotPayload(
bytes: Uint8Array,
): z.infer<typeof TerminalStateSchema> | null {
const parsed = decodeJsonPayload(bytes);
const result = TerminalStateSchema.safeParse(parsed);
return result.success ? result.data : null;
}
export function encodeTerminalResizePayload(
input: z.infer<typeof TerminalStreamResizeSchema>,
): Uint8Array {
return encodeJsonPayload(input);
}
export function decodeTerminalResizePayload(
bytes: Uint8Array,
): z.infer<typeof TerminalStreamResizeSchema> | null {
const parsed = decodeJsonPayload(bytes);
const result = TerminalStreamResizeSchema.safeParse(parsed);
return result.success ? result.data : null;
}
function encodeJsonPayload(value: unknown): Uint8Array {
return new TextEncoder().encode(JSON.stringify(value));
}
function decodeJsonPayload(bytes: Uint8Array): unknown {
const text = new TextDecoder().decode(bytes);
return JSON.parse(text);
}
+35 -45
View File
@@ -388,7 +388,7 @@ describe("Terminal", () => {
});
describe("subscribe", () => {
it("receives full state on initial subscription", async () => {
it("receives a snapshot on initial subscription", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
@@ -405,12 +405,12 @@ describe("Terminal", () => {
await new Promise((resolve) => setTimeout(resolve, 100));
expect(messages.length).toBeGreaterThan(0);
expect(messages[0].type).toBe("full");
expect(messages[0].type).toBe("snapshot");
unsubscribe();
});
it("receives messages on updates", async () => {
it("receives output and snapshot messages on updates", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
@@ -432,7 +432,8 @@ describe("Terminal", () => {
await waitForLines(session, ["$ echo test", "test", "$"]);
expect(messages.length).toBeGreaterThan(0);
expect(messages.some((message) => message.type === "output")).toBe(true);
expect(messages.some((message) => message.type === "snapshot")).toBe(true);
unsubscribe();
});
@@ -463,8 +464,8 @@ describe("Terminal", () => {
});
});
describe("raw output stream", () => {
it("streams raw output chunks with byte offsets", async () => {
describe("stream snapshots", () => {
it("streams raw output messages without replay metadata", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
@@ -475,28 +476,23 @@ describe("Terminal", () => {
await waitForLines(session, ["$"]);
const chunks: Array<{ data: string; startOffset: number; endOffset: number }> = [];
const sub = session.subscribeRaw((chunk) => {
chunks.push({
data: chunk.data,
startOffset: chunk.startOffset,
endOffset: chunk.endOffset,
});
const outputMessages: string[] = [];
const unsubscribe = session.subscribe((message) => {
if (message.type !== "output") {
return;
}
outputMessages.push(message.data);
});
session.send({ type: "input", data: "echo raw-stream\r" });
await waitForLines(session, ["$ echo raw-stream", "raw-stream", "$"]);
expect(chunks.length).toBeGreaterThan(0);
expect(chunks[chunks.length - 1].endOffset).toBe(session.getOutputOffset());
for (let i = 1; i < chunks.length; i++) {
expect(chunks[i].startOffset).toBe(chunks[i - 1].endOffset);
}
expect(outputMessages.join("")).toContain("raw-stream");
sub.unsubscribe();
unsubscribe();
});
it("replays buffered output from offset", async () => {
it("sends the current snapshot to a new subscriber instead of replaying raw output", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
@@ -507,21 +503,9 @@ describe("Terminal", () => {
await waitForLines(session, ["$"]);
const seen: Array<{ data: string; endOffset: number; replay: boolean }> = [];
const firstSub = session.subscribeRaw((chunk) => {
seen.push({
data: chunk.data,
endOffset: chunk.endOffset,
replay: chunk.replay,
});
});
session.send({ type: "input", data: "echo before-detach\r" });
await waitForLines(session, ["$ echo before-detach", "before-detach", "$"]);
const resumeOffset = seen[seen.length - 1]?.endOffset ?? 0;
firstSub.unsubscribe();
session.send({ type: "input", data: "echo after-detach\r" });
await waitForLines(session, [
"$ echo before-detach",
@@ -531,20 +515,26 @@ describe("Terminal", () => {
"$",
]);
const replayed: string[] = [];
const secondSub = session.subscribeRaw(
(chunk) => {
if (chunk.replay) {
replayed.push(chunk.data);
}
},
{ fromOffset: resumeOffset },
);
let snapshotText = "";
const unsubscribe = session.subscribe((message) => {
if (message.type !== "snapshot") {
return;
}
snapshotText = [...message.state.scrollback, ...message.state.grid]
.map((row) =>
row
.map((cell) => cell.char)
.join("")
.trimEnd(),
)
.join("\n");
});
const replayText = replayed.join("");
expect(replayText).toContain("after-detach");
expect(secondSub.replayedFrom).toBeGreaterThanOrEqual(resumeOffset);
secondSub.unsubscribe();
await new Promise((resolve) => setTimeout(resolve, 100));
expect(snapshotText).toContain("before-detach");
expect(snapshotText).toContain("after-detach");
unsubscribe();
});
});
+7 -139
View File
@@ -34,35 +34,14 @@ export interface TerminalState {
cursor: Pos;
}
export interface CellChange {
row: number;
col: number;
cell: Cell;
}
export interface TerminalRawChunk {
data: string;
startOffset: number;
endOffset: number;
replay: boolean;
}
export interface TerminalRawSubscriptionResult {
unsubscribe: () => void;
replayedFrom: number;
currentOffset: number;
earliestAvailableOffset: number;
reset: boolean;
}
export type ClientMessage =
| { type: "input"; data: string }
| { type: "resize"; rows: number; cols: number }
| { type: "mouse"; row: number; col: number; button: number; action: "down" | "up" | "move" };
export type ServerMessage =
| { type: "full"; state: TerminalState }
| { type: "diff"; changes: CellChange[]; cursor: Pos };
| { type: "output"; data: string }
| { type: "snapshot"; state: TerminalState };
export interface TerminalSession {
id: string;
@@ -71,11 +50,6 @@ export interface TerminalSession {
send(msg: ClientMessage): void;
subscribe(listener: (msg: ServerMessage) => void): () => void;
onExit(listener: () => void): () => void;
subscribeRaw(
listener: (chunk: TerminalRawChunk) => void,
options?: { fromOffset?: number },
): TerminalRawSubscriptionResult;
getOutputOffset(): number;
getState(): TerminalState;
kill(): void;
}
@@ -263,16 +237,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
const id = randomUUID();
const listeners = new Set<(msg: ServerMessage) => void>();
const rawListeners = new Set<(chunk: TerminalRawChunk) => void>();
const exitListeners = new Set<() => void>();
const rawOutputChunks: Array<{
startOffset: number;
endOffset: number;
data: string;
}> = [];
const maxRawBufferBytes = 8 * 1024 * 1024;
let rawBufferBytes = 0;
let outputOffset = 0;
let killed = false;
let disposed = false;
let exitEmitted = false;
@@ -329,7 +294,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
stateBroadcastScheduled = false;
terminal.dispose();
listeners.clear();
rawListeners.clear();
exitListeners.clear();
}
@@ -340,7 +304,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
}
const state = getState();
for (const listener of listeners) {
listener({ type: "full", state });
listener({ type: "snapshot", state });
}
}
@@ -355,66 +319,12 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
}, STATE_BROADCAST_INTERVAL_MS);
}
function appendRawOutputChunk(data: string): void {
const chunkBytes = Buffer.byteLength(data);
if (chunkBytes <= 0) {
return;
}
const startOffset = outputOffset;
const endOffset = startOffset + chunkBytes;
outputOffset = endOffset;
rawOutputChunks.push({
startOffset,
endOffset,
data,
});
rawBufferBytes += chunkBytes;
while (rawBufferBytes > maxRawBufferBytes && rawOutputChunks.length > 0) {
const removed = rawOutputChunks.shift();
if (!removed) {
break;
}
rawBufferBytes -= removed.endOffset - removed.startOffset;
}
const chunk: TerminalRawChunk = {
data,
startOffset,
endOffset,
replay: false,
};
for (const listener of rawListeners) {
listener(chunk);
}
}
function getEarliestAvailableOffset(): number {
const earliest = rawOutputChunks[0];
return earliest ? earliest.startOffset : outputOffset;
}
function alignOffsetToChunkBoundary(offset: number): number {
if (offset === outputOffset) {
return offset;
}
for (const chunk of rawOutputChunks) {
if (offset === chunk.startOffset || offset === chunk.endOffset) {
return offset;
}
if (offset > chunk.startOffset && offset < chunk.endOffset) {
return chunk.startOffset;
}
}
return offset;
}
// Pipe PTY output to terminal emulator
ptyProcess.onData((data) => {
if (killed) return;
appendRawOutputChunk(data);
for (const listener of listeners) {
listener({ type: "output", data });
}
terminal.write(data, () => {
scheduleStateBroadcast();
});
@@ -460,10 +370,9 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
function subscribe(listener: (msg: ServerMessage) => void): () => void {
listeners.add(listener);
// Send initial full state
queueMicrotask(() => {
if (listeners.has(listener)) {
listener({ type: "full", state: getState() });
listener({ type: "snapshot", state: getState() });
}
});
@@ -490,45 +399,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
};
}
function subscribeRaw(
listener: (chunk: TerminalRawChunk) => void,
options?: { fromOffset?: number },
): TerminalRawSubscriptionResult {
const requestedOffset = Math.max(0, Math.floor(options?.fromOffset ?? 0));
const earliestAvailableOffset = getEarliestAvailableOffset();
const clampedOffset = Math.max(requestedOffset, earliestAvailableOffset);
const replayedFrom = alignOffsetToChunkBoundary(clampedOffset);
const reset = replayedFrom !== requestedOffset;
for (const chunk of rawOutputChunks) {
if (chunk.endOffset <= replayedFrom) {
continue;
}
listener({
data: chunk.data,
startOffset: chunk.startOffset,
endOffset: chunk.endOffset,
replay: true,
});
}
rawListeners.add(listener);
return {
unsubscribe: () => {
rawListeners.delete(listener);
},
replayedFrom,
currentOffset: outputOffset,
earliestAvailableOffset,
reset,
};
}
function getOutputOffset(): number {
return outputOffset;
}
function kill(): void {
if (!killed) {
killed = true;
@@ -548,8 +418,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
send,
subscribe,
onExit,
subscribeRaw,
getOutputOffset,
getState,
kill,
};