UNPKG

naisys

Version:

Node.js Autonomous Intelligence System

340 lines 13.4 kB
import xterm from "@xterm/headless"; import { spawn } from "child_process"; import * as fs from "fs"; import * as os from "os"; import stripAnsi from "strip-ansi"; import treeKill from "tree-kill"; import * as config from "../config.js"; import * as output from "../utils/output.js"; import * as pathService from "../utils/pathService.js"; import { NaisysPath } from "../utils/pathService.js"; var ShellEvent; (function (ShellEvent) { ShellEvent["Ouptput"] = "stdout"; ShellEvent["Error"] = "stderr"; ShellEvent["Exit"] = "exit"; })(ShellEvent || (ShellEvent = {})); let _process; let _currentProcessId; let _commandOutput = ""; let _currentPath; let _terminal; let _bufferChangeEvent; let _currentBufferType = "normal"; let _resolveCurrentCommand; let _currentCommandTimeout; /** How we know the command has completed when running the command inside a shell like bash or wsl */ const _commandDelimiter = "__COMMAND_END_X7YUTT__"; let _wrapperSuspended = false; const _queuedOutput = []; async function ensureOpen() { if (_process) { return; } resetCommand(); const spawnCmd = os.platform() === "win32" ? "wsl" : "bash"; _process = spawn(spawnCmd, [], { stdio: "pipe" }); const pid = _process.pid; if (!pid) { throw "Shell process failed to start"; } _currentProcessId = pid; _process.stdout.on("data", (data) => { processOutput(data, ShellEvent.Ouptput, pid); }); _process.stderr.on("data", (data) => { processOutput(data, ShellEvent.Error, pid); }); _process.on("close", (code) => { processOutput(Buffer.from(`${code}`), ShellEvent.Exit, pid); }); // Init users home dir on first run, on shell crash/rerun go back to the current path if (!_currentPath) { output.comment("NEW SHELL OPENED. PID: " + pid); errorIfNotEmpty(await executeCommand(`mkdir -p ${config.naisysFolder}/home/` + config.agent.username)); errorIfNotEmpty(await executeCommand(`cd ${config.naisysFolder}/home/` + config.agent.username)); } else { output.comment("SHELL RESTORED. PID: " + pid); errorIfNotEmpty(await executeCommand("cd " + _currentPath)); } // Stop running commands if one fails // Often the LLM will give us back all kinds of invalid commands, we want to break on the first one // Unfortunately this also causes the shell to exit on failures, so we need to handle that //commentIfNotEmpty(await executeCommand("set -e")); } /** Basically don't show anything in the console unless there is an error */ function errorIfNotEmpty(response) { if (response) { output.error(response); } } function processOutput(rawDataStr, eventType, pid) { if (_wrapperSuspended) { _queuedOutput.push({ rawDataStr, eventType, pid }); return; } let dataStr = stripAnsi(rawDataStr.toString()); if (pid != _currentProcessId) { output.comment(`Ignoring '${eventType}' from old shell process ${pid}: ` + dataStr); return; } if (!_resolveCurrentCommand) { output.comment(`Ignoring '${eventType}' from process ${pid} with no resolve handler: ` + dataStr); return; } if (eventType === ShellEvent.Exit) { output.error(`SHELL EXIT. PID: ${_process?.pid}, CODE: ${rawDataStr}`); let finalOutput = _currentBufferType == "alternate" ? _getTerminalActiveBuffer() : _commandOutput.trim(); if (finalOutput.endsWith("command not found")) { finalOutput += `\nNAISYS: Make sure that you are using valid linux commands, and that any non-commands are prefixed with the 'commment' command.`; } finalOutput += `\nNAISYS: Command killed.`; resetProcess(); _completeCommand(finalOutput); return; } // Should only happen back in normal mode, so we don't need to modify the rawDataStr let endDelimiterHit = false; const endDelimiterPos = dataStr.indexOf(_commandDelimiter); if (endDelimiterPos != -1 && // Quotes will only precede the delimiter if the echo command got in the output, so don't count it // For example running nano or vi will cause this dataStr[endDelimiterPos - 1] != '"') { endDelimiterHit = true; dataStr = dataStr.slice(0, endDelimiterPos); // If it does happen somehow, log it so I can figure out why/how and what to do about it if (_currentBufferType == "alternate") { output.error("UNEXPECTED END DELIMITER IN ALTERNATE BUFFER: " + dataStr); } } // If we're in alternate mode, just write the data to the terminal // When the buffer changes back to normal, the output will be copied back to the command output if (_currentBufferType == "normal") { _commandOutput += dataStr; } // TODO: get token size of buffer, if too big, switch it front/middle/back _terminal?.write(rawDataStr); // Not synchronous, second param takes a call back, don't need to handle it AFAIK if (endDelimiterHit) { const finalOutput = _commandOutput.trim(); resetCommand(); _completeCommand(finalOutput); } } export async function executeCommand(command) { if (_wrapperSuspended) { throw "Use continueCommand to send input to a shell command in process"; } command = command.trim(); _lastCommand = command; // Set here before it gets reset by the multi line script below await ensureOpen(); if (_currentPath && command.split("\n").length > 1) { command = await putMultilineCommandInAScript(command); } return new Promise((resolve, reject) => { _resolveCurrentCommand = resolve; if (!_process) { reject("Shell process is not open"); return; } const commandWithDelimiter = `${command}\necho "${_commandDelimiter}"\n`; _process.stdin.write(commandWithDelimiter); // Set timeout to wait for response from command setCommandTimeout(); }); } /** The LLM made its decision on how it wants to continue with the shell that previously timed out */ export function continueCommand(command) { if (!_wrapperSuspended) { throw "Shell is not suspended, use execute command"; } command = command.trim(); _wrapperSuspended = false; let choice; if (command != "wait" && command != "kill") { choice = "input"; } else { choice = command; } return new Promise((resolve, reject) => { _resolveCurrentCommand = resolve; // If new output from the shell was queued while waiting for the LLM to decide what to do if (_queuedOutput.length > 0) { for (const output of _queuedOutput) { processOutput(output.rawDataStr, output.eventType, output.pid); } _queuedOutput.length = 0; // If processing queue resolved the command, then we're done if (!_resolveCurrentCommand) { return; } // Used to return here if LLM was sending if output was generated while waiting for the LLM // In normal mode this would make the log confusing and out of order // But since we only use the terminal in alternate mode, this is fine and works // with commands like `mtr` changing the display type } // LLM wants to wait for more output if (choice == "wait") { setCommandTimeout(); return; } // Else LLM wants to kill the process else if (choice == "kill") { if (!_currentProcessId) { reject("No process to kill"); } else if (resetShell(_currentProcessId)) { return; // Wait for exit event } else { reject("Unable to kill. Process not found"); } return; } // Else LLM wants to send input to the process else { if (!_process) { reject("Shell process is not open"); return; } _process.stdin.write(command + "\n"); _lastCommand = command; setCommandTimeout(); } }); } let _startCommandTime; /** Pulled out because for commands like 'wait' we want to vary the run time based on the 'last command' */ let _lastCommand; function setCommandTimeout() { _startCommandTime = new Date(); let timeoutSeconds = config.shellCommand.timeoutSeconds; if (config.shellCommand.longRunningCommands.some((cmd) => _lastCommand?.startsWith(cmd))) { timeoutSeconds = config.shellCommand.longRunningTimeoutSeconds; } _currentCommandTimeout = setTimeout(() => { returnControlToNaisys(); }, timeoutSeconds * 1000); } function returnControlToNaisys() { _wrapperSuspended = true; _queuedOutput.length = 0; // Flush the output to the consol, and give the LLM instructions of how it might continue let outputWithInstruction = _currentBufferType == "alternate" ? _getTerminalActiveBuffer() : _commandOutput.trim(); _commandOutput = ""; // Don't clear the alternate buffer, it's a special terminal full screen mode that the // LLM might want to see updates too if (_currentBufferType != "alternate") { resetTerminal(); } const waitSeconds = Math.round((new Date().getTime() - _startCommandTime.getTime()) / 1000); outputWithInstruction += `\nNAISYS: Command interrupted after waiting ${waitSeconds} seconds.`; _completeCommand(outputWithInstruction); } function resetShell(pid) { if (!_process || _process.pid != pid) { output.comment("Ignoring timeout for old shell process " + pid); return false; } output.error(`KILL-TREE SIGNAL SENT TO PID: ${_process.pid}`); treeKill(pid, "SIGKILL"); // Should trigger the process close event from here return true; } export async function getCurrentPath() { // If wrapper suspended just give the last known path if (_wrapperSuspended) { return _currentPath; } await ensureOpen(); _currentPath = await executeCommand("pwd"); return _currentPath; } export async function terminate() { /*const exitCode = */ await executeCommand("exit"); // For some reason showing the exit code clears the console resetProcess(); } function resetCommand() { _commandOutput = ""; resetTerminal(); clearTimeout(_currentCommandTimeout); } function resetTerminal() { _bufferChangeEvent?.dispose(); _terminal?.dispose(); _terminal = new xterm.Terminal({ allowProposedApi: true, rows: process.stdout.rows, cols: process.stdout.columns, }); _currentBufferType = "normal"; _bufferChangeEvent = _terminal.buffer.onBufferChange((buffer) => { // If changing back to normal, copy the alternate buffer back to the output // so it shows up when the command is resolved if (_currentBufferType == "alternate" && buffer.type == "normal") { output.comment("NAISYS: BUFFER CHANGE BACK TO NORMAL"); _commandOutput += "\n" + _getTerminalActiveBuffer() + "\n"; } _currentBufferType = buffer.type; }); } function resetProcess() { resetCommand(); _process?.removeAllListeners(); _process = undefined; _terminal?.dispose(); _terminal = undefined; } /** Wraps multi line commands in a script to make it easier to diagnose the source of errors based on line number * May also help with common escaping errors */ function putMultilineCommandInAScript(command) { const scriptPath = new NaisysPath(`${config.naisysFolder}/agent-data/${config.agent.username}/multiline-command.sh`); pathService.ensureFileDirExists(scriptPath); // set -e causes the script to exit on the first error const scriptContent = `#!/bin/bash set -e cd ${_currentPath} ${command.trim()}`; // create/write file fs.writeFileSync(scriptPath.toHostPath(), scriptContent); // `Path` is set to the ./bin folder because custom NAISYS commands that follow shell commands will be handled by the shell, which will fail // so we need to remind the LLM that 'naisys commands cannot be used with other commands on the same prompt' // `source` will run the script in the current shell, so any change directories in the script will persist in the current shell return `PATH=${config.binPath}:$PATH source ${scriptPath.getNaisysPath()}`; } function _completeCommand(output) { if (!_resolveCurrentCommand) { throw "No command to resolve"; } _resolveCurrentCommand(output); _resolveCurrentCommand = undefined; } export function isShellSuspended() { return _wrapperSuspended; } /** * The alternate/active buffer is a special terminal mode that runs full screen * independent of the 'normal' buffer that is more like a log */ function _getTerminalActiveBuffer() { let output = ""; const bufferLineCount = _terminal?.buffer.normal?.length || 0; for (let i = 0; i < bufferLineCount; i++) { const line = _terminal?.buffer.alternate ?.getLine(i) ?.translateToString() .trim(); if (line) { output += line + "\n"; } } return output.trim(); } //# sourceMappingURL=shellWrapper.js.map