diff options
| author | Taylor Mullen <[email protected]> | 2025-05-11 12:34:41 -0700 |
|---|---|---|
| committer | N. Taylor Mullen <[email protected]> | 2025-05-11 12:35:55 -0700 |
| commit | cf91f72c5c0079ec4b5db67d0f8fcac043d31088 (patch) | |
| tree | aa90476c90ee87810f4f8b1c0086619e680aeba7 /packages/server/src/tools/terminal.ts | |
| parent | dcb67c32a5e246f9df64a18399c6a13051db7f30 (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.ts | 911 |
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)}`, - ); - } - } - } -} |
