UNPKG

@wonderwhy-er/desktop-commander

Version:

MCP server for terminal operations and file editing

261 lines (260 loc) • 12.1 kB
/** * REPL and Process State Detection Utilities * Detects from its output whether a running process is waiting for input. * Whether it has finished is known from its exit, never from its output * (see TerminalManager.getProcessState). */ // Common REPL prompts. Most are generic ("... ", "> ", "+ ", "| "): inside or // at the end of a longer line they are ordinary text (pytest -v's "collecting // ... ", #196; a markdown table's "| a | b |"), so they count only in a last // line made of prompts alone: the prompt itself, the prompts a REPL that // doesn't echo input printed one after another ("> | " from node -i), or, // from a REPL that writes its prompts to stderr (python -i, bash -i), the // prompts it wrote there (">>> ... "). const REPL_PROMPTS = { python: ['>>> ', '... '], node: ['> ', '... ', '| '], // "| ": Node 24's continuation, "... " before it r: ['> ', '+ '], julia: ['julia> ', ' '], // julia continuation is spaces shell: ['$ ', '# ', '% '], mysql: ['mysql> ', ' -> '], postgres: ['=# ', '-# '], redis: ['redis> '], mongo: ['> ', '... '], powershell: ['>> '] // continuation; after "> ", so it is reported as "> ", as before }; const PROMPTS = [...new Set(Object.values(REPL_PROMPTS).flat())]; const PROMPTS_ONLY = new RegExp(`^(?:${PROMPTS.map(escapeRegExp).join('|')})+$`); // Prompts that also count at the end of a longer line: named ones, after // output that didn't end in a newline ("done>>> "), and a shell or psql // prompt's end ("bash-5.2$ ", "user@host dir % ", "postgres=# ") const LINE_END_PROMPTS = ['>>> ', 'julia> ', 'mysql> ', 'redis> ', '$ ', '# ', '% ']; // PowerShell's prompt (powershell.exe, pwsh), at the end of a line: "PS ", a // location (a drive, provider or path: "C:\Users\me", "/Users/me", "HKLM:\"), // then "> " (">> " in a nested or debugger prompt, "[DBG]: PS C:\>> ") const POWERSHELL_PROMPT_TEXT = String.raw `PS [^<>|\n]*[\\/:][^<>|\n]*>>? `; const POWERSHELL_PROMPT = new RegExp(`${POWERSHELL_PROMPT_TEXT}$`); // cmd.exe's prompt: the whole last line is a path and ">", with nothing after // it ("C:\Users\me>"); output lines end in a newline, a prompt doesn't const CMD_PROMPT_TEXT = String.raw `[A-Za-z]:\\[^<>|"*?\n]*>`; const CMD_PROMPT = new RegExp(`^${CMD_PROMPT_TEXT}$`); // The same shell prompts at the start of a line, where the shell echoes the // next input line after them ("PS C:\Users\me> echo b", "C:\Users\me>echo b") const SHELL_PROMPTS_AT_START = [new RegExp(`^${POWERSHELL_PROMPT_TEXT}`), new RegExp(`^${CMD_PROMPT_TEXT}`)]; // The REPL prompts output cleaning removes, one or more in a row at the start // of a line (">>> ... " from a REPL that reads several lines at once) const REPL_PROMPT = String.raw `(?:>>> |\.\.\. |>> |> |\| |\+ )`; const REPL_PROMPT_RUN = new RegExp(`^${REPL_PROMPT}+`); const REPL_PROMPTS_ALONE = new RegExp(`^${REPL_PROMPT}+$`); const REPL_PROMPT_TOKEN = new RegExp(REPL_PROMPT, 'g'); // The prompts of a REPL waiting for more of a statement (Python, Node, PowerShell, R) const CONTINUATION_PROMPTS = ['... ', '| ', '>> ', '+ ']; /** The prompt the last line of output is, or ends in, if any */ function findPrompt(lastLine) { if (PROMPTS_ONLY.test(lastLine)) { return PROMPTS.find(prompt => lastLine.endsWith(prompt)); } const lineEnd = LINE_END_PROMPTS.find(prompt => lastLine.endsWith(prompt)); if (lineEnd) return lineEnd; // Reported as "> ", which a PowerShell prompt was reported as before #196 if (POWERSHELL_PROMPT.test(lastLine) || CMD_PROMPT.test(lastLine)) return '> '; return undefined; } /** * How much of the end of the output state detection examines. A prompt is * the last thing a process writes before it waits for input, so the end of * the output decides the state; examining only this much keeps detection's * cost the same however much output there is. */ export const STATE_DETECTION_TAIL_CHARS = 4096; /** * Analyze a running process's output to determine whether it is waiting for * input. Output text never makes a process finished: a line such as * "Error: retrying" doesn't end it. * * `output` is what the process wrote most recently. For a process writing to * both stdout and stderr it can be the recent output of each stream that may * have written last (see TerminalManager.getProcessState): their relative * order is unknown, so the process is waiting for input if any of them ends * in a prompt. Only the last STATE_DETECTION_TAIL_CHARS of each are examined. */ export function analyzeProcessState(output, pid) { const tails = (typeof output === 'string' ? [output] : output) .map(text => text.slice(-STATE_DETECTION_TAIL_CHARS)) .filter(text => text.trim().length > 0); const states = tails.map(analyzeOutputTail); return states.find(state => state.isWaitingForInput) ?? { isWaitingForInput: false, isFinished: false, isRunning: true, lastOutput: tails.join('\n') }; } /** * The state the end of one stream's output shows (see analyzeProcessState). */ function analyzeOutputTail(output) { const lines = output.split('\n'); const lastLine = lines[lines.length - 1] || ''; // Check for REPL prompts (waiting for input) const detectedPrompt = findPrompt(lastLine); if (detectedPrompt) { return { isWaitingForInput: true, isFinished: false, isRunning: true, detectedPrompt, lastOutput: output }; } // Default: process is running, not waiting for input return { isWaitingForInput: false, isFinished: false, isRunning: true, lastOutput: output }; } /** * Clean output by removing prompts and input echoes. `readAtPrompt`: whether * the process was waiting at a prompt when the input was sent; one that * wasn't (a shell reading commands without a prompt) prints none for it. */ export function cleanProcessOutput(output, inputSent, readAtPrompt = true) { // A process at a prompt prints one prompt for each input line it reads const printed = readAtPrompt && inputSent !== undefined ? inputLinesSent(inputSent).length : 0; const withoutEcho = removeInputEcho(output, inputSent); const cleaned = removePrompts(withoutEcho.output, printed - withoutEcho.prompts); // A line repeating the input is the answer, not an echo, when nothing else // came back: node -i answers `10` with "10", which was removed as the echo. // Not at a continuation prompt: the process waits for more of the input, // so it hasn't answered it if (!cleaned && withoutEcho.output !== output && !waitsAtContinuationPrompt(output)) { return removePrompts(output, printed); } return cleaned; } /** Whether the output ends at a continuation prompt (">> " after "if ($true) {") */ function waitsAtContinuationPrompt(output) { const lastLine = output.slice(output.lastIndexOf('\n') + 1); if (!REPL_PROMPTS_ALONE.test(lastLine)) return false; const prompts = lastLine.match(REPL_PROMPT_TOKEN) ?? []; return CONTINUATION_PROMPTS.includes(prompts[prompts.length - 1]); } /** * The output without the process's echo of the input, if it echoed it. A * process that echoes (powershell.exe, cmd.exe, a terminal over ssh -t) * repeats each input line where the output for it starts: the first line at * the start of the output, each later one after the prompt it was read at * ("PS C:\project> echo b", "C:\project>echo b", ">> }"). A line the process * has read (it printed a prompt for it) that isn't repeated there means the * process doesn't echo, and a line equal to an input line is output (node -i * answers `1` with "1"): the output is kept as it is. Lines not read yet (the * call answered at an earlier prompt) have no echo yet. Also returns how many * prompts went with the echoes. */ function removeInputEcho(output, inputSent) { if (!inputSent) return { output, prompts: 0 }; const lines = output.split('\n'); const echoes = new Set(); let next = 0; for (const [i, inputLine] of inputLinesSent(inputSent).entries()) { const echo = inputLine.trim(); if (!echo) continue; // An empty line's echo can't be told from an empty output line const at = i === 0 ? (isEchoLine(lines, 0, echo, true) ? 0 : -1) : lines.findIndex((_, index) => index >= next && isEchoLine(lines, index, echo, false)); if (at < 0) { // A process prints one prompt for each line it reads const linesRead = lines.reduce((count, line) => count + promptsAtStart(line), 0); if (linesRead > i) return { output, prompts: 0 }; break; } echoes.add(at); next = at + 1; } return { output: lines.filter((_, index) => !echoes.has(index)).join('\n'), prompts: [...echoes].reduce((count, index) => count + promptsAtStart(lines[index]), 0), }; } /** How many prompts `line` starts with: REPL prompts, one or more in a row, or a shell's prompt */ function promptsAtStart(line) { const run = REPL_PROMPT_RUN.exec(line)?.[0]; if (run) return run.match(REPL_PROMPT_TOKEN)?.length ?? 0; return SHELL_PROMPTS_AT_START.some(prompt => prompt.test(line)) ? 1 : 0; } /** The lines of an input as the process reads them (sendInputToProcess ends it with a newline) */ function inputLinesSent(input) { return (input.endsWith('\n') ? input.slice(0, -1) : input).split('\n'); } /** * Whether lines[index] is the echo of an input line: alone on the line (the * first input line's, at the start of the output) or after a prompt (a later * line's), and ended by the newline that was sent with it */ function isEchoLine(lines, index, echo, alone) { if (index >= lines.length - 1) return false; const line = lines[index].trimEnd(); if (!line.endsWith(echo)) return false; const before = line.slice(0, line.length - echo.length).trimEnd(); if (alone) return before === ''; return before !== '' && (findPrompt(before) !== undefined || findPrompt(`${before} `) !== undefined); } /** * The output without the REPL prompts the process printed for the input (at * most `printed`), and nothing that only looks like one. The last line, when * it is a prompt, is where the process waits now (a REPL's is removed; a * shell's, "PS C:\project> ", stays in the output as before). The others * start a line: the one where the output for the next input line begins * ("1\n> 2\n> " for `1` and `2` sent to node -i). More prompt-like line starts * than that means some are output ("+ added" in a diff, "> quoted"), and * those lines are all kept as they are. */ function removePrompts(output, printed) { const lines = output.split('\n'); const last = lines.length - 1; let left = printed; if (findPrompt(lines[last]) !== undefined) { left -= Math.max(1, promptsAtStart(lines[last])); if (REPL_PROMPTS_ALONE.test(lines[last])) lines[last] = ''; } const runs = lines.map((line, index) => (index < last ? REPL_PROMPT_RUN.exec(line)?.[0] ?? '' : '')); const found = runs.reduce((count, run) => count + (run.match(REPL_PROMPT_TOKEN)?.length ?? 0), 0); if (found <= left) { runs.forEach((run, index) => { lines[index] = lines[index].slice(run.length); }); } return lines.join('\n').trim(); } /** * Escape special regex characters */ function escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * Format process state for user display */ export function formatProcessStateMessage(state, pid) { if (state.isWaitingForInput) { return `Process ${pid} is waiting for input${state.detectedPrompt ? ` (detected: "${state.detectedPrompt.trim()}")` : ''}`; } else if (state.isFinished) { return `Process ${pid} has finished execution`; } else { return `Process ${pid} is running`; } }