Files
oh-my-pi/packages/coding-agent/src/tools/bash.ts
T
can1357 8276390877 refactor(coding-agent): standardized XML tags and RFC 2119 keywords across prompts
- Standardized XML tag naming from snake_case to kebab-case across 50+ prompt files for consistency.
- Replaced imperative language with RFC 2119 keywords (MUST/SHOULD/MAY/MUST NOT) throughout system and tool prompts for clarity.
- Removed artifactsDir parameter from Python executor and simplified environment variable handling to use PI_SESSION_FILE only.
- Renamed read_path.md to read-path.md and updated memory guidance with hierarchy rules and conflict resolution workflow.
- Added noEscape option to bash URL expansion and extracted cwd parameter from leading cd commands for improved path handling.
- Exported NO_PAGER_ENV constant from bash-interactive module for centralized environment variable management.
2026-02-22 17:12:27 +01:00

453 lines
15 KiB
TypeScript

import * as fs from "node:fs";
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 { $env, isEnoent } from "@oh-my-pi/pi-utils";
import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { Type } from "@sinclair/typebox";
import { renderPromptTemplate } from "../config/prompt-templates";
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";
import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
import { DEFAULT_MAX_BYTES, TailBuffer } from "../session/streaming-output";
import { renderStatusLine } from "../tui";
import { CachedOutputBlock } from "../tui/output-block";
import type { ToolSession } from ".";
import { type BashInteractiveResult, NO_PAGER_ENV, runInteractiveBashPty } from "./bash-interactive";
import { checkBashInterception } from "./bash-interceptor";
import { applyHeadTail } from "./bash-normalize";
import { expandInternalUrls, type InternalUrlExpansionOptions } from "./bash-skill-urls";
import { formatStyledTruncationWarning, type OutputMeta } from "./output-meta";
import { resolveToCwd } from "./path-utils";
import { replaceTabs } from "./render-utils";
import { ToolAbortError, ToolError } from "./tool-errors";
import { toolResult } from "./tool-result";
export const BASH_DEFAULT_PREVIEW_LINES = 10;
const bashSchemaBase = Type.Object({
command: Type.String({ description: "Command to execute" }),
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (default: 300)" })),
cwd: Type.Optional(Type.String({ description: "Working directory (default: cwd)" })),
head: Type.Optional(Type.Number({ description: "Return only first N lines of output" })),
tail: Type.Optional(Type.Number({ description: "Return only last N lines of output" })),
pty: Type.Optional(
Type.Boolean({
description: "Run in PTY mode when command needs a real terminal (e.g. sudo/ssh/top/less); default: false",
}),
),
});
const bashSchemaWithAsync = Type.Object({
...bashSchemaBase.properties,
async: Type.Optional(
Type.Boolean({
description: "Run in background; returns immediately with a job ID. Result delivered as follow-up.",
}),
),
});
type BashToolSchema = typeof bashSchemaBase | typeof bashSchemaWithAsync;
export interface BashToolInput {
command: string;
timeout?: number;
cwd?: string;
head?: number;
tail?: number;
async?: boolean;
pty?: boolean;
}
export interface BashToolDetails {
meta?: OutputMeta;
async?: {
state: "running" | "completed" | "failed";
jobId: string;
type: "bash";
};
}
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.
*
* Executes bash commands with optional timeout and working directory.
*/
export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
readonly name = "bash";
readonly label = "Bash";
readonly description: string;
readonly parameters: BashToolSchema;
readonly concurrency = "exclusive";
readonly #asyncEnabled: boolean;
constructor(private readonly session: ToolSession) {
this.#asyncEnabled = this.session.settings.get("async.enabled");
this.parameters = this.#asyncEnabled ? bashSchemaWithAsync : bashSchemaBase;
this.description = renderPromptTemplate(bashDescription, { asyncEnabled: this.#asyncEnabled });
}
#formatResultOutput(result: BashResult | BashInteractiveResult, headLines?: number, tailLines?: number): string {
let outputText = normalizeResultOutput(result);
const headTailResult = applyHeadTail(outputText, headLines, tailLines);
if (headTailResult.applied) {
outputText = headTailResult.text;
}
if (!outputText) {
outputText = "(no output)";
}
return outputText;
}
#buildResultText(result: BashResult | BashInteractiveResult, timeoutSec: number, outputText: string): string {
if (result.cancelled) {
throw new ToolError(normalizeResultOutput(result) || "Command aborted");
}
if (isInteractiveResult(result) && result.timedOut) {
throw new ToolError(normalizeResultOutput(result) || `Command timed out after ${timeoutSec} seconds`);
}
if (result.exitCode === undefined) {
throw new ToolError(`${outputText}\n\nCommand failed: missing exit status`);
}
if (result.exitCode !== 0) {
throw new ToolError(`${outputText}\n\nCommand exited with code ${result.exitCode}`);
}
return outputText;
}
async execute(
_toolCallId: string,
{
command: rawCommand,
timeout: rawTimeout = 300,
cwd,
head,
tail,
async: asyncRequested = false,
pty = false,
}: BashToolInput,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<BashToolDetails>,
ctx?: AgentToolContext,
): Promise<AgentToolResult<BashToolDetails>> {
let command = rawCommand;
// Extract leading `cd <path> && ...` into cwd when the model ignores the cwd parameter.
if (!cwd) {
const cdMatch = command.match(/^cd\s+((?:[^&\\]|\\.)+?)\s*&&\s*/);
if (cdMatch) {
cwd = cdMatch[1].trim().replace(/^["']|["']$/g, "");
command = command.slice(cdMatch[0].length);
}
}
if (asyncRequested && !this.#asyncEnabled) {
throw new ToolError("Async bash execution is disabled. Enable async.enabled to use async mode.");
}
// Only apply explicit head/tail params from tool input.
const headLines = head;
const tailLines = tail;
// Check interception if enabled and available tools are known
if (this.session.settings.get("bashInterceptor.enabled")) {
const rules = this.session.settings.getBashInterceptorRules();
const interception = checkBashInterception(command, ctx?.toolNames ?? [], rules);
if (interception.block) {
throw new ToolError(interception.message ?? "Command blocked");
}
}
const internalUrlOptions: InternalUrlExpansionOptions = {
skills: this.session.skills ?? [],
internalRouter: this.session.internalRouter,
};
command = await expandInternalUrls(command, internalUrlOptions);
// Resolve protocol URLs (skill://, agent://, etc.) in extracted cwd.
if (cwd?.includes("://")) {
cwd = await expandInternalUrls(cwd, { ...internalUrlOptions, noEscape: true });
}
const commandCwd = cwd ? resolveToCwd(cwd, this.session.cwd) : this.session.cwd;
let cwdStat: fs.Stats;
try {
cwdStat = await fs.promises.stat(commandCwd);
} catch (err) {
if (isEnoent(err)) {
throw new ToolError(`Working directory does not exist: ${commandCwd}`);
}
throw err;
}
if (!cwdStat.isDirectory()) {
throw new ToolError(`Working directory is not a directory: ${commandCwd}`);
}
// Clamp to reasonable range: 1s - 3600s (1 hour)
const timeoutSec = Math.max(1, Math.min(3600, rawTimeout));
const timeoutMs = timeoutSec * 1000;
if (asyncRequested) {
const manager = this.session.asyncJobManager;
if (!manager) {
throw new ToolError("Async job manager unavailable for this session.");
}
const label = command.length > 120 ? `${command.slice(0, 117)}...` : command;
const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES);
const jobId = manager.register(
"bash",
label,
async ({ jobId, signal: runSignal, reportProgress }) => {
const { path: artifactPath, id: artifactId } =
(await this.session.allocateOutputArtifact?.("bash")) ?? {};
try {
const result = await executeBash(command, {
cwd: commandCwd,
sessionKey: `${this.session.getSessionId?.() ?? ""}:async:${jobId}`,
timeout: timeoutMs,
signal: runSignal,
env: NO_PAGER_ENV,
artifactPath,
artifactId,
onChunk: chunk => {
tailBuffer.append(chunk);
void reportProgress(tailBuffer.text(), { async: { state: "running", jobId, type: "bash" } });
},
});
const outputText = this.#formatResultOutput(result, headLines, tailLines);
const finalText = this.#buildResultText(result, timeoutSec, outputText);
await reportProgress(finalText, { async: { state: "completed", jobId, type: "bash" } });
return finalText;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await reportProgress(message, { async: { state: "failed", jobId, type: "bash" } });
throw error;
}
},
{
onProgress: (text, details) => {
onUpdate?.({ content: [{ type: "text", text }], details: details ?? {} });
},
},
);
return {
content: [{ type: "text", text: `Background job ${jobId} started: ${label}` }],
details: { async: { state: "running", jobId, type: "bash" } },
};
}
// Track output for streaming updates (tail only)
const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES);
// Allocate artifact for truncated output storage
const { path: artifactPath, id: artifactId } = (await this.session.allocateOutputArtifact?.("bash")) ?? {};
const usePty = pty && $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,
artifactPath,
artifactId,
})
: await executeBash(command, {
cwd: commandCwd,
sessionKey: this.session.getSessionId?.() ?? undefined,
timeout: timeoutMs,
signal,
env: NO_PAGER_ENV,
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(normalizeResultOutput(result) || "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`);
}
const outputText = this.#formatResultOutput(result, headLines, tailLines);
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}`);
}
return resultBuilder.done();
}
}
// =============================================================================
// TUI Renderer
// =============================================================================
interface BashRenderArgs {
command?: string;
timeout?: number;
cwd?: string;
}
interface BashRenderContext {
/** Raw output text */
output?: string;
/** Whether output came from artifact storage */
isFullOutput?: boolean;
/** Whether output is expanded */
expanded?: boolean;
/** Number of preview lines when collapsed */
previewLines?: number;
/** Timeout in seconds */
timeout?: number;
}
function formatBashCommand(args: BashRenderArgs, _uiTheme: Theme): string {
const command = args.command || "…";
const prompt = "$";
const cwd = getProjectDir();
let displayWorkdir = args.cwd;
if (displayWorkdir) {
const resolvedCwd = path.resolve(cwd);
const resolvedWorkdir = path.resolve(displayWorkdir);
if (resolvedWorkdir === resolvedCwd) {
displayWorkdir = undefined;
} else {
const relativePath = path.relative(resolvedCwd, resolvedWorkdir);
const isWithinCwd =
relativePath && !relativePath.startsWith("..") && !relativePath.startsWith(`..${path.sep}`);
if (isWithinCwd) {
displayWorkdir = relativePath;
}
}
}
return displayWorkdir ? `${prompt} cd ${displayWorkdir} && ${command}` : `${prompt} ${command}`;
}
export const bashToolRenderer = {
renderCall(args: BashRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
const cmdText = formatBashCommand(args, uiTheme);
const text = renderStatusLine({ icon: "pending", title: "Bash", description: cmdText }, uiTheme);
return new Text(text, 0, 0);
},
renderResult(
result: {
content: Array<{ type: string; text?: string }>;
details?: BashToolDetails;
isError?: boolean;
},
options: RenderResultOptions & { renderContext?: BashRenderContext },
uiTheme: Theme,
args?: BashRenderArgs,
): Component {
const cmdText = args ? formatBashCommand(args, uiTheme) : undefined;
const isError = result.isError === true;
const header = renderStatusLine({ icon: isError ? "error" : "success", title: "Bash" }, uiTheme);
const details = result.details;
const outputBlock = new CachedOutputBlock();
return {
render: (width: number): string[] => {
// REACTIVE: read mutable options at render time
const { renderContext } = options;
const expanded = renderContext?.expanded ?? options.expanded;
const previewLines = renderContext?.previewLines ?? BASH_DEFAULT_PREVIEW_LINES;
// Get output from context (preferred) or fall back to result content
const output = renderContext?.output ?? result.content?.find(c => c.type === "text")?.text ?? "";
const displayOutput = output.trimEnd();
const showingFullOutput = expanded && renderContext?.isFullOutput === true;
// Build truncation warning
const timeoutSeconds = renderContext?.timeout;
const timeoutLine =
typeof timeoutSeconds === "number"
? uiTheme.fg(
"dim",
`${uiTheme.format.bracketLeft}Timeout: ${timeoutSeconds}s${uiTheme.format.bracketRight}`,
)
: undefined;
let warningLine: string | undefined;
if (details?.meta?.truncation && !showingFullOutput) {
warningLine = formatStyledTruncationWarning(details.meta, uiTheme) ?? undefined;
}
const outputLines: string[] = [];
const hasOutput = displayOutput.trim().length > 0;
if (hasOutput) {
if (expanded) {
outputLines.push(
...displayOutput.split("\n").map(line => uiTheme.fg("toolOutput", replaceTabs(line))),
);
} else {
const styledOutput = displayOutput
.split("\n")
.map(line => uiTheme.fg("toolOutput", replaceTabs(line)))
.join("\n");
const textContent = styledOutput;
const result = truncateToVisualLines(textContent, previewLines, width);
if (result.skippedCount > 0) {
outputLines.push(
uiTheme.fg(
"dim",
`… (${result.skippedCount} earlier lines, showing ${result.visualLines.length} of ${result.skippedCount + result.visualLines.length}) (ctrl+o to expand)`,
),
);
}
outputLines.push(...result.visualLines);
}
}
if (timeoutLine) outputLines.push(timeoutLine);
if (warningLine) outputLines.push(warningLine);
return outputBlock.render(
{
header,
state: isError ? "error" : "success",
sections: [
{ lines: cmdText ? [uiTheme.fg("dim", cmdText)] : [] },
{ label: uiTheme.fg("toolTitle", "Output"), lines: outputLines },
],
width,
},
uiTheme,
);
},
invalidate: () => {
outputBlock.invalidate();
},
};
},
mergeCallAndResult: true,
inline: true,
};