pi-mono/apps/p2/plugins/terminals/protocol.ts
Armin Ronacher 47005dbe36 feat(p2): terminal tool for the agent, tmux-style argv, code mode only
The agent drives the session's terminals (shared with the owner's panel) with
tools.terminal({ command, args }): new-window, list-windows, send-keys, read,
capture-pane, kill-window, with waits (quiet, exit, /regex/) and plain-text
output. The terminal host keeps each terminal's last MiB of output and the
agent's read cursor, answers calls with ids, and does keyed calls once, so the
tool is replay-safe. AgentTerminals serves the session's current worker on its
environments; archiving closes a session's terminals. Without code mode the
worker registry installs tool_search once for all deferred tools.
2026-11-04 21:56:56 +01:00

171 lines
6.9 KiB
TypeScript

// Dedicated terminal byte channel and detached-host framing. Adapted from apps/mobile.
import type { Socket } from "node:net";
export type TerminalInfo = {
id: string;
sessionId: string;
/** Its number in the session, stable while it lives: the lowest free one from 1 (the agent's `-t 2`). */
index: number;
/** A name given when it was opened (`new-window -n`). */
name?: string;
/** The agent opened it (the terminal tool); the owner's own have no mark. */
agent?: true;
cwd: string;
env: string | null;
title: string;
createdAt: number;
cols: number;
rows: number;
exited?: { code?: number; error?: string };
};
export type TerminalTheme = { foreground: number; background: number; scheme: "light" | "dark" };
export type TerminalRequest =
| { type: "list"; sessionId: string }
| ({ type: "theme" } & TerminalTheme)
| { type: "open"; sessionId: string; cwd: string; env: string | null; cols: number; rows: number }
| { type: "attach"; terminalId: string }
| { type: "resize"; cols: number; rows: number }
| { type: "ack"; bytes: number }
| { type: "close"; force?: boolean }
| { type: "shutdown" }
/** A request with a reply (`reply` with the same id): the agent's terminal tool and the server's own requests. */
| { type: "call"; id: number; call: HostCall }
/** Ends a call still waiting (a wait for output): it replies with an error. */
| { type: "cancel"; id: number };
/** How long a read waits for output, and for what. A timeout is no error: the read returns what is there. */
export type TerminalWait = {
until: "quiet" | "exit" | "match";
/** `match`: a regular expression (JavaScript, multiline) tested against the plain text read. */
pattern?: string;
ignoreCase?: boolean;
/** `quiet`: how long without output counts as quiet. */
quietMs?: number;
timeoutMs: number;
};
/**
* The host's calls. Each names its session: a terminal of another session is unknown to it. `key` makes a call that
* changes something idempotent: a call with a key the host has seen returns the first call's result, done once.
*/
export type HostCall =
| { op: "list"; sessionId: string }
| {
op: "open";
sessionId: string;
key: string;
cwd: string;
env: string | null;
name?: string;
/** Typed into the new shell, then Enter. */
command?: string;
cols: number;
rows: number;
}
/** tmux `send-keys`: key names (`Enter`, `C-c`) as keys, other arguments as text; all text when `literal`. */
| { op: "send"; sessionId: string; key: string; target: string; keys: string[]; literal: boolean }
/** The terminal's read cursor: where the next read without `from` starts. */
| { op: "cursor"; sessionId: string; target: string }
/** Output since `from` (default: the read cursor) as plain text, after an optional wait; moves the read cursor. */
| { op: "read"; sessionId: string; target: string; from?: number; wait?: TerminalWait }
/** tmux `capture-pane`: the screen as plain text, with up to `scrollback` lines before it; styled with `escapes`. */
| { op: "capture"; sessionId: string; target: string; scrollback: number; escapes?: boolean }
| { op: "close"; sessionId: string; key: string; target: string }
/** Closes every terminal of the session (archived). */
| { op: "closeSession"; sessionId: string };
/** What a read returns. */
export type TerminalRead = {
terminal: TerminalInfo;
/** The output read: `from` to `to`, absolute byte offsets of the terminal's output. */
from: number;
to: number;
/** Plain text: escape sequences removed, carriage-return overwrites applied. */
text: string;
/** Bytes after `from` the host no longer kept (its ring holds the last MiB). */
skipped: number;
/** Characters of `text` left out at its start (it keeps the last `READ_TEXT_LIMIT`). */
truncated: number;
/** A full-screen program (alternate screen) runs: its screen as plain text, which says more than its output. */
screen?: string;
/** Why a wait ended. */
waited?: "quiet" | "exit" | "match" | "timeout" | "closed";
/** The job in front of the shell (an editor, a build), when one runs; absent at the prompt or once exited. */
foreground?: string;
};
export type HostResult =
| { kind: "list"; windows: { terminal: TerminalInfo; foreground?: string }[] }
| { kind: "terminal"; terminal: TerminalInfo }
| { kind: "cursor"; terminal: TerminalInfo; offset: number }
| ({ kind: "read" } & TerminalRead)
| { kind: "capture"; terminal: TerminalInfo; text: string }
| { kind: "closed"; terminalId: string }
| { kind: "done" };
/** Plain text a read returns at most: its last characters. */
export const READ_TEXT_LIMIT = 256 * 1024;
export type TerminalEvent =
| { type: "list"; terminals: TerminalInfo[] }
| { type: "attached"; terminal: TerminalInfo }
| { type: "changed"; terminal: TerminalInfo }
| { type: "closed"; terminalId: string }
| { type: "busy"; terminalId: string; program?: string }
| { type: "error"; message: string }
| { type: "hello"; version: string; running: number; pid: number }
| { type: "outdated"; running?: number }
/** The session's terminals changed (opened, closed, exited, renamed): sent to every connection that listed it. */
| { type: "terminals"; terminals: TerminalInfo[] }
| { type: "reply"; id: number; result: HostResult }
| { type: "reply"; id: number; error: string };
/**
* Output a viewer may have unacknowledged. It bounds how long a keystroke's echo can wait behind earlier output: at
* 5 Mbit/s, 256 KiB is 0.4 s. Viewers acknowledge every 64 KiB (Terminal.tsx), so four batches are in flight.
*/
export const TERMINAL_OUTPUT_WINDOW = 256 * 1024;
export const MAX_BACKLOG = 8 * 1024 * 1024;
const MAX_FRAME = 16 * 1024 * 1024;
export function encodeFrame(message: object | Uint8Array): Buffer {
const binary = message instanceof Uint8Array;
const body = binary ? message : Buffer.from(JSON.stringify(message), "utf8");
const header = Buffer.alloc(5);
header.writeUInt32BE(body.length + 1, 0);
header.writeUInt8(binary ? 1 : 0, 4);
return Buffer.concat([header, body]);
}
export function readFrames(socket: Socket, onFrame: (frame: unknown | Uint8Array) => void): void {
let pending: Buffer = Buffer.alloc(0);
socket.on("data", (chunk: Buffer) => {
let buffer = pending.length ? Buffer.concat([pending, chunk]) : chunk;
while (buffer.length >= 4) {
const length = buffer.readUInt32BE(0);
if (length < 1 || length > MAX_FRAME) {
socket.destroy(new Error("Corrupt terminal frame"));
return;
}
if (buffer.length < 4 + length) break;
const kind = buffer.readUInt8(4);
const body = buffer.subarray(5, 4 + length);
buffer = buffer.subarray(4 + length);
if (kind === 1) {
onFrame(new Uint8Array(body));
continue;
}
if (kind !== 0) {
socket.destroy(new Error("Corrupt terminal frame"));
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(body.toString("utf8"));
} catch {
socket.destroy(new Error("Corrupt terminal frame"));
return;
}
onFrame(parsed);
}
pending = Buffer.from(buffer);
});
}