/** * Subprocess management utilities for CLI providers */ import { spawn, type ChildProcess } from "child_process"; import readline from "readline"; export interface SubprocessOptions { command: string; args: string[]; cwd: string; env?: Record; abortController?: AbortController; timeout?: number; // Milliseconds of no output before timeout } export interface SubprocessResult { stdout: string; stderr: string; exitCode: number | null; } /** * Spawns a subprocess and streams JSONL output line-by-line */ export async function* spawnJSONLProcess( options: SubprocessOptions ): AsyncGenerator { const { command, args, cwd, env, abortController, timeout = 30000 } = options; const processEnv = { ...process.env, ...env, }; console.log(`[SubprocessManager] Spawning: ${command} ${args.slice(0, -1).join(" ")}`); console.log(`[SubprocessManager] Working directory: ${cwd}`); const childProcess: ChildProcess = spawn(command, args, { cwd, env: processEnv, stdio: ["ignore", "pipe", "pipe"], }); let stderrOutput = ""; let lastOutputTime = Date.now(); let timeoutHandle: NodeJS.Timeout | null = null; // Collect stderr for error reporting if (childProcess.stderr) { childProcess.stderr.on("data", (data: Buffer) => { const text = data.toString(); stderrOutput += text; console.error(`[SubprocessManager] stderr: ${text}`); }); } // Setup timeout detection const resetTimeout = () => { lastOutputTime = Date.now(); if (timeoutHandle) { clearTimeout(timeoutHandle); } timeoutHandle = setTimeout(() => { const elapsed = Date.now() - lastOutputTime; if (elapsed >= timeout) { console.error( `[SubprocessManager] Process timeout: no output for ${timeout}ms` ); childProcess.kill("SIGTERM"); } }, timeout); }; resetTimeout(); // Setup abort handling if (abortController) { abortController.signal.addEventListener("abort", () => { console.log("[SubprocessManager] Abort signal received, killing process"); if (timeoutHandle) { clearTimeout(timeoutHandle); } childProcess.kill("SIGTERM"); }); } // Parse stdout as JSONL (one JSON object per line) if (childProcess.stdout) { const rl = readline.createInterface({ input: childProcess.stdout, crlfDelay: Infinity, }); try { for await (const line of rl) { resetTimeout(); if (!line.trim()) continue; try { const parsed = JSON.parse(line); yield parsed; } catch (parseError) { console.error( `[SubprocessManager] Failed to parse JSONL line: ${line}`, parseError ); // Yield error but continue processing yield { type: "error", error: `Failed to parse output: ${line}`, }; } } } catch (error) { console.error("[SubprocessManager] Error reading stdout:", error); throw error; } finally { if (timeoutHandle) { clearTimeout(timeoutHandle); } } } // Wait for process to exit const exitCode = await new Promise((resolve) => { childProcess.on("exit", (code) => { console.log(`[SubprocessManager] Process exited with code: ${code}`); resolve(code); }); childProcess.on("error", (error) => { console.error("[SubprocessManager] Process error:", error); resolve(null); }); }); // Handle non-zero exit codes if (exitCode !== 0 && exitCode !== null) { const errorMessage = stderrOutput || `Process exited with code ${exitCode}`; console.error(`[SubprocessManager] Process failed: ${errorMessage}`); yield { type: "error", error: errorMessage, }; } // Process completed successfully if (exitCode === 0 && !stderrOutput) { console.log("[SubprocessManager] Process completed successfully"); } } /** * Spawns a subprocess and collects all output */ export async function spawnProcess( options: SubprocessOptions ): Promise { const { command, args, cwd, env, abortController } = options; const processEnv = { ...process.env, ...env, }; return new Promise((resolve, reject) => { const childProcess = spawn(command, args, { cwd, env: processEnv, stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; if (childProcess.stdout) { childProcess.stdout.on("data", (data: Buffer) => { stdout += data.toString(); }); } if (childProcess.stderr) { childProcess.stderr.on("data", (data: Buffer) => { stderr += data.toString(); }); } // Setup abort handling if (abortController) { abortController.signal.addEventListener("abort", () => { childProcess.kill("SIGTERM"); reject(new Error("Process aborted")); }); } childProcess.on("exit", (code) => { resolve({ stdout, stderr, exitCode: code }); }); childProcess.on("error", (error) => { reject(error); }); }); }