summaryrefslogtreecommitdiff
path: root/packages/cli/src/tools/terminal.tool.ts
diff options
context:
space:
mode:
authorTaylor Mullen <[email protected]>2025-04-17 18:06:21 -0400
committerN. Taylor Mullen <[email protected]>2025-04-17 15:29:34 -0700
commitcfc697a96d2e716a75e1c3b7f0f34fce81abaf1e (patch)
treee06bcba67ca71a874048aa887b17457dbd409bdf /packages/cli/src/tools/terminal.tool.ts
parent7928c1727f0b208ed34850cc89bbb36ea3dd23e5 (diff)
Run `npm run format`
- Also updated README.md accordingly. Part of https://b.corp.google.com/issues/411384603
Diffstat (limited to 'packages/cli/src/tools/terminal.tool.ts')
-rw-r--r--packages/cli/src/tools/terminal.tool.ts1808
1 files changed, 1030 insertions, 778 deletions
diff --git a/packages/cli/src/tools/terminal.tool.ts b/packages/cli/src/tools/terminal.tool.ts
index eef9b7d4..a51f8e6b 100644
--- a/packages/cli/src/tools/terminal.tool.ts
+++ b/packages/cli/src/tools/terminal.tool.ts
@@ -1,26 +1,35 @@
-import { spawn, SpawnOptions, ChildProcessWithoutNullStreams, exec } from 'child_process'; // Added 'exec'
+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';
import { BaseTool, ToolResult } from './tools.js';
import { SchemaValidator } from '../utils/schemaValidator.js';
-import { ToolCallConfirmationDetails, ToolConfirmationOutcome, ToolExecuteConfirmationDetails } from '../ui/types.js'; // Adjust path as needed
+import {
+ ToolCallConfirmationDetails,
+ ToolConfirmationOutcome,
+ ToolExecuteConfirmationDetails,
+} from '../ui/types.js'; // Adjust path as needed
import { BackgroundTerminalAnalyzer } from '../utils/BackgroundTerminalAnalyzer.js';
// --- Interfaces ---
export interface TerminalToolParams {
- command: string;
- description?: string;
- timeout?: number;
- runInBackground?: boolean;
+ 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;
+ // Add specific fields if needed for structured output from polling/LLM
+ // finalStdout?: string;
+ // finalStderr?: string;
+ // llmAnalysis?: string;
}
// --- Constants ---
@@ -32,54 +41,112 @@ const BACKGROUND_POLL_INTERVAL_MS = 5000; // 5 seconds interval for checking bac
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'
+ // 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
+ 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;
+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).
+ 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).
@@ -101,7 +168,7 @@ Usage Guidance & Restrictions:
* 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.
+ * 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:**
@@ -120,838 +187,1023 @@ Usage Guidance & Restrictions:
* 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']
- };
+ // --- 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,
+ );
- 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.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();
+ }
- this.initializeShell();
+ // --- Shell Initialization and Management (largely unchanged) ---
+ private initializeShell() {
+ if (this.bashProcess) {
+ try {
+ this.bashProcess.kill();
+ } catch (e) {
+ /* Ignore */
+ }
}
- // --- 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'],
+ };
- 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
- 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('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);
+ });
- 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}`),
+ );
+ }
+ }
- // 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
+ // --- Parameter Validation (unchanged) ---
+ invalidParams(params: TerminalToolParams): string | null {
+ if (
+ !SchemaValidator.validate(
+ this.parameterSchema as Record<string, unknown>,
+ params,
+ )
+ ) {
+ return `Parameters failed schema validation.`;
+ }
- } 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}`));
- }
+ const commandOriginal = params.command.trim();
+ if (!commandOriginal) {
+ return 'Command cannot be empty.';
}
+ const commandLower = commandOriginal.toLowerCase();
+ const commandParts = commandOriginal.split(/[\s;&&|]+/);
- // --- Parameter Validation (unchanged) ---
- invalidParams(params: TerminalToolParams): string | null {
- if (!SchemaValidator.validate(this.parameterSchema as Record<string, unknown>, params)) {
- return `Parameters failed schema validation.`;
- }
+ 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.`;
+ }
+ }
- const commandOriginal = params.command.trim();
- if (!commandOriginal) {
- return "Command cannot be empty.";
- }
- const commandLower = commandOriginal.toLowerCase();
- const commandParts = commandOriginal.split(/[\s;&&|]+/);
+ if (
+ params.timeout !== undefined &&
+ (typeof params.timeout !== 'number' || params.timeout <= 0)
+ ) {
+ return 'Timeout must be a positive number of milliseconds.';
+ }
- 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.`;
- }
- }
+ // 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.';
+ // }
- if (params.timeout !== undefined && (typeof params.timeout !== 'number' || params.timeout <= 0)) {
- return 'Timeout must be a positive number of milliseconds.';
- }
+ return null; // Parameters are valid
+ }
- // 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.';
- // }
+ // --- Description and Confirmation (unchanged) ---
+ getDescription(params: TerminalToolParams): string {
+ return params.description || params.command;
+ }
- return null; // Parameters are valid
- }
+ async shouldConfirmExecute(
+ params: TerminalToolParams,
+ ): Promise<ToolCallConfirmationDetails | false> {
+ const rootCommand =
+ params.command
+ .trim()
+ .split(/[\s;&&|]+/)[0]
+ ?.split(/[\/\\]/)
+ .pop() || 'unknown';
- // --- Description and Confirmation (unchanged) ---
- getDescription(params: TerminalToolParams): string {
- return params.description || params.command;
+ if (this.shouldAlwaysExecuteCommands.get(rootCommand)) {
+ return false;
}
- async shouldConfirmExecute(params: TerminalToolParams): Promise<ToolCallConfirmationDetails | false> {
- const rootCommand = params.command.trim().split(/[\s;&&|]+/)[0]?.split(/[\/\\]/).pop() || 'unknown';
+ const description = this.getDescription(params);
- if (this.shouldAlwaysExecuteCommands.get(rootCommand)) {
- return false;
+ 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;
+ }
- 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}`,
+ };
}
- // --- 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
- // 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());
+ });
+ }
- 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;
}
- private async triggerQueueProcessing(): Promise<void> {
- if (this.isExecuting || this.commandQueue.length === 0) {
- return;
- }
-
- this.isExecuting = true;
- const { params, resolve, reject } = this.commandQueue.shift()!;
+ 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());
- }
+ 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;
- // --- **** 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
+ 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."));
- }
+ 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
+ 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 ---
+ // --- 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;
+ 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
- );
+ // 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
+ 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.`;
+ 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
+ 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}`);
- });
- }
+ // 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);
+ // 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;
+ // --- 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
- }
- }
+ 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 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 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;
+ }
- // 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;
+ }
- // 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
+ }
- // 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
+ };
- return false; // More data or delimiters expected
- };
+ // Assign listeners
+ onStdoutData = (data: Buffer) => processDataChunk(data.toString(), false);
+ onStderrData = (data: Buffer) => processDataChunk(data.toString(), true);
- // 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;
- // --- 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;
- // 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;
- 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;
+ }
- // --- 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);
- // 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
- // 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;
+ }
- // --- 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 ---
- // --- 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';
- // --- 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();
- // 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
- });
- }
+ if (stderrTrimmed) {
+ displayOutput = stderrTrimmed;
+ } else if (stdoutTrimmed) {
+ displayOutput = stdoutTrimmed;
+ }
- } else {
- // --- Foreground task result (resolve immediately) ---
- let displayOutput = '';
- const stdoutTrimmed = launchStdout.trim();
- const stderrTrimmed = launchStderr.trim();
+ if (exitCode !== 0 && !displayOutput) {
+ displayOutput = `Failed with exit code: ${exitCode}`;
+ } else if (exitCode === 0 && !displayOutput) {
+ displayOutput = `Success (no output)`;
+ }
- if (stderrTrimmed) {
- displayOutput = stderrTrimmed;
- } else if (stdoutTrimmed) {
- displayOutput = stdoutTrimmed;
- }
+ 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
- if (exitCode !== 0 && !displayOutput) {
- displayOutput = `Failed with exit code: ${exitCode}`;
- } else if (exitCode === 0 && !displayOutput) {
- displayOutput = `Success (no output)`;
- }
+ // --- 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);
- 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 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.',
+ ),
+ );
+ }
- // --- 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."));
+ 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.`,
+ ),
+ );
}
- // 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);
+ });
+ } 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
- // --- 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."));
- }
+ return promise; // Return the promise created at the top
+ } // End of executeCommandInShell
- 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
+ // --- **** 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 = '';
- return promise; // Return the promise created at the top
- } // End of executeCommandInShell
+ // --- 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.`;
+ }
- // --- **** 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 = '';
+ // --- Clean up temp files ---
+ await this.cleanupTempFiles(tempStdoutPath, tempStderrPath);
+ // --- End Cleanup ---
- // --- 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;
+ const truncatedFinalStdout = this.truncateOutput(finalStdout);
+ const truncatedFinalStderr = this.truncateOutput(finalStderr);
- } 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 ---
+ // 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
- 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.`;
+ // --- **** 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)]);
+ }
- // --- 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"));
- }
+ // --- 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 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 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;
+ 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 (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());
- }
- }
+ 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;
- }
- };
+ 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)}`));
- };
+ 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);
+ // 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
+ // 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}`));
+ // 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}`),
+ );
}
- });
- }
-
- // --- 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]`;
+ });
+ } else {
+ throw new Error('Shell stdin not writable for pwd command.');
}
- return output;
- }
+ } catch (e: any) {
+ console.error('Exception writing pwd command:', e);
+ cleanupPwdListeners(
+ new Error(`Exception writing pwd command: ${e.message}`),
+ );
+ }
+ });
+ }
- // --- 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}`
- }));
+ // --- 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;
+ }
- // --- 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;
+ // --- 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}`,
+ }),
+ );
+ }
- this.clearQueue(new Error("BashTool is being destroyed."));
+ // --- 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;
- // Attempt to cleanup listeners for the *currently executing* command, if any
- try {
- this.currentCommandCleanup?.();
- } catch (e) {
- console.warn("Error during current command cleanup:", e)
- }
+ this.clearQueue(new Error('BashTool is being destroyed.'));
- // 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
+ // Attempt to cleanup listeners for the *currently executing* command, if any
+ try {
+ this.currentCommandCleanup?.();
+ } catch (e) {
+ console.warn('Error during current command cleanup:', e);
+ }
- proc.stdout?.removeAllListeners();
- proc.stderr?.removeAllListeners();
- proc.removeAllListeners('error');
- proc.removeAllListeners('close');
+ // 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
- // Ensure stdin is closed
- proc.stdin?.end();
+ proc.stdout?.removeAllListeners();
+ proc.stderr?.removeAllListeners();
+ proc.removeAllListeners('error');
+ proc.removeAllListeners('close');
- 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
+ // Ensure stdin is closed
+ proc.stdin?.end();
- } 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.
+ 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 {
}
-} // End of TerminalTool class \ No newline at end of file
+
+ // 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