Files
paseo/packages/server/src/terminal/terminal-worker-protocol.ts
T
Mohamed Boudra 9c926ed126 fix(terminal): preserve 10,000 rows with direct ANSI replay (#8282)
Restore configured terminal history on reconnect, retaining 10,000 rows plus the screen. Serialize the worker-owned xterm buffer directly to ANSI and forward negotiated bounded replay chunks while preserving ANSI-era clients and per-viewer live ordering.

Preserve captured geometry through replay, bound pressure recovery to 500 rows, and keep the omitted visible-history default at 200. Remove the full cell-grid copy and obsolete JSON rendering.

All 21 CI checks pass. Real browser, native Debug emulator/simulator and Linux Electron coverage and platform limits are documented in the PR. The Android Debug invalid native delegate dispatch also reproduces on upstream main; its cause and any change in frequency remain unproven.

Credit Manav Dhindsa (DrakenKor), #6139 and #6148, and Lennart Schoch, #6743, for prior ANSI replay and retention work informing this change.

Closes #6137.
Closes #6147.
2026-10-31 10:51:45 +07:00

211 lines
5.6 KiB
TypeScript

import type { TerminalExitInfo, ServerMessage, ClientMessage } from "./terminal.js";
import type { TerminalReplayEvent, TerminalReplayOptions } from "./terminal-replay.js";
export interface WorkerTerminalState {
rows: number;
cols: number;
hasOutput: boolean;
title?: string;
}
import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity";
import type { CaptureTerminalLinesResult } from "./terminal-capture.js";
export interface WorkerTerminalInfo {
id: string;
name: string;
cwd: string;
workspaceId?: string;
title?: string;
activity: TerminalActivity | null;
}
export interface WorkerCreateTerminalOptions {
id?: string;
cwd: string;
workspaceId?: string;
name?: string;
title?: string;
env?: Record<string, string>;
command?: string;
args?: string[];
rows?: number;
cols?: number;
activityToken?: string;
activityUrl?: string | null;
// The worker outlives the daemon that started it, so the controlling daemon
// sends its own environment and Paseo CLI path with every create instead of
// letting the worker reuse whatever it inherited at launch.
baseEnv?: Record<string, string>;
paseoCliPath?: string | null;
}
// Bumped when a request, response or event changes shape. The frozen envelope never changes
// across versions: the frame format, `hello`/`helloAck`, the `listTerminals` info fields, and
// `shutdown`. A daemon drives a worker of its own version, drains one a version older (no new
// terminals; it exits after its last terminal), shuts down anything older, and leaves a newer
// worker running untouched. See docs/terminal-worker.md.
export const TERMINAL_WORKER_PROTOCOL_VERSION = 5;
// Test-only: the protocol version a worker reports, so tests can stand up older and newer
// workers from one build.
export const TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV = "PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE";
/** How a daemon at `current` treats a worker reporting `reported`. */
export type TerminalWorkerProtocolFit = "current" | "previous" | "obsolete" | "newer";
export function classifyTerminalWorkerProtocol(
reported: unknown,
current: number = TERMINAL_WORKER_PROTOCOL_VERSION,
): TerminalWorkerProtocolFit {
// A version this daemon cannot read comes from a Paseo it does not know: treat it as newer.
if (typeof reported !== "number" || !Number.isSafeInteger(reported)) return "newer";
if (reported === current) return "current";
// Protocols 1 through 4 were unreleased; they have no draining adapter.
if (reported === current - 1 && reported >= 5) return "previous";
return reported < current ? "obsolete" : "newer";
}
export interface TerminalWorkerHello {
type: "hello";
secret: string;
protocolVersion: number;
}
export interface TerminalWorkerHelloAck {
type: "helloAck";
pid: number;
protocolVersion: number;
}
export interface WorkerListedTerminal {
terminal: WorkerTerminalInfo;
state: WorkerTerminalState;
activityToken: string | null;
replayPreamble: string;
}
export interface WorkerKillAndWaitOptions {
gracefulTimeoutMs?: number;
forceTimeoutMs?: number;
}
export type TerminalWorkerRequest =
| {
type: "createTerminal";
requestId: string;
options: WorkerCreateTerminalOptions;
}
| {
type: "registerCwdEnv";
requestId: string;
cwd: string;
env: Record<string, string>;
}
| {
type: "setActivity";
requestId: string;
terminalId: string;
state: TerminalActivityState;
}
| {
type: "clearAttention";
requestId: string;
terminalId: string;
}
| {
type: "killTerminal";
requestId: string;
terminalId: string;
}
| {
type: "killTerminalAndWait";
requestId: string;
terminalId: string;
options?: WorkerKillAndWaitOptions;
}
| {
type: "replayTerminal";
requestId: string;
terminalId: string;
options?: TerminalReplayOptions;
}
| {
type: "captureTerminal";
requestId: string;
terminalId: string;
start?: number;
end?: number;
stripAnsi?: boolean;
}
| {
type: "listTerminals";
requestId: string;
}
| {
// Kill every terminal (waiting for each program to exit) and exit.
type: "shutdown";
requestId: string;
}
| {
type: "send";
requestId: string;
terminalId: string;
message: ClientMessage;
};
export type TerminalWorkerResponse =
| {
type: "response";
requestId: string;
ok: true;
result?: unknown;
}
| {
type: "response";
requestId: string;
ok: false;
error: string;
};
export type TerminalWorkerEvent =
| { type: "terminalReplay"; requestId: string; event: TerminalReplayEvent }
| {
type: "terminalCreated";
terminal: WorkerTerminalInfo;
state: WorkerTerminalState;
}
| {
type: "terminalMessage";
terminalId: string;
message: ServerMessage;
replayPreamble: string;
}
| {
type: "terminalExit";
terminalId: string;
info: TerminalExitInfo;
}
| {
type: "terminalTitleChange";
terminalId: string;
title?: string;
}
| {
type: "terminalCommandFinished";
terminalId: string;
info: {
exitCode: number | null;
};
}
| {
type: "terminalActivityChange";
terminalId: string;
activity: TerminalActivity | null;
previous: TerminalActivity | null;
};
export type TerminalWorkerToParentMessage = TerminalWorkerResponse | TerminalWorkerEvent;
export type TerminalWorkerListResult = WorkerListedTerminal[];
export type TerminalWorkerCaptureResult = CaptureTerminalLinesResult;