mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 13:42:50 +00:00
* Improve mobile terminal typing and selection * Address mobile terminal review feedback * Remove dead native terminal input branch * Replay bracketed paste in terminal preamble * Stop per-key terminal resize claims * Stabilize terminal resize browser test * Use tracked terminal mode for native paste * Restore terminal toolbar focus handling * Adapt native terminal to active visibility state * Stabilize native mobile terminal interactions * Add native terminal fallback boundary * test(app): expect explicit terminal resize reclaim * fix(app): stabilize terminal buffer coordinates * fix(app): restrict terminal resize ownership * fix(terminal): gate restored input modes * fix(app): recover stalled terminal WebViews * chore(app): remove obsolete terminal harnesses * fix(app): make terminal resize claims explicit * test(app): stabilize native terminal device flows * test(app): track absolute scrollback coordinates * fix(app): isolate terminal WebView streams * fix(terminal): arbitrate size ownership across clients The daemon now distinguishes explicit ownership claims from passive geometry updates. This keeps the active client authoritative while allowing its split, viewport, and keyboard changes to resize the PTY without idle clients stealing it. * test(server): make worker resize assertion portable Query the worker's authoritative terminal snapshot instead of invoking a Unix-only shell command, so the resize regression exercises the same boundary on Windows and Unix.
1096 lines
34 KiB
TypeScript
1096 lines
34 KiB
TypeScript
import type pino from "pino";
|
|
import type {
|
|
CaptureTerminalRequest,
|
|
CreateTerminalRequest,
|
|
KillTerminalRequest,
|
|
ListTerminalsRequest,
|
|
RenameTerminalRequest,
|
|
SessionInboundMessage,
|
|
SessionOutboundMessage,
|
|
SubscribeTerminalRequest,
|
|
SubscribeTerminalsRequest,
|
|
TerminalInput,
|
|
UnsubscribeTerminalRequest,
|
|
UnsubscribeTerminalsRequest,
|
|
} from "../server/messages.js";
|
|
import { killTerminalsForWorkspace as killWorkspaceTerminals } from "../server/workspace-archive-service.js";
|
|
import {
|
|
TerminalStreamOpcode,
|
|
decodeTerminalResizePayload,
|
|
encodeTerminalStreamFrame,
|
|
type TerminalStreamFrame,
|
|
} from "@getpaseo/protocol/binary-frames/index";
|
|
import { TerminalOutputCoalescer } from "./terminal-output-coalescer.js";
|
|
import {
|
|
MAX_CLIENT_BUFFERED_BYTES,
|
|
MAX_TERMINAL_OUTPUT_FRAME_BYTES,
|
|
encodeLegacyTerminalSnapshotFrame,
|
|
encodeTerminalRestoreFrame,
|
|
resolveRestoreAfterOutputOverflow,
|
|
resolveTerminalRestoreSnapshotOptions,
|
|
resolveTerminalSubscriptionSnapshotMode,
|
|
type TerminalRestoreOptions,
|
|
} from "./terminal-restore.js";
|
|
import type { TerminalSession } from "./terminal.js";
|
|
import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js";
|
|
import { applyTerminalSize } from "./terminal-size-ownership.js";
|
|
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
|
|
import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscription-key";
|
|
|
|
const MAX_TERMINAL_STREAM_SLOTS = 256;
|
|
|
|
interface BufferedTerminalOutput {
|
|
data: string;
|
|
revision?: number;
|
|
}
|
|
|
|
interface ActiveTerminalStream {
|
|
terminalId: string;
|
|
slot: number;
|
|
unsubscribe: () => void;
|
|
needsSnapshot: boolean;
|
|
snapshotInFlight: boolean;
|
|
readyRevision?: number;
|
|
restore?: TerminalRestoreOptions;
|
|
bufferedOutputs: BufferedTerminalOutput[];
|
|
outputBytesSinceSnapshot: number;
|
|
outputCoalescer: TerminalOutputCoalescer;
|
|
}
|
|
|
|
interface SnapshotSendResult {
|
|
shouldContinue: boolean;
|
|
replayRevision?: number;
|
|
}
|
|
|
|
export interface TerminalSessionControllerOptions {
|
|
terminalManager: TerminalManager | null;
|
|
emit: (msg: SessionOutboundMessage) => void;
|
|
emitBinary: (frame: Uint8Array) => void;
|
|
hasBinaryChannel: () => boolean;
|
|
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
|
sessionLogger: pino.Logger;
|
|
listTerminalWorkspaceRefs?: () => Promise<readonly TerminalWorkspaceRef[]>;
|
|
listTerminalWorkspaceRoots?: () => Promise<readonly string[]>;
|
|
// Whether the connected client can reflow restored snapshots. When true the
|
|
// daemon attaches per-row soft-wrap flags to snapshots; otherwise it omits them
|
|
// so old (strict-schema) clients still parse the snapshot.
|
|
clientSupportsWrapReflow?: () => boolean;
|
|
// Current max bytes queued on the client's transport(s) but not yet sent.
|
|
// Drives the snapshot catch-up fallback: a keeping-up client reports ~0 and
|
|
// keeps streaming; a backed-up client trips the snapshot path. Defaults to a
|
|
// constant 0 (no backpressure signal) so callers without a transport always
|
|
// stream.
|
|
// Bytes queued on the client transport but not yet sent, or null when the
|
|
// transport exposes no backpressure signal (e.g. the multiplexed relay socket).
|
|
getClientBufferedAmount?: () => number | null;
|
|
}
|
|
|
|
interface TerminalWorkspaceRef {
|
|
workspaceId: string;
|
|
cwd: string;
|
|
}
|
|
|
|
export interface TerminalSessionControllerMetrics {
|
|
directorySubscriptionCount: number;
|
|
streamSubscriptionCount: number;
|
|
}
|
|
|
|
type TerminalDispatchableMessage =
|
|
| SubscribeTerminalsRequest
|
|
| UnsubscribeTerminalsRequest
|
|
| ListTerminalsRequest
|
|
| CreateTerminalRequest
|
|
| SubscribeTerminalRequest
|
|
| UnsubscribeTerminalRequest
|
|
| TerminalInput
|
|
| KillTerminalRequest
|
|
| CaptureTerminalRequest
|
|
| RenameTerminalRequest;
|
|
|
|
const TERMINAL_MESSAGE_TYPES: ReadonlySet<TerminalDispatchableMessage["type"]> = new Set([
|
|
"subscribe_terminals_request",
|
|
"unsubscribe_terminals_request",
|
|
"list_terminals_request",
|
|
"create_terminal_request",
|
|
"subscribe_terminal_request",
|
|
"unsubscribe_terminal_request",
|
|
"terminal_input",
|
|
"kill_terminal_request",
|
|
"capture_terminal_request",
|
|
"terminal.rename.request",
|
|
]);
|
|
|
|
export class TerminalSessionController {
|
|
private readonly terminalManager: TerminalManager | null;
|
|
private readonly emit: (msg: SessionOutboundMessage) => void;
|
|
private readonly emitBinary: (frame: Uint8Array) => void;
|
|
private readonly hasBinaryChannel: () => boolean;
|
|
private readonly isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
|
private readonly sessionLogger: pino.Logger;
|
|
private readonly listTerminalWorkspaceRefs: () => Promise<readonly TerminalWorkspaceRef[]>;
|
|
private readonly listTerminalWorkspaceRoots: () => Promise<readonly string[]>;
|
|
private readonly clientSupportsWrapReflow: () => boolean;
|
|
private readonly getClientBufferedAmount: () => number | null;
|
|
private readonly terminalSizeOwner = {};
|
|
|
|
// A subscription is scoped to a (cwd, workspaceId) pair, keyed by
|
|
// terminalSubscriptionKey: two workspaces sharing a cwd subscribe and unsub
|
|
// independently, and each only receives its own workspace's terminals. The
|
|
// workspaceId is absent for old clients, which key to the cwd alone.
|
|
private readonly subscribedDirectories = new Map<
|
|
string,
|
|
{ cwd: string; workspaceId: string | undefined }
|
|
>();
|
|
private unsubscribeTerminalsChanged: (() => void) | null = null;
|
|
private readonly exitSubscriptions = new Map<string, () => void>();
|
|
private readonly activeStreams = new Map<number, ActiveTerminalStream>();
|
|
private readonly idToSlot = new Map<string, number>();
|
|
private nextSlot = 0;
|
|
|
|
constructor(options: TerminalSessionControllerOptions) {
|
|
this.terminalManager = options.terminalManager;
|
|
this.emit = options.emit;
|
|
this.emitBinary = options.emitBinary;
|
|
this.hasBinaryChannel = options.hasBinaryChannel;
|
|
this.isPathWithinRoot = options.isPathWithinRoot;
|
|
this.sessionLogger = options.sessionLogger;
|
|
this.listTerminalWorkspaceRefs = options.listTerminalWorkspaceRefs ?? (async () => []);
|
|
this.listTerminalWorkspaceRoots =
|
|
options.listTerminalWorkspaceRoots ??
|
|
(async () => (await this.listTerminalWorkspaceRefs()).map((workspace) => workspace.cwd));
|
|
this.clientSupportsWrapReflow = options.clientSupportsWrapReflow ?? (() => false);
|
|
this.getClientBufferedAmount = options.getClientBufferedAmount ?? (() => 0);
|
|
}
|
|
|
|
start(): void {
|
|
if (!this.terminalManager) {
|
|
return;
|
|
}
|
|
this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) => {
|
|
void this.handleTerminalsChanged(event);
|
|
});
|
|
}
|
|
|
|
getMetrics(): TerminalSessionControllerMetrics {
|
|
return {
|
|
directorySubscriptionCount: this.subscribedDirectories.size,
|
|
streamSubscriptionCount: this.activeStreams.size,
|
|
};
|
|
}
|
|
|
|
dispatch(msg: SessionInboundMessage): Promise<void> | undefined {
|
|
if (!isTerminalMessage(msg)) {
|
|
return undefined;
|
|
}
|
|
switch (msg.type) {
|
|
case "subscribe_terminals_request":
|
|
this.handleSubscribeTerminalsRequest(msg);
|
|
return undefined;
|
|
case "unsubscribe_terminals_request":
|
|
this.handleUnsubscribeTerminalsRequest(msg);
|
|
return undefined;
|
|
case "list_terminals_request":
|
|
return this.handleListTerminalsRequest(msg);
|
|
case "create_terminal_request":
|
|
return this.handleCreateTerminalRequest(msg);
|
|
case "subscribe_terminal_request":
|
|
return this.handleSubscribeTerminalRequest(msg);
|
|
case "unsubscribe_terminal_request":
|
|
this.handleUnsubscribeTerminalRequest(msg);
|
|
return undefined;
|
|
case "terminal_input":
|
|
this.handleTerminalInput(msg);
|
|
return undefined;
|
|
case "kill_terminal_request":
|
|
return this.handleKillTerminalRequest(msg);
|
|
case "capture_terminal_request":
|
|
return this.handleCaptureTerminalRequest(msg);
|
|
case "terminal.rename.request":
|
|
return this.handleRenameTerminalRequest(msg);
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
handleBinaryFrame(frame: TerminalStreamFrame): void {
|
|
const activeStream = this.activeStreams.get(frame.slot);
|
|
if (!activeStream || !this.terminalManager) {
|
|
return;
|
|
}
|
|
const terminal = this.terminalManager.getTerminal(activeStream.terminalId);
|
|
if (!terminal) {
|
|
this.detachStream(activeStream.terminalId, { emitExit: true });
|
|
return;
|
|
}
|
|
|
|
switch (frame.opcode) {
|
|
case TerminalStreamOpcode.Input: {
|
|
if (frame.payload.byteLength === 0) {
|
|
return;
|
|
}
|
|
const text = Buffer.from(frame.payload).toString("utf8");
|
|
if (!text) {
|
|
return;
|
|
}
|
|
terminal.send({ type: "input", data: text });
|
|
return;
|
|
}
|
|
|
|
case TerminalStreamOpcode.Resize: {
|
|
const resize = decodeTerminalResizePayload(frame.payload);
|
|
if (!resize) {
|
|
return;
|
|
}
|
|
applyTerminalSize(terminal, this.terminalSizeOwner, resize);
|
|
return;
|
|
}
|
|
|
|
default:
|
|
return;
|
|
}
|
|
}
|
|
|
|
killTerminalForClose(terminalId: string): { terminalId: string; success: boolean } {
|
|
if (!this.terminalManager) {
|
|
return { terminalId, success: false };
|
|
}
|
|
this.killTracked(terminalId, { emitExit: true });
|
|
return { terminalId, success: true };
|
|
}
|
|
|
|
async killTerminalsForWorkspace(workspaceId: string): Promise<void> {
|
|
return killWorkspaceTerminals(
|
|
{
|
|
detachTerminalStream: (terminalId, options) => void this.detachStream(terminalId, options),
|
|
sessionLogger: this.sessionLogger,
|
|
terminalManager: this.terminalManager,
|
|
},
|
|
workspaceId,
|
|
);
|
|
}
|
|
|
|
dispose(): void {
|
|
if (this.unsubscribeTerminalsChanged) {
|
|
this.unsubscribeTerminalsChanged();
|
|
this.unsubscribeTerminalsChanged = null;
|
|
}
|
|
this.subscribedDirectories.clear();
|
|
|
|
for (const unsubscribeExit of this.exitSubscriptions.values()) {
|
|
unsubscribeExit();
|
|
}
|
|
this.exitSubscriptions.clear();
|
|
|
|
for (const terminalId of Array.from(this.idToSlot.keys())) {
|
|
this.detachStream(terminalId, { emitExit: false });
|
|
}
|
|
}
|
|
|
|
private ensureExitSubscription(terminal: TerminalSession): void {
|
|
if (this.exitSubscriptions.has(terminal.id)) {
|
|
return;
|
|
}
|
|
const unsubscribeExit = terminal.onExit(() => {
|
|
this.handleTerminalExited(terminal.id);
|
|
});
|
|
this.exitSubscriptions.set(terminal.id, unsubscribeExit);
|
|
}
|
|
|
|
private handleTerminalExited(terminalId: string): void {
|
|
const unsubscribeExit = this.exitSubscriptions.get(terminalId);
|
|
if (unsubscribeExit) {
|
|
unsubscribeExit();
|
|
this.exitSubscriptions.delete(terminalId);
|
|
}
|
|
this.detachStream(terminalId, { emitExit: true });
|
|
}
|
|
|
|
private emitTerminalsChangedSnapshot(input: {
|
|
cwd: string;
|
|
terminals: Array<{
|
|
id: string;
|
|
name: string;
|
|
workspaceId: string;
|
|
title?: string;
|
|
activity: TerminalActivity | null;
|
|
}>;
|
|
}): void {
|
|
this.emit({
|
|
type: "terminals_changed",
|
|
payload: {
|
|
cwd: input.cwd,
|
|
terminals: input.terminals,
|
|
},
|
|
});
|
|
}
|
|
|
|
private toTerminalInfo(
|
|
terminal: Pick<TerminalSession, "id" | "name" | "workspaceId" | "getTitle" | "getActivity">,
|
|
): {
|
|
id: string;
|
|
name: string;
|
|
workspaceId: string;
|
|
title?: string;
|
|
activity: TerminalActivity | null;
|
|
} {
|
|
const title = terminal.getTitle();
|
|
const activity = terminal.getActivity();
|
|
return {
|
|
id: terminal.id,
|
|
name: terminal.name,
|
|
workspaceId: terminal.workspaceId,
|
|
...(title ? { title } : {}),
|
|
activity,
|
|
};
|
|
}
|
|
|
|
private async handleTerminalsChanged(event: TerminalsChangedEvent): Promise<void> {
|
|
// A terminal can live in a subdirectory of a subscribed workspace root (an
|
|
// agent can open one there). Deliver the change to every subscribed root at
|
|
// or above the terminal's cwd, keyed by that root, carrying the full
|
|
// aggregated list — so the client's cache replacement doesn't drop the
|
|
// terminals that live directly at the root.
|
|
const matchingSubscriptions = Array.from(this.subscribedDirectories.values()).filter(
|
|
(subscription) => this.isPathWithinRoot(subscription.cwd, event.cwd),
|
|
);
|
|
for (const subscription of matchingSubscriptions) {
|
|
await this.emitTerminalsSnapshotForSubscription(subscription);
|
|
}
|
|
}
|
|
|
|
private handleSubscribeTerminalsRequest(msg: SubscribeTerminalsRequest): void {
|
|
const subscription = { cwd: msg.cwd, workspaceId: msg.workspaceId };
|
|
this.subscribedDirectories.set(terminalSubscriptionKey(msg.cwd, msg.workspaceId), subscription);
|
|
void this.emitTerminalsSnapshotForSubscription(subscription);
|
|
}
|
|
|
|
private handleUnsubscribeTerminalsRequest(msg: UnsubscribeTerminalsRequest): void {
|
|
this.subscribedDirectories.delete(terminalSubscriptionKey(msg.cwd, msg.workspaceId));
|
|
}
|
|
|
|
private async emitTerminalsSnapshotForSubscription(subscription: {
|
|
cwd: string;
|
|
workspaceId: string | undefined;
|
|
}): Promise<void> {
|
|
const key = terminalSubscriptionKey(subscription.cwd, subscription.workspaceId);
|
|
if (!this.terminalManager || !this.subscribedDirectories.has(key)) {
|
|
return;
|
|
}
|
|
try {
|
|
const terminals = await this.getTerminalsForWorkspaceRoot(
|
|
subscription.cwd,
|
|
subscription.workspaceId,
|
|
);
|
|
for (const terminal of terminals) {
|
|
this.ensureExitSubscription(terminal);
|
|
}
|
|
if (!this.subscribedDirectories.has(key)) {
|
|
return;
|
|
}
|
|
this.emitTerminalsChangedSnapshot({
|
|
cwd: subscription.cwd,
|
|
terminals: terminals.map((terminal) => this.toTerminalInfo(terminal)),
|
|
});
|
|
} catch (error) {
|
|
this.sessionLogger.warn(
|
|
{ err: error, cwd: subscription.cwd },
|
|
"Failed to emit initial terminal snapshot",
|
|
);
|
|
}
|
|
}
|
|
|
|
private async handleListTerminalsRequest(msg: ListTerminalsRequest): Promise<void> {
|
|
if (!this.terminalManager) {
|
|
this.emit({
|
|
type: "list_terminals_response",
|
|
payload: {
|
|
...(msg.cwd ? { cwd: msg.cwd } : {}),
|
|
terminals: [],
|
|
requestId: msg.requestId,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const terminals =
|
|
typeof msg.cwd === "string"
|
|
? await this.getTerminalsForWorkspaceRoot(msg.cwd, msg.workspaceId)
|
|
: await this.getAllTerminalSessions();
|
|
for (const terminal of terminals) {
|
|
this.ensureExitSubscription(terminal);
|
|
}
|
|
this.emit({
|
|
type: "list_terminals_response",
|
|
payload: {
|
|
...(msg.cwd ? { cwd: msg.cwd } : {}),
|
|
terminals: terminals.map((terminal) => this.toTerminalInfo(terminal)),
|
|
requestId: msg.requestId,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
this.sessionLogger.error({ err: error, cwd: msg.cwd }, "Failed to list terminals");
|
|
this.emit({
|
|
type: "list_terminals_response",
|
|
payload: {
|
|
...(msg.cwd ? { cwd: msg.cwd } : {}),
|
|
terminals: [],
|
|
requestId: msg.requestId,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
private async getAllTerminalSessions(): Promise<TerminalSession[]> {
|
|
if (!this.terminalManager) {
|
|
return [];
|
|
}
|
|
const directories = this.terminalManager.listDirectories();
|
|
const manager = this.terminalManager;
|
|
const terminalsByDirectory = await Promise.all(
|
|
directories.map((cwd) => manager.getTerminals(cwd)),
|
|
);
|
|
return terminalsByDirectory.flat();
|
|
}
|
|
|
|
private async getTerminalsForWorkspaceRoot(
|
|
cwd: string,
|
|
workspaceId?: string,
|
|
): Promise<TerminalSession[]> {
|
|
if (!this.terminalManager) {
|
|
return [];
|
|
}
|
|
|
|
const terminals = await this.terminalManager.getTerminals(cwd, { workspaceId });
|
|
const workspaceRoots = await this.listTerminalWorkspaceRoots();
|
|
if (workspaceRoots.length === 0) {
|
|
return terminals;
|
|
}
|
|
|
|
return terminals.filter((terminal) =>
|
|
this.terminalBelongsToRoot(cwd, terminal.cwd, workspaceRoots),
|
|
);
|
|
}
|
|
|
|
private terminalBelongsToRoot(
|
|
rootCwd: string,
|
|
terminalCwd: string,
|
|
workspaceRoots: readonly string[],
|
|
): boolean {
|
|
const ownerRoot = this.resolveTerminalOwnerRoot(terminalCwd, workspaceRoots);
|
|
if (!ownerRoot) {
|
|
return this.isPathWithinRoot(rootCwd, terminalCwd);
|
|
}
|
|
return this.isSamePath(rootCwd, ownerRoot);
|
|
}
|
|
|
|
private resolveTerminalOwnerRoot(
|
|
terminalCwd: string,
|
|
workspaceRoots: readonly string[],
|
|
): string | null {
|
|
let ownerRoot: string | null = null;
|
|
for (const workspaceRoot of workspaceRoots) {
|
|
if (!this.isPathWithinRoot(workspaceRoot, terminalCwd)) {
|
|
continue;
|
|
}
|
|
if (!ownerRoot || workspaceRoot.length > ownerRoot.length) {
|
|
ownerRoot = workspaceRoot;
|
|
}
|
|
}
|
|
return ownerRoot;
|
|
}
|
|
|
|
private isSamePath(firstPath: string, secondPath: string): boolean {
|
|
return (
|
|
this.isPathWithinRoot(firstPath, secondPath) && this.isPathWithinRoot(secondPath, firstPath)
|
|
);
|
|
}
|
|
|
|
private async handleCreateTerminalRequest(msg: CreateTerminalRequest): Promise<void> {
|
|
if (!this.terminalManager) {
|
|
this.emit({
|
|
type: "create_terminal_response",
|
|
payload: {
|
|
terminal: null,
|
|
error: "Terminal manager not available",
|
|
requestId: msg.requestId,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (msg.agentId) {
|
|
this.emit({
|
|
type: "create_terminal_response",
|
|
payload: {
|
|
terminal: null,
|
|
error: `Agent-backed terminals are no longer supported for agent ${msg.agentId}`,
|
|
requestId: msg.requestId,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
const workspaceId = msg.workspaceId ?? (await this.resolveLegacyTerminalWorkspaceId(msg.cwd));
|
|
if (!workspaceId) {
|
|
this.emit({
|
|
type: "create_terminal_response",
|
|
payload: {
|
|
terminal: null,
|
|
error: "workspaceId is required",
|
|
requestId: msg.requestId,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
const session = await this.terminalManager.createTerminal({
|
|
cwd: msg.cwd,
|
|
workspaceId,
|
|
name: msg.name,
|
|
command: msg.command,
|
|
args: msg.args,
|
|
rows: msg.size?.rows,
|
|
cols: msg.size?.cols,
|
|
});
|
|
this.ensureExitSubscription(session);
|
|
this.emit({
|
|
type: "create_terminal_response",
|
|
payload: {
|
|
terminal: {
|
|
id: session.id,
|
|
name: session.name,
|
|
cwd: session.cwd,
|
|
workspaceId: session.workspaceId,
|
|
...(session.getTitle() ? { title: session.getTitle() } : {}),
|
|
activity: session.getActivity(),
|
|
},
|
|
error: null,
|
|
requestId: msg.requestId,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
this.sessionLogger.error({ err: error, cwd: msg.cwd }, "Failed to create terminal");
|
|
this.emit({
|
|
type: "create_terminal_response",
|
|
payload: {
|
|
terminal: null,
|
|
error: (error as Error).message,
|
|
requestId: msg.requestId,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
private async resolveLegacyTerminalWorkspaceId(cwd: string): Promise<string | null> {
|
|
const workspaceRefs = await this.listTerminalWorkspaceRefs();
|
|
if (workspaceRefs.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const exactMatch = workspaceRefs.find((workspace) => this.isSamePath(workspace.cwd, cwd));
|
|
if (exactMatch) {
|
|
return exactMatch.workspaceId;
|
|
}
|
|
|
|
const ownerRoot = this.resolveTerminalOwnerRoot(
|
|
cwd,
|
|
workspaceRefs.map((workspace) => workspace.cwd),
|
|
);
|
|
if (!ownerRoot) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
workspaceRefs.find((workspace) => this.isSamePath(workspace.cwd, ownerRoot))?.workspaceId ??
|
|
null
|
|
);
|
|
}
|
|
|
|
private async handleRenameTerminalRequest(msg: RenameTerminalRequest): Promise<void> {
|
|
const respond = (success: boolean, error: string | null): void => {
|
|
this.emit({
|
|
type: "terminal.rename.response",
|
|
payload: { requestId: msg.requestId, success, error },
|
|
});
|
|
};
|
|
|
|
const title = msg.title.trim();
|
|
if (title.length === 0) {
|
|
respond(false, "Title is required");
|
|
return;
|
|
}
|
|
if (title.length > 200) {
|
|
respond(false, "Title is too long");
|
|
return;
|
|
}
|
|
if (!this.terminalManager) {
|
|
respond(false, "Terminal manager not available");
|
|
return;
|
|
}
|
|
|
|
const renamed = this.terminalManager.setTerminalTitle(msg.terminalId, title);
|
|
respond(renamed, renamed ? null : "Terminal not found");
|
|
}
|
|
|
|
private async handleSubscribeTerminalRequest(msg: SubscribeTerminalRequest): Promise<void> {
|
|
if (!this.terminalManager) {
|
|
this.emit({
|
|
type: "subscribe_terminal_response",
|
|
payload: {
|
|
terminalId: msg.terminalId,
|
|
error: "Terminal manager not available",
|
|
requestId: msg.requestId,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
const session = this.terminalManager.getTerminal(msg.terminalId);
|
|
if (!session) {
|
|
this.emit({
|
|
type: "subscribe_terminal_response",
|
|
payload: {
|
|
terminalId: msg.terminalId,
|
|
error: "Terminal not found",
|
|
requestId: msg.requestId,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
this.ensureExitSubscription(session);
|
|
|
|
if (msg.restore?.size) {
|
|
applyTerminalSize(session, this.terminalSizeOwner, {
|
|
...msg.restore.size,
|
|
intent: "claim",
|
|
});
|
|
}
|
|
|
|
const slot = this.bindActiveStream(session, { restore: msg.restore });
|
|
if (slot === null) {
|
|
this.sessionLogger.warn(
|
|
{
|
|
terminalId: msg.terminalId,
|
|
activeTerminalStreamCount: this.activeStreams.size,
|
|
},
|
|
"Terminal stream slot exhaustion",
|
|
);
|
|
this.emit({
|
|
type: "subscribe_terminal_response",
|
|
payload: {
|
|
terminalId: msg.terminalId,
|
|
error: "No terminal stream slots available",
|
|
requestId: msg.requestId,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
this.emit({
|
|
type: "subscribe_terminal_response",
|
|
payload: {
|
|
terminalId: msg.terminalId,
|
|
slot,
|
|
error: null,
|
|
requestId: msg.requestId,
|
|
},
|
|
});
|
|
|
|
const activeStream = this.activeStreams.get(slot);
|
|
if (activeStream) {
|
|
void this.trySendSnapshot(activeStream);
|
|
}
|
|
}
|
|
|
|
private handleUnsubscribeTerminalRequest(msg: UnsubscribeTerminalRequest): void {
|
|
this.detachStream(msg.terminalId, { emitExit: false });
|
|
}
|
|
|
|
private handleTerminalInput(msg: TerminalInput): void {
|
|
if (!this.terminalManager) {
|
|
return;
|
|
}
|
|
const session = this.terminalManager.getTerminal(msg.terminalId);
|
|
if (!session) {
|
|
this.sessionLogger.warn({ terminalId: msg.terminalId }, "Terminal not found for input");
|
|
return;
|
|
}
|
|
this.ensureExitSubscription(session);
|
|
|
|
if (msg.message.type === "resize") {
|
|
applyTerminalSize(session, this.terminalSizeOwner, msg.message);
|
|
return;
|
|
}
|
|
|
|
session.send(msg.message);
|
|
}
|
|
|
|
private killTracked(terminalId: string, options?: { emitExit: boolean }): void {
|
|
this.detachStream(terminalId, { emitExit: options?.emitExit ?? true });
|
|
this.terminalManager?.killTerminal(terminalId);
|
|
}
|
|
|
|
private async handleKillTerminalRequest(msg: KillTerminalRequest): Promise<void> {
|
|
const result = this.killTerminalForClose(msg.terminalId);
|
|
this.emit({
|
|
type: "kill_terminal_response",
|
|
payload: {
|
|
terminalId: result.terminalId,
|
|
success: result.success,
|
|
requestId: msg.requestId,
|
|
},
|
|
});
|
|
}
|
|
|
|
private async handleCaptureTerminalRequest(msg: CaptureTerminalRequest): Promise<void> {
|
|
if (!this.terminalManager) {
|
|
this.emit({
|
|
type: "capture_terminal_response",
|
|
payload: {
|
|
terminalId: msg.terminalId,
|
|
lines: [],
|
|
totalLines: 0,
|
|
requestId: msg.requestId,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
const session = this.terminalManager.getTerminal(msg.terminalId);
|
|
if (!session) {
|
|
this.emit({
|
|
type: "capture_terminal_response",
|
|
payload: {
|
|
terminalId: msg.terminalId,
|
|
lines: [],
|
|
totalLines: 0,
|
|
requestId: msg.requestId,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
this.ensureExitSubscription(session);
|
|
|
|
try {
|
|
const capture = await this.terminalManager.captureTerminal(msg.terminalId, {
|
|
start: msg.start,
|
|
end: msg.end,
|
|
stripAnsi: msg.stripAnsi,
|
|
});
|
|
this.emit({
|
|
type: "capture_terminal_response",
|
|
payload: {
|
|
terminalId: msg.terminalId,
|
|
lines: capture.lines,
|
|
totalLines: capture.totalLines,
|
|
requestId: msg.requestId,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
this.sessionLogger.error(
|
|
{ err: error, terminalId: msg.terminalId },
|
|
"Failed to capture terminal",
|
|
);
|
|
this.emit({
|
|
type: "capture_terminal_response",
|
|
payload: {
|
|
terminalId: msg.terminalId,
|
|
lines: [],
|
|
totalLines: 0,
|
|
requestId: msg.requestId,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
private bindActiveStream(
|
|
terminal: TerminalSession,
|
|
options?: { restore?: TerminalRestoreOptions },
|
|
): number | null {
|
|
if (!this.hasBinaryChannel()) {
|
|
return null;
|
|
}
|
|
|
|
const existingSlot = this.idToSlot.get(terminal.id);
|
|
if (typeof existingSlot === "number") {
|
|
const existingStream = this.activeStreams.get(existingSlot);
|
|
if (existingStream) {
|
|
existingStream.needsSnapshot = true;
|
|
existingStream.restore = options?.restore;
|
|
return existingSlot;
|
|
}
|
|
this.idToSlot.delete(terminal.id);
|
|
}
|
|
|
|
const slot = this.allocateSlot();
|
|
if (slot === null) {
|
|
return null;
|
|
}
|
|
|
|
const activeStream: ActiveTerminalStream = {
|
|
terminalId: terminal.id,
|
|
slot,
|
|
unsubscribe: () => {},
|
|
needsSnapshot: true,
|
|
snapshotInFlight: false,
|
|
readyRevision: undefined,
|
|
restore: options?.restore,
|
|
bufferedOutputs: [],
|
|
outputBytesSinceSnapshot: 0,
|
|
outputCoalescer: new TerminalOutputCoalescer({
|
|
timers: { setTimeout, clearTimeout },
|
|
onFlush: ({ payload }) => {
|
|
if (this.activeStreams.get(slot) !== activeStream) {
|
|
return;
|
|
}
|
|
activeStream.outputBytesSinceSnapshot += payload.byteLength;
|
|
// Catch up via a snapshot only when the client is BOTH far behind in
|
|
// produced output AND actually backed up on the wire. A client that
|
|
// keeps draining reports ~0 buffered, so it streams continuously even
|
|
// past the byte threshold. outputBytesSinceSnapshot keeps accumulating
|
|
// in that case — it's harmless, it only gates the snapshot decision at
|
|
// the instant backpressure appears, and trySendSnapshot resets it to 0.
|
|
// A null reading means the transport exposes no backpressure signal
|
|
// (e.g. the multiplexed relay socket); there we can't tell a slow client
|
|
// from a fast one, so fall back unconditionally at the byte threshold to
|
|
// keep a slow relay client from falling unboundedly behind.
|
|
const clientBufferedAmount = this.getClientBufferedAmount();
|
|
if (
|
|
activeStream.outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES &&
|
|
(clientBufferedAmount === null || clientBufferedAmount > MAX_CLIENT_BUFFERED_BYTES)
|
|
) {
|
|
activeStream.restore = resolveRestoreAfterOutputOverflow(activeStream.restore);
|
|
activeStream.needsSnapshot = true;
|
|
void this.trySendSnapshot(activeStream);
|
|
return;
|
|
}
|
|
this.emitBinary(
|
|
encodeTerminalStreamFrame({
|
|
opcode: TerminalStreamOpcode.Output,
|
|
slot,
|
|
payload,
|
|
}),
|
|
);
|
|
},
|
|
}),
|
|
};
|
|
|
|
this.activeStreams.set(slot, activeStream);
|
|
this.idToSlot.set(terminal.id, slot);
|
|
|
|
activeStream.unsubscribe = terminal.subscribe(
|
|
(message) => {
|
|
if (this.activeStreams.get(slot) !== activeStream) {
|
|
return;
|
|
}
|
|
if (message.type === "snapshot" || message.type === "snapshotReady") {
|
|
activeStream.readyRevision = message.revision;
|
|
activeStream.outputCoalescer.flush();
|
|
activeStream.needsSnapshot = true;
|
|
void this.trySendSnapshot(activeStream);
|
|
return;
|
|
}
|
|
if (message.type === "titleChange") {
|
|
return;
|
|
}
|
|
if (message.data.length === 0) {
|
|
return;
|
|
}
|
|
if (activeStream.needsSnapshot || activeStream.snapshotInFlight) {
|
|
activeStream.bufferedOutputs.push({
|
|
data: message.data,
|
|
revision: message.revision,
|
|
});
|
|
return;
|
|
}
|
|
activeStream.outputCoalescer.handle(message.data);
|
|
},
|
|
{ initialSnapshot: resolveTerminalSubscriptionSnapshotMode(options?.restore) },
|
|
);
|
|
return slot;
|
|
}
|
|
|
|
private async trySendSnapshot(activeStream: ActiveTerminalStream): Promise<void> {
|
|
if (
|
|
this.activeStreams.get(activeStream.slot) !== activeStream ||
|
|
!activeStream.needsSnapshot ||
|
|
activeStream.snapshotInFlight
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const terminalManager = this.terminalManager;
|
|
if (!terminalManager) {
|
|
this.detachStream(activeStream.terminalId, { emitExit: true });
|
|
return;
|
|
}
|
|
const terminal = terminalManager.getTerminal(activeStream.terminalId);
|
|
if (!terminal) {
|
|
this.detachStream(activeStream.terminalId, { emitExit: true });
|
|
return;
|
|
}
|
|
if (activeStream.restore && activeStream.readyRevision === undefined) {
|
|
return;
|
|
}
|
|
|
|
activeStream.outputCoalescer.flush();
|
|
activeStream.snapshotInFlight = true;
|
|
try {
|
|
const restore = activeStream.restore;
|
|
const snapshotResult = restore
|
|
? await this.emitRestoreSnapshot(activeStream, terminalManager, restore)
|
|
: await this.emitLegacySnapshot(activeStream, terminalManager);
|
|
if (!snapshotResult.shouldContinue) {
|
|
return;
|
|
}
|
|
this.replayTerminalOutputAfterSnapshot(activeStream, terminal, snapshotResult.replayRevision);
|
|
activeStream.needsSnapshot = false;
|
|
activeStream.outputBytesSinceSnapshot = 0;
|
|
} catch (error) {
|
|
this.sessionLogger.warn(
|
|
{ err: error, terminalId: activeStream.terminalId },
|
|
"Failed to pull terminal snapshot",
|
|
);
|
|
activeStream.needsSnapshot = true;
|
|
} finally {
|
|
activeStream.snapshotInFlight = false;
|
|
}
|
|
}
|
|
|
|
private async emitLegacySnapshot(
|
|
activeStream: ActiveTerminalStream,
|
|
terminalManager: TerminalManager,
|
|
): Promise<SnapshotSendResult> {
|
|
const snapshot = await terminalManager.getTerminalState(activeStream.terminalId, {
|
|
includeWrapFlags: this.clientSupportsWrapReflow(),
|
|
});
|
|
if (this.activeStreams.get(activeStream.slot) !== activeStream) {
|
|
return { shouldContinue: false };
|
|
}
|
|
if (!snapshot) {
|
|
this.detachStream(activeStream.terminalId, { emitExit: true });
|
|
return { shouldContinue: false };
|
|
}
|
|
|
|
this.emitBinary(
|
|
encodeLegacyTerminalSnapshotFrame({
|
|
slot: activeStream.slot,
|
|
snapshot,
|
|
}),
|
|
);
|
|
// The snapshot frame went out-of-band; keep the replay that follows on the
|
|
// coalescer's trailing path so it doesn't flush back-to-back with it.
|
|
activeStream.outputCoalescer.markFlushed();
|
|
return { shouldContinue: true, replayRevision: snapshot.revision };
|
|
}
|
|
|
|
private async emitRestoreSnapshot(
|
|
activeStream: ActiveTerminalStream,
|
|
terminalManager: TerminalManager,
|
|
restore: TerminalRestoreOptions,
|
|
): Promise<SnapshotSendResult> {
|
|
const snapshotOptions = resolveTerminalRestoreSnapshotOptions(restore);
|
|
if (snapshotOptions === null) {
|
|
return { shouldContinue: true };
|
|
}
|
|
|
|
const snapshot = await terminalManager.getTerminalState(activeStream.terminalId, {
|
|
...snapshotOptions,
|
|
includeWrapFlags: this.clientSupportsWrapReflow(),
|
|
});
|
|
if (this.activeStreams.get(activeStream.slot) !== activeStream) {
|
|
return { shouldContinue: false };
|
|
}
|
|
if (!snapshot) {
|
|
this.detachStream(activeStream.terminalId, { emitExit: true });
|
|
return { shouldContinue: false };
|
|
}
|
|
|
|
this.emitBinary(
|
|
encodeTerminalRestoreFrame({
|
|
slot: activeStream.slot,
|
|
snapshot,
|
|
}),
|
|
);
|
|
// The restore frame went out-of-band; keep the replay that follows on the
|
|
// coalescer's trailing path so it doesn't flush back-to-back with it.
|
|
activeStream.outputCoalescer.markFlushed();
|
|
return { shouldContinue: true, replayRevision: snapshot.revision };
|
|
}
|
|
|
|
private replayTerminalOutputAfterSnapshot(
|
|
activeStream: ActiveTerminalStream,
|
|
terminal: TerminalSession,
|
|
replayRevision: number | undefined,
|
|
): void {
|
|
const replayPreamble = terminal.getReplayPreamble();
|
|
if (replayPreamble.length > 0) {
|
|
activeStream.outputCoalescer.handle(replayPreamble);
|
|
}
|
|
|
|
const bufferedOutputs = activeStream.bufferedOutputs.splice(
|
|
0,
|
|
activeStream.bufferedOutputs.length,
|
|
);
|
|
for (const output of bufferedOutputs) {
|
|
if (
|
|
replayRevision !== undefined &&
|
|
output.revision !== undefined &&
|
|
output.revision <= replayRevision
|
|
) {
|
|
continue;
|
|
}
|
|
activeStream.outputCoalescer.handle(output.data);
|
|
}
|
|
}
|
|
|
|
private allocateSlot(): number | null {
|
|
for (let attempt = 0; attempt < MAX_TERMINAL_STREAM_SLOTS; attempt += 1) {
|
|
const slot = (this.nextSlot + attempt) % MAX_TERMINAL_STREAM_SLOTS;
|
|
if (this.activeStreams.has(slot)) {
|
|
continue;
|
|
}
|
|
this.nextSlot = (slot + 1) % MAX_TERMINAL_STREAM_SLOTS;
|
|
return slot;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private detachStream(terminalId: string, options?: { emitExit: boolean }): boolean {
|
|
const slot = this.idToSlot.get(terminalId);
|
|
if (typeof slot !== "number") {
|
|
return false;
|
|
}
|
|
const activeStream = this.activeStreams.get(slot);
|
|
if (!activeStream) {
|
|
this.idToSlot.delete(terminalId);
|
|
return false;
|
|
}
|
|
activeStream.outputCoalescer.flush();
|
|
activeStream.bufferedOutputs.length = 0;
|
|
this.activeStreams.delete(slot);
|
|
this.idToSlot.delete(terminalId);
|
|
try {
|
|
activeStream.unsubscribe();
|
|
} catch (error) {
|
|
this.sessionLogger.warn({ err: error }, "Failed to unsubscribe terminal stream");
|
|
}
|
|
if (options?.emitExit) {
|
|
this.emit({
|
|
type: "terminal_stream_exit",
|
|
payload: {
|
|
terminalId: activeStream.terminalId,
|
|
},
|
|
});
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function isTerminalMessage(msg: SessionInboundMessage): msg is TerminalDispatchableMessage {
|
|
return TERMINAL_MESSAGE_TYPES.has(msg.type as TerminalDispatchableMessage["type"]);
|
|
}
|