mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
319 lines
13 KiB
TypeScript
319 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. Only turns the owner started may call it (`origin: "owner"`): the terminals are the owner's
|
|
// too, possibly connected where the session's `bash` would not reach (an ssh session, a waiting sudo prompt).
|
|
// 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,
|
|
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 { defineP2Tool, type P2Tool } from "@p2/app/v1/worker";
|
|
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<P2Tool> {
|
|
return defineExtension<P2Tool>({
|
|
name: TERMINAL_EXTENSION,
|
|
tools: [
|
|
defineP2Tool({
|
|
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",
|
|
origin: "owner",
|
|
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}`}`;
|
|
}
|