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
JavaScript
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();