@shelltender/server
Version:
Server-side terminal session management for Shelltender
1,609 lines (1,588 loc) • 106 kB
JavaScript
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var index_exports = {};
__export(index_exports, {
AdminSessionProxy: () => AdminSessionProxy,
AgenticCodingPatterns: () => AgenticCodingPatterns,
AnsiMatcher: () => AnsiMatcher,
BOX_DRAWING_CHARS: () => BOX_DRAWING_CHARS,
BufferManager: () => BufferManager,
CommonFilters: () => CommonFilters,
CommonPatterns: () => CommonPatterns,
CommonProcessors: () => CommonProcessors,
CustomMatcher: () => CustomMatcher,
EventManager: () => EventManager,
PatternMatcher: () => PatternMatcher,
PatternMatcherFactory: () => PatternMatcherFactory,
PipelineIntegration: () => PipelineIntegration,
RegexMatcher: () => RegexMatcher,
RestrictedShell: () => RestrictedShell,
SessionManager: () => SessionManager,
SessionStore: () => SessionStore,
StringMatcher: () => StringMatcher,
THINKING_ANIMATION_SYMBOLS: () => THINKING_ANIMATION_SYMBOLS,
TerminalDataPipeline: () => TerminalDataPipeline,
WebSocketServer: () => WebSocketServer,
createShelltender: () => createShelltender,
createShelltenderServer: () => createShelltenderServer,
detectAIStatus: () => detectAIStatus,
detectEnvironment: () => detectEnvironment,
extractActionWord: () => extractActionWord,
extractTerminalTitle: () => extractTerminalTitle,
getAgenticPatternsByCategory: () => getAgenticPatternsByCategory,
getAllAgenticPatterns: () => getAllAgenticPatterns,
getAllPatterns: () => getAllPatterns,
getPatternsByCategory: () => getPatternsByCategory,
searchPatterns: () => searchPatterns,
startShelltender: () => startShelltender,
validateConfiguration: () => validateConfiguration
});
module.exports = __toCommonJS(index_exports);
// src/SessionManager.ts
var pty = __toESM(require("node-pty"), 1);
var import_uuid = require("uuid");
var import_events = require("events");
// src/RestrictedShell.ts
var path = __toESM(require("path"), 1);
var fs = __toESM(require("fs"), 1);
var RestrictedShell = class {
constructor(options) {
this.options = options;
this.restrictedPath = options.restrictToPath ? path.resolve(options.restrictToPath) : options.cwd || process.env.HOME || "/";
this.allowUpward = options.allowUpwardNavigation ?? !options.restrictToPath;
this.blockedCommands = new Set(options.blockedCommands || [
"sudo",
"su",
"chmod",
"chown",
"mount",
"umount"
]);
if (options.readOnlyMode) {
this.addReadOnlyRestrictions();
}
}
addReadOnlyRestrictions() {
const writeCommands = [
"rm",
"rmdir",
"mv",
"cp",
"mkdir",
"touch",
"dd",
"nano",
"vim",
"vi",
"emacs",
">",
">>"
];
writeCommands.forEach((cmd) => this.blockedCommands.add(cmd));
}
// Create initialization script for the shell
getInitScript() {
const scripts = [];
if (this.options.restrictToPath) {
scripts.push(`
# Restrict navigation
export RESTRICTED_PATH="${this.restrictedPath}"
# Override cd command
cd() {
local target="$1"
if [ -z "$target" ]; then
target="$RESTRICTED_PATH"
fi
# Resolve the absolute path
local abs_path=$(realpath -m "$target" 2>/dev/null || echo "$target")
# Check if path is within restricted area
if [[ ! "$abs_path" =~ ^"$RESTRICTED_PATH" ]]; then
echo "Access denied: Cannot navigate outside restricted area" >&2
return 1
fi
# Use builtin cd
builtin cd "$target"
}
# Override pwd to show relative path
pwd() {
local current=$(builtin pwd)
if [[ "$current" =~ ^"$RESTRICTED_PATH" ]]; then
echo "\${current#$RESTRICTED_PATH}" | sed 's/^$/\\//'
else
echo "/"
fi
}
`);
}
if (this.blockedCommands.size > 0) {
for (const cmd of this.blockedCommands) {
scripts.push(`
${cmd}() {
echo "Command '${cmd}' is not allowed in this session" >&2
return 1
}
`);
}
}
if (this.options.readOnlyMode) {
scripts.push(`
# Redirect write operations
set -o noclobber # Prevent overwriting files
# Make common directories read-only
alias rm='echo "Write operations are disabled" >&2; false'
alias touch='echo "Write operations are disabled" >&2; false'
`);
}
if (this.options.restrictToPath || this.options.readOnlyMode) {
scripts.push(`
unset HISTFILE
export HISTSIZE=0
`);
}
return scripts.join("\n");
}
// Validate a command before execution
validateCommand(command) {
const parts = command.trim().split(/\s+/);
const cmd = parts[0];
if (this.blockedCommands.has(cmd)) {
return {
allowed: false,
reason: `Command '${cmd}' is not allowed in this session`
};
}
if (this.options.restrictToPath && !this.allowUpward) {
if (command.includes("../") || command.includes("..\\")) {
return {
allowed: false,
reason: "Path traversal is not allowed"
};
}
}
if (this.options.restrictToPath) {
const absolutePathRegex = /\/[^\s]+/g;
const matches = command.match(absolutePathRegex) || [];
for (const match of matches) {
const absPath = path.resolve(match);
if (!absPath.startsWith(this.restrictedPath)) {
return {
allowed: false,
reason: `Access to path '${match}' is not allowed`
};
}
}
}
return { allowed: true };
}
// Get the shell command and args
getShellCommand() {
const initScript = this.getInitScript();
const tempInitFile = `/tmp/.terminal_init_${Date.now()}.sh`;
fs.writeFileSync(tempInitFile, initScript);
return {
command: this.options.command || "/bin/bash",
args: [
"--rcfile",
tempInitFile,
...this.options.args || []
],
env: {
...this.options.env,
...this.options.restrictToPath && {
PS1: "[Restricted] \\w\\$ "
}
}
};
}
};
// src/SessionManager.ts
var SessionManager = class extends import_events.EventEmitter {
constructor(sessionStore) {
super();
this.sessions = /* @__PURE__ */ new Map();
this.restoredSessions = /* @__PURE__ */ new Set();
this.sessionStore = sessionStore;
this.setMaxListeners(100);
this.restoreSessions();
}
async restoreSessions() {
const savedSessions = await this.sessionStore.loadAllSessions();
for (const [sessionId, storedSession] of savedSessions) {
try {
const env = {
...process.env,
LANG: "en_US.UTF-8",
LC_ALL: "en_US.UTF-8",
LC_CTYPE: "en_US.UTF-8",
TERM: "xterm-256color"
};
const ptyProcess = pty.spawn("/bin/bash", [], {
name: "xterm-256color",
cols: storedSession.session.cols,
rows: storedSession.session.rows,
cwd: storedSession.cwd || process.env.HOME,
env
});
const session = {
...storedSession.session,
id: sessionId,
lastAccessedAt: /* @__PURE__ */ new Date()
};
this.sessions.set(sessionId, {
pty: ptyProcess,
session,
clients: /* @__PURE__ */ new Set()
});
this.setupPtyHandlers(sessionId, ptyProcess);
this.restoredSessions.add(sessionId);
if (storedSession.buffer) {
this.emit("data", sessionId, storedSession.buffer, { source: "restored" });
}
} catch (error) {
console.error(`Failed to restore session ${sessionId}:`, error);
await this.sessionStore.deleteSession(sessionId);
}
}
}
setupPtyHandlers(sessionId, ptyProcess) {
let saveTimer = null;
let hasReceivedOutput = false;
ptyProcess.onData((data) => {
this.emit("data", sessionId, data, { source: "pty" });
if (this.restoredSessions.has(sessionId) && !hasReceivedOutput) {
hasReceivedOutput = true;
this.restoredSessions.delete(sessionId);
}
});
ptyProcess.onExit(() => {
this.emit("sessionEnd", sessionId);
this.sessions.delete(sessionId);
this.restoredSessions.delete(sessionId);
this.sessionStore.deleteSession(sessionId);
});
}
createSession(options = {}) {
const cols = options.cols || 80;
const rows = options.rows || 24;
const sessionId = options.id || (0, import_uuid.v4)();
const session = {
id: sessionId,
createdAt: /* @__PURE__ */ new Date(),
lastAccessedAt: /* @__PURE__ */ new Date(),
cols,
rows,
command: options.command,
args: options.args,
locked: options.locked
};
let command = options.command || process.env.SHELL || "/bin/sh";
let args = options.args || [];
let cwd = options.cwd || process.cwd();
let env = {
...process.env,
...options.env,
LANG: "en_US.UTF-8",
LC_ALL: "en_US.UTF-8",
LC_CTYPE: "en_US.UTF-8",
TERM: "xterm-256color"
};
if (options.restrictToPath || options.blockedCommands || options.readOnlyMode) {
const restrictedShell = new RestrictedShell(options);
const shellConfig = restrictedShell.getShellCommand();
command = shellConfig.command;
args = shellConfig.args;
env = { ...env, ...shellConfig.env };
}
let ptyProcess;
try {
ptyProcess = pty.spawn(command, args, {
name: "xterm-256color",
cols,
rows,
cwd,
env
});
} catch (error) {
const errorMessage = `Failed to create PTY session: ${error instanceof Error ? error.message : String(error)}`;
const debugInfo = {
command,
args,
cwd,
cols,
rows,
platform: process.platform,
shell: command,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : void 0
};
console.error(errorMessage, debugInfo);
if (error instanceof Error && error.message.includes("ENOENT")) {
throw new Error(`Shell not found: ${command}. Try using /bin/sh or install ${command}`);
}
throw new Error(`${errorMessage} (command: ${command}, cwd: ${cwd})`);
}
this.sessions.set(sessionId, {
pty: ptyProcess,
session,
clients: /* @__PURE__ */ new Set()
});
this.setupPtyHandlers(sessionId, ptyProcess);
this.sessionStore.saveSession(sessionId, session, "", cwd);
return session;
}
getSession(sessionId) {
const processInfo = this.sessions.get(sessionId);
if (processInfo) {
processInfo.session.lastAccessedAt = /* @__PURE__ */ new Date();
return processInfo.session;
}
return null;
}
writeToSession(sessionId, data) {
const processInfo = this.sessions.get(sessionId);
if (processInfo) {
try {
processInfo.pty.write(data);
return true;
} catch (error) {
return false;
}
}
return false;
}
// Send a command to a session (adds newline automatically)
sendCommand(sessionId, command) {
return this.writeToSession(sessionId, command + "\n");
}
// Send raw input without modification
sendRawInput(sessionId, data) {
return this.writeToSession(sessionId, data);
}
// Send special keys
sendKey(sessionId, key) {
const keyMap = {
"ctrl-c": "",
"ctrl-d": "",
"ctrl-z": "",
"ctrl-r": "",
"tab": " ",
"escape": "\x1B",
"up": "\x1B[A",
"down": "\x1B[B",
"left": "\x1B[D",
"right": "\x1B[C"
};
const sequence = keyMap[key];
if (!sequence) return false;
return this.writeToSession(sessionId, sequence);
}
resizeSession(sessionId, cols, rows) {
const processInfo = this.sessions.get(sessionId);
if (processInfo) {
processInfo.pty.resize(cols, rows);
processInfo.session.cols = cols;
processInfo.session.rows = rows;
return true;
}
return false;
}
addClient(sessionId, clientId) {
const processInfo = this.sessions.get(sessionId);
if (processInfo) {
processInfo.clients.add(clientId);
}
}
removeClient(sessionId, clientId) {
const processInfo = this.sessions.get(sessionId);
if (processInfo) {
processInfo.clients.delete(clientId);
}
}
getAllSessions() {
return Array.from(this.sessions.values()).map((p) => p.session);
}
killSession(sessionId) {
const processInfo = this.sessions.get(sessionId);
if (processInfo) {
processInfo.pty.kill();
this.sessions.delete(sessionId);
this.emit("sessionEnd", sessionId);
this.sessionStore.deleteSession(sessionId);
return true;
}
return false;
}
// Implement IDataEmitter interface methods
onData(callback) {
this.on("data", callback);
return () => this.off("data", callback);
}
onSessionEnd(callback) {
this.on("sessionEnd", callback);
return () => this.off("sessionEnd", callback);
}
getActiveSessionIds() {
return Array.from(this.sessions.keys());
}
getSessionMetadata(sessionId) {
const processInfo = this.sessions.get(sessionId);
if (!processInfo) return null;
return {
id: sessionId,
command: processInfo.session.command || "/bin/bash",
args: processInfo.session.args || [],
createdAt: processInfo.session.createdAt,
isActive: true
};
}
getAllSessionMetadata() {
return Array.from(this.sessions.keys()).map((id) => this.getSessionMetadata(id)).filter(Boolean);
}
};
// src/BufferManager.ts
var BufferManager = class {
constructor(maxBufferSize = 1e5) {
this.buffers = /* @__PURE__ */ new Map();
this.sequencedBuffers = /* @__PURE__ */ new Map();
this.maxBufferSize = maxBufferSize;
}
/**
* Set the event manager for pattern matching
*/
setEventManager(eventManager) {
this.eventManager = eventManager;
}
addToBuffer(sessionId, data) {
if (!this.buffers.has(sessionId)) {
this.buffers.set(sessionId, "");
}
let buffer = this.buffers.get(sessionId);
buffer += data;
if (buffer.length > this.maxBufferSize) {
buffer = buffer.slice(buffer.length - this.maxBufferSize);
}
this.buffers.set(sessionId, buffer);
if (!this.sequencedBuffers.has(sessionId)) {
this.sequencedBuffers.set(sessionId, {
entries: [],
nextSequence: 0,
totalSize: 0
});
}
const sessionBuffer = this.sequencedBuffers.get(sessionId);
const sequence = sessionBuffer.nextSequence++;
sessionBuffer.entries.push({
sequence,
data,
timestamp: Date.now()
});
sessionBuffer.totalSize += data.length;
while (sessionBuffer.totalSize > this.maxBufferSize && sessionBuffer.entries.length > 0) {
const removed = sessionBuffer.entries.shift();
sessionBuffer.totalSize -= removed.data.length;
}
if (this.eventManager) {
setImmediate(() => {
this.eventManager.processData(sessionId, data, buffer);
});
}
return sequence;
}
getBuffer(sessionId) {
return this.buffers.get(sessionId) || "";
}
getBufferWithSequence(sessionId) {
const buffer = this.sequencedBuffers.get(sessionId);
if (!buffer || buffer.entries.length === 0) {
return { data: "", lastSequence: -1 };
}
const data = buffer.entries.map((e) => e.data).join("");
const lastSequence = buffer.entries[buffer.entries.length - 1].sequence;
return { data, lastSequence };
}
getIncrementalData(sessionId, fromSequence) {
const buffer = this.sequencedBuffers.get(sessionId);
if (!buffer || buffer.entries.length === 0) {
return { data: "", lastSequence: fromSequence };
}
const newEntries = buffer.entries.filter((e) => e.sequence > fromSequence);
if (newEntries.length === 0) {
return { data: "", lastSequence: fromSequence };
}
const data = newEntries.map((e) => e.data).join("");
const lastSequence = newEntries[newEntries.length - 1].sequence;
return { data, lastSequence };
}
clearBuffer(sessionId) {
this.buffers.delete(sessionId);
this.sequencedBuffers.delete(sessionId);
}
getAllSessions() {
return Array.from(this.buffers.keys());
}
};
// src/SessionStore.ts
var import_promises = __toESM(require("fs/promises"), 1);
var import_path = __toESM(require("path"), 1);
var SessionStore = class {
constructor(storePath = ".sessions") {
this.initialized = false;
this.initPromise = null;
this.storePath = storePath;
}
async initialize() {
if (this.initialized) return;
if (this.initPromise) return this.initPromise;
this.initPromise = this.ensureStoreExists();
await this.initPromise;
this.initialized = true;
}
async ensureStoreExists() {
try {
await import_promises.default.mkdir(this.storePath, { recursive: true });
} catch (error) {
console.error("Error creating session store directory:", error);
throw error;
}
}
async saveSession(sessionId, session, buffer, cwd) {
await this.initialize();
try {
const sessionData = {
session,
buffer,
cwd,
env: {
TERM: process.env.TERM || "xterm-256color",
LANG: process.env.LANG || "en_US.UTF-8"
}
};
const filePath = import_path.default.join(this.storePath, `${sessionId}.json`);
await import_promises.default.writeFile(filePath, JSON.stringify(sessionData, null, 2), "utf-8");
} catch (error) {
console.error(`Error saving session ${sessionId}:`, error);
}
}
async loadSession(sessionId) {
await this.initialize();
try {
const filePath = import_path.default.join(this.storePath, `${sessionId}.json`);
const data = await import_promises.default.readFile(filePath, "utf-8");
return JSON.parse(data);
} catch (error) {
return null;
}
}
async loadAllSessions() {
await this.initialize();
const sessions = /* @__PURE__ */ new Map();
try {
const files = await import_promises.default.readdir(this.storePath);
for (const file of files) {
if (file.endsWith(".json")) {
const sessionId = file.replace(".json", "");
const session = await this.loadSession(sessionId);
if (session) {
sessions.set(sessionId, session);
}
}
}
} catch (error) {
console.error("Error loading sessions:", error);
}
return sessions;
}
async deleteSession(sessionId) {
await this.initialize();
try {
const filePath = import_path.default.join(this.storePath, `${sessionId}.json`);
await import_promises.default.unlink(filePath);
} catch (error) {
}
}
async deleteAllSessions() {
await this.initialize();
try {
await import_promises.default.rm(this.storePath, { recursive: true, force: true });
} catch (error) {
}
}
async updateSessionBuffer(sessionId, buffer) {
await this.initialize();
try {
const filePath = import_path.default.join(this.storePath, `${sessionId}.json`);
const data = await import_promises.default.readFile(filePath, "utf-8");
const storedSession = JSON.parse(data);
if (storedSession.buffer !== buffer) {
storedSession.buffer = buffer;
await import_promises.default.writeFile(filePath, JSON.stringify(storedSession, null, 2), "utf-8");
}
} catch (error) {
}
}
async saveSessionPatterns(sessionId, patterns) {
await this.initialize();
try {
const filePath = import_path.default.join(this.storePath, `${sessionId}.json`);
const data = await import_promises.default.readFile(filePath, "utf-8");
const storedSession = JSON.parse(data);
storedSession.patterns = patterns;
await import_promises.default.writeFile(filePath, JSON.stringify(storedSession, null, 2), "utf-8");
} catch (error) {
console.error(`Error saving patterns for session ${sessionId}:`, error);
}
}
async getSessionPatterns(sessionId) {
await this.initialize();
try {
const session = await this.loadSession(sessionId);
return session?.patterns || [];
} catch (error) {
return [];
}
}
};
// src/WebSocketServer.ts
var import_ws = require("ws");
// src/admin/AdminSessionProxy.ts
var import_events2 = require("events");
var AdminSessionProxy = class extends import_events2.EventEmitter {
constructor(sessionManager) {
super();
this.sessionManager = sessionManager;
this.attachedSessions = /* @__PURE__ */ new Map();
}
async attachToSession(sessionId, mode = "read-only") {
const session = this.sessionManager.getSession(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not found`);
}
this.attachedSessions.set(sessionId, {
sessionId,
mode,
attachedAt: /* @__PURE__ */ new Date()
});
this.emit("attached", { sessionId, mode });
}
async detachFromSession(sessionId) {
this.attachedSessions.delete(sessionId);
this.emit("detached", { sessionId });
}
async writeToSession(sessionId, data) {
const handle = this.attachedSessions.get(sessionId);
if (!handle || handle.mode !== "interactive") {
throw new Error("Session not in interactive mode");
}
this.sessionManager.writeToSession(sessionId, data);
}
getAttachedSessions() {
return Array.from(this.attachedSessions.values());
}
};
// src/WebSocketServer.ts
var WebSocketServer = class _WebSocketServer {
constructor(wss, sessionManager, bufferManager, eventManager, sessionStore) {
this.clients = /* @__PURE__ */ new Map();
this.clientStates = /* @__PURE__ */ new Map();
this.clientPatterns = /* @__PURE__ */ new Map();
this.clientEventSubscriptions = /* @__PURE__ */ new Map();
this.monitorClients = /* @__PURE__ */ new Set();
this.adminClients = /* @__PURE__ */ new Map();
this.wss = wss;
this.sessionManager = sessionManager;
this.bufferManager = bufferManager;
this.eventManager = eventManager;
this.sessionStore = sessionStore;
this.adminProxy = new AdminSessionProxy(this.sessionManager);
if (this.eventManager) {
this.setupEventSystem();
}
this.setupWebSocketHandlers();
}
static create(config, sessionManager, bufferManager, eventManager, sessionStore) {
let wss;
if (typeof config === "number") {
wss = new import_ws.WebSocketServer({ port: config, host: "0.0.0.0" });
} else {
const { server, port, noServer, path: path5, ...wsOptions } = config;
if (server && path5) {
wss = new import_ws.WebSocketServer({ noServer: true, ...wsOptions });
server.on("upgrade", function upgrade(request, socket, head) {
socket.on("error", (err) => {
console.error("Socket error:", err);
});
const pathname = new URL(request.url || "", `http://${request.headers.host}`).pathname;
if (pathname === path5) {
wss.handleUpgrade(request, socket, head, function done(ws) {
wss.emit("connection", ws, request);
});
} else {
socket.destroy();
}
});
} else if (server) {
wss = new import_ws.WebSocketServer({ server, ...wsOptions });
} else if (noServer) {
wss = new import_ws.WebSocketServer({ noServer: true, ...wsOptions });
} else if (port !== void 0) {
wss = new import_ws.WebSocketServer({
port,
host: wsOptions.host || "0.0.0.0",
...wsOptions
});
} else {
throw new Error("WebSocketServer requires either port, server, or noServer option");
}
}
return new _WebSocketServer(wss, sessionManager, bufferManager, eventManager, sessionStore);
}
setupWebSocketHandlers() {
this.wss.on("connection", (ws) => {
const clientId = Math.random().toString(36).substring(7);
this.clients.set(clientId, ws);
this.clientStates.set(clientId, {
clientId,
sessionIds: /* @__PURE__ */ new Set(),
lastReceivedSequence: /* @__PURE__ */ new Map(),
connectionTime: Date.now(),
isIncrementalClient: false
});
ws.on("message", (message) => {
try {
const data = JSON.parse(message);
this.handleMessage(clientId, ws, data);
} catch (error) {
ws.send(JSON.stringify({ type: "error", data: "Invalid message format" }));
}
});
ws.on("close", () => {
const clientState = this.clientStates.get(clientId);
if (clientState) {
clientState.sessionIds.forEach((sessionId) => {
this.sessionManager.removeClient(sessionId, clientId);
});
}
if (this.eventManager) {
const patterns = this.clientPatterns.get(clientId);
if (patterns) {
for (const patternId of patterns) {
this.eventManager.unregisterPattern(patternId).catch((err) => {
});
}
}
}
this.adminClients.forEach((adminSet, sessionId) => {
adminSet.delete(ws);
if (adminSet.size === 0) {
this.adminClients.delete(sessionId);
}
});
this.clients.delete(clientId);
this.clientStates.delete(clientId);
this.clientPatterns.delete(clientId);
this.clientEventSubscriptions.delete(clientId);
this.monitorClients.delete(clientId);
});
ws.on("error", (error) => {
});
});
}
handleMessage(clientId, ws, data) {
if (data.type.startsWith("admin-")) {
this.handleAdminMessage(clientId, ws, data);
return;
}
const handlers = {
"create": this.handleCreateSession.bind(this),
"connect": this.handleConnectSession.bind(this),
"input": this.handleSessionInput.bind(this),
"resize": this.handleSessionResize.bind(this),
"disconnect": this.handleSessionDisconnect.bind(this),
"register-pattern": this.handleRegisterPattern.bind(this),
"unregister-pattern": this.handleUnregisterPattern.bind(this),
"subscribe-events": this.handleSubscribeEvents.bind(this),
"unsubscribe-events": this.handleUnsubscribeEvents.bind(this),
"monitor-all": this.handleMonitorAll.bind(this)
};
const handler = handlers[data.type];
if (handler) {
handler(clientId, ws, data);
} else {
ws.send(JSON.stringify({
type: "error",
data: `Unknown message type: ${data.type}`
}));
}
}
handleCreateSession(clientId, ws, data) {
try {
const options = data.options || {};
if (data.cols) options.cols = data.cols;
if (data.rows) options.rows = data.rows;
const requestedSessionId = data.sessionId || data.options && data.options.id;
if (requestedSessionId) {
options.id = requestedSessionId;
const existingSession = this.sessionManager.getSession(requestedSessionId);
if (existingSession) {
this.sessionManager.addClient(requestedSessionId, clientId);
const clientState2 = this.clientStates.get(clientId);
if (clientState2) {
clientState2.sessionIds.add(requestedSessionId);
}
const response2 = {
type: "created",
sessionId: requestedSessionId,
session: existingSession
};
ws.send(JSON.stringify(response2));
return;
}
}
const session = this.sessionManager.createSession(options);
this.sessionManager.addClient(session.id, clientId);
const clientState = this.clientStates.get(clientId);
if (clientState) {
clientState.sessionIds.add(session.id);
}
const response = {
type: "created",
sessionId: session.id,
session
};
ws.send(JSON.stringify(response));
} catch (error) {
console.error("[WebSocketServer] Error creating session:", error);
ws.send(JSON.stringify({
type: "error",
data: error instanceof Error ? error.message : "Failed to create session",
requestId: data.requestId
}));
}
}
handleConnectSession(clientId, ws, data) {
if (!data.sessionId) {
ws.send(JSON.stringify({
type: "error",
data: "Session ID required"
}));
return;
}
const session = this.sessionManager.getSession(data.sessionId);
if (session) {
this.sessionManager.addClient(data.sessionId, clientId);
const clientState = this.clientStates.get(clientId);
if (!clientState) {
ws.send(JSON.stringify({
type: "error",
data: "Client state not found"
}));
return;
}
clientState.sessionIds.add(data.sessionId);
const useIncremental = data.useIncrementalUpdates === true || data.incremental === true;
clientState.isIncrementalClient = useIncremental;
let response = {
type: "connect",
sessionId: data.sessionId,
session
};
if (useIncremental && data.lastSequence !== void 0) {
const lastClientSequence = data.lastSequence;
const { data: incrementalData, lastSequence } = this.bufferManager.getIncrementalData(
data.sessionId,
lastClientSequence
);
if (incrementalData) {
response.incrementalData = incrementalData;
response.fromSequence = lastClientSequence;
response.lastSequence = lastSequence;
} else {
response.lastSequence = lastClientSequence;
}
clientState.lastReceivedSequence.set(data.sessionId, lastSequence);
} else {
const { data: scrollback, lastSequence } = this.bufferManager.getBufferWithSequence(data.sessionId);
response.scrollback = scrollback;
response.lastSequence = lastSequence;
if (useIncremental) {
clientState.lastReceivedSequence.set(data.sessionId, lastSequence);
}
}
ws.send(JSON.stringify(response));
} else {
ws.send(JSON.stringify({
type: "error",
data: "Session not found"
}));
}
}
handleSessionInput(clientId, ws, data) {
if (!data.sessionId || !data.data) {
ws.send(JSON.stringify({
type: "error",
data: "Session ID and data required"
}));
return;
}
const success = this.sessionManager.writeToSession(data.sessionId, data.data);
if (!success) {
ws.send(JSON.stringify({
type: "error",
data: "Failed to write to session - session may be disconnected",
sessionId: data.sessionId
}));
}
}
handleSessionResize(clientId, ws, data) {
if (!data.sessionId || !data.cols || !data.rows) {
ws.send(JSON.stringify({
type: "error",
data: "Session ID, cols, and rows required"
}));
return;
}
this.sessionManager.resizeSession(data.sessionId, data.cols, data.rows);
this.broadcastToSession(data.sessionId, {
type: "resize",
sessionId: data.sessionId,
cols: data.cols,
rows: data.rows
});
}
handleSessionDisconnect(clientId, ws, data) {
if (data.sessionId) {
this.sessionManager.removeClient(data.sessionId, clientId);
const clientState = this.clientStates.get(clientId);
if (clientState) {
clientState.sessionIds.delete(data.sessionId);
clientState.lastReceivedSequence.delete(data.sessionId);
}
}
}
async handleAdminMessage(clientId, ws, message) {
try {
switch (message.type) {
case "admin-list-sessions":
const sessions = this.sessionManager.getAllSessionMetadata();
ws.send(JSON.stringify({
type: "admin-sessions-list",
sessions
}));
break;
case "admin-attach":
if (!message.sessionId) return;
await this.adminProxy.attachToSession(message.sessionId, message.mode);
if (!this.adminClients.has(message.sessionId)) {
this.adminClients.set(message.sessionId, /* @__PURE__ */ new Set());
}
this.adminClients.get(message.sessionId).add(ws);
const buffer = this.bufferManager.getBuffer(message.sessionId);
ws.send(JSON.stringify({
type: "buffer",
sessionId: message.sessionId,
data: buffer
}));
break;
case "admin-detach":
if (!message.sessionId) return;
await this.adminProxy.detachFromSession(message.sessionId);
this.adminClients.get(message.sessionId)?.delete(ws);
break;
case "admin-input":
if (!message.sessionId || !message.data) return;
await this.adminProxy.writeToSession(message.sessionId, message.data);
break;
}
} catch (error) {
ws.send(JSON.stringify({
type: "error",
message: error.message
}));
}
}
broadcastToSession(sessionId, data) {
this.clients.forEach((ws, clientId) => {
const clientState = this.clientStates.get(clientId);
if (clientState && clientState.sessionIds.has(sessionId) && ws.readyState === ws.OPEN) {
if (data.type === "output" && data.sequence !== void 0) {
if (clientState.isIncrementalClient) {
clientState.lastReceivedSequence.set(sessionId, data.sequence);
}
}
ws.send(JSON.stringify(data));
}
});
if (data.type === "output" && this.monitorClients.size > 0) {
const monitorMessage = {
type: "session-output",
sessionId,
data: data.data,
timestamp: (/* @__PURE__ */ new Date()).toISOString()
};
this.monitorClients.forEach((monitorId) => {
const ws = this.clients.get(monitorId);
if (ws && ws.readyState === ws.OPEN) {
ws.send(JSON.stringify(monitorMessage));
}
});
}
const adminViewers = this.adminClients.get(sessionId);
if (adminViewers && adminViewers.size > 0) {
const adminData = JSON.stringify(data);
adminViewers.forEach((ws) => {
if (ws.readyState === ws.OPEN) {
ws.send(adminData);
}
});
}
}
setupEventSystem() {
if (!this.eventManager) return;
this.eventManager.on("terminal-event", (event) => {
const message = {
type: "terminal-event",
event
};
this.clients.forEach((ws, clientId) => {
const subscriptions = this.clientEventSubscriptions.get(clientId);
if (subscriptions && subscriptions.has(event.type) && ws.readyState === ws.OPEN) {
ws.send(JSON.stringify(message));
}
});
});
}
async handleRegisterPattern(clientId, ws, message) {
if (!this.eventManager) {
ws.send(JSON.stringify({
type: "error",
data: "Event system not enabled",
requestId: message.requestId
}));
return;
}
try {
const patternId = await this.eventManager.registerPattern(message.sessionId, message.config);
if (!this.clientPatterns.has(clientId)) {
this.clientPatterns.set(clientId, /* @__PURE__ */ new Set());
}
this.clientPatterns.get(clientId).add(patternId);
ws.send(JSON.stringify({
type: "pattern-registered",
patternId,
requestId: message.requestId
}));
} catch (error) {
ws.send(JSON.stringify({
type: "error",
data: error instanceof Error ? error.message : "Unknown error",
requestId: message.requestId
}));
}
}
async handleUnregisterPattern(clientId, ws, message) {
if (!this.eventManager) {
ws.send(JSON.stringify({
type: "error",
data: "Event system not enabled",
requestId: message.requestId
}));
return;
}
try {
await this.eventManager.unregisterPattern(message.patternId);
const patterns = this.clientPatterns.get(clientId);
if (patterns) {
patterns.delete(message.patternId);
}
ws.send(JSON.stringify({
type: "pattern-unregistered",
patternId: message.patternId,
requestId: message.requestId
}));
} catch (error) {
ws.send(JSON.stringify({
type: "error",
data: error instanceof Error ? error.message : "Unknown error",
requestId: message.requestId
}));
}
}
handleSubscribeEvents(clientId, ws, message) {
if (!this.clientEventSubscriptions.has(clientId)) {
this.clientEventSubscriptions.set(clientId, /* @__PURE__ */ new Set());
}
const subscriptions = this.clientEventSubscriptions.get(clientId);
for (const eventType of message.eventTypes) {
subscriptions.add(eventType);
}
ws.send(JSON.stringify({
type: "subscribed",
eventTypes: message.eventTypes
}));
}
handleUnsubscribeEvents(clientId, ws, message) {
const subscriptions = this.clientEventSubscriptions.get(clientId);
if (subscriptions) {
for (const eventType of message.eventTypes) {
subscriptions.delete(eventType);
}
}
ws.send(JSON.stringify({
type: "unsubscribed",
eventTypes: message.eventTypes
}));
}
handleMonitorAll(clientId, ws, data) {
const authKey = data.authKey || data.auth;
const expectedAuthKey = process.env.SHELLTENDER_MONITOR_AUTH_KEY || "default-monitor-key";
if (authKey !== expectedAuthKey) {
ws.send(JSON.stringify({
type: "error",
data: "Invalid authentication key for monitor mode",
requestId: data.requestId
}));
return;
}
this.monitorClients.add(clientId);
ws.isMonitor = true;
ws.send(JSON.stringify({
type: "monitor-mode-enabled",
message: "Successfully enabled monitor mode. You will receive all terminal output.",
sessionCount: this.sessionManager.getAllSessions().length
}));
}
// Get number of clients connected to a specific session
getSessionClientCount(sessionId) {
let count = 0;
this.clients.forEach((ws, clientId) => {
const clientState = this.clientStates.get(clientId);
if (clientState && clientState.sessionIds.has(sessionId) && ws.readyState === ws.OPEN) {
count++;
}
});
return count;
}
// Get all client connections grouped by session
getClientsBySession() {
const sessionClients = /* @__PURE__ */ new Map();
this.clients.forEach((ws, clientId) => {
const clientState = this.clientStates.get(clientId);
if (clientState && ws.readyState === ws.OPEN) {
clientState.sessionIds.forEach((sessionId) => {
sessionClients.set(sessionId, (sessionClients.get(sessionId) || 0) + 1);
});
}
});
return sessionClients;
}
};
// src/events/EventManager.ts
var import_events3 = require("events");
// src/patterns/PatternMatcher.ts
var PatternMatcher = class {
constructor(config, id) {
this.config = config;
this.id = id;
this.lastMatchTime = 0;
this.matchCount = 0;
}
/**
* Get the pattern ID
*/
getId() {
return this.id;
}
/**
* Get the pattern name
*/
getName() {
return this.config.name;
}
/**
* Get the pattern configuration
*/
getConfig() {
return this.config;
}
/**
* Wrapper method that handles debouncing and performance tracking
*/
tryMatch(data, buffer) {
const now = Date.now();
if (this.config.options?.debounce) {
if (now - this.lastMatchTime < this.config.options.debounce) {
return null;
}
}
const result = this.measureMatch(() => this.match(data, buffer));
if (result) {
this.lastMatchTime = now;
this.matchCount++;
}
return result;
}
/**
* Helper for performance tracking
*/
measureMatch(fn) {
const start = performance.now();
const result = fn();
const duration = performance.now() - start;
if (duration > 10) {
console.warn(
`[PatternMatcher] Slow match detected: ${this.config.name} took ${duration.toFixed(2)}ms`
);
}
return result;
}
/**
* Get matcher statistics
*/
getStats() {
return {
id: this.id,
name: this.config.name,
type: this.config.type,
matchCount: this.matchCount,
lastMatchTime: this.lastMatchTime
};
}
/**
* Validate the pattern configuration
* Can be overridden by subclasses for specific validation
*/
validate() {
if (!this.config.name) {
throw new Error("Pattern name is required");
}
if (!this.config.type) {
throw new Error("Pattern type is required");
}
}
};
// src/patterns/RegexMatcher.ts
var RegexMatcher = class extends PatternMatcher {
constructor(config, id) {
super(config, id);
this.regex = this.createRegex();
}
/**
* Create the RegExp instance from the pattern configuration
*/
createRegex() {
if (this.config.pattern instanceof RegExp) {
return this.config.pattern;
}
if (typeof this.config.pattern === "string") {
const flags = this.buildFlags();
return new RegExp(this.config.pattern, flags);
}
throw new Error("RegexMatcher requires a string or RegExp pattern");
}
/**
* Build regex flags from configuration options
*/
buildFlags() {
let flags = "";
flags += "g";
if (this.config.options?.caseSensitive === false) {
flags += "i";
}
if (this.config.options?.multiline) {
flags += "m";
}
return flags;
}
/**
* Perform the regex match
*/
match(data, buffer) {
this.regex.lastIndex = 0;
const match = this.regex.exec(data);
if (match) {
return {
match: match[0],
position: match.index,
groups: this.extractGroups(match)
};
}
if (this.config.options?.multiline && buffer !== data) {
this.regex.lastIndex = 0;
const bufferMatch = this.regex.exec(buffer);
if (bufferMatch) {
return {
match: bufferMatch[0],
position: bufferMatch.index,
groups: this.extractGroups(bufferMatch)
};
}
}
return null;
}
/**
* Extract named and numbered capture groups
*/
extractGroups(match) {
if (match.length <= 1) {
return void 0;
}
const groups = {};
for (let i = 1; i < match.length; i++) {
if (match[i] !== void 0) {
groups[i.toString()] = match[i];
}
}
if (match.groups) {
Object.assign(groups, match.groups);
}
return Object.keys(groups).length > 0 ? groups : void 0;
}
/**
* Validate the regex pattern
*/
validate() {
super.validate();
try {
this.regex.test("");
} catch (error) {
throw new Error(`Invalid regex pattern: ${error instanceof Error ? error.message : "Unknown error"}`);
}
}
};
// src/patterns/StringMatcher.ts
var StringMatcher = class extends PatternMatcher {
constructor(config, id) {
super(config, id);
if (typeof this.config.pattern !== "string") {
throw new Error("StringMatcher requires a string pattern");
}
this.searchString = this.config.pattern;
this.caseSensitive = this.config.options?.caseSensitive ?? true;
if (!this.caseSensitive) {
this.searchString = this.searchString.toLowerCase();
}
}
/**
* Perform the string match
*/
match(data, buffer) {
const searchIn = this.caseSensitive ? data : data.toLowerCase();
const index = searchIn.indexOf(this.searchString);
if (index !== -1) {
const match = data.substring(index, index + this.searchString.length);
return {
match,
position: index
};
}
return null;
}
/**
* Validate the string pattern
*/
validate() {
super.validate();
if (!this.searchString || this.searchString.length === 0) {
throw new Error("String pattern cannot be empty");
}
}
};
// src/patterns/AnsiMatcher.ts
var _AnsiMatcher = class _AnsiMatcher extends PatternMatcher {
constructor(config, id) {
super(config, id);
this.category = null;
if (typeof this.config.pattern === "string") {
this.pattern = this.getPatternByName(this.config.pattern) || new RegExp(this.config.pattern, "g");
this.category = this.config.pattern;
} else if (this.config.pattern instanceof RegExp) {
this.pattern = new RegExp(this.config.pattern.source, "g");
} else {
this.pattern = _AnsiMatcher.ANSI_PATTERNS.any;
this.category = "any";
}
}
/**
* Get predefined pattern by name
*/
getPatternByName(name) {
switch (name) {
case "csi":
case "cursor":
case "color":
return _AnsiMatcher.ANSI_PATTERNS.csi;
case "osc":
case "title":
return _AnsiMatcher.ANSI_PATTERNS.osc;
case "esc":
return _AnsiMatcher.ANSI_PATTERNS.esc;
case "any":
case "all":
return _AnsiMatcher.ANSI_PATTERNS.any;
default:
return null;
}
}
/**
* Perform the ANSI sequence match
*/
match(data, buffer) {
this.pattern.lastIndex = 0;
const match = this.pattern.exec(data);
if (match) {
const result = {
match: match[0],
position: match.index
};
const parsed = this.parseAnsiSequence(match);
if (parsed) {
result.groups = {
type: parsed.type,
...parsed.data
};
}
return result;
}
return null;
}
/**
* Parse ANSI sequence into structured data
*/
parseAnsiSequence(match) {
const fullMatch = match[0];
if (fullMatch.startsWith("\x1B[")) {
const params = match[1] || "";
const command = match[2] || "";
return {
type: this.categorizeCsiCommand(command),
data: {
command,
params: params ? params.split(";").map((p) => parseInt(p, 10) || 0) : [],
raw: fullMatch
}
};
}
if (fullMatch.startsWith("\x1B]")) {
const oscMatch = fullMatch.match(/\x1b\](\d+);([^\x07]*)\x07/);
const code = oscMatch ? oscMatch[1] : "";
const data = oscMatch ? oscMatch[2] : "";
return {
type: "osc",
data: {
code: parseInt(code, 10) || 0,
data: data || "",
raw: fullMatch
}
};
}
if (fullMatch.startsWith("\x1B")) {
const command = match[5] || fullMatch[1];
return {
type: "esc",
data: {
command,
raw: fullMatch
}
};
}
return null;
}
/**
* Categorize CSI commands
*/
categorizeCsiCommand(command) {
switch (command) {
// Cursor movement
case "A":
// Up
case "B":
// Down
case "C":
// Forward
case "D":
// Back
case "H":
// Position
case "f":
return "cursor";
// Colors and styling
case "m":
return "color";
// Screen/line clearing
case "J":
// Clear screen
case "K":
return "clear";
// Others
default:
return "other";
}
}
/**
* Validate the ANSI pattern
*/
validate() {
super.validate();
}
};
// Common ANSI escape sequence patterns
_AnsiMatcher.ANSI_PATTERNS = {
// CSI sequences (most common - cursor, color, etc.)
csi: /\x1b\[([0-9;]*)([@-~])/g,
// OSC sequences (window title, etc.)
osc: /\x1b\]([0-9]+);([^\x07\x1b]*)\x07/g,
// Simple ESC sequences
esc: /\x1b([A-Z\\^_@\[\]])/g,
// Any ANSI sequence (catch-all)
any: /\x1b(?:\[([0-9;]*)([@-~])|\]([0-9]+);([^\x07\x1b]*)\x07|([A-Z\\^_@\[\]]))/g
};
var AnsiMatcher = _AnsiMatcher;
// src/patterns/CustomMatcher.ts
var CustomMatcher = class extends PatternMatcher {
constructor(config, id) {
super(config, id);
if (typeof this.config.pattern !== "function") {
throw new Error("CustomMatcher requires a function pattern");
}
this.matcherFn = this.config.pattern;
}
/**
* Perform the custom match
*/
match(data, buffer) {
try {
return this.matcherFn(data, buffer);
} catch (error) {
console.error(`[CustomMatcher] Error in custom matcher ${this.config.name}:`, error);
return null;
}
}
/**
* Validate the custom mat