2025-04-17 18:06:21 -04:00
import {
spawn ,
SpawnOptions ,
ChildProcessWithoutNullStreams ,
exec ,
} from 'child_process' ; // Added 'exec'
2025-04-15 21:41:08 -07:00
import path from 'path' ;
import os from 'os' ;
import crypto from 'crypto' ;
2025-04-17 12:03:02 -07:00
import { promises as fs } from 'fs' ;
2025-04-17 17:25:01 -04:00
import { BaseTool , ToolResult } from './tools.js' ;
2025-04-17 12:03:02 -07:00
import { SchemaValidator } from '../utils/schemaValidator.js' ;
2025-04-17 18:06:21 -04:00
import {
ToolCallConfirmationDetails ,
ToolConfirmationOutcome ,
ToolExecuteConfirmationDetails ,
} from '../ui/types.js' ; // Adjust path as needed
2025-04-15 21:41:08 -07:00
import { BackgroundTerminalAnalyzer } from '../utils/BackgroundTerminalAnalyzer.js' ;
// --- Interfaces ---
export interface TerminalToolParams {
2025-04-17 18:06:21 -04:00
command : string ;
description? : string ;
timeout? : number ;
runInBackground? : boolean ;
2025-04-15 21:41:08 -07:00
}
export interface TerminalToolResult extends ToolResult {
2025-04-17 18:06:21 -04:00
// Add specific fields if needed for structured output from polling/LLM
// finalStdout?: string;
// finalStderr?: string;
// llmAnalysis?: string;
2025-04-15 21:41:08 -07:00
}
// --- 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 = [
2025-04-17 18:06:21 -04:00
// 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' ,
2025-04-15 21:41:08 -07:00
] ;
// --- Helper Type for Command Queue ---
interface QueuedCommand {
2025-04-17 18:06:21 -04:00
params : TerminalToolParams ;
resolve : ( result : TerminalToolResult ) = > void ;
reject : ( error : Error ) = > void ;
confirmationDetails : ToolExecuteConfirmationDetails | false ; // Kept for potential future use
2025-04-15 21:41:08 -07:00
}
/**
* Implementation of the terminal tool that executes shell commands within a persistent session.
*/
2025-04-17 18:06:21 -04:00
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).
2025-04-15 21:41:08 -07:00
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:**
2025-04-17 18:06:21 -04:00
* 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.
2025-04-15 21:41:08 -07:00
* 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. ` ;
2025-04-17 18:06:21 -04:00
// --- 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 */
}
}
2025-04-15 21:41:08 -07:00
2025-04-17 18:06:21 -04:00
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
2025-04-15 21:41:08 -07:00
this . shellReady = new Promise ( ( resolve , reject ) = > {
2025-04-17 18:06:21 -04:00
this . resolveShellReady = resolve ;
this . rejectShellReady = reject ;
2025-04-15 21:41:08 -07:00
} ) ;
2025-04-17 18:06:21 -04:00
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' ) ,
) ;
2025-04-15 21:41:08 -07:00
}
2025-04-17 18:06:21 -04:00
} , 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. ` ;
}
2025-04-15 21:41:08 -07:00
2025-04-17 18:06:21 -04:00
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. ` ;
}
}
2025-04-15 21:41:08 -07:00
2025-04-17 18:06:21 -04:00
if (
params . timeout !== undefined &&
( typeof params . timeout !== 'number' || params . timeout <= 0 )
) {
return 'Timeout must be a positive number of milliseconds.' ;
2025-04-15 21:41:08 -07:00
}
2025-04-17 18:06:21 -04:00
// 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 ;
}
2025-04-15 21:41:08 -07:00
2025-04-17 18:06:21 -04:00
const description = this . getDescription ( params ) ;
2025-04-15 21:41:08 -07:00
2025-04-17 18:06:21 -04:00
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 ) ;
2025-04-15 21:41:08 -07:00
}
2025-04-17 18:06:21 -04:00
} ,
} ;
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 } ` ,
} ;
2025-04-15 21:41:08 -07:00
}
2025-04-17 18:06:21 -04:00
// 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 ;
2025-04-15 21:41:08 -07:00
}
2025-04-17 18:06:21 -04:00
this . isExecuting = true ;
const { params , resolve , reject } = this . commandQueue . shift ( ) ! ;
2025-04-15 21:41:08 -07:00
2025-04-17 18:06:21 -04:00
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 ( ) ) ;
2025-04-15 21:41:08 -07:00
}
2025-04-17 18:06:21 -04:00
}
// --- **** 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
2025-04-15 21:41:08 -07:00
2025-04-17 18:06:21 -04:00
// --- 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 } ` ,
) ;
} ) ;
2025-04-15 21:41:08 -07:00
}
2025-04-17 18:06:21 -04:00
// 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 } ` ,
2025-04-15 21:41:08 -07:00
} ) ;
2025-04-17 18:06:21 -04:00
} , 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
}
2025-04-15 21:41:08 -07:00
}
2025-04-17 18:06:21 -04:00
// 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 ,
) ;
}
2025-04-15 21:41:08 -07:00
}
2025-04-17 18:06:21 -04:00
// 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 ,
) ;
}
}
2025-04-15 21:41:08 -07:00
2025-04-17 18:06:21 -04:00
// 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 ;
}
2025-04-15 21:41:08 -07:00
2025-04-17 18:06:21 -04:00
// Append remaining data
if ( dataToProcess . length > 0 ) {
if ( isStderr ) stderrBuffer += dataToProcess ;
else stdoutBuffer += dataToProcess ;
}
2025-04-15 21:41:08 -07:00
2025-04-17 18:06:21 -04:00
// Check completion criteria
if ( receivedEndDelimiter && exitCode !== null ) {
setImmediate ( cleanupAndResolve ) ; // Use setImmediate
return true ; // Signal completion of this command's stream processing
}
2025-04-15 21:41:08 -07:00
2025-04-17 18:06:21 -04:00
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 } ` ,
2025-04-15 21:41:08 -07:00
) ;
2025-04-17 18:06:21 -04:00
} ,
) ;
}
return ;
2025-04-15 21:41:08 -07:00
}
2025-04-17 18:06:21 -04:00
// 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 ;
2025-04-15 21:41:08 -07:00
}
2025-04-17 18:06:21 -04:00
// --- 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 ) {
2025-04-15 21:41:08 -07:00
try {
2025-04-17 18:06:21 -04:00
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 ,
) ;
}
2025-04-15 21:41:08 -07:00
}
2025-04-17 18:06:21 -04:00
}
}
// --- 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 ) ;
2025-04-15 21:41:08 -07:00
}
2025-04-17 18:06:21 -04:00
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
2025-04-15 21:41:08 -07:00
2025-04-17 18:06:21 -04:00
// --- 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. ` ,
) ,
) ;
2025-04-15 21:41:08 -07:00
}
2025-04-17 18:06:21 -04:00
} ) ;
} 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 } ` ) ,
) ;
2025-04-15 21:41:08 -07:00
}
2025-04-17 18:06:21 -04:00
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
2025-04-15 21:41:08 -07:00
}
2025-04-17 18:06:21 -04:00
// --- 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. ` ;
2025-04-15 21:41:08 -07:00
}
2025-04-17 18:06:21 -04:00
// --- 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 ) ;
2025-04-15 21:41:08 -07:00
}
2025-04-17 18:06:21 -04:00
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 } ` ) ,
) ;
2025-04-15 21:41:08 -07:00
}
2025-04-17 18:06:21 -04:00
} ) ;
2025-04-15 21:41:08 -07:00
} else {
2025-04-17 18:06:21 -04:00
throw new Error ( 'Shell stdin not writable for pwd command.' ) ;
2025-04-15 21:41:08 -07:00
}
2025-04-17 18:06:21 -04:00
} 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 ) ;
}
2025-04-15 21:41:08 -07:00
2025-04-17 18:06:21 -04:00
// 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 {
2025-04-15 21:41:08 -07:00
}
2025-04-17 18:06:21 -04:00
// 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