summaryrefslogtreecommitdiff
path: root/packages/server/src/tools/terminal.ts
diff options
context:
space:
mode:
authorTaylor Mullen <[email protected]>2025-05-11 12:34:41 -0700
committerN. Taylor Mullen <[email protected]>2025-05-11 12:35:55 -0700
commitcf91f72c5c0079ec4b5db67d0f8fcac043d31088 (patch)
treeaa90476c90ee87810f4f8b1c0086619e680aeba7 /packages/server/src/tools/terminal.ts
parentdcb67c32a5e246f9df64a18399c6a13051db7f30 (diff)
Remove terminal tool and dependencies.
- We now solely use the shell tool. This deletes all content around the legacy terminal tool so we can focus on improving the new Shell tool. - Remove instances from sandboxing, tests, utilities etc.
Diffstat (limited to 'packages/server/src/tools/terminal.ts')
-rw-r--r--packages/server/src/tools/terminal.ts911
1 files changed, 0 insertions, 911 deletions
diff --git a/packages/server/src/tools/terminal.ts b/packages/server/src/tools/terminal.ts
deleted file mode 100644
index af558fb0..00000000
--- a/packages/server/src/tools/terminal.ts
+++ /dev/null
@@ -1,911 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import {
- spawn,
- SpawnOptions,
- ChildProcessWithoutNullStreams,
-} from 'child_process';
-import path from 'path';
-import os from 'os';
-import crypto from 'crypto';
-import { promises as fs } from 'fs';
-import { SchemaValidator } from '../utils/schemaValidator.js';
-import { getErrorMessage, isNodeError } from '../utils/errors.js';
-import { Config } from '../config/config.js';
-import {
- BaseTool,
- ToolCallConfirmationDetails,
- ToolConfirmationOutcome,
- ToolExecuteConfirmationDetails,
- ToolResult,
-} from './tools.js';
-import { BackgroundTerminalAnalyzer } from '../utils/BackgroundTerminalAnalyzer.js';
-
-export interface TerminalToolParams {
- command: string;
- description?: string;
- timeout?: number;
- runInBackground?: boolean;
-}
-
-const MAX_OUTPUT_LENGTH = 10000;
-const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
-const MAX_TIMEOUT_OVERRIDE_MS = 10 * 60 * 1000;
-const BACKGROUND_LAUNCH_TIMEOUT_MS = 15 * 1000;
-const BACKGROUND_POLL_TIMEOUT_MS = 30000;
-
-interface QueuedCommand {
- params: TerminalToolParams;
- resolve: (result: ToolResult) => void;
- reject: (error: Error) => void;
-}
-
-export class TerminalTool extends BaseTool<TerminalToolParams, ToolResult> {
- static Name: string = 'execute_bash_command';
- private bashProcess: ChildProcessWithoutNullStreams | null = null;
- private currentCwd: string;
- private isExecuting: boolean = false;
- private commandQueue: QueuedCommand[] = [];
- private currentCommandCleanup: (() => void) | null = null;
- private shouldAlwaysExecuteCommands: Map<string, boolean> = new Map();
- private shellReady: Promise<void>;
- private resolveShellReady: (() => void) | undefined;
- private rejectShellReady: ((reason?: unknown) => void) | undefined;
- private readonly backgroundTerminalAnalyzer: BackgroundTerminalAnalyzer;
-
- constructor(
- private readonly rootDirectory: string,
- private readonly config: Config,
- private readonly outputLimit: number = MAX_OUTPUT_LENGTH,
- ) {
- const toolDisplayName = 'Terminal';
- const toolDescription = `Executes one or more bash commands sequentially in a secure and persistent interactive shell session. Can run commands in the foreground (waiting for completion) or background (returning after launch, with subsequent status polling).
-
-Core Functionality:
-* Starts in project root: '${path.basename(rootDirectory)}'. Current Directory starts as: ${rootDirectory} (will update based on 'cd' commands).
-* Persistent State: Environment variables and the current working directory (\`pwd\`) persist between calls to this tool.
-* **Execution Modes:**
- * **Foreground (default):** Waits for the command to complete. Captures stdout, stderr, and exit code. Output is truncated if it exceeds ${outputLimit} characters.
- * **Background (\`runInBackground: true\`):** Appends \`&\` to the command and redirects its output to temporary files. Returns *after* the command is launched, providing the Process ID (PID) and launch status. Subsequently, the tool **polls** for the background process status for up to ${BACKGROUND_POLL_TIMEOUT_MS / 1000} seconds. Once the process finishes or polling times out, the tool reads the captured stdout/stderr from the temporary files, runs an internal LLM analysis on the output, cleans up the files, and returns the final status, captured output, and analysis.
-* Timeout: Optional timeout per 'execute' call (default: ${DEFAULT_TIMEOUT_MS / 60000} min, max override: ${MAX_TIMEOUT_OVERRIDE_MS / 60000} min for foreground). Background *launch* has a fixed shorter timeout (${BACKGROUND_LAUNCH_TIMEOUT_MS / 1000}s) for the launch attempt itself. Background *polling* has its own timeout (${BACKGROUND_POLL_TIMEOUT_MS / 1000}s). Timeout attempts SIGINT for foreground commands.
-
-Usage Guidance & Restrictions:
-
-1. **Directory/File Verification (IMPORTANT):**
- * BEFORE executing commands that create files or directories (e.g., \`mkdir foo/bar\`, \`touch new/file.txt\`, \`git clone ...\`), use the dedicated File System tool (e.g., 'list_directory') to verify the target parent directory exists and is the correct location.
- * Example: Before running \`mkdir foo/bar\`, first use the File System tool to check that \`foo\` exists in the current directory (\`${rootDirectory}\` initially, check current CWD if it changed).
-
-2. **Use Specialized Tools (CRITICAL):**
- * Do NOT use this tool for filesystem searching (\`find\`, \`grep\`). Use the dedicated Search tool instead.
- * Do NOT use this tool for reading files (\`cat\`, \`head\`, \`tail\`, \`less\`, \`more\`). Use the dedicated File Reader tool instead.
- * Do NOT use this tool for listing files (\`ls\`). Use the dedicated File System tool ('list_directory') instead. Relying on this tool's output for directory structure is unreliable due to potential truncation and lack of structured data.
-
-3. **Command Execution Notes:**
- * Chain multiple commands using shell operators like ';' or '&&'. Do NOT use newlines within the 'command' parameter string itself (newlines are fine inside quoted arguments).
- * The shell's current working directory is tracked internally. While \`cd\` is permitted if the user explicitly asks or it's necessary for a workflow, **strongly prefer** using absolute paths or paths relative to the *known* current working directory to avoid errors. Check the '(Executed in: ...)' part of the previous command's output for the CWD.
- * Good example (if CWD is /workspace/project): \`pytest tests/unit\` or \`ls /workspace/project/data\`
- * Less preferred: \`cd tests && pytest unit\` (only use if necessary or requested)
-
-4. **Background Tasks (\`runInBackground: true\`):**
- * Use this for commands that are intended to run continuously (e.g., \`node server.js\`, \`npm start\`).
- * The tool initially returns success if the process *launches* successfully, along with its PID.
- * **Polling & Final Result:** The tool then monitors the process. The *final* result (delivered after polling completes or times out) will include:
- * The final status (completed or timed out).
- * The complete stdout and stderr captured in temporary files (truncated if necessary).
- * An LLM-generated analysis/summary of the output.
- * The initial exit code (usually 0) signifies successful *launching*; the final status indicates completion or timeout after polling.
-
-Use this tool for running build steps (\`npm install\`, \`make\`), linters (\`eslint .\`), test runners (\`pytest\`, \`jest\`), code formatters (\`prettier --write .\`), package managers (\`pip install\`), version control operations (\`git status\`, \`git diff\`), starting background servers/services (\`node server.js --runInBackground true\`), or other safe, standard command-line operations within the project workspace.`;
- const toolParameterSchema = {
- type: 'object',
- properties: {
- command: {
- description: `The exact bash command or sequence of commands (using ';' or '&&') to execute. Must adhere to usage guidelines. Example: 'npm install && npm run build'`,
- type: 'string',
- },
- description: {
- description: `Optional: A brief, user-centric explanation of what the command does and why it's being run. Used for logging and confirmation prompts. Example: 'Install project dependencies'`,
- type: 'string',
- },
- timeout: {
- description: `Optional execution time limit in milliseconds for FOREGROUND commands. Max ${MAX_TIMEOUT_OVERRIDE_MS}ms (${MAX_TIMEOUT_OVERRIDE_MS / 60000} min). Defaults to ${DEFAULT_TIMEOUT_MS}ms (${DEFAULT_TIMEOUT_MS / 60000} min) if not specified or invalid. Ignored if 'runInBackground' is true.`,
- type: 'number',
- },
- runInBackground: {
- description: `If true, execute the command in the background using '&'. Defaults to false. Use for servers or long tasks.`,
- type: 'boolean',
- },
- },
- required: ['command'],
- };
- super(
- TerminalTool.Name,
- toolDisplayName,
- toolDescription,
- toolParameterSchema,
- );
- this.rootDirectory = path.resolve(rootDirectory);
- this.currentCwd = this.rootDirectory;
- this.outputLimit = outputLimit;
- this.shellReady = new Promise((resolve, reject) => {
- this.resolveShellReady = resolve;
- this.rejectShellReady = reject;
- });
- this.backgroundTerminalAnalyzer = new BackgroundTerminalAnalyzer(config);
- this.initializeShell();
- }
-
- private initializeShell() {
- if (this.bashProcess) {
- try {
- this.bashProcess.kill();
- } catch {
- /* Ignore */
- }
- }
- const spawnOptions: SpawnOptions = {
- cwd: this.rootDirectory,
- shell: true,
- env: { ...process.env },
- stdio: ['pipe', 'pipe', 'pipe'],
- };
- try {
- const bashPath = os.platform() === 'win32' ? 'bash.exe' : 'bash';
- this.bashProcess = spawn(
- bashPath,
- ['-s'],
- spawnOptions,
- ) as ChildProcessWithoutNullStreams;
- this.currentCwd = this.rootDirectory;
- this.bashProcess.on('error', (err) => {
- console.error('Persistent Bash Error:', err);
- this.rejectShellReady?.(err);
- this.bashProcess = null;
- this.isExecuting = false;
- this.clearQueue(
- new Error(`Persistent bash process failed to start: ${err.message}`),
- );
- });
- this.bashProcess.on('close', (code, signal) => {
- this.bashProcess = null;
- this.isExecuting = false;
- this.rejectShellReady?.(
- new Error(
- `Persistent bash process exited (code: ${code}, signal: ${signal})`,
- ),
- );
- this.clearQueue(
- new Error(
- `Persistent bash process exited unexpectedly (code: ${code}, signal: ${signal}). State is lost. Queued commands cancelled.`,
- ),
- );
- if (signal !== 'SIGINT') {
- this.shellReady = new Promise((resolve, reject) => {
- this.resolveShellReady = resolve;
- this.rejectShellReady = reject;
- });
- setTimeout(() => this.initializeShell(), 1000);
- }
- });
- setTimeout(() => {
- if (this.bashProcess && !this.bashProcess.killed) {
- this.resolveShellReady?.();
- } else if (!this.bashProcess) {
- // Error likely handled
- } else {
- this.rejectShellReady?.(
- new Error('Shell killed during initialization'),
- );
- }
- }, 1000);
- } catch (error: unknown) {
- console.error('Failed to spawn persistent bash:', error);
- this.rejectShellReady?.(error);
- this.bashProcess = null;
- this.clearQueue(
- new Error(`Failed to spawn persistent bash: ${getErrorMessage(error)}`),
- );
- }
- }
-
- validateToolParams(params: TerminalToolParams): string | null {
- if (
- !SchemaValidator.validate(
- this.parameterSchema as Record<string, unknown>,
- params,
- )
- ) {
- return `Parameters failed schema validation.`;
- }
- if (!params.command.trim()) {
- return 'Command cannot be empty.';
- }
- if (
- params.timeout !== undefined &&
- (typeof params.timeout !== 'number' || params.timeout <= 0)
- ) {
- return 'Timeout must be a positive number of milliseconds.';
- }
- return null;
- }
-
- getDescription(params: TerminalToolParams): string {
- return params.description || params.command;
- }
-
- async shouldConfirmExecute(
- params: TerminalToolParams,
- ): Promise<ToolCallConfirmationDetails | false> {
- if (this.validateToolParams(params)) {
- return false; // skip confirmation, execute call will fail immediately
- }
- const rootCommand =
- params.command
- .trim()
- .split(/[\s;&&|]+/)[0]
- ?.split(/[/\\]/)
- .pop() || 'unknown';
- if (this.shouldAlwaysExecuteCommands.get(rootCommand)) {
- return false;
- }
- const confirmationDetails: ToolExecuteConfirmationDetails = {
- title: 'Confirm Shell Command',
- command: params.command,
- rootCommand,
- onConfirm: async (outcome: ToolConfirmationOutcome) => {
- if (outcome === ToolConfirmationOutcome.ProceedAlways) {
- this.shouldAlwaysExecuteCommands.set(rootCommand, true);
- }
- },
- };
- return confirmationDetails;
- }
-
- async execute(
- params: TerminalToolParams,
- _signal: AbortSignal,
- ): Promise<ToolResult> {
- const validationError = this.validateToolParams(params);
- if (validationError) {
- return {
- llmContent: `Command rejected: ${params.command}\nReason: ${validationError}`,
- returnDisplay: `Error: ${validationError}`,
- };
- }
- return new Promise((resolve) => {
- const queuedItem: QueuedCommand = {
- params,
- resolve,
- reject: (error) =>
- resolve({
- llmContent: `Internal tool error for command: ${params.command}\nError: ${error.message}`,
- returnDisplay: `Internal Tool Error: ${error.message}`,
- }),
- };
- this.commandQueue.push(queuedItem);
- setImmediate(() => this.triggerQueueProcessing());
- });
- }
-
- private async triggerQueueProcessing(): Promise<void> {
- if (this.isExecuting || this.commandQueue.length === 0) {
- return;
- }
- this.isExecuting = true;
- const { params, resolve, reject } = this.commandQueue.shift()!;
- try {
- await this.shellReady;
- if (!this.bashProcess || this.bashProcess.killed) {
- throw new Error(
- 'Persistent bash process is not available or was killed.',
- );
- }
- const result = await this.executeCommandInShell(params);
- resolve(result);
- } catch (error: unknown) {
- console.error(`Error executing command "${params.command}":`, error);
- if (error instanceof Error) {
- reject(error);
- } else {
- reject(new Error('Unknown error occurred: ' + JSON.stringify(error)));
- }
- } finally {
- this.isExecuting = false;
- setImmediate(() => this.triggerQueueProcessing());
- }
- }
-
- private executeCommandInShell(
- params: TerminalToolParams,
- ): Promise<ToolResult> {
- let tempStdoutPath: string | null = null;
- let tempStderrPath: string | null = null;
- let originalResolve: (value: ToolResult | PromiseLike<ToolResult>) => void;
- let originalReject: (reason?: unknown) => void;
- const promise = new Promise<ToolResult>((resolve, reject) => {
- originalResolve = resolve;
- originalReject = reject;
- if (!this.bashProcess) {
- return reject(
- new Error('Bash process is not running. Cannot execute command.'),
- );
- }
- const isBackgroundTask = params.runInBackground ?? false;
- const commandUUID = crypto.randomUUID();
- const startDelimiter = `::START_CMD_${commandUUID}::`;
- const endDelimiter = `::END_CMD_${commandUUID}::`;
- const exitCodeDelimiter = `::EXIT_CODE_${commandUUID}::`;
- const pidDelimiter = `::PID_${commandUUID}::`;
- if (isBackgroundTask) {
- try {
- const tempDir = os.tmpdir();
- tempStdoutPath = path.join(tempDir, `term_out_${commandUUID}.log`);
- tempStderrPath = path.join(tempDir, `term_err_${commandUUID}.log`);
- } catch (err: unknown) {
- return reject(
- new Error(
- `Failed to determine temporary directory: ${getErrorMessage(err)}`,
- ),
- );
- }
- }
- let stdoutBuffer = '';
- let stderrBuffer = '';
- let commandOutputStarted = false;
- let exitCode: number | null = null;
- let backgroundPid: number | null = null;
- let receivedEndDelimiter = false;
- const effectiveTimeout = isBackgroundTask
- ? BACKGROUND_LAUNCH_TIMEOUT_MS
- : Math.min(
- params.timeout ?? DEFAULT_TIMEOUT_MS,
- MAX_TIMEOUT_OVERRIDE_MS,
- );
- let onStdoutData: ((data: Buffer) => void) | null = null;
- let onStderrData: ((data: Buffer) => void) | null = null;
- let launchTimeoutId: NodeJS.Timeout | null = null;
- launchTimeoutId = setTimeout(() => {
- const timeoutMessage = isBackgroundTask
- ? `Background command launch timed out after ${effectiveTimeout}ms.`
- : `Command timed out after ${effectiveTimeout}ms.`;
- if (!isBackgroundTask && this.bashProcess && !this.bashProcess.killed) {
- try {
- this.bashProcess.stdin.write('\x03');
- } catch (e: unknown) {
- console.error('Error writing SIGINT on timeout:', e);
- }
- }
- const listenersToClean = { onStdoutData, onStderrData };
- cleanupListeners(listenersToClean);
- if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
- this.cleanupTempFiles(tempStdoutPath, tempStderrPath).catch((err) => {
- console.warn(
- `Error cleaning up temp files on timeout: ${err.message}`,
- );
- });
- }
- originalResolve({
- llmContent: `Command execution failed: ${timeoutMessage}\nCommand: ${params.command}\nExecuted in: ${this.currentCwd}\n${isBackgroundTask ? 'Mode: Background Launch' : `Mode: Foreground\nTimeout Limit: ${effectiveTimeout}ms`}\nPartial Stdout (Launch):\n${this.truncateOutput(stdoutBuffer)}\nPartial Stderr (Launch):\n${this.truncateOutput(stderrBuffer)}\nNote: ${isBackgroundTask ? 'Launch failed or took too long.' : 'Attempted interrupt (SIGINT). Shell state might be unpredictable if command ignored interrupt.'}`,
- returnDisplay: `Timeout: ${timeoutMessage}`,
- });
- }, effectiveTimeout);
- const processDataChunk = (chunk: string, isStderr: boolean): boolean => {
- let dataToProcess = chunk;
- if (!commandOutputStarted) {
- const startIndex = dataToProcess.indexOf(startDelimiter);
- if (startIndex !== -1) {
- commandOutputStarted = true;
- dataToProcess = dataToProcess.substring(
- startIndex + startDelimiter.length,
- );
- } else {
- return false;
- }
- }
- const pidIndex = dataToProcess.indexOf(pidDelimiter);
- if (pidIndex !== -1) {
- const pidMatch = dataToProcess
- .substring(pidIndex + pidDelimiter.length)
- .match(/^(\d+)/);
- if (pidMatch?.[1]) {
- backgroundPid = parseInt(pidMatch[1], 10);
- const pidEndIndex =
- pidIndex + pidDelimiter.length + pidMatch[1].length;
- const beforePid = dataToProcess.substring(0, pidIndex);
- if (isStderr) stderrBuffer += beforePid;
- else stdoutBuffer += beforePid;
- dataToProcess = dataToProcess.substring(pidEndIndex);
- } else {
- const beforePid = dataToProcess.substring(0, pidIndex);
- if (isStderr) stderrBuffer += beforePid;
- else stdoutBuffer += beforePid;
- dataToProcess = dataToProcess.substring(
- pidIndex + pidDelimiter.length,
- );
- }
- }
- const exitCodeIndex = dataToProcess.indexOf(exitCodeDelimiter);
- if (exitCodeIndex !== -1) {
- const exitCodeMatch = dataToProcess
- .substring(exitCodeIndex + exitCodeDelimiter.length)
- .match(/^(\d+)/);
- if (exitCodeMatch?.[1]) {
- exitCode = parseInt(exitCodeMatch[1], 10);
- const beforeExitCode = dataToProcess.substring(0, exitCodeIndex);
- if (isStderr) stderrBuffer += beforeExitCode;
- else stdoutBuffer += beforeExitCode;
- dataToProcess = dataToProcess.substring(
- exitCodeIndex +
- exitCodeDelimiter.length +
- exitCodeMatch[1].length,
- );
- } else {
- const beforeExitCode = dataToProcess.substring(0, exitCodeIndex);
- if (isStderr) stderrBuffer += beforeExitCode;
- else stdoutBuffer += beforeExitCode;
- dataToProcess = dataToProcess.substring(
- exitCodeIndex + exitCodeDelimiter.length,
- );
- }
- }
- const endDelimiterIndex = dataToProcess.indexOf(endDelimiter);
- if (endDelimiterIndex !== -1) {
- receivedEndDelimiter = true;
- const beforeEndDelimiter = dataToProcess.substring(
- 0,
- endDelimiterIndex,
- );
- if (isStderr) stderrBuffer += beforeEndDelimiter;
- else stdoutBuffer += beforeEndDelimiter;
- const afterEndDelimiter = dataToProcess.substring(
- endDelimiterIndex + endDelimiter.length,
- );
- const exitCodeEchoMatch = afterEndDelimiter.match(/^(\d+)/);
- dataToProcess = exitCodeEchoMatch
- ? afterEndDelimiter.substring(exitCodeEchoMatch[1].length)
- : afterEndDelimiter;
- }
- if (dataToProcess.length > 0) {
- if (isStderr) stderrBuffer += dataToProcess;
- else stdoutBuffer += dataToProcess;
- }
- if (receivedEndDelimiter && exitCode !== null) {
- setImmediate(cleanupAndResolve);
- return true;
- }
- return false;
- };
- onStdoutData = (data: Buffer) => processDataChunk(data.toString(), false);
- onStderrData = (data: Buffer) => processDataChunk(data.toString(), true);
- const cleanupListeners = (listeners?: {
- onStdoutData: ((data: Buffer) => void) | null;
- onStderrData: ((data: Buffer) => void) | null;
- }) => {
- if (launchTimeoutId) clearTimeout(launchTimeoutId);
- launchTimeoutId = null;
- const stdoutListener = listeners?.onStdoutData ?? onStdoutData;
- const stderrListener = listeners?.onStderrData ?? onStderrData;
- if (this.bashProcess && !this.bashProcess.killed) {
- if (stdoutListener)
- this.bashProcess.stdout.removeListener('data', stdoutListener);
- if (stderrListener)
- this.bashProcess.stderr.removeListener('data', stderrListener);
- }
- if (this.currentCommandCleanup === cleanupListeners) {
- this.currentCommandCleanup = null;
- }
- onStdoutData = null;
- onStderrData = null;
- };
- this.currentCommandCleanup = cleanupListeners;
- const cleanupAndResolve = async () => {
- if (
- !this.currentCommandCleanup ||
- this.currentCommandCleanup !== cleanupListeners
- ) {
- if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
- this.cleanupTempFiles(tempStdoutPath, tempStderrPath).catch(
- (err) => {
- console.warn(
- `Error cleaning up temp files for superseded command: ${err.message}`,
- );
- },
- );
- }
- return;
- }
- const launchStdout = this.truncateOutput(stdoutBuffer);
- const launchStderr = this.truncateOutput(stderrBuffer);
- const listenersToClean = { onStdoutData, onStderrData };
- cleanupListeners(listenersToClean);
- if (exitCode === null) {
- console.error(
- `CRITICAL: Command "${params.command}" (background: ${isBackgroundTask}) finished delimiter processing but exitCode is null.`,
- );
- const errorMode = isBackgroundTask
- ? 'Background Launch'
- : 'Foreground';
- if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
- await this.cleanupTempFiles(tempStdoutPath, tempStderrPath);
- }
- originalResolve({
- llmContent: `Command: ${params.command}\nExecuted in: ${this.currentCwd}\nMode: ${errorMode}\nExit Code: -2 (Internal Error: Exit code not captured)\nStdout (during setup):\n${launchStdout}\nStderr (during setup):\n${launchStderr}`,
- returnDisplay:
- `Internal Error: Failed to capture command exit code.\n${launchStdout}\nStderr: ${launchStderr}`.trim(),
- });
- return;
- }
- let cwdUpdateError = '';
- if (!isBackgroundTask) {
- const mightChangeCwd = params.command.trim().startsWith('cd ');
- if (exitCode === 0 || mightChangeCwd) {
- try {
- const latestCwd = await this.getCurrentShellCwd();
- if (this.currentCwd !== latestCwd) {
- this.currentCwd = latestCwd;
- }
- } catch (e: unknown) {
- if (exitCode === 0) {
- cwdUpdateError = `\nWarning: Failed to verify/update current working directory after command: ${getErrorMessage(e)}`;
- console.error(
- 'Failed to update CWD after successful command:',
- e,
- );
- }
- }
- }
- }
- if (isBackgroundTask) {
- const launchSuccess = exitCode === 0;
- const pidString =
- backgroundPid !== null ? backgroundPid.toString() : 'Not Captured';
- if (
- launchSuccess &&
- backgroundPid !== null &&
- tempStdoutPath &&
- tempStderrPath
- ) {
- this.inspectBackgroundProcess(
- backgroundPid,
- params.command,
- this.currentCwd,
- launchStdout,
- launchStderr,
- tempStdoutPath,
- tempStderrPath,
- originalResolve,
- );
- } else {
- const reason =
- backgroundPid === null
- ? 'PID not captured'
- : `Launch failed (Exit Code: ${exitCode})`;
- const displayMessage = `Failed to launch process in background (${reason})`;
- console.error(
- `Background launch failed for command: ${params.command}. Reason: ${reason}`,
- );
- if (tempStdoutPath && tempStderrPath) {
- await this.cleanupTempFiles(tempStdoutPath, tempStderrPath);
- }
- originalResolve({
- llmContent: `Background Command Launch Failed: ${params.command}\nExecuted in: ${this.currentCwd}\nReason: ${reason}\nPID: ${pidString}\nExit Code (Launch): ${exitCode}\nStdout (During Launch):\n${launchStdout}\nStderr (During Launch):\n${launchStderr}`,
- returnDisplay: displayMessage,
- });
- }
- } else {
- let displayOutput = '';
- const stdoutTrimmed = launchStdout.trim();
- const stderrTrimmed = launchStderr.trim();
- if (stderrTrimmed) {
- displayOutput = stderrTrimmed;
- } else if (stdoutTrimmed) {
- displayOutput = stdoutTrimmed;
- }
- if (exitCode !== 0 && !displayOutput) {
- displayOutput = `Failed with exit code: ${exitCode}`;
- } else if (exitCode === 0 && !displayOutput) {
- displayOutput = `Success (no output)`;
- }
- originalResolve({
- llmContent: `Command: ${params.command}\nExecuted in: ${this.currentCwd}\nExit Code: ${exitCode}\nStdout:\n${launchStdout}\nStderr:\n${launchStderr}${cwdUpdateError}`,
- returnDisplay: displayOutput.trim() || `Exit Code: ${exitCode}`,
- });
- }
- };
- if (!this.bashProcess || this.bashProcess.killed) {
- console.error(
- 'Bash process lost or killed before listeners could be attached.',
- );
- if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
- this.cleanupTempFiles(tempStdoutPath, tempStderrPath).catch((err) => {
- console.warn(
- `Error cleaning up temp files on attach failure: ${err.message}`,
- );
- });
- }
- return originalReject(
- new Error(
- 'Bash process lost or killed before listeners could be attached.',
- ),
- );
- }
- if (onStdoutData) this.bashProcess.stdout.on('data', onStdoutData);
- if (onStderrData) this.bashProcess.stderr.on('data', onStderrData);
- let commandToWrite: string;
- if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
- commandToWrite = `echo "${startDelimiter}"; { { ${params.command} > "${tempStdoutPath}" 2> "${tempStderrPath}"; } & } 2>/dev/null; __LAST_PID=$!; echo "${pidDelimiter}$__LAST_PID" >&2; echo "${exitCodeDelimiter}$?" >&2; echo "${endDelimiter}$?" >&1\n`;
- } else if (!isBackgroundTask) {
- commandToWrite = `echo "${startDelimiter}"; ${params.command}; __EXIT_CODE=$?; echo "${exitCodeDelimiter}$__EXIT_CODE" >&2; echo "${endDelimiter}$__EXIT_CODE" >&1\n`;
- } else {
- return originalReject(
- new Error(
- 'Internal setup error: Missing temporary file paths for background execution.',
- ),
- );
- }
- try {
- if (this.bashProcess?.stdin?.writable) {
- this.bashProcess.stdin.write(commandToWrite, (err) => {
- if (err) {
- console.error(
- `Error writing command "${params.command}" to bash stdin (callback):`,
- err,
- );
- const listenersToClean = { onStdoutData, onStderrData };
- cleanupListeners(listenersToClean);
- if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
- this.cleanupTempFiles(tempStdoutPath, tempStderrPath).catch(
- (e) => console.warn(`Cleanup failed: ${e.message}`),
- );
- }
- originalReject(
- new Error(
- `Shell stdin write error: ${err.message}. Command likely did not execute.`,
- ),
- );
- }
- });
- } else {
- throw new Error(
- 'Shell stdin is not writable or process closed when attempting to write command.',
- );
- }
- } catch (e: unknown) {
- console.error(
- `Error writing command "${params.command}" to bash stdin (sync):`,
- e,
- );
- const listenersToClean = { onStdoutData, onStderrData };
- cleanupListeners(listenersToClean);
- if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
- this.cleanupTempFiles(tempStdoutPath, tempStderrPath).catch((err) =>
- console.warn(`Cleanup failed: ${err.message}`),
- );
- }
- originalReject(
- new Error(
- `Shell stdin write exception: ${getErrorMessage(e)}. Command likely did not execute.`,
- ),
- );
- }
- });
- return promise;
- }
-
- private async inspectBackgroundProcess(
- pid: number,
- command: string,
- cwd: string,
- initialStdout: string,
- initialStderr: string,
- tempStdoutPath: string,
- tempStderrPath: string,
- resolve: (value: ToolResult | PromiseLike<ToolResult>) => void,
- ): Promise<void> {
- let finalStdout = '';
- let finalStderr = '';
- let llmAnalysis = '';
- let fileReadError = '';
- try {
- const { status, summary } = await this.backgroundTerminalAnalyzer.analyze(
- pid,
- tempStdoutPath,
- tempStderrPath,
- command,
- );
- if (status === 'Unknown') llmAnalysis = `LLM analysis failed: ${summary}`;
- else llmAnalysis = summary;
- } catch (llmerror: unknown) {
- console.error(
- `LLM analysis failed for PID ${pid} command "${command}":`,
- llmerror,
- );
- llmAnalysis = `LLM analysis failed: ${getErrorMessage(llmerror)}`;
- }
- try {
- finalStdout = await fs.readFile(tempStdoutPath, 'utf-8');
- finalStderr = await fs.readFile(tempStderrPath, 'utf-8');
- } catch (err: unknown) {
- console.error(`Error reading temp output files for PID ${pid}:`, err);
- fileReadError = `\nWarning: Failed to read temporary output files (${getErrorMessage(err)}). Final output may be incomplete.`;
- }
- await this.cleanupTempFiles(tempStdoutPath, tempStderrPath);
- const truncatedFinalStdout = this.truncateOutput(finalStdout);
- const truncatedFinalStderr = this.truncateOutput(finalStderr);
- resolve({
- llmContent: `Background Command: ${command}\nLaunched in: ${cwd}\nPID: ${pid}\n--- LLM Analysis ---\n${llmAnalysis}\n--- Final Stdout (from ${path.basename(tempStdoutPath)}) ---\n${truncatedFinalStdout}\n--- Final Stderr (from ${path.basename(tempStderrPath)}) ---\n${truncatedFinalStderr}\n--- Launch Stdout ---\n${initialStdout}\n--- Launch Stderr ---\n${initialStderr}${fileReadError}`,
- returnDisplay: `(PID: ${pid}): ${this.truncateOutput(llmAnalysis, 200)}`,
- });
- }
-
- private async cleanupTempFiles(
- stdoutPath: string | null,
- stderrPath: string | null,
- ): Promise<void> {
- const unlinkQuietly = async (filePath: string | null) => {
- if (!filePath) return;
- try {
- await fs.unlink(filePath);
- } catch (err: unknown) {
- if (!isNodeError(err) || err.code !== 'ENOENT') {
- console.warn(
- `Failed to delete temporary file '${filePath}': ${getErrorMessage(err)}`,
- );
- }
- }
- };
- await Promise.all([unlinkQuietly(stdoutPath), unlinkQuietly(stderrPath)]);
- }
-
- private getCurrentShellCwd(): Promise<string> {
- return new Promise((resolve, reject) => {
- if (
- !this.bashProcess ||
- !this.bashProcess.stdin?.writable ||
- this.bashProcess.killed
- ) {
- return reject(
- new Error(
- 'Shell not running, stdin not writable, or killed for PWD check',
- ),
- );
- }
- const pwdUuid = crypto.randomUUID();
- const pwdDelimiter = `::PWD_${pwdUuid}::`;
- let pwdOutput = '';
- let onPwdData: ((data: Buffer) => void) | null = null;
- let onPwdError: ((data: Buffer) => void) | null = null;
- let pwdTimeoutId: NodeJS.Timeout | null = null;
- let finished = false;
- const cleanupPwdListeners = (err?: Error) => {
- if (finished) return;
- finished = true;
- if (pwdTimeoutId) clearTimeout(pwdTimeoutId);
- pwdTimeoutId = null;
- const stdoutListener = onPwdData;
- const stderrListener = onPwdError;
- onPwdData = null;
- onPwdError = null;
- if (this.bashProcess && !this.bashProcess.killed) {
- if (stdoutListener)
- this.bashProcess.stdout.removeListener('data', stdoutListener);
- if (stderrListener)
- this.bashProcess.stderr.removeListener('data', stderrListener);
- }
- if (err) {
- reject(err);
- } else {
- resolve(pwdOutput.trim());
- }
- };
- onPwdData = (data: Buffer) => {
- if (!onPwdData) return;
- const dataStr = data.toString();
- const delimiterIndex = dataStr.indexOf(pwdDelimiter);
- if (delimiterIndex !== -1) {
- pwdOutput += dataStr.substring(0, delimiterIndex);
- cleanupPwdListeners();
- } else {
- pwdOutput += dataStr;
- }
- };
- onPwdError = (data: Buffer) => {
- if (!onPwdError) return;
- const dataStr = data.toString();
- console.error(`Error during PWD check: ${dataStr}`);
- cleanupPwdListeners(
- new Error(
- `Stderr received during pwd check: ${this.truncateOutput(dataStr, 100)}`,
- ),
- );
- };
- this.bashProcess.stdout.on('data', onPwdData);
- this.bashProcess.stderr.on('data', onPwdError);
- pwdTimeoutId = setTimeout(() => {
- cleanupPwdListeners(new Error('Timeout waiting for pwd response'));
- }, 5000);
- try {
- const pwdCommand = `printf "%s" "$PWD"; printf "${pwdDelimiter}";\n`;
- if (this.bashProcess?.stdin?.writable) {
- this.bashProcess.stdin.write(pwdCommand, (err) => {
- if (err) {
- console.error('Error writing pwd command (callback):', err);
- cleanupPwdListeners(
- new Error(`Failed to write pwd command: ${err.message}`),
- );
- }
- });
- } else {
- throw new Error('Shell stdin not writable for pwd command.');
- }
- } catch (e: unknown) {
- console.error('Exception writing pwd command:', e);
- cleanupPwdListeners(
- new Error(`Exception writing pwd command: ${getErrorMessage(e)}`),
- );
- }
- });
- }
-
- private truncateOutput(output: string, limit?: number): string {
- const effectiveLimit = limit ?? this.outputLimit;
- if (output.length > effectiveLimit) {
- return (
- output.substring(0, effectiveLimit) +
- `\n... [Output truncated at ${effectiveLimit} characters]`
- );
- }
- return output;
- }
-
- private clearQueue(error: Error) {
- const queue = this.commandQueue;
- this.commandQueue = [];
- queue.forEach(({ resolve, params }) =>
- resolve({
- llmContent: `Command cancelled: ${params.command}\nReason: ${error.message}`,
- returnDisplay: `Command Cancelled: ${error.message}`,
- }),
- );
- }
-
- destroy() {
- this.rejectShellReady?.(
- new Error('BashTool destroyed during initialization or operation.'),
- );
- this.rejectShellReady = undefined;
- this.resolveShellReady = undefined;
- this.clearQueue(new Error('BashTool is being destroyed.'));
- try {
- this.currentCommandCleanup?.();
- } catch (e) {
- console.warn('Error during current command cleanup:', e);
- }
- if (this.bashProcess) {
- const proc = this.bashProcess;
- const pid = proc.pid;
- this.bashProcess = null;
- proc.stdout?.removeAllListeners();
- proc.stderr?.removeAllListeners();
- proc.removeAllListeners('error');
- proc.removeAllListeners('close');
- proc.stdin?.end();
- try {
- proc.kill('SIGTERM');
- setTimeout(() => {
- if (!proc.killed) {
- proc.kill('SIGKILL');
- }
- }, 500);
- } catch (e: unknown) {
- console.warn(
- `Error trying to kill bash process PID: ${pid}: ${getErrorMessage(e)}`,
- );
- }
- }
- }
-}