diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.ts index e7ccefcd..74dade5e 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.ts @@ -120,13 +120,19 @@ export const useShellCommandProcessor = ( stdio: ['ignore', 'pipe', 'pipe'], }); + let exited = false; let output = ''; const handleOutput = (data: string) => { - output += data; - setPendingHistoryItem({ - type: 'info', - text: output, - }); + // continue to consume post-exit for background processes + // removing listeners can overflow OS buffer and block subprocesses + // destroying (e.g. child.stdout.destroy()) can terminate subprocesses via SIGPIPE + if (!exited) { + output += data; + setPendingHistoryItem({ + type: 'info', + text: output, + }); + } }; child.stdout.on('data', handleOutput); child.stderr.on('data', handleOutput); @@ -136,7 +142,8 @@ export const useShellCommandProcessor = ( error = err; }); - child.on('close', (code, signal) => { + child.on('exit', (code, signal) => { + exited = true; setPendingHistoryItem(null); output = output.trim() || '(Command produced no output)'; if (error) { diff --git a/packages/server/src/tools/shell.ts b/packages/server/src/tools/shell.ts index a708c93f..5e1edd85 100644 --- a/packages/server/src/tools/shell.ts +++ b/packages/server/src/tools/shell.ts @@ -143,8 +143,7 @@ export class ShellTool extends BaseTool { let command = params.command.trim(); if (!command.endsWith('&')) command += ';'; - // note the final echo is only to trigger the stderr handler below - command = `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; ( trap '' PIPE ; echo >&2 ); exit $__code;`; + command = `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`; // spawn command in specified directory (or project root if not specified) const shell = spawn('bash', ['-c', command], { @@ -153,35 +152,33 @@ export class ShellTool extends BaseTool { cwd: path.resolve(this.config.getTargetDir(), params.directory || ''), }); + let exited = false; let stdout = ''; let output = ''; shell.stdout.on('data', (data: Buffer) => { - const str = data.toString(); - stdout += str; - output += str; - if (onOutputChunk) { - onOutputChunk(str); + // continue to consume post-exit for background processes + // removing listeners can overflow OS buffer and block subprocesses + // destroying (e.g. shell.stdout.destroy()) can terminate subprocesses via SIGPIPE + if (!exited) { + const str = data.toString(); + stdout += str; + output += str; + if (onOutputChunk) { + onOutputChunk(str); + } } }); let stderr = ''; shell.stderr.on('data', (data: Buffer) => { - let str = data.toString(); - // if the temporary file exists, close the streams and finalize stdout/stderr - // otherwise these streams can delay termination ('close' event) until all background processes exit - if (fs.existsSync(tempFilePath)) { - shell.stdout.destroy(); - shell.stderr.destroy(); - // exclude final \n, which should be from echo >&2 unless there are background processes writing to stderr - if (str.endsWith('\n')) { - str = str.slice(0, -1); + if (!exited) { + const str = data.toString(); + stderr += str; + output += str; + if (onOutputChunk) { + onOutputChunk(str); } } - stderr += str; - output += str; - if (onOutputChunk) { - onOutputChunk(str); - } }); let error: Error | null = null; @@ -193,14 +190,15 @@ export class ShellTool extends BaseTool { let code: number | null = null; let processSignal: NodeJS.Signals | null = null; - const closeHandler = ( + const exitHandler = ( _code: number | null, _signal: NodeJS.Signals | null, ) => { + exited = true; code = _code; processSignal = _signal; }; - shell.on('close', closeHandler); + shell.on('exit', exitHandler); const abortHandler = () => { if (shell.pid) { @@ -220,7 +218,7 @@ export class ShellTool extends BaseTool { abortSignal.addEventListener('abort', abortHandler); // wait for the shell to exit - await new Promise((resolve) => shell.on('close', resolve)); + await new Promise((resolve) => shell.on('exit', resolve)); abortSignal.removeEventListener('abort', abortHandler);