feat: add exec pty support

This commit is contained in:
Peter Steinberger
2026-01-17 04:57:04 +00:00
parent 312cb75c50
commit c4ea25a509
11 changed files with 244 additions and 32 deletions

View File

@@ -17,7 +17,7 @@ Key parameters:
- `background` (bool): background immediately
- `timeout` (seconds, default 1800): kill the process after this timeout
- `elevated` (bool): run on host if elevated mode is enabled/allowed
- Need a real TTY? Use the tmux skill.
- Need a real TTY? Set `pty: true`.
- `workdir`, `env`
Behavior:

View File

@@ -17,8 +17,9 @@ Background sessions are scoped per agent; `process` only sees sessions from the
- `yieldMs` (default 10000): auto-background after delay
- `background` (bool): background immediately
- `timeout` (seconds, default 1800): kill on expiry
- `pty` (bool): run in a pseudo-terminal when available (TTY-only CLIs, coding agents, terminal UIs)
- `elevated` (bool): run on host if elevated mode is enabled/allowed (only changes behavior when the agent is sandboxed)
- Need a real TTY? Use the tmux skill.
- Need a fully interactive session? Use `pty: true` and the `process` tool for stdin/output.
Note: `elevated` is ignored when sandboxing is off (exec already runs on the host).
## Examples

View File

@@ -169,7 +169,7 @@ Core parameters:
- `background` (immediate background)
- `timeout` (seconds; kills the process if exceeded, default 1800)
- `elevated` (bool; run on host if elevated mode is enabled/allowed; only changes behavior when the agent is sandboxed)
- Need a real TTY? Use the tmux skill.
- Need a real TTY? Set `pty: true`.
Notes:
- Returns `status: "running"` with a `sessionId` when backgrounded.

View File

@@ -138,6 +138,7 @@
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.4",
"@lydell/node-pty": "1.2.0-beta.3",
"@mariozechner/pi-agent-core": "0.46.0",
"@mariozechner/pi-ai": "0.46.0",
"@mariozechner/pi-coding-agent": "^0.46.0",

63
pnpm-lock.yaml generated
View File

@@ -28,6 +28,9 @@ importers:
'@homebridge/ciao':
specifier: ^1.3.4
version: 1.3.4
'@lydell/node-pty':
specifier: 1.2.0-beta.3
version: 1.2.0-beta.3
'@mariozechner/pi-agent-core':
specifier: 0.46.0
version: 0.46.0(ws@8.19.0)(zod@4.3.5)
@@ -935,6 +938,39 @@ packages:
'@lit/reactive-element@2.1.2':
resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==}
'@lydell/node-pty-darwin-arm64@1.2.0-beta.3':
resolution: {integrity: sha512-owcv+e1/OSu3bf9ZBdUQqJsQF888KyuSIiPYFNn0fLhgkhm9F3Pvha76Kj5mCPnodf7hh3suDe7upw7GPRXftQ==}
cpu: [arm64]
os: [darwin]
'@lydell/node-pty-darwin-x64@1.2.0-beta.3':
resolution: {integrity: sha512-k38O+UviWrWdxtqZBBc/D8NJU11Rey8Y2YMwSWNxLv3eXZZdF5IVpbBkI/2RmLsV5nCcciqLPbukxeZnEfPlwA==}
cpu: [x64]
os: [darwin]
'@lydell/node-pty-linux-arm64@1.2.0-beta.3':
resolution: {integrity: sha512-HUwRpGu3O+4sv9DAQFKnyW5LYhyYu2SDUa/bdFO/t4dIFCM4uDJEq47wfRM7+aYtJTi1b3lakN8SlWeuFQqJQQ==}
cpu: [arm64]
os: [linux]
'@lydell/node-pty-linux-x64@1.2.0-beta.3':
resolution: {integrity: sha512-+RRY0PoCUeQaCvPR7/UnkGbxulwbFtoTWJfe+o4T1RcNtngrgaI55I9nl8CD8uqhGrB3smKuyvPM5UtwGhASUw==}
cpu: [x64]
os: [linux]
'@lydell/node-pty-win32-arm64@1.2.0-beta.3':
resolution: {integrity: sha512-UEDd9ASp2M3iIYpIzfmfBlpyn4+K1G4CAjYcHWStptCkefoSVXWTiUBIa1KjBjZi3/xmsHIDpBEYTkGWuvLt2Q==}
cpu: [arm64]
os: [win32]
'@lydell/node-pty-win32-x64@1.2.0-beta.3':
resolution: {integrity: sha512-TpdqSFYx7/Rj+68tuP6F/lkRYrHCYAIJgaS1bx3SctTkb5QAQCFwOKHd4xlsivmEOMT2LdhkJggPxwX9PAO5pQ==}
cpu: [x64]
os: [win32]
'@lydell/node-pty@1.2.0-beta.3':
resolution: {integrity: sha512-ngGAItlRhmJXrhspxt8kX13n1dVFqzETOq0m/+gqSkO8NJBvNMwP7FZckMwps2UFySdr4yxCXNGu/bumg5at6A==}
'@mariozechner/clipboard-darwin-arm64@0.3.0':
resolution: {integrity: sha512-7i4bitLzRSij0fj6q6tPmmf+JrwHqfBsBmf8mOcLVv0LVexD+4gEsyMait4i92exKYmCfna6uHKVS84G4nqehg==}
engines: {node: '>= 10'}
@@ -5142,6 +5178,33 @@ snapshots:
dependencies:
'@lit-labs/ssr-dom-shim': 1.5.1
'@lydell/node-pty-darwin-arm64@1.2.0-beta.3':
optional: true
'@lydell/node-pty-darwin-x64@1.2.0-beta.3':
optional: true
'@lydell/node-pty-linux-arm64@1.2.0-beta.3':
optional: true
'@lydell/node-pty-linux-x64@1.2.0-beta.3':
optional: true
'@lydell/node-pty-win32-arm64@1.2.0-beta.3':
optional: true
'@lydell/node-pty-win32-x64@1.2.0-beta.3':
optional: true
'@lydell/node-pty@1.2.0-beta.3':
optionalDependencies:
'@lydell/node-pty-darwin-arm64': 1.2.0-beta.3
'@lydell/node-pty-darwin-x64': 1.2.0-beta.3
'@lydell/node-pty-linux-arm64': 1.2.0-beta.3
'@lydell/node-pty-linux-x64': 1.2.0-beta.3
'@lydell/node-pty-win32-arm64': 1.2.0-beta.3
'@lydell/node-pty-win32-x64': 1.2.0-beta.3
'@mariozechner/clipboard-darwin-arm64@0.3.0':
optional: true

View File

@@ -13,11 +13,18 @@ let jobTtlMs = clampTtl(Number.parseInt(process.env.PI_BASH_JOB_TTL_MS ?? "", 10
export type ProcessStatus = "running" | "completed" | "failed" | "killed";
export type SessionStdin = {
write: (data: string, cb?: (err?: Error | null) => void) => void;
end: () => void;
destroyed?: boolean;
};
export interface ProcessSession {
id: string;
command: string;
scopeKey?: string;
child?: ChildProcessWithoutNullStreams;
stdin?: SessionStdin;
pid?: number;
startedAt: number;
cwd?: string;

View File

@@ -0,0 +1,20 @@
import { afterEach, expect, test } from "vitest";
import { createExecTool } from "./bash-tools.exec";
import { resetProcessRegistryForTests } from "./bash-process-registry";
afterEach(() => {
resetProcessRegistryForTests();
});
test("exec supports pty output", async () => {
const tool = createExecTool({ allowBackground: false });
const result = await tool.execute("toolcall", {
command: "node -e 'process.stdout.write(\"ok\")'",
pty: true,
});
expect(result.details.status).toBe("completed");
const text = result.content?.[0]?.text ?? "";
expect(text).toContain("ok");
});

View File

@@ -1,10 +1,16 @@
import { spawn } from "node:child_process";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { logInfo } from "../logger.js";
import { addSession, appendOutput, markBackgrounded, markExited } from "./bash-process-registry.js";
import {
type SessionStdin,
addSession,
appendOutput,
markBackgrounded,
markExited,
} from "./bash-process-registry.js";
import type { BashSandboxConfig } from "./bash-tools.shared.js";
import {
buildDockerExecArgs,
@@ -29,6 +35,26 @@ const DEFAULT_MAX_OUTPUT = clampNumber(
const DEFAULT_PATH =
process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
type PtyExitEvent = { exitCode: number; signal?: number };
type PtyListener<T> = (event: T) => void;
type PtyHandle = {
pid: number;
write: (data: string | Buffer) => void;
onData: (listener: PtyListener<string>) => void;
onExit: (listener: PtyListener<PtyExitEvent>) => void;
};
type PtySpawn = (
file: string,
args: string[] | string,
options: {
name?: string;
cols?: number;
rows?: number;
cwd?: string;
env?: Record<string, string>;
},
) => PtyHandle;
export type ExecToolDefaults = {
backgroundMs?: number;
timeoutSec?: number;
@@ -62,6 +88,11 @@ const execSchema = Type.Object({
description: "Timeout in seconds (optional, kills process on expiry)",
}),
),
pty: Type.Optional(
Type.Boolean({
description: "Run in a pseudo-terminal (PTY) when available (TTY-required CLIs, coding agents)",
}),
),
elevated: Type.Optional(
Type.Boolean({
description: "Run on the host with elevated permissions (if allowed)",
@@ -106,7 +137,7 @@ export function createExecTool(
name: "exec",
label: "exec",
description:
"Execute shell commands with background continuation. Use yieldMs/background to continue later via process tool. For real TTY mode, use the tmux skill.",
"Execute shell commands with background continuation. Use yieldMs/background to continue later via process tool. Use pty=true for TTY-required commands (terminal UIs, coding agents).",
parameters: execSchema,
execute: async (_toolCallId, args, signal, onUpdate) => {
const params = args as {
@@ -116,6 +147,7 @@ export function createExecTool(
yieldMs?: number;
background?: boolean;
timeout?: number;
pty?: boolean;
elevated?: boolean;
};
@@ -202,15 +234,20 @@ export function createExecTool(
containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
})
: mergedEnv;
const child = sandbox
? spawn(
const usePty = params.pty === true && !sandbox;
let child: ChildProcessWithoutNullStreams | null = null;
let pty: PtyHandle | null = null;
let stdin: SessionStdin | undefined;
if (sandbox) {
child = spawn(
"docker",
buildDockerExecArgs({
containerName: sandbox.containerName,
command: params.command,
workdir: containerWorkdir ?? sandbox.containerWorkdir,
env,
tty: false,
tty: params.pty === true,
}),
{
cwd: workdir,
@@ -219,21 +256,61 @@ export function createExecTool(
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
},
)
: spawn(shell, [...shellArgs, params.command], {
) as ChildProcessWithoutNullStreams;
stdin = child.stdin;
} else if (usePty) {
const ptyModule = (await import("@lydell/node-pty")) as unknown as {
spawn?: PtySpawn;
default?: { spawn?: PtySpawn };
};
const spawnPty = ptyModule.spawn ?? ptyModule.default?.spawn;
if (!spawnPty) {
throw new Error("PTY support is unavailable (node-pty spawn not found).");
}
pty = spawnPty(shell, [...shellArgs, params.command], {
cwd: workdir,
env,
name: process.env.TERM ?? "xterm-256color",
cols: 120,
rows: 30,
});
stdin = {
destroyed: false,
write: (data, cb) => {
try {
pty?.write(data);
cb?.(null);
} catch (err) {
cb?.(err as Error);
}
},
end: () => {
try {
const eof = process.platform === "win32" ? "\x1a" : "\x04";
pty?.write(eof);
} catch {
// ignore EOF errors
}
},
};
} else {
child = spawn(shell, [...shellArgs, params.command], {
cwd: workdir,
env,
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
}) as ChildProcessWithoutNullStreams;
stdin = child.stdin;
}
const session = {
id: sessionId,
command: params.command,
scopeKey: defaults?.scopeKey,
child,
pid: child?.pid,
child: child ?? undefined,
stdin,
pid: child?.pid ?? pty?.pid,
startedAt,
cwd: workdir,
maxOutputChars: maxOutput,
@@ -321,21 +398,28 @@ export function createExecTool(
});
};
child.stdout.on("data", (data) => {
const handleStdout = (data: string) => {
const str = sanitizeBinaryOutput(data.toString());
for (const chunk of chunkString(str)) {
appendOutput(session, "stdout", chunk);
emitUpdate();
}
});
};
child.stderr.on("data", (data) => {
const handleStderr = (data: string) => {
const str = sanitizeBinaryOutput(data.toString());
for (const chunk of chunkString(str)) {
appendOutput(session, "stderr", chunk);
emitUpdate();
}
});
};
if (pty) {
pty.onData(handleStdout);
} else if (child) {
child.stdout.on("data", handleStdout);
child.stderr.on("data", handleStderr);
}
return new Promise<AgentToolResult<ExecToolDetails>>((resolve, reject) => {
rejectFn = reject;
@@ -393,6 +477,9 @@ export function createExecTool(
const isSuccess = code === 0 && !wasSignal && !signal?.aborted && !timedOut;
const status: "completed" | "failed" = isSuccess ? "completed" : "failed";
markExited(session, code, exitSignal, status);
if (!session.child && session.stdin) {
session.stdin.destroyed = true;
}
if (yielded || session.backgrounded) return;
@@ -433,17 +520,25 @@ export function createExecTool(
// `exit` can fire before stdio fully flushes (notably on Windows).
// `close` waits for streams to close, so aggregated output is complete.
child.once("close", (code, exitSignal) => {
handleExit(code, exitSignal);
});
if (pty) {
pty.onExit((event) => {
const rawSignal = event.signal ?? null;
const normalizedSignal = rawSignal === 0 ? null : rawSignal;
handleExit(event.exitCode ?? null, normalizedSignal);
});
} else if (child) {
child.once("close", (code, exitSignal) => {
handleExit(code, exitSignal);
});
child.once("error", (err) => {
if (yieldTimer) clearTimeout(yieldTimer);
if (timeoutTimer) clearTimeout(timeoutTimer);
if (timeoutFinalizeTimer) clearTimeout(timeoutFinalizeTimer);
markExited(session, null, null, "failed");
settle(() => reject(err));
});
child.once("error", (err) => {
if (yieldTimer) clearTimeout(yieldTimer);
if (timeoutTimer) clearTimeout(timeoutTimer);
if (timeoutFinalizeTimer) clearTimeout(timeoutFinalizeTimer);
markExited(session, null, null, "failed");
settle(() => reject(err));
});
}
});
},
};

View File

@@ -302,7 +302,8 @@ export function createProcessTool(
details: { status: "failed" },
};
}
if (!scopedSession.child?.stdin || scopedSession.child.stdin.destroyed) {
const stdin = scopedSession.stdin ?? scopedSession.child?.stdin;
if (!stdin || stdin.destroyed) {
return {
content: [
{
@@ -314,13 +315,13 @@ export function createProcessTool(
};
}
await new Promise<void>((resolve, reject) => {
scopedSession.child?.stdin.write(params.data ?? "", (err) => {
stdin.write(params.data ?? "", (err) => {
if (err) reject(err);
else resolve();
});
});
if (params.eof) {
scopedSession.child.stdin.end();
stdin.end();
}
return {
content: [

View File

@@ -166,7 +166,7 @@ export function buildAgentSystemPrompt(params: {
grep: "Search file contents for patterns",
find: "Find files by glob pattern",
ls: "List directory contents",
exec: "Run shell commands",
exec: "Run shell commands (pty available for TTY-required CLIs)",
process: "Manage background exec sessions",
web_search: "Search the web (Brave API)",
web_fetch: "Fetch and extract readable content from a URL",

24
src/types/lydell-node-pty.d.ts vendored Normal file
View File

@@ -0,0 +1,24 @@
declare module "@lydell/node-pty" {
export type PtyExitEvent = { exitCode: number; signal?: number };
export type PtyListener<T> = (event: T) => void;
export type PtyHandle = {
pid: number;
write: (data: string | Buffer) => void;
onData: (listener: PtyListener<string>) => void;
onExit: (listener: PtyListener<PtyExitEvent>) => void;
};
export type PtySpawn = (
file: string,
args: string[] | string,
options: {
name?: string;
cols?: number;
rows?: number;
cwd?: string;
env?: Record<string, string>;
},
) => PtyHandle;
export const spawn: PtySpawn;
}