UNPKG

termcode

Version:

Superior terminal AI coding agent with enterprise-grade security, intelligent error recovery, performance monitoring, and plugin system - Advanced Claude Code alternative

270 lines (269 loc) 9.96 kB
import { spawn } from "node:child_process"; import { log } from "../util/logging.js"; import { permissionManager } from "../security/permissions.js"; /** * Background command execution system inspired by Claude Code's Ctrl-b feature * Allows running long-running processes without blocking the main interface */ export class BackgroundExecutor { processes = new Map(); nextId = 1; /** * Start a command in the background */ async startBackground(command, args = [], cwd) { // Check permissions first const permission = await permissionManager.checkPermission('background', { command, args }, { command: `${command} ${args.join(' ')}` }); if (!permission.allowed) { throw new Error(`Background execution denied: ${permission.reason}`); } const id = `bg-${this.nextId++}`; const fullCommand = `${command} ${args.join(' ')}`; log.step("Starting background process", `${fullCommand} (ID: ${id})`); try { const childProcess = spawn(command, args, { cwd: cwd || process.cwd(), stdio: ['pipe', 'pipe', 'pipe'], detached: false, // Keep attached so we can manage it }); const backgroundProcess = { id, command, args, pid: childProcess.pid, process: childProcess, startTime: Date.now(), output: [], errorOutput: [], status: 'running' }; // Handle process output childProcess.stdout.on('data', (data) => { const lines = data.toString().split('\n').filter(line => line.trim()); backgroundProcess.output.push(...lines); // Log recent output (last 3 lines) if (lines.length > 0) { const recentLines = lines.slice(-3); log.raw(`${log.colors.dim(`[${id}]`)} ${recentLines.join('\n' + log.colors.dim(`[${id}]`) + ' ')}`); } }); childProcess.stderr.on('data', (data) => { const lines = data.toString().split('\n').filter(line => line.trim()); backgroundProcess.errorOutput.push(...lines); // Log errors if (lines.length > 0) { lines.forEach(line => { log.raw(`${log.colors.dim(`[${id}]`)} ${log.colors.red(line)}`); }); } }); childProcess.on('exit', (code, signal) => { backgroundProcess.status = code === 0 ? 'completed' : 'failed'; backgroundProcess.exitCode = code || undefined; const duration = Date.now() - backgroundProcess.startTime; const durationStr = `${(duration / 1000).toFixed(1)}s`; if (code === 0) { log.success(`Background process ${id} completed in ${durationStr}`); } else { log.error(`Background process ${id} failed with code ${code} after ${durationStr}`); } // Clean up process reference backgroundProcess.process = undefined; }); childProcess.on('error', (error) => { backgroundProcess.status = 'failed'; backgroundProcess.errorOutput.push(error.message); log.error(`Background process ${id} error:`, error.message); // Clean up process reference backgroundProcess.process = undefined; }); this.processes.set(id, backgroundProcess); return { id, message: `Background process started: ${fullCommand} (ID: ${id}, PID: ${childProcess.pid})` }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; log.error(`Failed to start background process:`, errorMessage); throw new Error(`Failed to start background process: ${errorMessage}`); } } /** * List all background processes */ listProcesses() { return Array.from(this.processes.values()).map(proc => ({ ...proc, process: undefined // Don't expose the process object })); } /** * Get a specific background process */ getProcess(id) { const proc = this.processes.get(id); if (proc) { return { ...proc, process: undefined // Don't expose the process object }; } return undefined; } /** * Kill a background process */ async killProcess(id) { const backgroundProcess = this.processes.get(id); if (!backgroundProcess) { log.warn(`Background process ${id} not found`); return false; } if (!backgroundProcess.process || backgroundProcess.status !== 'running') { log.warn(`Background process ${id} is not running`); return false; } try { backgroundProcess.process.kill('SIGTERM'); backgroundProcess.status = 'killed'; // Wait a bit for graceful shutdown, then force kill if needed setTimeout(() => { if (backgroundProcess.process && !backgroundProcess.process.killed) { backgroundProcess.process.kill('SIGKILL'); } }, 5000); log.info(`Killed background process ${id}`); return true; } catch (error) { log.error(`Failed to kill background process ${id}:`, error); return false; } } /** * Kill all background processes */ async killAllProcesses() { const runningProcesses = Array.from(this.processes.values()) .filter(proc => proc.status === 'running'); let killedCount = 0; for (const proc of runningProcesses) { const killed = await this.killProcess(proc.id); if (killed) { killedCount++; } } log.info(`Killed ${killedCount} background processes`); return killedCount; } /** * Get output from a background process */ getOutput(id, lines) { const proc = this.processes.get(id); if (!proc) { return null; } const stdout = lines ? proc.output.slice(-lines) : proc.output; const stderr = lines ? proc.errorOutput.slice(-lines) : proc.errorOutput; return { stdout, stderr }; } /** * Clear output from a background process */ clearOutput(id) { const proc = this.processes.get(id); if (!proc) { return false; } proc.output = []; proc.errorOutput = []; log.info(`Cleared output for background process ${id}`); return true; } /** * Clean up completed processes */ cleanup(olderThanMs = 300000) { const cutoffTime = Date.now() - olderThanMs; let cleanedCount = 0; for (const [id, proc] of this.processes) { if (proc.status !== 'running' && proc.startTime < cutoffTime) { this.processes.delete(id); cleanedCount++; } } if (cleanedCount > 0) { log.info(`Cleaned up ${cleanedCount} old background processes`); } return cleanedCount; } /** * Get background execution statistics */ getStats() { const processes = Array.from(this.processes.values()); const total = processes.length; const running = processes.filter(p => p.status === 'running').length; const completed = processes.filter(p => p.status === 'completed').length; const failed = processes.filter(p => p.status === 'failed').length; const killed = processes.filter(p => p.status === 'killed').length; const finishedProcesses = processes.filter(p => p.status !== 'running'); const averageRuntime = finishedProcesses.length > 0 ? finishedProcesses.reduce((sum, p) => { const endTime = Date.now(); // Approximate for running processes return sum + (endTime - p.startTime); }, 0) / finishedProcesses.length : 0; return { total, running, completed, failed, killed, averageRuntime }; } /** * Check if a process is running */ isRunning(id) { const proc = this.processes.get(id); return proc?.status === 'running' || false; } /** * Wait for a background process to complete */ async waitFor(id, timeoutMs) { const proc = this.processes.get(id); if (!proc) { return null; } if (proc.status !== 'running') { return proc; } return new Promise((resolve, reject) => { const timeout = timeoutMs ? setTimeout(() => { reject(new Error(`Timeout waiting for process ${id}`)); }, timeoutMs) : undefined; const checkStatus = () => { const currentProc = this.processes.get(id); if (!currentProc || currentProc.status !== 'running') { if (timeout) clearTimeout(timeout); resolve(currentProc || null); } else { setTimeout(checkStatus, 100); } }; checkStatus(); }); } } // Export singleton instance export const backgroundExecutor = new BackgroundExecutor();