feat(pty): added PTY-backed interactive command execution with streaming output support

- Added PTY (pseudo-terminal) backed interactive command execution with streaming output support via PtySession class in pi-natives.
- Added PtyStartOptions and PtyRunResult types to configure and report PTY session execution status.
- Added write(), resize(), and kill() methods to PtySession for interactive control of running commands.
- Added interactive bash execution via PTY with real-time terminal rendering and input forwarding in bash-interactive tool.
- Added --no-pty CLI flag and PI_NO_PTY environment variable to disable PTY-based interactive bash execution.
- Added bash.virtualTerminal setting to control PTY-backed interactive execution behavior.
This commit is contained in:
can1357
2026-02-12 04:34:24 +01:00
parent cf864bbf7e
commit 2c7a32f28c
19 changed files with 1076 additions and 88 deletions
+51 -28
View File
@@ -3,10 +3,10 @@ import * as path from "node:path";
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { $env, isEnoent } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import { renderPromptTemplate } from "../config/prompt-templates";
import { type BashExecutorOptions, executeBash } from "../exec/bash-executor";
import { type BashResult, executeBash } from "../exec/bash-executor";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { truncateToVisualLines } from "../modes/components/visual-truncate";
import type { Theme } from "../modes/theme/theme";
@@ -14,6 +14,7 @@ import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
import { renderStatusLine } from "../tui";
import { CachedOutputBlock } from "../tui/output-block";
import type { ToolSession } from ".";
import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-interactive";
import { checkBashInterception } from "./bash-interceptor";
import { applyHeadTail, normalizeBashCommand } from "./bash-normalize";
import type { OutputMeta } from "./output-meta";
@@ -42,6 +43,13 @@ export interface BashToolDetails {
export interface BashToolOptions {}
function normalizeResultOutput(result: BashResult | BashInteractiveResult): string {
return result.output || "";
}
function isInteractiveResult(result: BashResult | BashInteractiveResult): result is BashInteractiveResult {
return "timedOut" in result;
}
/**
* Bash tool implementation.
*
@@ -108,35 +116,49 @@ export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails> {
const extraEnv = artifactsDir ? { ARTIFACTS: artifactsDir } : undefined;
const { artifactPath, artifactId } = await allocateOutputArtifact(this.session, "bash");
const executorOptions: BashExecutorOptions = {
cwd: commandCwd,
timeout: timeoutMs,
signal,
env: extraEnv,
artifactPath,
artifactId,
onChunk: chunk => {
tailBuffer.append(chunk);
if (onUpdate) {
onUpdate({
content: [{ type: "text", text: tailBuffer.text() }],
details: {},
});
}
},
};
// Handle errors
const result = await executeBash(command, executorOptions);
const usePty =
this.session.settings.get("bash.virtualTerminal") === "on" &&
$env.PI_NO_PTY !== "1" &&
ctx?.hasUI === true &&
ctx.ui !== undefined;
const result: BashResult | BashInteractiveResult = usePty
? await runInteractiveBashPty(ctx.ui!, {
command,
cwd: commandCwd,
timeoutMs,
signal,
env: extraEnv,
artifactPath,
artifactId,
})
: await executeBash(command, {
cwd: commandCwd,
timeout: timeoutMs,
signal,
env: extraEnv,
artifactPath,
artifactId,
onChunk: chunk => {
tailBuffer.append(chunk);
if (onUpdate) {
onUpdate({
content: [{ type: "text", text: tailBuffer.text() }],
details: {},
});
}
},
});
if (result.cancelled) {
if (signal?.aborted) {
throw new ToolAbortError(result.output || "Command aborted");
throw new ToolAbortError(normalizeResultOutput(result) || "Command aborted");
}
throw new ToolError(result.output || "Command aborted");
throw new ToolError(normalizeResultOutput(result) || "Command aborted");
}
if (isInteractiveResult(result) && result.timedOut) {
throw new ToolError(normalizeResultOutput(result) || `Command timed out after ${timeoutSec} seconds`);
}
// Apply head/tail filtering if specified
let outputText = result.output || "";
let outputText = normalizeResultOutput(result);
const headTailResult = applyHeadTail(outputText, headLines, tailLines);
if (headTailResult.applied) {
outputText = headTailResult.text;
@@ -144,10 +166,11 @@ export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails> {
if (!outputText) {
outputText = "(no output)";
}
const details: BashToolDetails = {};
const resultBuilder = toolResult(details).text(outputText).truncationFromSummary(result, { direction: "tail" });
if (result.exitCode === undefined) {
throw new ToolError(`${outputText}\n\nCommand failed: missing exit status`);
}
if (result.exitCode !== 0 && result.exitCode !== undefined) {
throw new ToolError(`${outputText}\n\nCommand exited with code ${result.exitCode}`);
}