pi-mono/apps/p2/plugins/terminals/main.ts
Mario Zechner 2a32a605a9
Some checks are pending
CI / build-check (push) Waiting to run
CI / test (coding-agent) (push) Waiting to run
CI / test (durable) (push) Waiting to run
CI / test (other) (push) Waiting to run
CI / mcp-conformance (push) Waiting to run
feat(p2): Gondolin sandbox environments
A sandbox is a new env type (`type: "gondolin"`): a long-lived Gondolin micro-VM on an SSH environment, with
listed host folders and an HTTP(S) host allowlist. The worker reaches it over SSH to the host, where p2's launcher
(plugins/envs/sandbox: native, or Docker when the host's Node/QEMU are too old) runs pi-env's daemon inside the VM,
so tools, workspace, terminals, uploads and MCP work unchanged. Names outside the allowlist are refused before
the host resolves them.

Conversations working in a sandbox cannot leave it: location tools, subagent spawn/configure, AgentLocations.move,
the terminal tool, and one-shot agents (which now inherit their caller's own location) all keep them there; only the
owner's Relocate moves a session out, after a confirmation. conversationConfinement() exposes it to plugins.

Owner-only management in the Envs sheet: sandboxes listed under their host, host test reports sandbox support,
create/edit/start/stop/restart/test, lock icons in the top bar and sidebar. Tests in test/p2/sandboxes.test.ts
(real VM cases with P2_TEST_GONDOLIN=1) and ui-sandboxes.test.ts; docs in docs/remote-envs.md.
2026-11-05 23:35:09 +00:00

811 lines
29 KiB
TypeScript

import { randomBytes } from "node:crypto";
import { readFile, rm } from "node:fs/promises";
import { createServer, type Socket } from "node:net";
import { fileURLToPath } from "node:url";
import { Connection, detectPlatform, openTerminal, type RemoteTerminal } from "@earendil-works/pi-env";
import { takeProcessLock } from "../../src/runtime/process-lock.ts";
import { localSystem, requireTerminalSupport, terminalDaemon } from "./daemon.ts";
import { terminalHostAddress, terminalHostVersion } from "./detached.ts";
import { connectionRevision, envConnection, loadEnvs, sandboxHost, sshOptions } from "./envs.ts";
import {
BLINK,
BOLD,
type Cell,
FAINT,
Ghostty,
INVERSE,
INVISIBLE,
ITALIC,
STRIKETHROUGH,
UNDERLINE,
type VtTerminal,
} from "./ghostty.ts";
import { tmuxKey } from "./keys.ts";
import { OutputRing, plainText } from "./plain.ts";
import {
encodeFrame,
type HostCall,
type HostResult,
READ_TEXT_LIMIT,
readFrames,
TERMINAL_OUTPUT_WINDOW,
type TerminalEvent,
type TerminalInfo,
type TerminalRead,
type TerminalRequest,
type TerminalTheme,
type TerminalWait,
} from "./protocol.ts";
// The terminal host: owns the pi-env connections and the programs running in terminals, independent of the server and
// the session workers, so terminals survive their restarts (detached.ts). A headless libghostty terminal
// follows each one, so a browser that attaches later gets the current screen. It exits when it has had no terminals
// and no connections for a while. One per data directory. It is its own program, started by path (not a facet built by
// the plugin loader), so it and the files only it runs (daemon.ts, envs.ts, log-preload.ts) import p2's base code and
// Chord directly.
//
// The agent's terminal tool reaches the same terminals through calls with replies (`call`, see `HostCall`): each
// terminal keeps its last MiB of output and a read cursor for it here, so reads continue where the last one ended,
// also across server and worker restarts.
const IDLE_EXIT_MS = 10 * 60_000;
const SCROLLBACK = 10_000;
/** A client that falls this far behind is disconnected; reconnecting gets a fresh snapshot. */
const MAX_BACKLOG = 8 * 1024 * 1024;
/** The colors programs see until a viewer reports its theme: the browser's dark theme. */
const DEFAULT_THEME: TerminalTheme = { foreground: 0xe6e6e6, background: 0x141414, scheme: "dark" };
/** Output each terminal keeps for the agent's reads. */
const RING_BYTES = 1024 * 1024;
/** Running terminals the agent may have opened in one session. */
const MAX_AGENT_TERMINALS = 8;
/** Results of calls with a key the host remembers, so a repeated call (a replayed tool call) is not done twice. */
const REMEMBERED_CALLS = 1024;
/** A `match` wait tests the plain text of at most this much of the latest output. */
const MATCH_WINDOW = 64 * 1024;
/** How long without output a `quiet` wait waits by default. */
const DEFAULT_QUIET_MS = 500;
/** Lines of scrollback `capture` returns at most. */
const MAX_CAPTURE_SCROLLBACK = 5000;
const directory = process.argv[2];
if (!directory) throw new Error("usage: main.ts <data directory>");
const log = (message: string) => console.error(`${new Date().toISOString()} ${message}`);
const addressForLock = await terminalHostAddress(directory);
try {
await takeProcessLock(`p2:terminals:${addressForLock}`);
} catch {
// Another host serves this data directory.
process.exit(0);
}
const address = await terminalHostAddress(directory);
// We hold the lock: a socket file left behind is a killed host's.
if (!address.startsWith("\0") && !address.startsWith("\\\\")) await rm(address, { force: true });
/** The envs plugin's sandbox launcher (this host is a separate program, not a facet: no `pluginFile`). */
const sandboxLauncher = fileURLToPath(new URL("../envs/sandbox/", import.meta.url));
const version = await terminalHostVersion(fileURLToPath(new URL("../../", import.meta.url)));
const ghostty = await Ghostty.load(await readFile(new URL("vendor/ghostty/ghostty-vt.wasm", import.meta.url)));
/**
* Output flow control per viewer: `sent` and `acked` count output bytes (snapshots included) since it attached. A
* viewer with more than `TERMINAL_OUTPUT_WINDOW` unacknowledged output after its latest snapshot (see `lag`) is
* `behind`: it gets no more output, only the headless terminal follows the program. When its acknowledgements catch up
* it gets a fresh snapshot instead of the bytes it missed, so a flood (`find /`) costs a slow viewer one screen, not the
* whole stream, and nothing buffers without bound.
*
* The snapshot itself never counts against the window, as chord lets one large frame exceed its window: a screen with
* colored scrollback can be larger than the window, and counting it would mark the viewer behind as soon as it was sent,
* so every catch-up would send it again, forever, without any new output.
*/
type Client = {
socket: Socket;
attached?: Hosted;
theme: TerminalTheme;
sent: number;
acked: number;
/** `sent` right after the latest snapshot. */
snapshotEnd: number;
behind: boolean;
/** The session it listed last: it hears about that session's terminals changing (`terminals`). */
session?: string;
/** Cancels of its calls still running, by call id. */
calls: Map<number, () => void>;
};
type Hosted = {
info: TerminalInfo;
vt: VtTerminal;
pty?: RemoteTerminal;
/** The colors of its latest viewer, which answer the program's color queries. */
theme: TerminalTheme;
/** Input that arrived before the program started. */
pending: Uint8Array[];
pendingBytes: number;
clients: Set<Client>;
connection: string;
/** The last MiB of output, for the agent's reads. */
ring: OutputRing;
/** Where the agent's next read starts (an offset into `ring`). */
cursor: number;
/** Waits for output, called on each output, the exit and the close. */
waiters: Set<() => void>;
};
const terminals = new Map<string, Hosted>();
const clients = new Set<Client>();
/** pi-env connections by environment ("" for this machine), with the configuration they were made for. */
const connections = new Map<string, { revision: string; connection: Promise<Connection> }>();
let idleSince = Date.now();
function send(client: Client, message: TerminalEvent | Uint8Array): void {
if (client.socket.destroyed) return;
if (client.socket.writableLength > MAX_BACKLOG) {
client.socket.destroy();
return;
}
client.socket.write(encodeFrame(message));
}
/** Unacknowledged output after the latest snapshot. */
function lag(client: Client): number {
return client.sent - Math.max(client.acked, client.snapshotEnd);
}
function output(client: Client, bytes: Uint8Array): void {
if (client.behind) return;
send(client, bytes);
client.sent += bytes.length;
if (lag(client) > TERMINAL_OUTPUT_WINDOW) client.behind = true;
}
/** Replays the current screen to a viewer as on attach, which also resets its terminal. */
function resync(client: Client, hosted: Hosted): void {
client.behind = false;
send(client, { type: "attached", terminal: hosted.info });
const snapshot = hosted.vt.snapshot();
send(client, snapshot);
client.sent += snapshot.length;
client.snapshotEnd = client.sent;
}
function changed(hosted: Hosted): void {
for (const client of hosted.clients) send(client, { type: "changed", terminal: hosted.info });
listed(hosted.info.sessionId);
}
/** The session's terminals, oldest first. */
function sessionTerminals(sessionId: string): Hosted[] {
return [...terminals.values()].filter((hosted) => hosted.info.sessionId === sessionId);
}
/** Tells every connection that listed the session (its viewers) its terminals now. */
function listed(sessionId: string): void {
const list = sessionTerminals(sessionId).map((hosted) => hosted.info);
for (const client of clients) if (client.session === sessionId) send(client, { type: "terminals", terminals: list });
}
async function connection(env: string | null): Promise<{ connection: Connection; shellEnv: Record<string, string> }> {
if (env === null) {
let entry = connections.get("");
if (!entry) {
const started = (async () => {
const { platform, arch } = localSystem();
const binary = await terminalDaemon(platform, arch, true);
return new Connection({ command: [binary], onLog: (text) => log(`pi-env: ${text}`) });
})();
entry = { revision: "", connection: started };
connections.set("", entry);
started.catch(() => {
if (connections.get("") === entry) connections.delete("");
});
}
return { connection: await entry.connection, shellEnv: {} };
}
const configs = await loadEnvs(directory);
const config = configs[env];
if (!config) throw new Error(`Unknown environment ${env}`);
const revision = connectionRevision(configs, config);
let entry = connections.get(env);
if (!entry || entry.revision !== revision) {
const started = (async () => {
// A sandbox runs its host's daemon in its VM (both Linux on the host's architecture).
const target =
config.type === "ssh" ? sshOptions(directory, env, config) : await sandboxHost(directory, env, config.host);
const remote = await detectPlatform(target);
const binary = await terminalDaemon(remote.platform, remote.arch, false);
const daemon = envConnection(directory, env, config, {
launcher: sandboxLauncher,
binary,
onLog: (text) => log(`pi-env ${env}: ${text}`),
}).connection;
try {
await requireTerminalSupport(daemon);
return daemon;
} catch (error) {
daemon.close();
throw error;
}
})();
entry = { revision, connection: started };
connections.set(env, entry);
started.catch(() => {
if (connections.get(env) === entry) connections.delete(env);
});
}
return {
connection: await entry.connection,
shellEnv:
config.type === "ssh"
? { ...(config.shellEnv ?? {}), ...(config.shellPath ? { SHELL: config.shellPath } : {}) }
: {},
};
}
/** Stop a connection's daemon once none of its terminals runs. */
function release(key: string): void {
if ([...terminals.values()].some((hosted) => hosted.connection === key && !hosted.info.exited)) return;
const entry = connections.get(key);
connections.delete(key);
void entry?.connection.then((value) => value.close()).catch(() => {});
}
/** Show the terminal in a viewer's colors; programs that asked to hear about light/dark changes (mode 2031) do. */
function applyTheme(hosted: Hosted, theme: TerminalTheme): void {
const previous = hosted.theme;
hosted.theme = theme;
hosted.vt.setColors(theme);
if (previous.scheme !== theme.scheme && hosted.vt.mode(2031))
hosted.pty?.write(new TextEncoder().encode(`\x1b[?997;${theme.scheme === "dark" ? 1 : 2}n`));
}
function attach(client: Client, hosted: Hosted): void {
client.attached?.clients.delete(client);
client.attached = hosted;
hosted.clients.add(client);
applyTheme(hosted, client.theme);
client.sent = 0;
client.acked = 0;
resync(client, hosted);
}
type Opening = {
sessionId: string;
cwd: string;
env: string | null;
cols: number;
rows: number;
name?: string;
agent?: true;
};
/** A new terminal, listed at once; `started` settles once its program started or failed to. */
function create(request: Opening, theme: TerminalTheme): { hosted: Hosted; started: Promise<void> } {
const id = randomBytes(6).toString("base64url");
const taken = new Set(sessionTerminals(request.sessionId).map((each) => each.info.index));
let index = 1;
while (taken.has(index)) index++;
const info: TerminalInfo = {
id,
sessionId: request.sessionId,
index,
...(request.name ? { name: request.name } : {}),
...(request.agent ? { agent: true as const } : {}),
cwd: request.cwd,
env: request.env,
title: "",
createdAt: Date.now(),
cols: request.cols,
rows: request.rows,
};
let pty: RemoteTerminal | undefined;
const vt = ghostty.terminal({
cols: request.cols,
rows: request.rows,
scrollback: SCROLLBACK,
colors: theme,
colorScheme: () => hosted.theme.scheme,
// Answers to queries (cursor position, device attributes) come from here, never from the browsers.
onReply: (bytes) => {
if (terminals.has(id)) pty?.write(bytes);
},
});
const hosted: Hosted = {
info,
vt,
theme,
pending: [],
pendingBytes: 0,
clients: new Set(),
connection: request.env ?? "",
ring: new OutputRing(RING_BYTES),
cursor: 0,
waiters: new Set(),
};
terminals.set(id, hosted);
listed(request.sessionId);
const end = (exited: TerminalInfo["exited"]) => {
if (terminals.get(id) !== hosted) return;
info.exited = exited;
changed(hosted);
poke(hosted);
release(hosted.connection);
};
const started = (async () => {
try {
const { connection: daemon, shellEnv } = await connection(request.env);
pty = await openTerminal(daemon, {
cwd: request.cwd,
// COLORFGBG: light or dark for programs that only look at the environment.
env: {
...shellEnv,
TERM: "xterm-256color",
COLORTERM: "truecolor",
COLORFGBG: theme.scheme === "dark" ? "15;0" : "0;15",
},
cols: request.cols,
rows: request.rows,
onData: (bytes) => {
// Output racing a close: the headless terminal is already freed.
if (!terminals.has(id)) return;
vt.write(bytes);
hosted.ring.append(bytes);
for (const viewer of hosted.clients) output(viewer, bytes);
const title = vt.title();
if (title !== info.title) {
info.title = title;
changed(hosted);
}
poke(hosted);
},
});
hosted.pty = pty;
// Closed meanwhile: the program must not outlive its terminal.
if (!terminals.has(id)) pty.close();
if (info.cols !== request.cols || info.rows !== request.rows) pty.resize(info.cols, info.rows);
for (const bytes of hosted.pending.splice(0)) pty.write(bytes);
hosted.pendingBytes = 0;
pty.exited.then(
(code) => end({ code }),
(error: unknown) => end({ error: error instanceof Error ? error.message : String(error) }),
);
} catch (error) {
end({ error: error instanceof Error ? error.message : String(error) });
}
})();
return { hosted, started };
}
/** Wakes the terminal's waits. */
function poke(hosted: Hosted): void {
for (const waiter of hosted.waiters) waiter();
}
/** Input for the program; held until it started. False when too much is held already. */
function input(hosted: Hosted, bytes: Uint8Array): boolean {
if (hosted.pty) {
hosted.pty.write(bytes);
return true;
}
if (hosted.pendingBytes + bytes.length > MAX_BACKLOG) return false;
hosted.pending.push(bytes);
hosted.pendingBytes += bytes.length;
return true;
}
function close(hosted: Hosted): void {
if (terminals.get(hosted.info.id) !== hosted) return;
terminals.delete(hosted.info.id);
hosted.pty?.close();
for (const client of hosted.clients) {
send(client, { type: "closed", terminalId: hosted.info.id });
client.attached = undefined;
}
hosted.clients.clear();
hosted.vt.free();
release(hosted.connection);
poke(hosted);
listed(hosted.info.sessionId);
}
function handle(client: Client, message: unknown): void {
if (message instanceof Uint8Array) {
const hosted = client.attached;
if (!hosted || hosted.info.exited) return;
if (!input(hosted, message)) client.socket.destroy();
return;
}
const request = message as TerminalRequest;
switch (request.type) {
case "list":
client.session = request.sessionId;
send(client, { type: "list", terminals: sessionTerminals(request.sessionId).map((hosted) => hosted.info) });
return;
case "open":
attach(client, create(request, client.theme).hosted);
return;
case "call":
call(client, request.id, request.call);
return;
case "cancel":
client.calls.get(request.id)?.();
return;
case "attach": {
const hosted = terminals.get(request.terminalId);
if (hosted) attach(client, hosted);
else send(client, { type: "error", message: "This terminal no longer exists" });
return;
}
case "ack": {
client.acked = Math.min(client.sent, client.acked + request.bytes);
const hosted = client.attached;
if (hosted && client.behind && lag(client) <= TERMINAL_OUTPUT_WINDOW / 4) resync(client, hosted);
return;
}
case "theme":
client.theme = { foreground: request.foreground, background: request.background, scheme: request.scheme };
if (client.attached) applyTheme(client.attached, client.theme);
return;
case "resize": {
const hosted = client.attached;
if (!hosted) return;
if (hosted.info.cols === request.cols && hosted.info.rows === request.rows) return;
hosted.info.cols = request.cols;
hosted.info.rows = request.rows;
hosted.vt.resize(request.cols, request.rows);
hosted.pty?.resize(request.cols, request.rows);
changed(hosted);
return;
}
case "close": {
const hosted = client.attached;
if (!hosted) return;
if (request.force || !hosted.pty || hosted.info.exited) {
close(hosted);
return;
}
// A job in front of the shell (an editor, a build): the viewer asks before killing it. Background jobs do not
// count. When the daemon cannot tell (it predates `ptyForeground`, or the terminal just ended), close.
void hosted.pty
.foreground()
.catch(() => undefined)
.then((job) => {
if (terminals.get(hosted.info.id) !== hosted) return;
if (job && !hosted.info.exited)
send(client, {
type: "busy",
terminalId: hosted.info.id,
...(job.name ? { program: job.name } : {}),
});
else close(hosted);
});
return;
}
case "shutdown":
log("shutting down on request");
for (const hosted of terminals.values()) close(hosted);
for (const entry of connections.values()) void entry.connection.then((value) => value.close()).catch(() => {});
// Let the daemons see their input end before this process goes.
setTimeout(() => process.exit(0), 100);
return;
default:
send(client, { type: "error", message: "Unknown request" });
}
}
/** Results of calls with a key, by session, operation and key; the oldest go first. */
const remembered = new Map<string, Promise<HostResult>>();
/** Runs a call and replies; the caller may cancel it while it waits. */
function call(client: Client, id: number, request: HostCall): void {
const cancelled = Promise.withResolvers<never>();
cancelled.promise.catch(() => {});
client.calls.set(id, () => cancelled.reject(new Error("cancelled")));
const result = "key" in request ? once(request) : perform(request, cancelled.promise);
Promise.race([result, cancelled.promise])
.then(
(value) => send(client, { type: "reply", id, result: value }),
(error: unknown) =>
send(client, { type: "reply", id, error: error instanceof Error ? error.message : String(error) }),
)
.finally(() => client.calls.delete(id));
}
/** A call with a key, done once: a call with a key the host has seen gets the first one's result. */
function once(request: Extract<HostCall, { key: string }>): Promise<HostResult> {
const key = JSON.stringify([request.sessionId, request.op, request.key]);
const known = remembered.get(key);
if (known) return known;
// Calls with a key never wait, so nothing cancels them.
const first = perform(request, new Promise<never>(() => {}));
remembered.set(key, first);
// A failed call may be tried again.
first.catch(() => {
if (remembered.get(key) === first) remembered.delete(key);
});
for (const old of remembered.keys()) {
if (remembered.size <= REMEMBERED_CALLS) break;
remembered.delete(old);
}
return first;
}
/** One call; `cancelled` rejects when the caller gives up (a wait then ends). */
async function perform(request: HostCall, cancelled: Promise<never>): Promise<HostResult> {
switch (request.op) {
case "list":
return {
kind: "list",
windows: await Promise.all(
sessionTerminals(request.sessionId).map(async (hosted) => {
const job = await foreground(hosted);
return { terminal: hosted.info, ...(job ? { foreground: job } : {}) };
}),
),
};
case "open":
return { kind: "terminal", terminal: await openForAgent(request) };
case "send": {
const hosted = target(request.sessionId, request.target);
if (hosted.info.exited) throw new Error(`window ${hosted.info.index} has exited`);
const parts: Uint8Array[] = [];
for (const key of request.keys) {
const press = request.literal ? undefined : tmuxKey(key);
const bytes = press ? hosted.vt.encodeKey(press) : new TextEncoder().encode(key);
if (!bytes) throw new Error(`unknown key: ${key}`);
parts.push(bytes);
}
if (!input(hosted, Buffer.concat(parts))) throw new Error("too much input before the program started");
return { kind: "terminal", terminal: hosted.info };
}
case "cursor": {
const hosted = target(request.sessionId, request.target);
return { kind: "cursor", terminal: hosted.info, offset: hosted.cursor };
}
case "read":
return { kind: "read", ...(await read(request, cancelled)) };
case "capture": {
const hosted = target(request.sessionId, request.target);
return {
kind: "capture",
terminal: hosted.info,
text: capture(hosted.vt, request.scrollback, request.escapes === true),
};
}
case "close": {
const hosted = target(request.sessionId, request.target);
close(hosted);
return { kind: "closed", terminalId: hosted.info.id };
}
case "closeSession":
for (const hosted of sessionTerminals(request.sessionId)) close(hosted);
return { kind: "done" };
}
}
/** The session's terminal `name`: its id, its number (`2` or tmux's `:2`), or its name. */
function target(sessionId: string, name: string): Hosted {
const own = sessionTerminals(sessionId);
const bare = name.startsWith(":") ? name.slice(1) : name;
const found =
own.find((hosted) => hosted.info.id === name) ??
own.find((hosted) => String(hosted.info.index) === bare) ??
own.find((hosted) => hosted.info.name === bare);
if (!found) throw new Error(`can't find window: ${name}`);
return found;
}
/** A terminal the agent opens; its program has started when this returns, or it is gone with the error. */
async function openForAgent(request: Extract<HostCall, { op: "open" }>): Promise<TerminalInfo> {
const running = sessionTerminals(request.sessionId).filter((hosted) => hosted.info.agent && !hosted.info.exited);
if (running.length >= MAX_AGENT_TERMINALS)
throw new Error(
`the agent has ${MAX_AGENT_TERMINALS} terminals running in this session already: close one with kill-window`,
);
const { hosted, started } = create(
{
sessionId: request.sessionId,
cwd: request.cwd,
env: request.env,
cols: request.cols,
rows: request.rows,
agent: true,
...(request.name ? { name: request.name } : {}),
},
DEFAULT_THEME,
);
// The shell reads it once it is ready, as typed ahead.
if (request.command) input(hosted, new TextEncoder().encode(`${request.command}\r`));
await started;
const failed = hosted.info.exited?.error;
if (failed) {
close(hosted);
throw new Error(failed);
}
return hosted.info;
}
/** Output since `from` (default: the read cursor) as plain text, after the wait; moves the read cursor past it. */
async function read(request: Extract<HostCall, { op: "read" }>, cancelled: Promise<never>): Promise<TerminalRead> {
const hosted = target(request.sessionId, request.target);
const from = request.from ?? hosted.cursor;
const waited = request.wait ? await Promise.race([waitFor(hosted, from, request.wait), cancelled]) : undefined;
const { bytes, skipped } = hosted.ring.since(from);
const to = hosted.ring.end;
hosted.cursor = Math.max(hosted.cursor, to);
const plain = plainText(new TextDecoder().decode(bytes));
const truncated = Math.max(0, plain.length - READ_TEXT_LIMIT);
const open = terminals.get(hosted.info.id) === hosted;
const screen = open && hosted.vt.alternateScreen() ? capture(hosted.vt, 0, false) : undefined;
const job = await foreground(hosted);
return {
terminal: hosted.info,
from,
to,
text: truncated ? plain.slice(truncated) : plain,
skipped,
truncated,
...(screen === undefined ? {} : { screen }),
...(waited ? { waited } : {}),
...(job ? { foreground: job } : {}),
};
}
/** The name of the job in front of the terminal's shell, while one runs. */
async function foreground(hosted: Hosted): Promise<string | undefined> {
if (terminals.get(hosted.info.id) !== hosted || !hosted.pty || hosted.info.exited) return undefined;
const job = await hosted.pty.foreground().catch(() => undefined);
return job ? (job.name ?? `process ${job.pid}`) : undefined;
}
/**
* Waits for the terminal: until its output is quiet, matches, or it exits, whichever was asked, or the timeout. It
* also ends when the program exits or the terminal closes.
*/
function waitFor(hosted: Hosted, from: number, wait: TerminalWait): Promise<NonNullable<TerminalRead["waited"]>> {
// A bad pattern fails the read before it waits.
const pattern = wait.until === "match" ? new RegExp(wait.pattern ?? "", wait.ignoreCase ? "mi" : "m") : undefined;
return new Promise((resolve) => {
let quiet: ReturnType<typeof setTimeout> | undefined;
let check: ReturnType<typeof setTimeout> | undefined;
const matches = () => {
const { bytes } = hosted.ring.since(Math.max(from, hosted.ring.end - MATCH_WINDOW));
return pattern?.test(plainText(new TextDecoder().decode(bytes))) === true;
};
const finish = (why: NonNullable<TerminalRead["waited"]>) => {
clearTimeout(timeout);
clearTimeout(quiet);
clearTimeout(check);
hosted.waiters.delete(waiter);
resolve(why);
};
const waiter = () => {
if (terminals.get(hosted.info.id) !== hosted) finish(pattern && matches() ? "match" : "closed");
else if (hosted.info.exited) finish(pattern && matches() ? "match" : "exit");
else if (wait.until === "quiet") {
clearTimeout(quiet);
quiet = setTimeout(() => finish("quiet"), wait.quietMs ?? DEFAULT_QUIET_MS);
} else if (pattern && check === undefined)
// Output comes in many small chunks: test once they settled a little.
check = setTimeout(() => {
check = undefined;
if (matches()) finish("match");
}, 50);
};
const timeout = setTimeout(() => finish("timeout"), wait.timeoutMs);
hosted.waiters.add(waiter);
if (pattern && matches()) finish("match");
else waiter();
});
}
/**
* The screen as plain text, with up to `scrollback` lines before it; trailing blank lines left out. `escapes` keeps
* colors and attributes as SGR sequences (tmux's `capture-pane -e`).
*/
function capture(vt: VtTerminal, scrollback: number, escapes: boolean): string {
const { total, length } = vt.scrollbar();
const first = Math.max(0, total - length - Math.min(scrollback, MAX_CAPTURE_SCROLLBACK));
const lines: string[] = [];
// The viewport shows `length` rows at a time: page from `first` to the bottom, then scroll back down.
vt.scroll("bottom");
vt.scroll(first - (total - length));
let next = first;
for (;;) {
const shown = vt.scrollbar();
const rows = vt.frame(true).changed.map((row) => (escapes ? styledRow(row.cells) : plainRow(row.cells)));
lines.push(...rows.slice(next - shown.offset));
next = shown.offset + rows.length;
if (shown.offset + shown.length >= shown.total) break;
vt.scroll(shown.length);
}
vt.scroll("bottom");
while (lines.length > 0 && plainText(lines.at(-1) as string) === "") lines.pop();
return lines.join("\n");
}
function plainRow(cells: readonly Cell[]): string {
return cells
.filter((cell) => cell.width !== 0)
.map((cell) => cell.text || " ")
.join("")
.replace(/\s+$/, "");
}
/** SGR parameters of cell flags, in `Cell.flags` bit order. */
const SGR_FLAGS: [number, number][] = [
[BOLD, 1],
[FAINT, 2],
[ITALIC, 3],
[UNDERLINE, 4],
[BLINK, 5],
[INVERSE, 7],
[INVISIBLE, 8],
[STRIKETHROUGH, 9],
];
/** A row with its colors and attributes as SGR sequences, reset at its end. */
function styledRow(cells: readonly Cell[]): string {
const color = (base: number, value: number) =>
value < 0 ? [] : [base, 2, (value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff];
let row = "";
let style = "0";
for (const cell of cells) {
if (cell.width === 0) continue;
const params = [
0,
...SGR_FLAGS.filter(([flag]) => cell.flags & flag).map(([, sgr]) => sgr),
...color(38, cell.fg),
...color(48, cell.bg),
].join(";");
if (params !== style) {
row += `\x1b[${params}m`;
style = params;
}
row += cell.text || " ";
}
// Unstyled trailing blanks carry nothing.
const trimmed = style === "0" ? row.replace(/ +$/, "") : row;
return `${trimmed}${style === "0" ? "" : "\x1b[0m"}`;
}
const server = createServer((socket) => {
const client: Client = {
socket,
theme: DEFAULT_THEME,
sent: 0,
acked: 0,
snapshotEnd: 0,
behind: false,
calls: new Map(),
};
clients.add(client);
send(client, {
type: "hello",
version,
pid: process.pid,
running: [...terminals.values()].filter((hosted) => !hosted.info.exited).length,
});
readFrames(socket, (message) => {
try {
handle(client, message);
} catch (error) {
send(client, { type: "error", message: error instanceof Error ? error.message : String(error) });
}
});
socket.on("error", () => socket.destroy());
socket.on("close", () => {
clients.delete(client);
client.attached?.clients.delete(client);
for (const cancel of client.calls.values()) cancel();
idleSince = Date.now();
});
});
server.listen(address, () => log(`terminal host ${process.pid} (${version}) listening`));
setInterval(() => {
const running = [...terminals.values()].some((hosted) => !hosted.info.exited);
if (running || clients.size > 0) idleSince = Date.now();
else if (Date.now() - idleSince > IDLE_EXIT_MS) {
log("idle, exiting");
process.exit(0);
}
}, 30_000).unref();