511 lines
20 KiB
JavaScript
511 lines
20 KiB
JavaScript
|
|
"use strict";
|
|||
|
|
|
|||
|
|
const { StringDecoder } = require("node:string_decoder");
|
|||
|
|
const iconv = require("iconv-lite");
|
|||
|
|
const {
|
|||
|
|
stripAnsi,
|
|||
|
|
isDefaultPowerShellPromptLine,
|
|||
|
|
isDefaultCmdPromptLine,
|
|||
|
|
isDefaultPosixPromptLine,
|
|||
|
|
} = require("./shellUtils.cjs");
|
|||
|
|
const { classifyLocalShellType } = require("../../../lib/localShell.cjs");
|
|||
|
|
|
|||
|
|
// Build a stateful decoder for a full exec call. Serial data events can
|
|||
|
|
// split multi-byte characters across chunks (very common on GBK/GB18030
|
|||
|
|
// consoles), and a stateless iconv.decode per chunk would emit
|
|||
|
|
// replacement bytes for the leading half. StringDecoder and
|
|||
|
|
// iconv.getDecoder both preserve partial-byte state across write() calls
|
|||
|
|
// and flush any trailing bytes on end(), which is what we need.
|
|||
|
|
function createStatefulDecoder(encoding) {
|
|||
|
|
const enc = encoding || "utf8";
|
|||
|
|
if (Buffer.isEncoding(enc)) {
|
|||
|
|
return new StringDecoder(enc);
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
return iconv.getDecoder(enc);
|
|||
|
|
} catch {
|
|||
|
|
return new StringDecoder("utf8");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function detectShellKind(shellPath, platform = process.platform) {
|
|||
|
|
return classifyLocalShellType(shellPath, platform);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function subscribeToPtyData(ptyStream, onData) {
|
|||
|
|
if (typeof ptyStream?.onData === "function") {
|
|||
|
|
const disposable = ptyStream.onData((data) => onData(data));
|
|||
|
|
return () => {
|
|||
|
|
try {
|
|||
|
|
disposable?.dispose?.();
|
|||
|
|
} catch {
|
|||
|
|
// Ignore cleanup failures
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (typeof ptyStream?.on === "function" && typeof ptyStream?.removeListener === "function") {
|
|||
|
|
ptyStream.on("data", onData);
|
|||
|
|
return () => {
|
|||
|
|
try {
|
|||
|
|
ptyStream.removeListener("data", onData);
|
|||
|
|
} catch {
|
|||
|
|
// Ignore cleanup failures
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
throw new Error("PTY stream does not support data subscriptions");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function hasExpectedPromptSuffix(text, expectedPrompt) {
|
|||
|
|
if (!expectedPrompt) return false;
|
|||
|
|
const normalizedText = stripAnsi(String(text || "")).replace(/\r/g, "");
|
|||
|
|
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
|
|||
|
|
return !!normalizedPrompt && normalizedText.endsWith(normalizedPrompt);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function escapePosixSingleQuoted(text) {
|
|||
|
|
return String(text || "").replace(/'/g, "'\\''");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function escapePowerShellSingleQuoted(text) {
|
|||
|
|
return String(text || "").replace(/'/g, "''");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function escapeFishSingleQuoted(text) {
|
|||
|
|
return String(text || "").replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function escapeCmdForNestedShell(text) {
|
|||
|
|
return String(text || "").replace(/"/g, '""').replace(/%/g, "%%");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Matches PowerShell's default prompt only (e.g. `PS C:\Users\alice>`,
|
|||
|
|
// `PS>`). Custom prompt functions (oh-my-posh, starship, PSReadLine themes
|
|||
|
|
// that emit `❯`/`λ`/etc.) intentionally fall through — we'd rather miss
|
|||
|
|
// the override than wrap a fish/zsh prompt as PowerShell. Pattern lives
|
|||
|
|
// in shellUtils.cjs so prompt extraction and wrapper selection share one
|
|||
|
|
// source of truth.
|
|||
|
|
function isPowerShellPrompt(prompt) {
|
|||
|
|
// Treat `\r` as a line break too so a PSReadLine/ConPTY redraw like
|
|||
|
|
// `PS C:\old>\rPS C:\new>` is matched against the redrawn last line,
|
|||
|
|
// not the doubled string.
|
|||
|
|
const lastLine = stripAnsi(String(prompt || ""))
|
|||
|
|
.replace(/\r/g, "\n")
|
|||
|
|
.split("\n")
|
|||
|
|
.pop()
|
|||
|
|
.replace(/\s+$/, "");
|
|||
|
|
return isDefaultPowerShellPromptLine(lastLine);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isCmdPrompt(prompt) {
|
|||
|
|
const lastLine = stripAnsi(String(prompt || ""))
|
|||
|
|
.replace(/\r/g, "\n")
|
|||
|
|
.split("\n")
|
|||
|
|
.pop()
|
|||
|
|
.replace(/\s+$/, "");
|
|||
|
|
return isDefaultCmdPromptLine(lastLine);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isPosixPrompt(prompt) {
|
|||
|
|
const lastLine = stripAnsi(String(prompt || ""))
|
|||
|
|
.replace(/\r/g, "\n")
|
|||
|
|
.split("\n")
|
|||
|
|
.pop()
|
|||
|
|
.replace(/\s+$/, "");
|
|||
|
|
return isDefaultPosixPromptLine(lastLine);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Prompt-driven override is intentionally narrow: only flip to PowerShell
|
|||
|
|
// when the session has no confirmed shell type. This keeps the issue #841
|
|||
|
|
// fix working for remote Windows shells that never set shellKind at connect
|
|||
|
|
// time, while preventing a malicious remote process from spoofing a
|
|||
|
|
// `PS ...>` line on a real bash/zsh/fish/cmd session to coerce a single
|
|||
|
|
// mis-wrapped command.
|
|||
|
|
//
|
|||
|
|
// Remote login-shell probing stores a *soft* hint (`loginShellHint` /
|
|||
|
|
// session._loginShellKind) without pinning session.shellKind:
|
|||
|
|
// - hint "fish" → fish wrapper (issue #1854) without permanent pin
|
|||
|
|
// - hint "posix" → native posix wrapper evaluated by interactive bash/zsh
|
|||
|
|
// (NOT sh -c / dash — Codex P2 on #2061)
|
|||
|
|
// - hint "powershell" / "cmd" → Windows DefaultShell (issue #2959) without
|
|||
|
|
// permanent pin, so a live opposing PS/cmd prompt can still win, and a
|
|||
|
|
// live `user@host:...$` POSIX prompt (e.g. WSL nesting) can override too
|
|||
|
|
// - live PS ...> still overrides when base kind is open
|
|||
|
|
// - live C:\...> selects cmd when base kind is open (Windows OpenSSH default)
|
|||
|
|
//
|
|||
|
|
// Universe of shellKind values (see lib/localShell.cjs:23-33 and
|
|||
|
|
// terminalBridge.cjs:368, :932, :1074):
|
|||
|
|
// "posix" | "powershell" | "cmd" | "fish" | "unknown" | "raw" | "" | undefined
|
|||
|
|
// Excluded on purpose from prompt override:
|
|||
|
|
// - "posix" / "fish" / "cmd" / "powershell": confirmed local/spawn kinds —
|
|||
|
|
// never override (anti-spoof for #841).
|
|||
|
|
// - "raw": serial / network device — execViaRawPty bypasses buildWrappedCommand.
|
|||
|
|
const SHELL_KINDS_OPEN_TO_PROMPT_OVERRIDE = new Set([
|
|||
|
|
"",
|
|||
|
|
"unknown",
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
const LOGIN_SHELL_HINTS = new Set(["posix", "fish", "powershell", "cmd"]);
|
|||
|
|
|
|||
|
|
function resolveEffectiveShellKind(shellKind, expectedPrompt, options = {}) {
|
|||
|
|
const baseKind = shellKind || "";
|
|||
|
|
const hint = options.loginShellHint || "";
|
|||
|
|
if (SHELL_KINDS_OPEN_TO_PROMPT_OVERRIDE.has(baseKind)) {
|
|||
|
|
if (isPowerShellPrompt(expectedPrompt)) {
|
|||
|
|
return "powershell";
|
|||
|
|
}
|
|||
|
|
if (isCmdPrompt(expectedPrompt)) {
|
|||
|
|
return "cmd";
|
|||
|
|
}
|
|||
|
|
// Windows OpenSSH DefaultShell soft hint + nested WSL/bash: live
|
|||
|
|
// `user@host:...$` must win so AI does not type a PS/cmd wrapper into
|
|||
|
|
// a POSIX shell and hang on markers. Fish/posix soft hints stay put —
|
|||
|
|
// those login shells already share this prompt family.
|
|||
|
|
if (
|
|||
|
|
isPosixPrompt(expectedPrompt)
|
|||
|
|
&& (hint === "powershell" || hint === "cmd")
|
|||
|
|
) {
|
|||
|
|
return "posix";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (baseKind) return baseKind;
|
|||
|
|
|
|||
|
|
// Soft login-shell hint from remote probe (not a permanent pin).
|
|||
|
|
if (LOGIN_SHELL_HINTS.has(hint)) return hint;
|
|||
|
|
|
|||
|
|
return "posix";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Discard unfinished prompt-line input before the agent wrapper so typed-but-
|
|||
|
|
// not-entered text is not concatenated onto the injected command (#2962).
|
|||
|
|
// Raw/serial devices have no portable line-kill binding; leave them alone.
|
|||
|
|
function buildPendingInputClearPrefix(shellKind) {
|
|||
|
|
switch (shellKind) {
|
|||
|
|
case "raw":
|
|||
|
|
return "";
|
|||
|
|
case "cmd":
|
|||
|
|
return "\x1b";
|
|||
|
|
case "powershell":
|
|||
|
|
// Vi gg plus a counted dd removes the whole multiline buffer, including
|
|||
|
|
// in PSReadLine 2.0 where dG is unavailable. Escape+r is Emacs
|
|||
|
|
// RevertLine. Repeated Escape clears Windows mode, and the final
|
|||
|
|
// i+Backspace leaves every mode on an empty editable line.
|
|||
|
|
return "\x1bggd2147483647d\x1br\x1b\x1bi\x08";
|
|||
|
|
default:
|
|||
|
|
// Kill the suffix before the prefix. Canonical/no-editing terminals do
|
|||
|
|
// not bind Ctrl+K; the trailing Ctrl+U must erase that literal byte too.
|
|||
|
|
return "\x0b\x15";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function bashHistoryScratchNames(marker) {
|
|||
|
|
const suffix = String(marker || "").toLowerCase().replace(/[^a-z0-9]/g, "").slice(-12) || "dflt";
|
|||
|
|
return { entry: `__nc_h_${suffix}`, dispatcher: `__nc_d_${suffix}` };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildBashHistoryCleanup(marker, keepDispatcher = false) {
|
|||
|
|
// Expanded dispatcher names bypass aliases. Verify that a dispatcher really
|
|||
|
|
// executes builtins before trusting an empty history read from a no-op function.
|
|||
|
|
// After deletion, verify the entry is gone: a shadowed history function may
|
|||
|
|
// delete only in a subshell. Stop as soon as the real dispatcher succeeds.
|
|||
|
|
// Invocation-specific scratch names avoid readonly user variables. Clear the
|
|||
|
|
// history-bearing scratch through the verified dispatcher, never plain unset.
|
|||
|
|
const { entry, dispatcher } = bashHistoryScratchNames(marker);
|
|||
|
|
const unsetNames = keepDispatcher ? entry : `${entry} ${dispatcher}`;
|
|||
|
|
return `[ "\${BASH_VERSION-}" ]&&{ for ${dispatcher} in command builtin;do ${entry}=$($${dispatcher} printf x);[ "$${entry}" = x ]||continue;${entry}=$($${dispatcher} history 1);case "$${entry}" in *${marker}*) ${entry}=\${${entry}#"\${${entry}%%[^[:space:]]*}"};$${dispatcher} history -d "\${${entry}%%[[:space:]]*}";${entry}=$($${dispatcher} history 1);case "$${entry}" in *${marker}*) continue;;esac;;esac;$${dispatcher} unset ${unsetNames};break;done; } 2>/dev/null`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildPosixWrapperBody(command, marker, startFormat) {
|
|||
|
|
const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= ";
|
|||
|
|
const commandLines = String(command || "").replace(/\r\n?/g, "\n").split("\n");
|
|||
|
|
let cmdAssign = commandLines.length > 1
|
|||
|
|
? `${marker}_cmd=$(printf '%s\\n' ${commandLines.map((line) => `'${escapePosixSingleQuoted(line)}'`).join(" ")})`
|
|||
|
|
: `${marker}_cmd='${escapePosixSingleQuoted(command)}'`;
|
|||
|
|
if (Buffer.byteLength(cmdAssign, 'utf8') > 650) {
|
|||
|
|
// Canonical PTYs limit bytes per physical input line, regardless of write
|
|||
|
|
// pacing. Emit bounded quoted pieces inside one command substitution; each
|
|||
|
|
// continuation keeps the marker visible to the terminal echo filter.
|
|||
|
|
const writes = [];
|
|||
|
|
for (const [index, line] of commandLines.entries()) {
|
|||
|
|
let chunk = '';
|
|||
|
|
let bytes = 0;
|
|||
|
|
for (const character of line) {
|
|||
|
|
const quoted = escapePosixSingleQuoted(character);
|
|||
|
|
const size = Buffer.byteLength(quoted, 'utf8');
|
|||
|
|
if (bytes + size > 512) {
|
|||
|
|
writes.push(`printf '%s' '${chunk}'`);
|
|||
|
|
chunk = '';
|
|||
|
|
bytes = 0;
|
|||
|
|
}
|
|||
|
|
chunk += quoted;
|
|||
|
|
bytes += size;
|
|||
|
|
}
|
|||
|
|
writes.push(`printf '${index < commandLines.length - 1 ? '%s\\n' : '%s'}' '${chunk}'`);
|
|||
|
|
}
|
|||
|
|
cmdAssign = `${marker}_cmd=$(${writes.join(`; \\\n: '${marker}'; `)})`;
|
|||
|
|
}
|
|||
|
|
const historyCleanup = buildBashHistoryCleanup(marker);
|
|||
|
|
const prefix = `${marker}=0; ${cmdAssign}; { printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"`;
|
|||
|
|
const suffix = `${historyCleanup}; (exit $__NCMCP_rc); }`;
|
|||
|
|
const separator = prefix.length + suffix.length + 2 > 1000
|
|||
|
|
? `; \\\n: '${marker}'; ` : "; ";
|
|||
|
|
return `${prefix}${separator}${suffix}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildWrappedCommand(command, shellKind, marker, separateStartMarker = false) {
|
|||
|
|
// A live probe leaves its completion marker unterminated to hide the next
|
|||
|
|
// prompt. With terminal echo disabled, only the wrapper can end that line.
|
|||
|
|
const startFormat = separateStartMarker ? "\\n%s\\n" : "%s\\n";
|
|||
|
|
switch (shellKind) {
|
|||
|
|
case "powershell": {
|
|||
|
|
const psPager = "$env:PAGER='cat'; $env:SYSTEMD_PAGER=''; $env:GIT_PAGER='cat'; $env:LESS=''; ";
|
|||
|
|
const psEscaped = escapePowerShellSingleQuoted(command);
|
|||
|
|
return (
|
|||
|
|
`$${marker}=0; $${marker}_cmd='${psEscaped}'; & { Write-Output '${marker}_S'; ${psPager}$LASTEXITCODE=$null; try { Invoke-Expression $${marker}_cmd; $${marker}_rc = if ($LASTEXITCODE -ne $null) { $LASTEXITCODE } elseif ($?) { 0 } else { 1 } } catch { $${marker}_rc = 1 }; Write-Output "${marker}_E:$${marker}_rc" }\r\n`
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
case "cmd": {
|
|||
|
|
const cmdEscaped = escapeCmdForNestedShell(command);
|
|||
|
|
return (
|
|||
|
|
`set "${marker}=0" & set "${marker}_CMD=${cmdEscaped}" & (echo ${marker}_S & set "PAGER=cat" & set "SYSTEMD_PAGER=" & set "GIT_PAGER=cat" & set "LESS=" & call cmd /d /s /c "%${marker}_CMD%" & call echo ${marker}_E:^%errorlevel^%)\r\n`
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
case "fish":
|
|||
|
|
// Leading space: see the comment in the POSIX branch below. Fish
|
|||
|
|
// does not skip leading-space commands by default, but users can
|
|||
|
|
// define a `fish_should_add_to_history` function that filters them
|
|||
|
|
// — this prefix is what lets that opt-in actually take effect.
|
|||
|
|
return (
|
|||
|
|
` set ${marker} 0; function __ncmcp_int --on-signal INT; printf '%s\\n' '${marker}_E:130'; functions -e __ncmcp_int; end; ` +
|
|||
|
|
`set -l ${marker}_cmd '${escapeFishSingleQuoted(command)}'; ` +
|
|||
|
|
`begin; set -gx PAGER cat; set -gx SYSTEMD_PAGER ''; set -gx GIT_PAGER cat; set -gx LESS ''; ` +
|
|||
|
|
`printf '${startFormat}' '${marker}_S'; eval \$${marker}_cmd; set __NCMCP_rc $status; ` +
|
|||
|
|
`functions -e __ncmcp_int; printf '%s\\n' '${marker}_E:'\$__NCMCP_rc; end\n`
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
case "posix":
|
|||
|
|
default: {
|
|||
|
|
// Compound command with an early marker on each physical line.
|
|||
|
|
//
|
|||
|
|
// Layout: __NCMCP_xxx=0; { ... MARKER_S; eval command; MARKER_E; }
|
|||
|
|
//
|
|||
|
|
// Key design decisions:
|
|||
|
|
//
|
|||
|
|
// 1) __NCMCP_xxx=0 at the VERY START ensures the PTY echo line
|
|||
|
|
// contains __NCMCP_ in its first few bytes. This is critical:
|
|||
|
|
// preload.cjs filters chunks by buffering incomplete lines that
|
|||
|
|
// contain __NCMCP_. Without this prefix, the first chunk of a
|
|||
|
|
// long echo line might not contain the marker and would leak
|
|||
|
|
// through to the terminal as garbage.
|
|||
|
|
//
|
|||
|
|
// 2) The user command is executed via eval on a quoted string. This
|
|||
|
|
// keeps shell syntax errors inside the eval call so the wrapper
|
|||
|
|
// can still emit the end marker and return a non-zero exit code.
|
|||
|
|
//
|
|||
|
|
// 3) The complete { ... } group is parsed before execution, so SIGINT
|
|||
|
|
// cannot cause bash to flush the end marker from the input buffer.
|
|||
|
|
// trap ':' INT lets child processes receive SIGINT normally while
|
|||
|
|
// preventing the shell from aborting the compound command.
|
|||
|
|
//
|
|||
|
|
// 4) The eval runs inside a subshell ( ... ) so shell-terminating
|
|||
|
|
// constructs in the generated command — set -e / set -o errexit
|
|||
|
|
// followed by a failure, exit, shell option changes, traps,
|
|||
|
|
// function/alias definitions — end or mutate only the subshell,
|
|||
|
|
// never the user's active login shell (issue #1850). set -e still
|
|||
|
|
// behaves normally *inside* the command, and the subshell shares
|
|||
|
|
// the PTY so the user sees all output live. The intentional
|
|||
|
|
// trade-off is that cd/export no longer persist into the user's
|
|||
|
|
// shell or across agent commands; the terminal.execute tool
|
|||
|
|
// description tells the model to combine cd with its command.
|
|||
|
|
// Earlier attempts (PRs #1852/#1882) that instead tried to detect
|
|||
|
|
// dangerous commands grew into shell parsing and were abandoned —
|
|||
|
|
// do not reintroduce detection here.
|
|||
|
|
//
|
|||
|
|
// Leading single space: lets bash/zsh skip recording this command
|
|||
|
|
// in history when the user already has HISTCONTROL=ignorespace
|
|||
|
|
// (bash) or HIST_IGNORE_SPACE (zsh) configured — Debian/Ubuntu and
|
|||
|
|
// most Oh-My-Zsh setups have this on by default; CentOS/RHEL users
|
|||
|
|
// can opt in by adding `HISTCONTROL=ignoreboth` to ~/.bashrc.
|
|||
|
|
// Without that config the prefix is harmless; it just doesn't
|
|||
|
|
// suppress history recording.
|
|||
|
|
return ` ${buildPosixWrapperBody(command, marker, startFormat)}\n`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function findEndMarker(outputText, marker, { allowInline = false } = {}) {
|
|||
|
|
const endPattern = marker + "_E:";
|
|||
|
|
let searchFrom = 0;
|
|||
|
|
while (searchFrom < outputText.length) {
|
|||
|
|
const endIdx = outputText.indexOf(endPattern, searchFrom);
|
|||
|
|
if (endIdx === -1) return null;
|
|||
|
|
|
|||
|
|
// Before the start marker is confirmed, require a line boundary so the
|
|||
|
|
// echoed wrapper command cannot be mistaken for real completion. Once the
|
|||
|
|
// command has started, the random marker can safely follow output that did
|
|||
|
|
// not end with a newline.
|
|||
|
|
if (allowInline || endIdx === 0 || outputText[endIdx - 1] === "\n" || outputText[endIdx - 1] === "\r") {
|
|||
|
|
const afterEnd = outputText.slice(endIdx + endPattern.length);
|
|||
|
|
const codeMatch = afterEnd.match(/^(\d+)/);
|
|||
|
|
const exitCode = codeMatch ? parseInt(codeMatch[1], 10) : null;
|
|||
|
|
if (exitCode !== null) {
|
|||
|
|
return { endIdx, exitCode };
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
searchFrom = endIdx + 1;
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function normalizePtyOutput(stdout, {
|
|||
|
|
stripMarkers = false,
|
|||
|
|
expectedPrompt = "",
|
|||
|
|
trimOutput = true,
|
|||
|
|
stripPrompt = true,
|
|||
|
|
markerToStrip = null,
|
|||
|
|
} = {}) {
|
|||
|
|
let cleaned = stripAnsi(stdout || "").replace(/\r/g, "");
|
|||
|
|
if (stripMarkers) {
|
|||
|
|
// Prefer the job-specific marker so user output that contains "__NCMCP_"
|
|||
|
|
// (e.g. printf '__NCMCP_demo\n') is preserved.
|
|||
|
|
const pattern = markerToStrip
|
|||
|
|
? new RegExp(`^[^\r\n]*${markerToStrip}[^\r\n]*[\r\n]*`, "gm")
|
|||
|
|
: /^[^\r\n]*__NCMCP_[^\r\n]*[\r\n]*/gm;
|
|||
|
|
cleaned = cleaned.replace(pattern, "");
|
|||
|
|
}
|
|||
|
|
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
|
|||
|
|
if (stripPrompt && normalizedPrompt && cleaned.endsWith(normalizedPrompt)) {
|
|||
|
|
cleaned = cleaned.slice(0, cleaned.length - normalizedPrompt.length);
|
|||
|
|
}
|
|||
|
|
return trimOutput ? cleaned.trim() : cleaned;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function appendBoundedOutput(current, chunk, maxBufferedChars) {
|
|||
|
|
const limit = Number.isFinite(maxBufferedChars) ? Math.max(0, Math.floor(maxBufferedChars)) : 0;
|
|||
|
|
const currentText = String(current || "");
|
|||
|
|
const chunkText = String(chunk || "");
|
|||
|
|
if (limit > 0 && chunkText.length >= limit) {
|
|||
|
|
return {
|
|||
|
|
text: chunkText.slice(-limit),
|
|||
|
|
dropped: currentText.length + chunkText.length - limit,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
const combined = `${currentText}${chunkText}`;
|
|||
|
|
if (limit <= 0 || combined.length <= limit) {
|
|||
|
|
return { text: combined, dropped: 0 };
|
|||
|
|
}
|
|||
|
|
const dropped = combined.length - limit;
|
|||
|
|
return {
|
|||
|
|
text: combined.slice(dropped),
|
|||
|
|
dropped,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function consumeVisibleText(carry, chunk) {
|
|||
|
|
const input = `${carry || ""}${chunk || ""}`;
|
|||
|
|
if (!input) {
|
|||
|
|
return { visibleText: "", carry: "" };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let visibleText = "";
|
|||
|
|
let index = 0;
|
|||
|
|
|
|||
|
|
while (index < input.length) {
|
|||
|
|
const ch = input[index];
|
|||
|
|
|
|||
|
|
if (ch === "\r") {
|
|||
|
|
// Preserve \r so consumers / serializers can collapse progress-bar
|
|||
|
|
// redraws to the latest frame. \r\n becomes a single \n.
|
|||
|
|
if (input[index + 1] === "\n") {
|
|||
|
|
visibleText += "\n";
|
|||
|
|
index += 2;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
visibleText += "\r";
|
|||
|
|
index += 1;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (ch !== "\u001b") {
|
|||
|
|
visibleText += ch;
|
|||
|
|
index += 1;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (index + 1 >= input.length) {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const next = input[index + 1];
|
|||
|
|
|
|||
|
|
if (next === "[") {
|
|||
|
|
let cursor = index + 2;
|
|||
|
|
let complete = false;
|
|||
|
|
while (cursor < input.length) {
|
|||
|
|
const code = input.charCodeAt(cursor);
|
|||
|
|
if (code >= 0x40 && code <= 0x7e) {
|
|||
|
|
index = cursor + 1;
|
|||
|
|
complete = true;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
cursor += 1;
|
|||
|
|
}
|
|||
|
|
if (!complete) break;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (next === "]") {
|
|||
|
|
let cursor = index + 2;
|
|||
|
|
let complete = false;
|
|||
|
|
while (cursor < input.length) {
|
|||
|
|
const oscChar = input[cursor];
|
|||
|
|
if (oscChar === "\u0007") {
|
|||
|
|
index = cursor + 1;
|
|||
|
|
complete = true;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
if (oscChar === "\u001b") {
|
|||
|
|
if (cursor + 1 >= input.length) break;
|
|||
|
|
if (input[cursor + 1] === "\\") {
|
|||
|
|
index = cursor + 2;
|
|||
|
|
complete = true;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
cursor += 1;
|
|||
|
|
}
|
|||
|
|
if (!complete) break;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
visibleText += ch;
|
|||
|
|
index += 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
visibleText,
|
|||
|
|
carry: input.slice(index),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
module.exports = {
|
|||
|
|
createStatefulDecoder,
|
|||
|
|
detectShellKind,
|
|||
|
|
subscribeToPtyData,
|
|||
|
|
hasExpectedPromptSuffix,
|
|||
|
|
resolveEffectiveShellKind,
|
|||
|
|
buildPendingInputClearPrefix,
|
|||
|
|
buildWrappedCommand,
|
|||
|
|
buildBashHistoryCleanup,
|
|||
|
|
bashHistoryScratchNames,
|
|||
|
|
findEndMarker,
|
|||
|
|
normalizePtyOutput,
|
|||
|
|
appendBoundedOutput,
|
|||
|
|
consumeVisibleText,
|
|||
|
|
stripAnsi,
|
|||
|
|
};
|