pi-mono/apps/p2/plugins/terminals/tool.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

316 lines
13 KiB
TypeScript

// The agent's `terminal` tool (worker): tmux commands as argv on the session's terminals, the ones the owner sees in
// the terminal panel. Code mode scripts call it (`tools.terminal({ command, args })`); without code mode `tool_search`
// loads it, as MCP tools. Replay-safe: the host does a call with the tool call's key once, and reads start where the
// first attempt's did (memoized), so a call a restart interrupted runs again without typing or opening twice.
import {
defineExtension,
defineTool,
type Extension,
type ToolExecutionApi,
type ToolExecutionResult,
} from "@earendil-works/pi-durable";
import { arr, bool, int, obj, opt, type Static, str, union } from "@earendil-works/pi-schema";
import { type Context, isRooted, joinPath, type Location, literal } from "@p2/app/v1";
import {
COMMANDS,
CommandError,
parseCommand,
type TerminalCommand,
type WaitFor,
type WaitOptions,
} from "./commands.ts";
import type { TerminalInfoSchema, TerminalReadSchema, TerminalWaitSchema } from "./contract.ts";
type Info = Static<typeof TerminalInfoSchema>;
type Read = Static<typeof TerminalReadSchema>;
type Wait = Static<typeof TerminalWaitSchema>;
/** The server's terminal calls for this session (`AgentTerminals`), each cancelled with `context`. */
export type TerminalBackend = {
list(context: Context): Promise<{ terminal: Info; foreground?: string }[]>;
open(args: { key: string; location: Location; name?: string; command?: string }, context: Context): Promise<Info>;
send(args: { key: string; target: string; keys: string[]; literal: boolean }, context: Context): Promise<Info>;
cursor(target: string, context: Context): Promise<{ terminal: Info; offset: number }>;
read(args: { target: string; from?: number; wait?: Wait }, context: Context): Promise<Read>;
capture(
args: { target: string; scrollback: number; escapes: boolean },
context: Context,
): Promise<{ terminal: Info; text: string }>;
close(args: { key: string; target: string }, context: Context): Promise<{ terminalId: string }>;
};
/**
* How the model reaches the tool, as MCP tools: from code mode scripts (`searchTools()` finds it), or, without code
* mode, by loading it with `tool_search`.
*/
export type TerminalReach = "codemode" | "tool_search";
export const TERMINAL_EXTENSION = "p2-terminals";
/** Characters of output a call returns at most: the last ones. */
const OUTPUT_CHARS = 16 * 1024;
/** Timeouts of waits by default, in seconds: send-keys returns sooner, its program usually answers at once. */
const DEFAULT_TIMEOUT_S = 10;
const SEND_TIMEOUT_S = 5;
const DESCRIPTION = `Use the session's terminals, the ones the user sees in p2's terminal panel, like tmux. Only when the user asks for a terminal, or to drive an interactive program in one; never instead of bash.
Call tools.terminal({ command, args }), args being tmux's argv after the command name (no quoting). Windows are numbered per session (1, 2, ...); -t takes a number or a name.
- new-window [-n name] [-c dir] [-w W] [-T s] [command ...]: a shell at your cwd (-c: relative to it); command is typed into it, then Enter.
- list-windows
- send-keys -t T [-l] [-w W] [-T s] key ...: tmux key names (Enter, C-c, Up, Escape, Tab, BSpace, M-x, F1) are keys, other args text; -l: all text.
- read -t T [-w W] [-T s]: output since your last read.
- capture-pane -t T [-S -N] [-e]: the screen, plus N scrollback lines; -e keeps colors.
- kill-window -t T
new-window, send-keys and read return the output since your last read of that window, after waiting: W is quiet (0.5 s without output; the default of new-window and send-keys), exit, /regex/ or none (read's default); -T is the timeout in seconds (default 10, send-keys 5, max 120). A timeout is no error.
Result: { window, output, running, foreground?, exitCode?, screen?, skipped?, timedOut? }; output is plain text, or a full-screen program's screen (screen: true); list-windows: { windows }.
const { window } = await tools.terminal({ command: "new-window", args: ["-n", "dev", "npm run dev"] });
await tools.terminal({ command: "send-keys", args: ["-t", String(window), "C-c"] });`;
const WindowSchema = obj({
window: int(),
name: opt(str()),
title: opt(str()),
location: str(),
opener: union([literal("user"), literal("agent")]),
running: bool(),
foreground: opt(str()),
exitCode: opt(int()),
error: opt(str()),
});
/** What scripts get: one shape for every command, its fields as they apply. */
const ResultSchema = obj({
window: opt(int()),
output: opt(str()),
running: opt(bool()),
foreground: opt(str()),
exitCode: opt(int()),
error: opt(str()),
screen: opt(bool()),
skipped: opt(int()),
timedOut: opt(bool()),
killed: opt(bool()),
windows: opt(arr(WindowSchema)),
});
type Result = Static<typeof ResultSchema>;
/** The extension offering the `terminal` tool, reached as `reach` says. */
export function createTerminalExtension(
reach: TerminalReach,
backend: TerminalBackend,
locate: (api: ToolExecutionApi, context: Context) => Promise<Location>,
): Extension {
return defineExtension({
name: TERMINAL_EXTENSION,
tools: [
defineTool({
name: "terminal",
description: DESCRIPTION,
parameters: obj({
command: union(COMMANDS.map((name) => literal(name))),
args: opt(arr(str(), { description: "tmux's arguments after the command name, one per element." })),
}),
structuredOutputSchema: ResultSchema,
// Not offered to the model; only scripts call it while code mode is on.
deferred: true,
...(reach === "codemode" ? { callers: ["tools" as const] } : {}),
outputLimits: { maxBytes: OUTPUT_CHARS + 1024, retain: "tail" },
replay: "safe",
execute: async (args, api, context) => {
let command: TerminalCommand;
try {
command = parseCommand(args.command, args.args ?? []);
} catch (error) {
if (error instanceof CommandError) return failure(error.message);
throw error;
}
try {
const { result, terminalId } = await run(command, backend, locate, api, context);
return {
output: [{ type: "text", text: describe(result) }],
structuredOutput: result,
// The card's link to the window.
details: {
command: args.command,
args: args.args ?? [],
...(result.window === undefined ? {} : { window: result.window }),
...(terminalId === undefined ? {} : { terminalId }),
},
};
} catch (error) {
if (context.abortSignal?.aborted) throw error;
return failure(error instanceof Error ? error.message : String(error));
}
},
}),
],
});
}
function failure(text: string): ToolExecutionResult {
return { output: [{ type: "text", text }], isError: true };
}
async function run(
command: TerminalCommand,
backend: TerminalBackend,
locate: (api: ToolExecutionApi, context: Context) => Promise<Location>,
api: ToolExecutionApi,
context: Context,
): Promise<{ result: Result; terminalId?: string }> {
const key = String(api.taskId);
const read = async (args: Parameters<TerminalBackend["read"]>[0]) => {
const value = await backend.read(args, context);
return { result: readResult(value), terminalId: value.terminal.id };
};
switch (command.command) {
case "new-window": {
const here = await locate(api, context);
const directory = command.directory;
const location =
directory === undefined
? here
: { env: here.env, cwd: isRooted(directory) ? directory : joinPath(here.cwd, directory) };
const opened = await backend.open(
{
key,
location,
...(command.name ? { name: command.name } : {}),
...(command.shellCommand ? { command: command.shellCommand } : {}),
},
context,
);
const wait = waitOf(command, { kind: "quiet" }, DEFAULT_TIMEOUT_S);
return read({ target: opened.id, from: 0, ...(wait ? { wait } : {}) });
}
case "list-windows": {
const windows = await backend.list(context);
return { result: { windows: windows.map(({ terminal, foreground }) => windowOf(terminal, foreground)) } };
}
case "send-keys": {
const start = await readStart(command.target, backend, api, context);
await backend.send({ key, target: start.id, keys: command.keys, literal: command.literal }, context);
const wait = waitOf(command, { kind: "quiet" }, SEND_TIMEOUT_S);
return read({ target: start.id, from: start.offset, ...(wait ? { wait } : {}) });
}
case "read": {
const start = await readStart(command.target, backend, api, context);
const wait = waitOf(command, { kind: "none" }, DEFAULT_TIMEOUT_S);
return read({ target: start.id, from: start.offset, ...(wait ? { wait } : {}) });
}
case "capture-pane": {
const { target, scrollback, escapes } = command;
const { terminal, text } = await backend.capture({ target, scrollback, escapes }, context);
return { result: { ...stateOf(terminal), ...cut(text, 0) }, terminalId: terminal.id };
}
case "kill-window": {
// A rerun closes the window the first attempt found, never another that took its number since.
const known = await api.memo<{ id: string; window: number }>("window", context);
const target = known ?? (await backend.cursor(command.target, context)).terminal;
const window = "window" in target ? target.window : target.index;
await api.memo("window", { id: target.id, window }, context);
await backend.close({ key, target: target.id }, context);
return { result: { window, killed: true } };
}
}
}
/** Where this call's read starts: the window's read cursor when the call first ran (memoized for a rerun). */
async function readStart(
target: string,
backend: TerminalBackend,
api: ToolExecutionApi,
context: Context,
): Promise<{ id: string; offset: number }> {
const known = await api.memo<{ id: string; offset: number }>("start", context);
if (known) return known;
const { terminal, offset } = await backend.cursor(target, context);
return api.memo("start", { id: terminal.id, offset }, context);
}
/** The wait a command asks for, or its default; none for `-w none`. */
function waitOf(options: WaitOptions, fallback: WaitFor, timeout: number): Wait | undefined {
const wait = options.wait ?? fallback;
const timeoutMs = Math.round((options.timeout ?? timeout) * 1000);
switch (wait.kind) {
case "none":
return undefined;
case "quiet":
case "exit":
return { until: wait.kind, timeoutMs };
case "match":
return { until: "match", pattern: wait.pattern, ...(wait.ignoreCase ? { ignoreCase: true } : {}), timeoutMs };
}
}
function readResult(read: Read): Result {
const screen = read.screen !== undefined;
return {
...stateOf(read.terminal, read.foreground),
...cut(read.screen ?? read.text, screen ? 0 : read.skipped + read.truncated),
...(screen ? { screen: true } : {}),
...(read.waited === "timeout" ? { timedOut: true } : {}),
};
}
/** The output's last `OUTPUT_CHARS`, with what was left out before it counted in `skipped`. */
function cut(text: string, skipped: number): { output: string; skipped?: number } {
const over = Math.max(0, text.length - OUTPUT_CHARS);
const total = skipped + over;
return { output: over ? text.slice(over) : text, ...(total ? { skipped: total } : {}) };
}
function stateOf(terminal: Info, foreground?: string): Result {
return {
window: terminal.index,
running: terminal.exited === undefined,
...(foreground ? { foreground } : {}),
...(terminal.exited?.code === undefined ? {} : { exitCode: terminal.exited.code }),
...(terminal.exited?.error === undefined ? {} : { error: terminal.exited.error }),
};
}
function windowOf(terminal: Info, foreground: string | undefined): Static<typeof WindowSchema> {
return {
window: terminal.index,
...(terminal.name ? { name: terminal.name } : {}),
...(terminal.title ? { title: terminal.title } : {}),
location: terminal.env ? `${terminal.env}:${terminal.cwd}` : terminal.cwd,
opener: terminal.agent ? "agent" : "user",
...stateOf(terminal, foreground),
running: terminal.exited === undefined,
};
}
/** The result as the transcript and the model (without code mode) read it. */
function describe(result: Result): string {
if (result.windows)
return result.windows.length === 0
? "No windows."
: result.windows
.map(
(window) =>
`${window.window}: ${[
window.name ?? window.title ?? "",
window.location,
`opened by ${window.opener}`,
status(window),
]
.filter(Boolean)
.join(" · ")}`,
)
.join("\n");
if (result.killed) return `Killed window ${result.window}.`;
const lines = [`window ${result.window} · ${status(result)}${result.screen ? " · full-screen program" : ""}`];
if (result.skipped) lines.push(`… ${result.skipped} characters skipped`);
if (result.output) lines.push(result.output);
if (result.timedOut && result.running)
lines.push(`(timed out: still running${result.foreground ? ` (foreground: ${result.foreground})` : ""})`);
return lines.join("\n");
}
function status(result: Pick<Result, "running" | "foreground" | "exitCode" | "error">): string {
if (result.running) return result.foreground ? `running ${result.foreground}` : "at the prompt";
if (result.error) return `failed: ${result.error}`;
return `exited${result.exitCode === undefined ? "" : ` with code ${result.exitCode}`}`;
}