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