UNPKG

@nanocollective/nanocoder

Version:

A local-first CLI coding agent that brings the power of agentic coding tools like Claude Code and Gemini CLI to local models or controlled APIs like OpenRouter

110 lines 4.44 kB
import { spawn } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { EventEmitter } from 'node:events'; import { platform } from 'node:process'; import { BASH_OUTPUT_PREVIEW_LENGTH, INTERVAL_BASH_PROGRESS_MS, } from '../constants.js'; const isWindows = platform === 'win32'; export class BashExecutor extends EventEmitter { executions = new Map(); execute(command) { const executionId = randomUUID(); const state = { executionId, command, outputPreview: '', fullOutput: '', stderr: '', isComplete: false, exitCode: null, error: null, }; const proc = isWindows ? spawn('cmd', ['/c', command]) : spawn('sh', ['-c', command]); // Collect output proc.stdout.on('data', (data) => { state.fullOutput += data.toString(); state.outputPreview = state.fullOutput.slice(-BASH_OUTPUT_PREVIEW_LENGTH); // Emit progress immediately when output is received // This ensures fast commands still show streaming output this.emit('progress', { ...state }); }); proc.stderr.on('data', (data) => { state.stderr += data.toString(); // Emit progress immediately when stderr is received this.emit('progress', { ...state }); }); // Progress interval - emit updates every 500ms // Using unref() so this interval doesn't prevent Node.js from exiting // (important for tests and clean shutdown) const intervalId = setInterval(() => { this.emit('progress', { ...state }); }, INTERVAL_BASH_PROGRESS_MS); intervalId.unref(); const promise = new Promise((resolve, _reject) => { // Store resolve function so cancel() can resolve the promise this.executions.set(executionId, { state, process: proc, intervalId, resolve, }); proc.on('close', (code) => { // Only process if not already handled by cancel() if (!this.executions.has(executionId)) return; clearInterval(intervalId); state.isComplete = true; state.exitCode = code; this.emit('complete', { ...state }); this.executions.delete(executionId); resolve({ ...state }); }); proc.on('error', (error) => { // Only process if not already handled by cancel() if (!this.executions.has(executionId)) return; clearInterval(intervalId); state.isComplete = true; state.error = error.message; this.emit('complete', { ...state }); this.executions.delete(executionId); resolve({ ...state }); // Resolve with error state instead of rejecting }); }); // Emit initial state this.emit('start', { ...state }); return { executionId, promise }; } cancel(executionId) { const execution = this.executions.get(executionId); if (!execution) return false; clearInterval(execution.intervalId); // Destroy stdio streams to prevent them from keeping the event loop alive execution.process.stdout?.destroy(); execution.process.stderr?.destroy(); execution.process.stdin?.destroy(); execution.process.kill('SIGTERM'); execution.state.isComplete = true; execution.state.error = 'Cancelled by user'; this.emit('complete', { ...execution.state }); // Resolve the promise with the cancelled state execution.resolve({ ...execution.state }); this.executions.delete(executionId); return true; } getState(executionId) { const execution = this.executions.get(executionId); return execution ? { ...execution.state } : undefined; } hasActiveExecutions() { return this.executions.size > 0; } getActiveExecutionIds() { return Array.from(this.executions.keys()); } } // Singleton instance for app-wide use export const bashExecutor = new BashExecutor(); //# sourceMappingURL=bash-executor.js.map