import type { ChildProcess } from 'child_process'import { stat } from 'fs/promises'import type { Readable } from 'stream'import treeKill from 'tree-kill'import { generateTaskId } from '../Task.js'import { formatDuration } from './format.js'import {MAX_TASK_OUTPUT_BYTES,MAX_TASK_OUTPUT_BYTES_DISPLAY,} from './task/diskOutput.js'import { TaskOutput } from './task/TaskOutput.js'export type ExecResult = {stdout: stringstderr: stringcode: numberinterrupted: booleanbackgroundTaskId?: stringbackgroundedByUser?: boolean/** Set when assistant-mode auto-backgrounded a long-running blocking command. */assistantAutoBackgrounded?: boolean/** Set when stdout was too large to fit inline — points to the output file on disk. */outputFilePath?: string/** Total size of the output file in bytes (set when outputFilePath is set). */outputFileSize?: number/** The task ID for the output file (set when outputFilePath is set). */outputTaskId?: string/** Error message when the command failed before spawning (e.g., deleted cwd). */preSpawnError?: string}export type ShellCommand = {background: (backgroundTaskId: string) => booleanresult: Promise<ExecResult>kill: () => voidstatus: 'running' | 'backgrounded' | 'completed' | 'killed'/*** Cleans up stream resources (event listeners).* Should be called after the command completes or is killed to prevent memory leaks.*/cleanup: () => voidonTimeout?: (callback: (backgroundFn: (taskId: string) => boolean) => void,) => void/** The TaskOutput instance that owns all stdout/stderr data and progress. */taskOutput: TaskOutput}const SIGKILL = 137const SIGTERM = 143// Background tasks write stdout/stderr directly to a file fd (no JS involvement),// so a stuck append loop can fill the disk. Poll file size and kill when exceeded.const SIZE_WATCHDOG_INTERVAL_MS = 5_000function prependStderr(prefix: string, stderr: string): string {return stderr ? `${prefix}${stderr}` : prefix}/*** Thin pipe from a child process stream into TaskOutput.* Used in pipe mode (hooks) for stdout and stderr.* In file mode (bash commands), both fds go to the output file —* the child process streams are null and no wrappers are created.*/class StreamWrapper {#stream: Readable | null#isCleanedUp = false#taskOutput: TaskOutput | null#isStderr: boolean#onData = this.#dataHandler.bind(this)constructor(stream: Readable, taskOutput: TaskOutput, isStderr: boolean) {this.#stream = streamthis.#taskOutput = taskOutputthis.#isStderr = isStderr// Emit strings instead of Buffers - avoids repeated .toString() callsstream.setEncoding('utf-8')stream.on('data', this.#onData)}#dataHandler(data: Buffer | string): void {const str = typeof data === 'string' ? data : data.toString()if (this.#isStderr) {this.#taskOutput!.writeStderr(str)} else {this.#taskOutput!.writeStdout(str)}}cleanup(): void {if (this.#isCleanedUp) {return}this.#isCleanedUp = truethis.#stream!.removeListener('data', this.#onData)// Release references so the stream, its StringDecoder, and// the TaskOutput can be GC'd independently of this wrapper.this.#stream = nullthis.#taskOutput = nullthis.#onData = () => {}}}/*** Implementation of ShellCommand that wraps a child process.** For bash commands: both stdout and stderr go to a file fd via* stdio[1] and stdio[2] — no JS involvement. Progress is extracted* by polling the file tail.* For hooks: pipe mode with StreamWrappers for real-time detection.*/class ShellCommandImpl implements ShellCommand {#status: 'running' | 'backgrounded' | 'completed' | 'killed' = 'running'#backgroundTaskId: string | undefined#stdoutWrapper: StreamWrapper | null#stderrWrapper: StreamWrapper | null#childProcess: ChildProcess#timeoutId: NodeJS.Timeout | null = null#sizeWatchdog: NodeJS.Timeout | null = null#killedForSize = false#maxOutputBytes: number#abortSignal: AbortSignal#onTimeoutCallback:| ((backgroundFn: (taskId: string) => boolean) => void)| undefined#timeout: number#shouldAutoBackground: boolean#resultResolver: ((result: ExecResult) => void) | null = null#exitCodeResolver: ((code: number) => void) | null = null#boundAbortHandler: (() => void) | null = nullreadonly taskOutput: TaskOutputstatic #handleTimeout(self: ShellCommandImpl): void {if (self.#shouldAutoBackground && self.#onTimeoutCallback) {self.#onTimeoutCallback(self.background.bind(self))} else {self.#doKill(SIGTERM)}}readonly result: Promise<ExecResult>readonly onTimeout?: (callback: (backgroundFn: (taskId: string) => boolean) => void,) => voidconstructor(childProcess: ChildProcess,abortSignal: AbortSignal,timeout: number,taskOutput: TaskOutput,shouldAutoBackground = false,maxOutputBytes = MAX_TASK_OUTPUT_BYTES,) {this.#childProcess = childProcessthis.#abortSignal = abortSignalthis.#timeout = timeoutthis.#shouldAutoBackground = shouldAutoBackgroundthis.#maxOutputBytes = maxOutputBytesthis.taskOutput = taskOutput// In file mode (bash commands), both stdout and stderr go to the// output file fd — childProcess.stdout/.stderr are both null.// In pipe mode (hooks), wrap streams to funnel data into TaskOutput.this.#stderrWrapper = childProcess.stderr? new StreamWrapper(childProcess.stderr, taskOutput, true): nullthis.#stdoutWrapper = childProcess.stdout? new StreamWrapper(childProcess.stdout, taskOutput, false): nullif (shouldAutoBackground) {this.onTimeout = (callback): void => {this.#onTimeoutCallback = callback}}this.result = this.#createResultPromise()}get status(): 'running' | 'backgrounded' | 'completed' | 'killed' {return this.#status}#abortHandler(): void {// On 'interrupt' (user submitted a new message), don't kill — let the// caller background the process so the model can see partial output.if (this.#abortSignal.reason === 'interrupt') {return}this.kill()}#exitHandler(code: number | null, signal: NodeJS.Signals | null): void {const exitCode =code !== null && code !== undefined? code: signal === 'SIGTERM'? 144: 1this.#resolveExitCode(exitCode)}#errorHandler(): void {this.#resolveExitCode(1)}#resolveExitCode(code: number): void {if (this.#exitCodeResolver) {this.#exitCodeResolver(code)this.#exitCodeResolver = null}}// Note: exit/error listeners are NOT removed here — they're needed for// the result promise to resolve. They clean up when the child process exits.#cleanupListeners(): void {this.#clearSizeWatchdog()const timeoutId = this.#timeoutIdif (timeoutId) {clearTimeout(timeoutId)this.#timeoutId = null}const boundAbortHandler = this.#boundAbortHandlerif (boundAbortHandler) {this.#abortSignal.removeEventListener('abort', boundAbortHandler)this.#boundAbortHandler = null}}#clearSizeWatchdog(): void {if (this.#sizeWatchdog) {clearInterval(this.#sizeWatchdog)this.#sizeWatchdog = null}}#startSizeWatchdog(): void {this.#sizeWatchdog = setInterval(() => {void stat(this.taskOutput.path).then(s => {// Bail if the watchdog was cleared while this stat was in flight// (process exited on its own) — otherwise we'd mislabel stderr.if (s.size > this.#maxOutputBytes &&this.#status === 'backgrounded' &&this.#sizeWatchdog !== null) {this.#killedForSize = truethis.#clearSizeWatchdog()this.#doKill(SIGKILL)}},() => {// ENOENT before first write, or unlinked mid-run — skip this tick},)}, SIZE_WATCHDOG_INTERVAL_MS)this.#sizeWatchdog.unref()}#createResultPromise(): Promise<ExecResult> {this.#boundAbortHandler = this.#abortHandler.bind(this)this.#abortSignal.addEventListener('abort', this.#boundAbortHandler, {once: true,})// Use 'exit' not 'close': 'close' waits for stdio to close, which includes// grandchild processes that inherit file descriptors (e.g. `sleep 30 &`).// 'exit' fires when the shell itself exits, returning control immediately.this.#childProcess.once('exit', this.#exitHandler.bind(this))this.#childProcess.once('error', this.#errorHandler.bind(this))this.#timeoutId = setTimeout(ShellCommandImpl.#handleTimeout,this.#timeout,this,) as NodeJS.Timeoutconst exitPromise = new Promise<number>(resolve => {this.#exitCodeResolver = resolve})return new Promise<ExecResult>(resolve => {this.#resultResolver = resolvevoid exitPromise.then(this.#handleExit.bind(this))})}async #handleExit(code: number): Promise<void> {this.#cleanupListeners()if (this.#status === 'running' || this.#status === 'backgrounded') {this.#status = 'completed'}const stdout = await this.taskOutput.getStdout()const result: ExecResult = {code,stdout,stderr: this.taskOutput.getStderr(),interrupted: code === SIGKILL,backgroundTaskId: this.#backgroundTaskId,}if (this.taskOutput.stdoutToFile && !this.#backgroundTaskId) {if (this.taskOutput.outputFileRedundant) {// Small file — full content is in result.stdout, delete the filevoid this.taskOutput.deleteOutputFile()} else {// Large file — tell the caller where the full output livesresult.outputFilePath = this.taskOutput.pathresult.outputFileSize = this.taskOutput.outputFileSizeresult.outputTaskId = this.taskOutput.taskId}}if (this.#killedForSize) {result.stderr = prependStderr(`Background command killed: output file exceeded ${MAX_TASK_OUTPUT_BYTES_DISPLAY}`,result.stderr,)} else if (code === SIGTERM) {result.stderr = prependStderr(`Command timed out after ${formatDuration(this.#timeout)}`,result.stderr,)}const resultResolver = this.#resultResolverif (resultResolver) {this.#resultResolver = nullresultResolver(result)}}#doKill(code?: number): void {this.#status = 'killed'if (this.#childProcess.pid) {treeKill(this.#childProcess.pid, 'SIGKILL')}this.#resolveExitCode(code ?? SIGKILL)}kill(): void {this.#doKill()}background(taskId: string): boolean {if (this.#status === 'running') {this.#backgroundTaskId = taskIdthis.#status = 'backgrounded'this.#cleanupListeners()if (this.taskOutput.stdoutToFile) {// File mode: child writes directly to the fd with no JS involvement.// The foreground timeout is gone, so watch file size to prevent// a stuck append loop from filling the disk (768GB incident).this.#startSizeWatchdog()} else {// Pipe mode: spill the in-memory buffer so readers can find it on disk.this.taskOutput.spillToDisk()}return true}return false}cleanup(): void {this.#stdoutWrapper?.cleanup()this.#stderrWrapper?.cleanup()this.taskOutput.clear()// Must run before nulling #abortSignal — #cleanupListeners() calls// removeEventListener on it. Without this, a kill()+cleanup() sequence// crashes: kill() queues #handleExit as a microtask, cleanup() nulls// #abortSignal, then #handleExit runs #cleanupListeners() on the null ref.this.#cleanupListeners()// Release references to allow GC of ChildProcess internals and AbortController chainthis.#childProcess = null!this.#abortSignal = null!this.#onTimeoutCallback = undefined}}/*** Wraps a child process to enable flexible handling of shell command execution.*/export function wrapSpawn(childProcess: ChildProcess,abortSignal: AbortSignal,timeout: number,taskOutput: TaskOutput,shouldAutoBackground = false,maxOutputBytes = MAX_TASK_OUTPUT_BYTES,): ShellCommand {return new ShellCommandImpl(childProcess,abortSignal,timeout,taskOutput,shouldAutoBackground,maxOutputBytes,)}/*** Static ShellCommand implementation for commands that were aborted before execution.*/class AbortedShellCommand implements ShellCommand {readonly status = 'killed' as constreadonly result: Promise<ExecResult>readonly taskOutput: TaskOutputconstructor(opts?: {backgroundTaskId?: stringstderr?: stringcode?: number}) {this.taskOutput = new TaskOutput(generateTaskId('local_bash'), null)this.result = Promise.resolve({code: opts?.code ?? 145,stdout: '',stderr: opts?.stderr ?? 'Command aborted before execution',interrupted: true,backgroundTaskId: opts?.backgroundTaskId,})}background(): boolean {return false}kill(): void {}cleanup(): void {}}export function createAbortedCommand(backgroundTaskId?: string,opts?: { stderr?: string; code?: number },): ShellCommand {return new AbortedShellCommand({backgroundTaskId,...opts,})}export function createFailedCommand(preSpawnError: string): ShellCommand {const taskOutput = new TaskOutput(generateTaskId('local_bash'), null)return {status: 'completed' as const,result: Promise.resolve({code: 1,stdout: '',stderr: preSpawnError,interrupted: false,preSpawnError,}),taskOutput,background(): boolean {return false},kill(): void {},cleanup(): void {},}}
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。