b2c94bef1a
- Created new @oh-my-pi/pi-utils workspace package with shared utilities for logging, process management, stream handling, and temporary directory management. - Migrated all packages to use centralized logger from @oh-my-pi/pi-utils instead of local winston implementations. - Replaced custom process spawning and stream reading implementations with standardized cspawn and readLines utilities across all modules. - Converted synchronous file operations and process spawning to async patterns using Bun shell syntax and fs/promises. - Added streaming edit abort functionality with configurable setting to abort on patch preview failures. - Updated test framework from vitest to bun:test across all test suites.
336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
import { relative, resolve, sep } 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, truncateToWidth } from "@oh-my-pi/pi-tui";
|
|
import { Type } from "@sinclair/typebox";
|
|
import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate";
|
|
import type { Theme } from "../../modes/interactive/theme/theme";
|
|
import bashDescription from "../../prompts/tools/bash.md" with { type: "text" };
|
|
import { type BashExecutorOptions, executeBash, executeBashWithOperations } from "../bash-executor";
|
|
import type { RenderResultOptions } from "../custom-tools/types";
|
|
import { renderPromptTemplate } from "../prompt-templates";
|
|
import { checkBashInterception, checkSimpleLsInterception } from "./bash-interceptor";
|
|
import type { ToolSession } from "./index";
|
|
import { resolveToCwd } from "./path-utils";
|
|
import { ToolUIKit } from "./render-utils";
|
|
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateTail } from "./truncate";
|
|
|
|
export const BASH_DEFAULT_PREVIEW_LINES = 10;
|
|
|
|
const bashSchema = Type.Object({
|
|
command: Type.String({ description: "Bash command to execute" }),
|
|
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
|
|
workdir: Type.Optional(
|
|
Type.String({ description: "Working directory for the command (default: current directory)" }),
|
|
),
|
|
});
|
|
|
|
export interface BashToolDetails {
|
|
truncation?: TruncationResult;
|
|
fullOutputPath?: string;
|
|
fullOutput?: string;
|
|
}
|
|
|
|
/**
|
|
* Pluggable operations for bash execution.
|
|
* Override to delegate command execution to remote systems.
|
|
*/
|
|
export interface BashOperations {
|
|
exec: (
|
|
command: string,
|
|
cwd: string,
|
|
options: {
|
|
onData: (data: Buffer) => void;
|
|
signal?: AbortSignal;
|
|
timeout?: number;
|
|
},
|
|
) => Promise<{ exitCode: number | null }>;
|
|
}
|
|
|
|
export interface BashToolOptions {
|
|
/** Custom operations for command execution. Default: local shell */
|
|
operations?: BashOperations;
|
|
}
|
|
|
|
/**
|
|
* Bash tool implementation.
|
|
*
|
|
* Executes bash commands with optional timeout and working directory.
|
|
* Supports custom operations for remote execution.
|
|
*/
|
|
export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails> {
|
|
public readonly name = "bash";
|
|
public readonly label = "Bash";
|
|
public readonly description: string;
|
|
public readonly parameters = bashSchema;
|
|
|
|
private readonly session: ToolSession;
|
|
private readonly options?: BashToolOptions;
|
|
|
|
constructor(session: ToolSession, options?: BashToolOptions) {
|
|
this.session = session;
|
|
this.options = options;
|
|
this.description = renderPromptTemplate(bashDescription);
|
|
}
|
|
|
|
public async execute(
|
|
_toolCallId: string,
|
|
{ command, timeout, workdir }: { command: string; timeout?: number; workdir?: string },
|
|
signal?: AbortSignal,
|
|
onUpdate?: AgentToolUpdateCallback<BashToolDetails>,
|
|
ctx?: AgentToolContext,
|
|
): Promise<AgentToolResult<BashToolDetails>> {
|
|
// Check interception if enabled and available tools are known
|
|
if (this.session.settings?.getBashInterceptorEnabled()) {
|
|
const rules = this.session.settings?.getBashInterceptorRules?.();
|
|
const interception = checkBashInterception(command, ctx?.toolNames ?? [], rules);
|
|
if (interception.block) {
|
|
throw new Error(interception.message);
|
|
}
|
|
if (this.session.settings?.getBashInterceptorSimpleLsEnabled?.() !== false) {
|
|
const lsInterception = checkSimpleLsInterception(command, ctx?.toolNames ?? []);
|
|
if (lsInterception.block) {
|
|
throw new Error(lsInterception.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
const commandCwd = workdir ? resolveToCwd(workdir, this.session.cwd) : this.session.cwd;
|
|
let cwdStat: Awaited<ReturnType<Bun.BunFile["stat"]>>;
|
|
try {
|
|
cwdStat = await Bun.file(commandCwd).stat();
|
|
} catch {
|
|
throw new Error(`Working directory does not exist: ${commandCwd}`);
|
|
}
|
|
if (!cwdStat.isDirectory()) {
|
|
throw new Error(`Working directory is not a directory: ${commandCwd}`);
|
|
}
|
|
|
|
// Track output for streaming updates
|
|
let currentOutput = "";
|
|
|
|
const executorOptions: BashExecutorOptions = {
|
|
cwd: commandCwd,
|
|
timeout: timeout ? timeout * 1000 : undefined, // Convert to milliseconds
|
|
signal,
|
|
onChunk: (chunk) => {
|
|
currentOutput += chunk;
|
|
if (onUpdate) {
|
|
const truncation = truncateTail(currentOutput);
|
|
onUpdate({
|
|
content: [{ type: "text", text: truncation.content || "" }],
|
|
details: truncation.truncated ? { truncation, fullOutput: currentOutput } : {},
|
|
});
|
|
}
|
|
},
|
|
};
|
|
|
|
// Use custom operations if provided, otherwise use default local executor
|
|
const result = this.options?.operations
|
|
? await executeBashWithOperations(command, commandCwd, this.options.operations, executorOptions)
|
|
: await executeBash(command, executorOptions);
|
|
|
|
// Handle errors
|
|
if (result.cancelled) {
|
|
throw new Error(result.output || "Command aborted");
|
|
}
|
|
|
|
// Apply tail truncation for final output
|
|
const truncation = truncateTail(result.output);
|
|
let outputText = truncation.content || "(no output)";
|
|
|
|
let details: BashToolDetails | undefined;
|
|
|
|
if (truncation.truncated) {
|
|
details = {
|
|
truncation,
|
|
fullOutputPath: result.fullOutputPath,
|
|
fullOutput: currentOutput,
|
|
};
|
|
|
|
const startLine = truncation.totalLines - truncation.outputLines + 1;
|
|
const endLine = truncation.totalLines;
|
|
|
|
if (truncation.lastLinePartial) {
|
|
const lastLineSize = formatSize(Buffer.byteLength(result.output.split("\n").pop() || "", "utf-8"));
|
|
outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${result.fullOutputPath}]`;
|
|
} else if (truncation.truncatedBy === "lines") {
|
|
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${result.fullOutputPath}]`;
|
|
} else {
|
|
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${result.fullOutputPath}]`;
|
|
}
|
|
}
|
|
|
|
if (result.exitCode !== 0 && result.exitCode !== undefined) {
|
|
outputText += `\n\nCommand exited with code ${result.exitCode}`;
|
|
throw new Error(outputText);
|
|
}
|
|
|
|
return { content: [{ type: "text", text: outputText }], details };
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// TUI Renderer
|
|
// =============================================================================
|
|
|
|
interface BashRenderArgs {
|
|
command?: string;
|
|
timeout?: number;
|
|
workdir?: string;
|
|
}
|
|
|
|
interface BashRenderContext {
|
|
/** Raw output text */
|
|
output?: string;
|
|
/** Whether output is expanded */
|
|
expanded?: boolean;
|
|
/** Number of preview lines when collapsed */
|
|
previewLines?: number;
|
|
/** Timeout in seconds */
|
|
timeout?: number;
|
|
}
|
|
|
|
// Preview line limit when not expanded (matches tool-execution behavior)
|
|
export const BASH_PREVIEW_LINES = 10;
|
|
|
|
export const bashToolRenderer = {
|
|
renderCall(args: BashRenderArgs, uiTheme: Theme): Component {
|
|
const ui = new ToolUIKit(uiTheme);
|
|
const command = args.command || uiTheme.format.ellipsis;
|
|
const prompt = uiTheme.fg("accent", "$");
|
|
const cwd = process.cwd();
|
|
let displayWorkdir = args.workdir;
|
|
|
|
if (displayWorkdir) {
|
|
const resolvedCwd = resolve(cwd);
|
|
const resolvedWorkdir = resolve(displayWorkdir);
|
|
if (resolvedWorkdir === resolvedCwd) {
|
|
displayWorkdir = undefined;
|
|
} else {
|
|
const relativePath = relative(resolvedCwd, resolvedWorkdir);
|
|
const isWithinCwd = relativePath && !relativePath.startsWith("..") && !relativePath.startsWith(`..${sep}`);
|
|
if (isWithinCwd) {
|
|
displayWorkdir = relativePath;
|
|
}
|
|
}
|
|
}
|
|
|
|
const cmdText = displayWorkdir
|
|
? `${prompt} ${uiTheme.fg("dim", `cd ${displayWorkdir} &&`)} ${command}`
|
|
: `${prompt} ${command}`;
|
|
const text = ui.title(cmdText);
|
|
return new Text(text, 0, 0);
|
|
},
|
|
|
|
renderResult(
|
|
result: {
|
|
content: Array<{ type: string; text?: string }>;
|
|
details?: BashToolDetails;
|
|
},
|
|
options: RenderResultOptions & { renderContext?: BashRenderContext },
|
|
uiTheme: Theme,
|
|
): Component {
|
|
const ui = new ToolUIKit(uiTheme);
|
|
const { renderContext } = options;
|
|
const details = result.details;
|
|
|
|
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 ?? "").trim();
|
|
const fullOutput = details?.fullOutput;
|
|
const displayOutput = expanded ? (fullOutput ?? output) : output;
|
|
const showingFullOutput = expanded && fullOutput !== undefined;
|
|
|
|
// Build truncation warning lines (static, doesn't depend on width)
|
|
const truncation = details?.truncation;
|
|
const fullOutputPath = details?.fullOutputPath;
|
|
const timeoutSeconds = renderContext?.timeout;
|
|
const timeoutLine =
|
|
typeof timeoutSeconds === "number"
|
|
? uiTheme.fg("dim", ui.wrapBrackets(`Timeout: ${timeoutSeconds}s`))
|
|
: undefined;
|
|
let warningLine: string | undefined;
|
|
if (fullOutputPath || (truncation?.truncated && !showingFullOutput)) {
|
|
const warnings: string[] = [];
|
|
if (fullOutputPath) {
|
|
warnings.push(`Full output: ${fullOutputPath}`);
|
|
}
|
|
if (truncation?.truncated && !showingFullOutput) {
|
|
if (truncation.truncatedBy === "lines") {
|
|
warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
|
|
} else {
|
|
warnings.push(
|
|
`Truncated: ${truncation.outputLines} lines shown (${ui.formatBytes(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`,
|
|
);
|
|
}
|
|
}
|
|
if (warnings.length > 0) {
|
|
warningLine = uiTheme.fg("warning", ui.wrapBrackets(warnings.join(". ")));
|
|
}
|
|
}
|
|
|
|
if (!displayOutput) {
|
|
// No output - just show warning if any
|
|
const lines = [timeoutLine, warningLine].filter(Boolean) as string[];
|
|
return new Text(lines.join("\n"), 0, 0);
|
|
}
|
|
|
|
if (expanded) {
|
|
// Show all lines when expanded
|
|
const styledOutput = displayOutput
|
|
.split("\n")
|
|
.map((line) => uiTheme.fg("toolOutput", line))
|
|
.join("\n");
|
|
const lines = [styledOutput, timeoutLine, warningLine].filter(Boolean) as string[];
|
|
return new Text(lines.join("\n"), 0, 0);
|
|
}
|
|
|
|
// Collapsed: use width-aware caching component
|
|
const styledOutput = displayOutput
|
|
.split("\n")
|
|
.map((line) => uiTheme.fg("toolOutput", line))
|
|
.join("\n");
|
|
const textContent = `\n${styledOutput}`;
|
|
|
|
let cachedWidth: number | undefined;
|
|
let cachedLines: string[] | undefined;
|
|
let cachedSkipped: number | undefined;
|
|
|
|
return {
|
|
render: (width: number): string[] => {
|
|
if (cachedLines === undefined || cachedWidth !== width) {
|
|
const result = truncateToVisualLines(textContent, previewLines, width);
|
|
cachedLines = result.visualLines;
|
|
cachedSkipped = result.skippedCount;
|
|
cachedWidth = width;
|
|
}
|
|
const outputLines: string[] = [];
|
|
if (cachedSkipped && cachedSkipped > 0) {
|
|
outputLines.push("");
|
|
const skippedLine = uiTheme.fg(
|
|
"dim",
|
|
`${uiTheme.format.ellipsis} (${cachedSkipped} earlier lines, showing ${cachedLines.length} of ${cachedSkipped + cachedLines.length}) (ctrl+o to expand)`,
|
|
);
|
|
outputLines.push(truncateToWidth(skippedLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis)));
|
|
}
|
|
outputLines.push(...cachedLines);
|
|
if (timeoutLine) {
|
|
outputLines.push(truncateToWidth(timeoutLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis)));
|
|
}
|
|
if (warningLine) {
|
|
outputLines.push(truncateToWidth(warningLine, width, uiTheme.fg("warning", uiTheme.format.ellipsis)));
|
|
}
|
|
return outputLines;
|
|
},
|
|
invalidate: () => {
|
|
cachedWidth = undefined;
|
|
cachedLines = undefined;
|
|
cachedSkipped = undefined;
|
|
},
|
|
};
|
|
},
|
|
};
|