naisys
Version:
Node.js Autonomous Intelligence System
340 lines • 13.4 kB
JavaScript
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