From c4ea25a5096ebf429243b99d469f1b79c99ee276 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 17 Jan 2026 04:57:04 +0000 Subject: [PATCH] feat: add exec pty support --- docs/gateway/background-process.md | 2 +- docs/tools/exec.md | 3 +- docs/tools/index.md | 2 +- package.json | 1 + pnpm-lock.yaml | 63 +++++++++++ src/agents/bash-process-registry.ts | 7 ++ src/agents/bash-tools.exec.pty.test.ts | 20 ++++ src/agents/bash-tools.exec.ts | 145 ++++++++++++++++++++----- src/agents/bash-tools.process.ts | 7 +- src/agents/system-prompt.ts | 2 +- src/types/lydell-node-pty.d.ts | 24 ++++ 11 files changed, 244 insertions(+), 32 deletions(-) create mode 100644 src/agents/bash-tools.exec.pty.test.ts create mode 100644 src/types/lydell-node-pty.d.ts diff --git a/docs/gateway/background-process.md b/docs/gateway/background-process.md index 3d79b052e..6a8a71bf3 100644 --- a/docs/gateway/background-process.md +++ b/docs/gateway/background-process.md @@ -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: diff --git a/docs/tools/exec.md b/docs/tools/exec.md index 2ef030b95..532d9ba93 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -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 diff --git a/docs/tools/index.md b/docs/tools/index.md index 25a1a8372..a3c4c68ac 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -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. diff --git a/package.json b/package.json index 7df4f34b8..91466b4a9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b7b18eba..f208ac41b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/agents/bash-process-registry.ts b/src/agents/bash-process-registry.ts index c68531d9c..070685300 100644 --- a/src/agents/bash-process-registry.ts +++ b/src/agents/bash-process-registry.ts @@ -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; diff --git a/src/agents/bash-tools.exec.pty.test.ts b/src/agents/bash-tools.exec.pty.test.ts new file mode 100644 index 000000000..c890f7c42 --- /dev/null +++ b/src/agents/bash-tools.exec.pty.test.ts @@ -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"); +}); diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 5ef6ccd15..3e621684a 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -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 = (event: T) => void; +type PtyHandle = { + pid: number; + write: (data: string | Buffer) => void; + onData: (listener: PtyListener) => void; + onExit: (listener: PtyListener) => void; +}; +type PtySpawn = ( + file: string, + args: string[] | string, + options: { + name?: string; + cols?: number; + rows?: number; + cwd?: string; + env?: Record; + }, +) => 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>((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)); + }); + } }); }, }; diff --git a/src/agents/bash-tools.process.ts b/src/agents/bash-tools.process.ts index 3b2a32800..e5e37d174 100644 --- a/src/agents/bash-tools.process.ts +++ b/src/agents/bash-tools.process.ts @@ -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((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: [ diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 86e5a4cac..fea4ef28e 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -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", diff --git a/src/types/lydell-node-pty.d.ts b/src/types/lydell-node-pty.d.ts new file mode 100644 index 000000000..be7c40b76 --- /dev/null +++ b/src/types/lydell-node-pty.d.ts @@ -0,0 +1,24 @@ +declare module "@lydell/node-pty" { + export type PtyExitEvent = { exitCode: number; signal?: number }; + export type PtyListener = (event: T) => void; + export type PtyHandle = { + pid: number; + write: (data: string | Buffer) => void; + onData: (listener: PtyListener) => void; + onExit: (listener: PtyListener) => void; + }; + + export type PtySpawn = ( + file: string, + args: string[] | string, + options: { + name?: string; + cols?: number; + rows?: number; + cwd?: string; + env?: Record; + }, + ) => PtyHandle; + + export const spawn: PtySpawn; +}