@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
JavaScript
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