diff options
Diffstat (limited to 'packages/cli/src/tools/terminal.tool.ts')
| -rw-r--r-- | packages/cli/src/tools/terminal.tool.ts | 960 |
1 files changed, 960 insertions, 0 deletions
diff --git a/packages/cli/src/tools/terminal.tool.ts b/packages/cli/src/tools/terminal.tool.ts new file mode 100644 index 00000000..ae33c107 --- /dev/null +++ b/packages/cli/src/tools/terminal.tool.ts @@ -0,0 +1,960 @@ +import { spawn, SpawnOptions, ChildProcessWithoutNullStreams, exec } from 'child_process'; // Added 'exec' +import path from 'path'; +import os from 'os'; +import crypto from 'crypto'; +import { promises as fs } from 'fs'; // Added fs.promises +import { BaseTool } from './BaseTool.js'; // Adjust path as needed +import { ToolResult } from './ToolResult.js'; // Adjust path as needed +import { SchemaValidator } from '../utils/schemaValidator.js'; // Adjust path as needed +import { ToolCallConfirmationDetails, ToolConfirmationOutcome, ToolExecuteConfirmationDetails } from '../ui/types.js'; // Adjust path as needed +import { GeminiClient } from '../core/GeminiClient.js'; +import { SchemaUnion, Type } from '@google/genai'; +import { BackgroundTerminalAnalyzer } from '../utils/BackgroundTerminalAnalyzer.js'; + +// --- Interfaces --- +export interface TerminalToolParams { + command: string; + description?: string; + timeout?: number; + runInBackground?: boolean; +} + +export interface TerminalToolResult extends ToolResult { + // Add specific fields if needed for structured output from polling/LLM + // finalStdout?: string; + // finalStderr?: string; + // llmAnalysis?: string; +} + +// --- Constants --- +const MAX_OUTPUT_LENGTH = 10000; // Default max output length +const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes (for foreground commands) +const MAX_TIMEOUT_OVERRIDE_MS = 10 * 60 * 1000; // 10 minutes (max override for foreground) +const BACKGROUND_LAUNCH_TIMEOUT_MS = 15 * 1000; // 15 seconds timeout for *launching* background tasks +const BACKGROUND_POLL_INTERVAL_MS = 5000; // 5 seconds interval for checking background process status +const BACKGROUND_POLL_TIMEOUT_MS = 30000; // 30 seconds total polling time for background process status + +const BANNED_COMMAND_ROOTS = [ + // Session/flow control (excluding cd) + 'alias', 'bg', 'command', 'declare', 'dirs', 'disown', 'enable', 'eval', 'exec', + 'exit', 'export', 'fc', 'fg', 'getopts', 'hash', 'history', 'jobs', 'kill', 'let', + 'local', 'logout', 'popd', 'printf', 'pushd', /* 'pwd' is safe */ 'read', 'readonly', 'set', + 'shift', 'shopt', 'source', 'suspend', 'test', 'times', 'trap', 'type', 'typeset', + 'ulimit', 'umask', 'unalias', 'unset', 'wait', + // Network commands + 'curl', 'wget', 'nc', 'telnet', 'ssh', 'scp', 'ftp', 'sftp', + 'http', 'https', 'ftp', 'rsync', + // Browsers/GUI launchers + 'lynx', 'w3m', 'links', 'elinks', 'httpie', 'xh', 'http-prompt', + 'chrome', 'firefox', 'safari', 'edge', 'xdg-open', 'open' +]; + + +// --- Helper Type for Command Queue --- +interface QueuedCommand { + params: TerminalToolParams; + resolve: (result: TerminalToolResult) => void; + reject: (error: Error) => void; + confirmationDetails: ToolExecuteConfirmationDetails | false; // Kept for potential future use +} + +/** + * Implementation of the terminal tool that executes shell commands within a persistent session. + */ +export class TerminalTool extends BaseTool<TerminalToolParams, TerminalToolResult> { + public static Name: string = 'execute_bash_command'; + + private readonly rootDirectory: string; + private readonly outputLimit: number; + 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(); // Track confirmation per root command + private shellReady: Promise<void>; + private resolveShellReady: (() => void) | undefined; // Definite assignment assertion + private rejectShellReady: ((reason?: any) => void) | undefined; // Definite assignment assertion + private readonly backgroundTerminalAnalyzer: BackgroundTerminalAnalyzer; + + + constructor(rootDirectory: string, outputLimit: number = MAX_OUTPUT_LENGTH) { + const toolDisplayName = 'Terminal'; + // --- LLM-Facing Description --- + // Updated description for background tasks to mention polling and LLM analysis + 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. **Security & Banned Commands:** + * Certain commands are banned for security (e.g., network: ${BANNED_COMMAND_ROOTS.filter(c => ['curl', 'wget', 'ssh'].includes(c)).join(', ')}; session: ${BANNED_COMMAND_ROOTS.filter(c => ['exit', 'export', 'kill'].includes(c)).join(', ')}; etc.). The full list is extensive. + * If you attempt a banned command, this tool will return an error explaining the restriction. You MUST relay this error clearly to the user. + +4. **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) + +5. **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.`; + // --- Parameter Schema --- + 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(); + + this.initializeShell(); + } + + // --- Shell Initialization and Management (largely unchanged) --- + private initializeShell() { + if (this.bashProcess) { + try { + this.bashProcess.kill(); + } catch (e) { /* 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; // Reset CWD on restart + + this.bashProcess.on('error', (err) => { + console.error('Persistent Bash Error:', err); + this.rejectShellReady?.(err); // Use optional chaining as reject might be cleared + 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; + // Only reject if it hasn't been resolved/rejected already + this.rejectShellReady?.(new Error(`Persistent bash process exited (code: ${code}, signal: ${signal})`)); + // Reset shell readiness promise for reinitialization attempts + this.shellReady = new Promise((resolve, reject) => { + this.resolveShellReady = resolve; + this.rejectShellReady = reject; + }); + this.clearQueue(new Error(`Persistent bash process exited unexpectedly (code: ${code}, signal: ${signal}). State is lost. Queued commands cancelled.`)); + // Attempt to reinitialize after a short delay + setTimeout(() => this.initializeShell(), 1000); + }); + + // Readiness check - ensure shell is responsive + // Slightly longer timeout to allow shell init + setTimeout(() => { + if (this.bashProcess && !this.bashProcess.killed) { + this.resolveShellReady?.(); // Use optional chaining + } else if (!this.bashProcess) { + // Error likely already handled by 'error' or 'close' event + } else { + // Process was killed during init? + this.rejectShellReady?.(new Error("Shell killed during initialization")); + } + }, 1000); // Increase readiness check timeout slightly + + } catch (error: any) { + console.error("Failed to spawn persistent bash:", error); + this.rejectShellReady?.(error); // Use optional chaining + this.bashProcess = null; + this.clearQueue(new Error(`Failed to spawn persistent bash: ${error.message}`)); + } + } + + // --- Parameter Validation (unchanged) --- + invalidParams(params: TerminalToolParams): string | null { + if (!SchemaValidator.validate(this.parameterSchema as Record<string, unknown>, params)) { + return `Parameters failed schema validation.`; + } + + const commandOriginal = params.command.trim(); + if (!commandOriginal) { + return "Command cannot be empty."; + } + const commandLower = commandOriginal.toLowerCase(); + const commandParts = commandOriginal.split(/[\s;&&|]+/); + + for (const part of commandParts) { + if (!part) continue; + // Improved check: strip leading special chars before checking basename + const cleanPart = part.replace(/^[^a-zA-Z0-9]+/, '').split(/[\/\\]/).pop() || part.replace(/^[^a-zA-Z0-9]+/, ''); + if (cleanPart && BANNED_COMMAND_ROOTS.includes(cleanPart.toLowerCase())) { + return `Command contains a banned keyword: '${cleanPart}'. Banned list includes network tools, session control, etc.`; + } + } + + if (params.timeout !== undefined && (typeof params.timeout !== 'number' || params.timeout <= 0)) { + return 'Timeout must be a positive number of milliseconds.'; + } + + // Relax the absolute path restriction slightly if needed, but generally good practice + // const firstCommandPart = commandParts[0]; + // if (firstCommandPart && (firstCommandPart.startsWith('/') || firstCommandPart.startsWith('\\'))) { + // return 'Executing commands via absolute paths (starting with \'/\' or \'\\\') is restricted. Use commands available in PATH or relative paths.'; + // } + + return null; // Parameters are valid + } + + // --- Description and Confirmation (unchanged) --- + getDescription(params: TerminalToolParams): string { + return params.description || params.command; + } + + async shouldConfirmExecute(params: TerminalToolParams): Promise<ToolCallConfirmationDetails | false> { + const rootCommand = params.command.trim().split(/[\s;&&|]+/)[0]?.split(/[\/\\]/).pop() || 'unknown'; + + if (this.shouldAlwaysExecuteCommands.get(rootCommand)) { + return false; + } + + const description = this.getDescription(params); + + const confirmationDetails: ToolExecuteConfirmationDetails = { + title: 'Confirm Shell Command', + command: params.command, + rootCommand: rootCommand, + description: `Execute in '${this.currentCwd}':\n${description}`, + onConfirm: async (outcome: ToolConfirmationOutcome) => { + if (outcome === ToolConfirmationOutcome.ProceedAlways) { + this.shouldAlwaysExecuteCommands.set(rootCommand, true); + } + }, + }; + return confirmationDetails; + } + + // --- Command Execution and Queueing (unchanged structure) --- + async execute(params: TerminalToolParams): Promise<TerminalToolResult> { + const validationError = this.invalidParams(params); + if (validationError) { + return { + llmContent: `Command rejected: ${params.command}\nReason: ${validationError}`, + returnDisplay: `Error: ${validationError}`, + }; + } + + // Assume confirmation is handled before calling execute + + return new Promise((resolve) => { + const queuedItem: QueuedCommand = { + params, + resolve, // Resolve outer promise + reject: (error) => resolve({ // Handle internal errors by resolving outer promise + llmContent: `Internal tool error for command: ${params.command}\nError: ${error.message}`, + returnDisplay: `Internal Tool Error: ${error.message}` + }), + confirmationDetails: false // Placeholder + }; + this.commandQueue.push(queuedItem); + // Ensure queue processing is triggered *after* adding the item + 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; // Wait for the shell to be ready (or reinitialized) + if (!this.bashProcess || this.bashProcess.killed) { // Check if killed + throw new Error("Persistent bash process is not available or was killed."); + } + // **** Core execution logic call **** + const result = await this.executeCommandInShell(params); + resolve(result); // Resolve the specific command's promise + } catch (error: any) { + console.error(`Error executing command "${params.command}":`, error); + reject(error); // Use the specific command's reject handler + } finally { + this.isExecuting = false; + // Use setImmediate to avoid potential deep recursion + setImmediate(() => this.triggerQueueProcessing()); + } + } + + + // --- **** MODIFIED: Core Command Execution Logic **** --- + private executeCommandInShell(params: TerminalToolParams): Promise<TerminalToolResult> { + // Define temp file paths here to be accessible throughout + let tempStdoutPath: string | null = null; + let tempStderrPath: string | null = null; + let originalResolve: (value: TerminalToolResult | PromiseLike<TerminalToolResult>) => void; // To pass to polling + let originalReject: (reason?: any) => void; + + const promise = new Promise<TerminalToolResult>((resolve, reject) => { + originalResolve = resolve; // Assign outer scope resolve + originalReject = reject; // Assign outer scope 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}::`; // For background PID + + // --- Initialize Temp Files for Background Task --- + 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: any) { + // If temp dir setup fails, reject immediately + return reject(new Error(`Failed to determine temporary directory: ${err.message}`)); + } + } + // --- End Temp File Init --- + + let stdoutBuffer = ''; // For launch output + let stderrBuffer = ''; // For launch output + let commandOutputStarted = false; + let exitCode: number | null = null; + let backgroundPid: number | null = null; // Store PID + let receivedEndDelimiter = false; + + // Timeout only applies to foreground execution or background *launch* phase + const effectiveTimeout = isBackgroundTask + ? BACKGROUND_LAUNCH_TIMEOUT_MS + : Math.min( + params.timeout ?? DEFAULT_TIMEOUT_MS, // Use default timeout if not provided + MAX_TIMEOUT_OVERRIDE_MS + ); + + let onStdoutData: ((data: Buffer) => void) | null = null; + let onStderrData: ((data: Buffer) => void) | null = null; + let launchTimeoutId: NodeJS.Timeout | null = null; // Renamed for clarity + + 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'); // Ctrl+C for foreground timeout + } catch (e: any) { console.error("Error writing SIGINT on timeout:", e); } + } + // Store listeners before calling cleanup, as cleanup nullifies them + const listenersToClean = { onStdoutData, onStderrData }; + cleanupListeners(listenersToClean); // Clean up listeners for this command + + // Clean up temp files if background launch timed out + if (isBackgroundTask && tempStdoutPath && tempStderrPath) { + this.cleanupTempFiles(tempStdoutPath, tempStderrPath).catch(err => { + console.warn(`Error cleaning up temp files on timeout: ${err.message}`); + }); + } + + // Resolve the main promise with timeout info + 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); + + // --- Data processing logic (refined slightly) --- + 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; // Still waiting for start delimiter + } + } + + // Process PID delimiter (mostly expected on stderr for background) + const pidIndex = dataToProcess.indexOf(pidDelimiter); + if (pidIndex !== -1) { + // Extract PID value strictly between delimiter and newline/end + 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 { + // Consume delimiter even if no number followed + const beforePid = dataToProcess.substring(0, pidIndex); + if (isStderr) stderrBuffer += beforePid; else stdoutBuffer += beforePid; + dataToProcess = dataToProcess.substring(pidIndex + pidDelimiter.length); + } + } + + + // Process Exit Code delimiter + 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); + } + } + + // Process End delimiter + const endDelimiterIndex = dataToProcess.indexOf(endDelimiter); + if (endDelimiterIndex !== -1) { + receivedEndDelimiter = true; + const beforeEndDelimiter = dataToProcess.substring(0, endDelimiterIndex); + if (isStderr) stderrBuffer += beforeEndDelimiter; else stdoutBuffer += beforeEndDelimiter; + // Consume delimiter and potentially the exit code echoed after it + const afterEndDelimiter = dataToProcess.substring(endDelimiterIndex + endDelimiter.length); + const exitCodeEchoMatch = afterEndDelimiter.match(/^(\d+)/); + dataToProcess = exitCodeEchoMatch ? afterEndDelimiter.substring(exitCodeEchoMatch[1].length) : afterEndDelimiter; + } + + // Append remaining data + if (dataToProcess.length > 0) { + if (isStderr) stderrBuffer += dataToProcess; else stdoutBuffer += dataToProcess; + } + + // Check completion criteria + if (receivedEndDelimiter && exitCode !== null) { + setImmediate(cleanupAndResolve); // Use setImmediate + return true; // Signal completion of this command's stream processing + } + + return false; // More data or delimiters expected + }; + + // Assign listeners + onStdoutData = (data: Buffer) => processDataChunk(data.toString(), false); + onStderrData = (data: Buffer) => processDataChunk(data.toString(), true); + + // --- Cleanup Logic --- + // Pass listeners to allow cleanup even if they are nullified later + const cleanupListeners = (listeners?: { onStdoutData: any, onStderrData: any }) => { + if (launchTimeoutId) clearTimeout(launchTimeoutId); + launchTimeoutId = null; + + // Use passed-in listeners if available, otherwise use current scope's + 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); + } + // Only nullify the *current command's* cleanup reference if it matches + if (this.currentCommandCleanup === cleanupListeners) { + this.currentCommandCleanup = null; + } + // Nullify the listener references in the outer scope regardless + onStdoutData = null; + onStderrData = null; + }; + // Store *this specific* cleanup function instance for the current command + this.currentCommandCleanup = cleanupListeners; + + // --- Final Resolution / Polling Logic --- + const cleanupAndResolve = async () => { + // Prevent double execution if cleanup was already called (e.g., by timeout) + if (!this.currentCommandCleanup || this.currentCommandCleanup !== cleanupListeners) { + // Ensure temp files are cleaned if this command was superseded but might have created them + if (isBackgroundTask && tempStdoutPath && tempStderrPath) { + this.cleanupTempFiles(tempStdoutPath, tempStderrPath).catch(err => { + console.warn(`Error cleaning up temp files for superseded command: ${err.message}`); + }); + } + return; + } + + // Capture initial output *before* cleanup nullifies buffers indirectly + const launchStdout = this.truncateOutput(stdoutBuffer); + const launchStderr = this.truncateOutput(stderrBuffer); + + // Store listeners before calling cleanup + const listenersToClean = { onStdoutData, onStderrData }; + cleanupListeners(listenersToClean); // Remove listeners and clear launch timeout NOW + + // --- Error check for missing exit code --- + 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({ // Use originalResolve as this is a failure *before* polling starts + 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; + } + + // --- CWD Update Logic (Only for Foreground Success or 'cd') --- + let cwdUpdateError = ''; + if (!isBackgroundTask) { // Only run for foreground + 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: any) { + if (exitCode === 0) { // Only warn if the command itself succeeded + cwdUpdateError = `\nWarning: Failed to verify/update current working directory after command: ${e.message}`; + console.error("Failed to update CWD after successful command:", e); + } + } + } + } + // --- End CWD Update --- + + // --- Result Formatting & Polling Decision --- + if (isBackgroundTask) { + const launchSuccess = exitCode === 0; + const pidString = backgroundPid !== null ? backgroundPid.toString() : 'Not Captured'; + + // Check if polling should start + if (launchSuccess && backgroundPid !== null && tempStdoutPath && tempStderrPath) { + // --- START POLLING --- + // Don't await this, let it run in the background and resolve the original promise later + this.inspectBackgroundProcess( + backgroundPid, + params.command, + this.currentCwd, // CWD at time of launch + launchStdout, // Initial output captured during launch + launchStderr, // Initial output captured during launch + tempStdoutPath, // Path for final stdout + tempStderrPath, // Path for final stderr + originalResolve // The resolve function of the main promise + ); + // IMPORTANT: Do NOT resolve the promise here. pollBackgroundProcess will do it. + // --- END POLLING --- + } else { + // Background launch failed OR PID was not captured OR temp files missing + 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}`); // ERROR LOG + // Ensure cleanup of temp files if launch failed + if (tempStdoutPath && tempStderrPath) { + await this.cleanupTempFiles(tempStdoutPath, tempStderrPath); + } + originalResolve({ // Use originalResolve as polling won't start + 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 { + // --- Foreground task result (resolve immediately) --- + 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({ // Use originalResolve for foreground result + llmContent: `Command: ${params.command}\nExecuted in: ${this.currentCwd}\nExit Code: ${exitCode}\nStdout:\n${launchStdout}\nStderr:\n${launchStderr}${cwdUpdateError}`, + returnDisplay: displayOutput.trim() || `Exit Code: ${exitCode}` // Ensure some display + }); + // --- End Foreground Result --- + } + }; // End of cleanupAndResolve + + + // --- Attach listeners --- + if (!this.bashProcess || this.bashProcess.killed) { + console.error("Bash process lost or killed before listeners could be attached."); + // Ensure temp files are cleaned up if they exist + 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.")); + } + // Defensive remove shouldn't be strictly necessary with current cleanup logic, but harmless + // if (onStdoutData) this.bashProcess.stdout.removeListener('data', onStdoutData); + // if (onStderrData) this.bashProcess.stderr.removeListener('data', onStderrData); + + // Attach the fresh listeners + if (onStdoutData) this.bashProcess.stdout.on('data', onStdoutData); + if (onStderrData) this.bashProcess.stderr.on('data', onStderrData); + + // --- Construct and Write Command --- + let commandToWrite: string; + if (isBackgroundTask && tempStdoutPath && tempStderrPath) { + // Background: Redirect command's stdout/stderr to temp files. + // Use subshell { ... } > file 2> file to redirect the command inside. + // Capture PID of the subshell. Capture exit code of the subshell launch. + // Ensure the subshell itself doesn't interfere with delimiter capture on stderr. + 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) { + // Foreground: Original structure. Capture command exit code. + commandToWrite = `echo "${startDelimiter}"; ${params.command}; __EXIT_CODE=$?; echo "${exitCodeDelimiter}$__EXIT_CODE" >&2; echo "${endDelimiter}$__EXIT_CODE" >&1\n`; + } else { + // Should not happen if background task setup failed, but handle defensively + 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); + // Store listeners before calling cleanup + const listenersToClean = { onStdoutData, onStderrData }; + cleanupListeners(listenersToClean); // Attempt cleanup + 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: any) { + console.error(`Error writing command "${params.command}" to bash stdin (sync):`, e); + // Store listeners before calling cleanup + const listenersToClean = { onStdoutData, onStderrData }; + cleanupListeners(listenersToClean); // Attempt cleanup + if (isBackgroundTask && tempStdoutPath && tempStderrPath) { + this.cleanupTempFiles(tempStdoutPath, tempStderrPath).catch(err => console.warn(`Cleanup failed: ${err.message}`)); + } + originalReject(new Error(`Shell stdin write exception: ${e.message}. Command likely did not execute.`)); + } + }); // End of main promise constructor + + return promise; // Return the promise created at the top + } // End of executeCommandInShell + + + // --- **** NEW: Background Process Polling **** --- + private async inspectBackgroundProcess( + pid: number, + command: string, + cwd: string, + initialStdout: string, // Stdout during launch phase + initialStderr: string, // Stderr during launch phase + tempStdoutPath: string, // Path to redirected stdout + tempStderrPath: string, // Path to redirected stderr + resolve: (value: TerminalToolResult | PromiseLike<TerminalToolResult>) => void // The original promise's resolve + ): Promise<void> { // This function manages its own lifecycle but resolves the outer promise + let finalStdout = ''; + let finalStderr = ''; + let llmAnalysis = ''; + let fileReadError = ''; + + // --- Call LLM Analysis --- + 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: any) { + console.error(`LLM analysis failed for PID ${pid} command "${command}":`, llmError); + llmAnalysis = `LLM analysis failed: ${llmError.message}`; // Include error in analysis placeholder + } + // --- End LLM Call --- + + try { + finalStdout = await fs.readFile(tempStdoutPath, 'utf-8'); + finalStderr = await fs.readFile(tempStderrPath, 'utf-8'); + } catch (err: any) { + console.error(`Error reading temp output files for PID ${pid}:`, err); + fileReadError = `\nWarning: Failed to read temporary output files (${err.message}). Final output may be incomplete.`; + } + + // --- Clean up temp files --- + await this.cleanupTempFiles(tempStdoutPath, tempStderrPath); + // --- End Cleanup --- + + const truncatedFinalStdout = this.truncateOutput(finalStdout); + const truncatedFinalStderr = this.truncateOutput(finalStderr); + + // Resolve the original promise passed into pollBackgroundProcess + 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)}` + }); + } // End of pollBackgroundProcess + + // --- **** NEW: Helper to cleanup temp files **** --- + 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: any) { + // Ignore errors like file not found (it might have been deleted already or failed to create) + if (err.code !== 'ENOENT') { + console.warn(`Failed to delete temporary file '${filePath}': ${err.message}`); + } else { + } + } + }; + // Run deletions concurrently and wait for both + await Promise.all([ + unlinkQuietly(stdoutPath), + unlinkQuietly(stderrPath) + ]); + } + + + // --- Get CWD (mostly unchanged, added robustness) --- + 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; // To catch errors during pwd + let pwdTimeoutId: NodeJS.Timeout | null = null; + let finished = false; // Prevent double resolution/rejection + + const cleanupPwdListeners = (err?: Error) => { + if (finished) return; // Already handled + finished = true; + if (pwdTimeoutId) clearTimeout(pwdTimeoutId); + pwdTimeoutId = null; + + const stdoutListener = onPwdData; // Capture current reference + const stderrListener = onPwdError; // Capture current reference + onPwdData = null; // Nullify before removing + 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 { + // Trim whitespace and trailing newlines robustly + resolve(pwdOutput.trim()); + } + } + + onPwdData = (data: Buffer) => { + if (!onPwdData) return; // Listener removed + const dataStr = data.toString(); + const delimiterIndex = dataStr.indexOf(pwdDelimiter); + if (delimiterIndex !== -1) { + pwdOutput += dataStr.substring(0, delimiterIndex); + cleanupPwdListeners(); // Resolve successfully + } else { + pwdOutput += dataStr; + } + }; + + onPwdError = (data: Buffer) => { + if (!onPwdError) return; // Listener removed + const dataStr = data.toString(); + // If delimiter appears on stderr, or any stderr occurs, treat as error + console.error(`Error during PWD check: ${dataStr}`); + cleanupPwdListeners(new Error(`Stderr received during pwd check: ${this.truncateOutput(dataStr, 100)}`)); + }; + + // Attach listeners + this.bashProcess.stdout.on('data', onPwdData); + this.bashProcess.stderr.on('data', onPwdError); + + // Set timeout + pwdTimeoutId = setTimeout(() => { + cleanupPwdListeners(new Error("Timeout waiting for pwd response")); + }, 5000); // 5 second timeout for pwd + + // Write command + try { + // Use printf for robustness against special characters in PWD and ensure newline + const pwdCommand = `printf "%s" "$PWD"; printf "${pwdDelimiter}";\n`; + if (this.bashProcess?.stdin?.writable) { + this.bashProcess.stdin.write(pwdCommand, (err) => { + if (err) { + // Error during write callback, likely means shell is unresponsive + 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: any) { + console.error("Exception writing pwd command:", e); + cleanupPwdListeners(new Error(`Exception writing pwd command: ${e.message}`)); + } + }); + } + + // --- Truncate Output (unchanged) --- + 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; + } + + // --- Clear Queue (unchanged) --- + private clearQueue(error: Error) { + const queuedCount = this.commandQueue.length; + 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 (Added cleanup for pending background tasks if possible) --- + destroy() { + // Reject any pending shell readiness promise + this.rejectShellReady?.(new Error("BashTool destroyed during initialization or operation.")); + this.rejectShellReady = undefined; // Prevent further calls + this.resolveShellReady = undefined; + + this.clearQueue(new Error("BashTool is being destroyed.")); + + // Attempt to cleanup listeners for the *currently executing* command, if any + try { + this.currentCommandCleanup?.(); + } catch (e) { + console.warn("Error during current command cleanup:", e) + } + + // Handle the bash process itself + if (this.bashProcess) { + const proc = this.bashProcess; // Reference before nullifying + const pid = proc.pid; + this.bashProcess = null; // Nullify reference immediately + + proc.stdout?.removeAllListeners(); + proc.stderr?.removeAllListeners(); + proc.removeAllListeners('error'); + proc.removeAllListeners('close'); + + // Ensure stdin is closed + proc.stdin?.end(); + + try { + // Don't wait for these, just attempt + proc.kill('SIGTERM'); // Attempt graceful first + setTimeout(() => { + if (!proc.killed) { + proc.kill('SIGKILL'); // Force kill if needed + } + }, 500); // 500ms grace period + + } catch (e: any) { + // Catch errors if process already exited etc. + console.warn(`Error trying to kill bash process PID: ${pid}: ${e.message}`); + } + } else { + } + + // Note: We cannot reliably clean up temp files for background tasks + // that were polling when destroy() was called without more complex state tracking. + // OS should eventually clean /tmp, or implement a startup cleanup routine if needed. + } +} // End of TerminalTool class
\ No newline at end of file |
