UNPKG

@wonderwhy-er/desktop-commander

Version:

MCP server for terminal operations and file editing

758 lines (757 loc) • 35.9 kB
import { spawn } from 'child_process'; import { DEFAULT_COMMAND_TIMEOUT, MAX_PROCESS_WAIT_MS } from './config.js'; import { configManager } from './config-manager.js'; import { capture } from "./utils/capture.js"; import { analyzeProcessState, STATE_DETECTION_TAIL_CHARS } from './utils/process-detection.js'; import { getDefaultShell, getShellSpawnArgs } from './utils/shell.js'; import { terminateProcessTree } from './utils/process-tree.js'; /** * Standard Windows PATHEXT value, used to repair a corrupted PATHEXT before * spawning child shells. * * On some Windows Claude Desktop / DXT launches the server process inherits a * broken PATHEXT (observed as ".CPL" only). Because we build the child env from * { ...process.env }, that broken value would propagate into every spawned * shell, stripping ".EXE" and breaking resolution of git / node / python / rg / * etc. (and even full-path .exe invocations under PowerShell). See issue #481. */ const STANDARD_PATHEXT = '.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC'; /** * Return a healthy PATHEXT for spawned Windows shells. * - Unset -> use the standard list. * - Missing ".EXE" -> corrupted; merge the standard list with whatever was * present (preserves any extra extensions, order-stable). * - Otherwise -> leave the inherited value untouched. */ function getRepairedPathExt() { const current = process.env.PATHEXT; if (!current) return STANDARD_PATHEXT; const exts = current.split(';').map(e => e.trim().toUpperCase()).filter(Boolean); if (!exts.includes('.EXE')) { return [...new Set([...STANDARD_PATHEXT.split(';'), ...exts])].join(';'); } return current; } const OUTPUT_STREAMS = ['stdout', 'stderr']; function newStreamTails() { return { stdout: { text: '', chars: 0, turn: 0 }, stderr: { text: '', chars: 0, turn: 0 } }; } /** * The one place that turns a caller's timeout_ms into the time a process wait * may actually block. When `capped`, the call returns at the ceiling with the * process still running and the caller continues with read_process_output. */ export function getProcessWaitLimit(timeoutMs, capMs = MAX_PROCESS_WAIT_MS) { return { waitMs: Math.min(timeoutMs, capMs), capMs, capped: timeoutMs > capMs }; } /** * Output buffering caps. Without a cap, a process emitting enough output makes * string concatenation throw "RangeError: Invalid string length" at V8's max * string size (~536M chars) inside a stdout 'data' handler — an uncaught * exception that kills the whole server (index.ts exits on uncaughtException). * The cap also bounds the cost of the reads that are O(total output), such as * since-start snapshot reads. */ export const MAX_BUFFERED_OUTPUT_CHARS = 50 * 1024 * 1024; // per session; oldest lines evicted first const MAX_LINE_CHARS = 1024 * 1024; // force-split longer lines so eviction can work const MAX_WAIT_OUTPUT_CHARS = 2 * 1024 * 1024; // start_process wait buffer (the initial output it returns) export class TerminalManager { constructor() { this.sessions = new Map(); this.completedSessions = new Map(); this.deliveryTurn = 0; this.deliveryTurnOpen = false; } /** * Send input to a running process * @param pid Process ID * @param input Text to send to the process * @returns Whether input was successfully sent */ sendInputToProcess(pid, input) { const session = this.sessions.get(pid); if (!session) { return false; } try { if (session.process.stdin && !session.process.stdin.destroyed) { // Ensure input ends with a newline for most REPLs const inputWithNewline = input.endsWith('\n') ? input : input + '\n'; session.process.stdin.write(inputWithNewline); return true; } return false; } catch (error) { console.error(`Error sending input to process ${pid}:`, error); return false; } } async executeCommand(command, timeoutMs = DEFAULT_COMMAND_TIMEOUT, shell, collectTiming = false, maxWaitMs = MAX_PROCESS_WAIT_MS) { let config = {}; try { config = await configManager.getConfig(); } catch (error) { // If there's an error getting the config, fall back to the default shell } // Get the shell from config if not specified const shellToUse = shell || config.defaultShell || getDefaultShell(); const waitLimit = getProcessWaitLimit(timeoutMs, maxWaitMs); // For REPL interactions, we need to ensure stdin, stdout, and stderr are properly configured // Note: No special stdio options needed here, Node.js handles pipes by default // Enhance SSH commands automatically let enhancedCommand = command; if (command.trim().startsWith('ssh ') && !command.includes(' -t')) { enhancedCommand = command.replace(/^ssh /, 'ssh -t '); console.log(`Enhanced SSH command: ${enhancedCommand}`); } // Get the appropriate spawn configuration for the shell, with login flags where appropriate const spawnConfig = getShellSpawnArgs(shellToUse, enhancedCommand); const spawnOptions = { env: { ...process.env, TERM: 'xterm-256color' // Better terminal compatibility }, windowsHide: true // Prevent visible console windows on Windows }; // Add shell option if needed (for unknown shells) if (spawnConfig.useShellOption) { spawnOptions.shell = spawnConfig.useShellOption; } // Repair PATHEXT on Windows before spawning. On some Windows DXT launches // the server process inherits a corrupted PATHEXT (e.g. ".CPL"), which we // would otherwise propagate via { ...process.env } and break command // resolution (git, node, python, rg, ...) in the spawned shell. See #481. if (process.platform === 'win32' && spawnOptions.env) { spawnOptions.env.PATHEXT = getRepairedPathExt(); } // On Windows, when we invoke cmd.exe directly and pass the user's command as a // single argument, Node/libuv applies MSVCRT-style quoting that escapes embedded // double quotes as \" . cmd.exe does not understand that escaping, so any command // containing quotes (e.g. a quoted path with spaces like "C:\Program Files\app.exe") // is corrupted before the shell ever parses it. Passing arguments verbatim lets // cmd handle its own quoting. Scoped to shells that set windowsVerbatim (cmd only) // because PowerShell/pwsh have different quote rules and must NOT use verbatim. if (process.platform === 'win32' && spawnConfig.windowsVerbatim) { spawnOptions.windowsVerbatimArguments = true; } // Spawn the process with appropriate arguments const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions); let output = ''; // spawn() reports failure asynchronously via an 'error' event; it does not // throw. Node rethrows an 'error' that has no listener as an uncaught // exception, and our process-level handler (src/index.ts) turns that into // process.exit(1). So an unresolvable executable — e.g. shell: "/usr/bin/bash" // on Windows — used to kill the entire MCP server one tick AFTER this // function had already returned "Failed to get process ID", making the crash // look unrelated to the command that caused it. Under `remote` the parent // then kept accepting tool calls over a dead stdio pipe ("Not connected"). // // Attach before any return path below, and keep it for the session lifetime // so a later runtime error (EPIPE on a closed stdin, ...) can't crash us either. let forwardProcessError = null; let pendingProcessError = null; childProcess.on('error', (err) => { if (forwardProcessError) { forwardProcessError(err); } else { pendingProcessError = err; console.error(`Process error for "${command}": ${err.message}`); } }); // Ensure childProcess.pid is defined before proceeding if (!childProcess.pid) { // Return a consistent error object instead of throwing const output = 'Error: Failed to get process ID. The command could not be executed.'; return { pid: -1, // Use -1 to indicate an error state output, isBlocked: false, processState: analyzeProcessState(output) }; } const session = { pid: childProcess.pid, process: childProcess, outputLines: [], // Line-based buffer lastReadIndex: 0, // Track where "new" output starts lastReadOpenLine: '', startTime: new Date(), bufferedChars: 0, evictedLines: 0, evictedChars: 0, streams: newStreamTails() }; this.sessions.set(childProcess.pid, session); // Timing telemetry const startTime = Date.now(); let firstOutputTime; let lastOutputTime; const outputEvents = []; let exitReason = 'timeout'; return new Promise((resolve) => { let resolved = false; let periodicCheck = null; let waitTimer = null; // Whether the process waits for input, judged as the periodic check does: // from the end of each output stream, not from the chunk that just came // (a chunk can end in the middle of a line, e.g. right after "<div>"). const waitingForInput = () => this.getProcessState(childProcess.pid)?.isWaitingForInput === true; const resolveOnce = (waitResult) => { if (resolved) return; resolved = true; if (periodicCheck) clearInterval(periodicCheck); if (waitTimer) clearTimeout(waitTimer); // The state the wait ended in, from the session: finished if the // process exited, else judged from its output. A process error after // the exit leaves no session behind, so the text it returns is judged. const result = { ...waitResult, processState: this.getProcessState(childProcess.pid) ?? analyzeProcessState(waitResult.output) }; // Add timing info if requested if (collectTiming) { const endTime = Date.now(); result.timingInfo = { startTime, endTime, totalDurationMs: endTime - startTime, exitReason, firstOutputTime, lastOutputTime, timeToFirstOutputMs: firstOutputTime ? firstOutputTime - startTime : undefined, outputEvents: outputEvents.length > 0 ? outputEvents : undefined }; } resolve(result); }; // Now that resolveOnce exists, route process errors into it, so the caller // doesn't sit waiting for output. The session goes only if the process // has exited: Node also emits 'error' when a kill fails or a message can't // be sent, and the process runs on. It then stays listed, readable and // terminable, and its 'exit' records the completion as usual. forwardProcessError = (err) => { if (childProcess.exitCode !== null || childProcess.signalCode !== null) { this.sessions.delete(childProcess.pid); } exitReason = 'process_exit'; resolveOnce({ pid: childProcess.pid, output: output + `\nProcess error: ${err.message}`, isBlocked: false }); }; // An error emitted between spawn and here (the common case — spawn errors // land on the next tick) is replayed rather than dropped. if (pendingProcessError) { forwardProcessError(pendingProcessError); } childProcess.stdout.on('data', (data) => { const text = data.toString(); const now = Date.now(); if (!firstOutputTime) firstOutputTime = now; lastOutputTime = now; // `output` only feeds the wait-phase result, so stop growing it once // resolved and keep only a bounded tail. if (!resolved) { output += text; if (output.length > MAX_WAIT_OUTPUT_CHARS) { output = output.slice(-Math.floor(MAX_WAIT_OUTPUT_CHARS / 2)); } } this.recordOutput(session, 'stdout', text); // Record output event if collecting timing if (collectTiming) { outputEvents.push({ timestamp: now, deltaMs: now - startTime, source: 'stdout', length: text.length, snippet: text.slice(0, 50).replace(/\n/g, '\\n') }); } // Immediate check for a prompt if (waitingForInput()) { exitReason = 'early_exit_quick_pattern'; if (collectTiming && outputEvents.length > 0) { outputEvents[outputEvents.length - 1].matchedPattern = 'quick_pattern'; } resolveOnce({ pid: childProcess.pid, output, isBlocked: true }); } }); childProcess.stderr.on('data', (data) => { const text = data.toString(); const now = Date.now(); if (!firstOutputTime) firstOutputTime = now; lastOutputTime = now; if (!resolved) { output += text; if (output.length > MAX_WAIT_OUTPUT_CHARS) { output = output.slice(-Math.floor(MAX_WAIT_OUTPUT_CHARS / 2)); } } this.recordOutput(session, 'stderr', text); // Record output event if collecting timing if (collectTiming) { outputEvents.push({ timestamp: now, deltaMs: now - startTime, source: 'stderr', length: text.length, snippet: text.slice(0, 50).replace(/\n/g, '\\n') }); } }); // Periodic comprehensive check every 100ms periodicCheck = setInterval(() => { if (waitingForInput()) { exitReason = 'early_exit_periodic_check'; resolveOnce({ pid: childProcess.pid, output, isBlocked: true }); } }, 100); // Timeout fallback, bounded by the wait ceiling so the call returns // before the MCP client gives up on it; the process keeps running. waitTimer = setTimeout(() => { exitReason = 'timeout'; resolveOnce({ pid: childProcess.pid, output, isBlocked: true, ...(waitLimit.capped && { waitCappedAtMs: waitLimit.capMs }) }); }, waitLimit.waitMs); childProcess.on('exit', (code, signal) => { if (childProcess.pid) { // Store completed session before removing active session this.completedSessions.set(childProcess.pid, { session, exitCode: code, signal, endTime: new Date() }); // Keep only last 100 completed sessions if (this.completedSessions.size > 100) { const oldestKey = Array.from(this.completedSessions.keys())[0]; this.completedSessions.delete(oldestKey); } this.sessions.delete(childProcess.pid); } exitReason = 'process_exit'; resolveOnce({ pid: childProcess.pid, output, isBlocked: false }); }); }); } /** * Add a chunk of one stream's output to the session: to the merged line * buffer, and to that stream's tail for state detection. */ recordOutput(session, stream, text) { this.appendToLineBuffer(session, text); const tail = session.streams[stream]; tail.text = (text.length >= STATE_DETECTION_TAIL_CHARS ? text : tail.text + text).slice(-STATE_DETECTION_TAIL_CHARS); tail.chars += text.length; tail.turn = this.currentDeliveryTurn(); } /** * Number of the event-loop turn now delivering output; every chunk * dispatched before the next check phase (setImmediate) shares it. * * stdout and stderr are separate pipes. Each turn delivers what the OS had * ready when the loop polled, so a chunk delivered in a later turn was * written after the chunks of earlier turns. Within one turn the pipes come * in either order: when python -i's final stdout newline and its stderr * ">>> " prompt are both waiting, the prompt is often delivered first. */ currentDeliveryTurn() { if (!this.deliveryTurnOpen) { this.deliveryTurnOpen = true; this.deliveryTurn++; setImmediate(() => { this.deliveryTurnOpen = false; }); } return this.deliveryTurn; } /** * Append text to a session's line buffer * Handles partial lines and newline splitting */ appendToLineBuffer(session, text) { if (!text) return; // Split text into lines, keeping track of whether text ends with newline const lines = text.split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i]; const isLastFragment = i === lines.length - 1; const endsWithNewline = text.endsWith('\n'); if (session.outputLines.length === 0) { // First line ever session.outputLines.push(line); } else if (i === 0) { // First fragment - append to last line (might be partial) session.outputLines[session.outputLines.length - 1] += line; } else { // Subsequent lines - add as new lines session.outputLines.push(line); } } // Appended text contributes exactly its length to the joined buffer // (its newlines become the join separators). session.bufferedChars += text.length; // A process printing without newlines grows a single line forever, which // eviction can't bound — force-split so no line exceeds MAX_LINE_CHARS. // Each inserted break adds one separator to the joined length. let lastIndex = session.outputLines.length - 1; while (session.outputLines[lastIndex].length > MAX_LINE_CHARS) { const overlong = session.outputLines[lastIndex]; session.outputLines[lastIndex] = overlong.slice(0, MAX_LINE_CHARS); session.outputLines.push(overlong.slice(MAX_LINE_CHARS)); session.bufferedChars += 1; lastIndex++; } // Enforce the per-session cap by evicting the oldest lines. Keeps the // buffer far below V8's max string length so concatenation and join() // can never throw "Invalid string length" and kill the server. // Count them first and drop them with one splice: on an array this large // every shift() copies the whole array, and a line-by-line shift() stalled // the event loop for seconds per chunk once a session reached the cap. let evicted = 0; while (session.bufferedChars > MAX_BUFFERED_OUTPUT_CHARS && evicted < session.outputLines.length - 1) { const droppedJoinedChars = session.outputLines[evicted].length + 1; // +1 for its join separator session.bufferedChars -= droppedJoinedChars; session.evictedChars += droppedJoinedChars; evicted++; } if (evicted > 0) { session.outputLines.splice(0, evicted); session.evictedLines += evicted; session.lastReadIndex = Math.max(0, session.lastReadIndex - evicted); } } /** * Read process output with pagination (like file reading) * @param pid Process ID * @param offset Line offset: 0=from lastReadIndex, positive=absolute, negative=tail * @param length Max lines to return * @param updateReadIndex Whether to update lastReadIndex (default: true for offset=0) */ readOutputPaginated(pid, offset = 0, length = 1000) { // First check active sessions const session = this.sessions.get(pid); if (session) { const result = this.readFromLineBuffer(session, offset, length, false, undefined); result.evictedLines = session.evictedLines; return result; } // Then check completed sessions: they keep their read position, so // reading on after the exit continues where the last read stopped const completedSession = this.completedSessions.get(pid); if (completedSession) { const runtimeMs = completedSession.endTime.getTime() - completedSession.session.startTime.getTime(); const result = this.readFromLineBuffer(completedSession.session, offset, length, true, completedSession.exitCode, runtimeMs); result.evictedLines = completedSession.session.evictedLines; result.signal = completedSession.signal; return result; } return null; } /** * Whether a default read (offset 0) has anything to return: complete lines * past the read position, or an unfinished last line with text a read * hasn't returned (see readFromLineBuffer). Line counts alone can't tell * the second: text appended to that line adds no line. */ hasUnreadOutput(pid) { const session = this.sessions.get(pid) ?? this.completedSessions.get(pid)?.session; if (!session) { return false; } return session.outputLines.length - 1 > session.lastReadIndex || TerminalManager.openLineHasNewText(session); } /** * Whether the unfinished last line has text that a default read hasn't * returned. Empty, it has nothing to return. Its text is compared with what * a read last returned only while it is the line that read stopped at: a * later line is new even when its text is the same (a REPL's next ">>> "). */ static openLineHasNewText(session) { const lines = session.outputLines; const openLine = lines[lines.length - 1] ?? ''; return openLine !== '' && (lines.length - 1 !== session.lastReadIndex || openLine !== session.lastReadOpenLine); } /** * Internal helper to read from a session's line buffer with offset/length */ readFromLineBuffer(session, offset, length, isComplete, exitCode, runtimeMs) { const lines = session.outputLines; // The empty last line after a trailing newline holds nothing: it isn't // counted (total, remaining, tail offsets) or returned. A last line with // text, unfinished or not, is. const totalLines = lines[lines.length - 1] === '' ? lines.length - 1 : lines.length; let startIndex; let linesToRead; let readableEnd = totalLines; if (offset < 0) { // Negative offset = start position from end, then read 'length' lines forward // e.g., offset=-50, length=10 means: start 50 lines from end, read 10 lines const fromEnd = Math.abs(offset); startIndex = Math.max(0, totalLines - fromEnd); linesToRead = lines.slice(startIndex, Math.min(startIndex + length, totalLines)); // Don't update lastReadIndex for tail reads } else if (offset === 0) { // offset=0 means "from where I last read" (like getNewOutput). // The last line is unfinished: output is appended to it until a newline // arrives, even after the exit (a process the command started can still // write). So it is returned only when it has text a read hasn't // returned, and the read position never moves past it: counting it as // read would lose whatever is appended to it next. const openLineIndex = Math.max(lines.length - 1, 0); readableEnd = TerminalManager.openLineHasNewText(session) ? lines.length : openLineIndex; startIndex = session.lastReadIndex; linesToRead = lines.slice(startIndex, Math.min(startIndex + length, readableEnd)); const readTo = startIndex + linesToRead.length; if (readTo === lines.length) { session.lastReadOpenLine = lines[lines.length - 1]; } session.lastReadIndex = Math.min(readTo, openLineIndex); } else { // Positive offset = absolute position startIndex = offset; linesToRead = lines.slice(startIndex, Math.min(startIndex + length, totalLines)); // Don't update lastReadIndex for absolute position reads } const readCount = linesToRead.length; const endIndex = startIndex + readCount; const remaining = Math.max(0, readableEnd - endIndex); return { lines: linesToRead, totalLines, readFrom: startIndex, readCount, remaining, isComplete, exitCode, runtimeMs }; } /** * Get total line count for a process */ getOutputLineCount(pid) { const session = this.sessions.get(pid); if (session) { return session.outputLines.length; } const completedSession = this.completedSessions.get(pid); if (completedSession) { return completedSession.session.outputLines.length; } return null; } /** * Legacy method for backward compatibility * Returns all new output since last read * @param maxLines Maximum lines to return (default: 1000 for context protection) * @deprecated Use readOutputPaginated instead */ getNewOutput(pid, maxLines = 1000) { const result = this.readOutputPaginated(pid, 0, maxLines); if (!result) return null; const output = result.lines.join('\n').trim(); // For completed sessions, append completion info with runtime if (result.isComplete) { const runtimeStr = result.runtimeMs !== undefined ? `\nRuntime: ${(result.runtimeMs / 1000).toFixed(2)}s` : ''; if (output) { return `${output}\n\nProcess completed with exit code ${result.exitCode}${runtimeStr}`; } else { return `Process completed with exit code ${result.exitCode}${runtimeStr}\n(No output produced)`; } } // Add truncation warning if there's more output if (result.remaining > 0) { return `${output}\n\n[Output truncated: ${result.remaining} more lines available. Use read_process_output with offset/length for full output.]`; } return output || null; } /** * Capture a snapshot of current output state for interaction tracking. * Used by interactWithProcess to know what output existed before sending input. */ captureOutputSnapshot(pid) { const session = this.sessions.get(pid); return session ? TerminalManager.endOfOutput(session) : null; } /** * Get output that appeared since a snapshot was taken. * This handles the case where output is appended to the last line (REPL prompts). * Also checks completed sessions in case process finished between snapshot and poll. */ getOutputSinceSnapshot(pid, snapshot) { return this.readOutputSince(pid, snapshot)?.output ?? null; } /** * Output that appeared since `snapshot`, plus the snapshot at the current end * of output. A poller passes `next` back in to read only the output that * arrived since its previous poll. Costs O(output since snapshot), never * O(whole buffer), so polling stays cheap however much history the session * has retained. Also checks completed sessions in case the process finished * between snapshot and poll. */ readOutputSince(pid, snapshot) { const buffer = this.sessions.get(pid) ?? this.completedSessions.get(pid)?.session; if (!buffer) { return null; } return { output: TerminalManager.outputSinceSnapshot(buffer, snapshot.totalChars), next: TerminalManager.endOfOutput(buffer) }; } /** * Snapshot at the current end of a buffer, in O(1): bufferedChars is the * joined length of the retained lines, so nothing is joined here. */ static endOfOutput(buffer) { return { // Absolute since process start (includes evicted output), so the // offset stays valid even if the cap evicts lines between // snapshot and read. totalChars: buffer.evictedChars + buffer.bufferedChars, lineCount: buffer.evictedLines + buffer.outputLines.length, streamChars: { stdout: buffer.streams.stdout.chars, stderr: buffer.streams.stderr.chars } }; } /** * The process's state: finished once it has exited, whatever its output * says; otherwise waiting for input or running, judged from the end of the * output it wrote since `since` (default: since it started). Reads only the * per-stream tails, so it costs the same however much output the process * has produced. The one place start_process, interact_with_process, * read_process_output and list_sessions get the state from. */ getProcessState(pid, since) { const session = this.sessions.get(pid); if (session) { return analyzeProcessState(TerminalManager.lastWrittenOutput(session.streams, since), pid); } const completedSession = this.completedSessions.get(pid); if (!completedSession) { return null; } const lastOutput = TerminalManager.lastWrittenOutput(completedSession.session.streams, since).join('\n'); return { isWaitingForInput: false, isFinished: true, isRunning: false, lastOutput }; } /** * The output since `since` of each stream that may have written last: the * streams delivered in the latest turn that delivered anything since then. * Their relative order within that turn is unknown (see * currentDeliveryTurn), so each is judged on its own; streams last * delivered in an earlier turn wrote before them and can't end the output. */ static lastWrittenOutput(streams, since) { const recent = OUTPUT_STREAMS .map(name => { const { text, chars, turn } = streams[name]; const newChars = chars - (since?.streamChars[name] ?? 0); return { text: newChars > 0 ? text.slice(-newChars) : '', turn }; }) .filter(stream => stream.text.length > 0); const lastTurn = Math.max(...recent.map(stream => stream.turn)); return recent.filter(stream => stream.turn === lastTurn).map(stream => stream.text); } /** * New output since a snapshot, in absolute (since process start) offsets: * the last (end - snapshot) chars of the joined buffer. Walks back from the * newest line only until the snapshot position is covered and joins just * those lines. If eviction dropped part of the unseen output, returns what * the buffer still holds — the oldest unseen chars are lost to the cap. */ static outputSinceSnapshot(buffer, snapshotTotalChars) { const { outputLines } = buffer; const newChars = buffer.evictedChars + buffer.bufferedChars - snapshotTotalChars; if (newChars <= 0 || outputLines.length === 0) { return ''; // No new output } let firstLine = outputLines.length - 1; let tailChars = outputLines[firstLine].length; // Joined length of outputLines[firstLine..] while (tailChars < newChars && firstLine > 0) { firstLine--; tailChars += outputLines[firstLine].length + 1; // +1 for its join separator } const tail = outputLines.slice(firstLine).join('\n'); return tail.substring(Math.max(0, tail.length - newChars)); } /** * Get a session by PID * @param pid Process ID * @returns The session or undefined if not found */ getSession(pid) { return this.sessions.get(pid); } /** * End a session: its shell and every process the command started (see * terminateProcessTree). Killing only the shell left the program running. * Resolves once they are gone: 'terminated', or 'failed' if some could not * be ended; 'not_found' when no session runs as `pid`. Never rejects. */ async forceTerminate(pid) { const session = this.sessions.get(pid); if (!session) { return 'not_found'; } if (!session.termination) { // Calls made while it runs share it; a failed one is dropped, so the next call tries again session.termination = terminateProcessTree(session.process).then((ended) => { if (!ended) session.termination = undefined; return ended; }); } if (await session.termination) { return 'terminated'; } capture('server_request_error', { error: 'Process tree termination failed', message: `Failed to terminate process ${pid}:` }); return 'failed'; } listActiveSessions() { const now = new Date(); return Array.from(this.sessions.values()).map(session => ({ pid: session.pid, // Waiting for input now, judged from its output like every other state answer isBlocked: this.getProcessState(session.pid)?.isWaitingForInput ?? false, runtime: now.getTime() - session.startTime.getTime() })); } listCompletedSessions() { return Array.from(this.completedSessions.values()); } } export const terminalManager = new TerminalManager();