Files
NetMesh/electron/bridges/terminalInterruptOutputGate.cjs
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

739 lines
26 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
const DEFAULT_QUIET_MS = 500;
const DEFAULT_PROMPT_QUIET_MS = 80;
const DEFAULT_MAX_DRAIN_MS = 2500;
const DEFAULT_PROMPT_CANDIDATE_BYTES = 512;
const OUTPUT_GATE_UNACKED_THRESHOLD = 8192;
const ESC = String.fromCharCode(27);
const TERMINAL_STATE_RESTORE_SEQUENCE_PATTERN = new RegExp(
`${ESC}\\[[0-?]*[ -/]*[@-~]|${ESC}[=>]`,
"g",
);
const PRIVATE_MODE_PATTERN = new RegExp(`^${ESC}\\[\\?([0-9;:]*)([hl])$`);
const TRAILING_RESTORE_CONTROL_PREFIX_PATTERN = new RegExp(`^${ESC}\\[\\?[0-9;:]*$`);
// Incomplete CSI (params/intermediates, no final byte) — e.g. ESC[0 or ESC[31
const TRAILING_CSI_CONTROL_PREFIX_PATTERN = new RegExp(`^${ESC}\\[[0-?]*[ -/]*$`);
const RESTORE_PRIVATE_MODE_PARAMS = new Set([
1,
47,
1000,
1002,
1003,
1004,
1005,
1006,
1015,
1047,
1048,
1049,
2004,
]);
const SHOW_CURSOR_PRIVATE_MODE_PARAM = 25;
function nowFromOptions(options = {}) {
return Number.isFinite(options.now) ? options.now : Date.now();
}
function byteLength(value) {
if (Buffer.isBuffer(value)) return value.length;
return Buffer.byteLength(String(value || ""));
}
function getStreamPaused(stream) {
try {
return typeof stream?.isPaused === "function" ? stream.isPaused() : false;
} catch {
return false;
}
}
function stripAnsi(value) {
const raw = String(value || "");
return raw
.replace(new RegExp(`${ESC}\\][\\s\\S]*?(?:\\x07|${ESC}\\\\)`, "g"), "")
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
}
function getPrivateModeParams(raw) {
const match = PRIVATE_MODE_PATTERN.exec(raw);
if (!match) return null;
const params = match[1]
.split(/[;:]/)
.map((param) => Number(param))
.filter((param) => Number.isFinite(param));
if (params.length === 0) return null;
return { params, final: match[2] };
}
function shouldPreserveTerminalStateRestore(raw) {
if (raw === `${ESC}>`) return true;
const privateModes = getPrivateModeParams(raw);
if (!privateModes) return false;
if (privateModes.final === "h") {
return privateModes.params.every((param) => param === SHOW_CURSOR_PRIVATE_MODE_PARAM);
}
return privateModes.params.every((param) => RESTORE_PRIVATE_MODE_PARAMS.has(param));
}
function getTrailingRestoreControlPrefix(text) {
const raw = String(text || "");
const escapeIndex = raw.lastIndexOf(ESC);
if (escapeIndex < 0) return "";
const suffix = raw.slice(escapeIndex);
if (suffix === ESC) return suffix;
// Hold any incomplete CSI (ESC[ / ESC[0 / ESC[31 / ESC[?1049), not only
// private-mode restore prefixes — styled password prompts can split mid-SGR.
if (TRAILING_CSI_CONTROL_PREFIX_PATTERN.test(suffix)) return suffix;
if (suffix.startsWith(`${ESC}[?`) && TRAILING_RESTORE_CONTROL_PREFIX_PATTERN.test(suffix)) {
return suffix;
}
return "";
}
function getTrailingOscControlPrefix(text) {
const raw = String(text || "");
const oscIndex = raw.lastIndexOf(`${ESC}]`);
if (oscIndex < 0) return "";
const suffix = raw.slice(oscIndex);
if (suffix.includes("\x07") || suffix.includes(`${ESC}\\`)) return "";
return suffix;
}
function getTrailingDisplayControlPrefix(text) {
return getTrailingRestoreControlPrefix(text) || getTrailingOscControlPrefix(text);
}
function extractTerminalStateRestoreControls(text, options = {}) {
const rawText = String(text || "");
const pending = options.holdTrailingPartial ? getTrailingDisplayControlPrefix(rawText) : "";
const searchableText = pending ? rawText.slice(0, -pending.length) : rawText;
let preserved = "";
for (const match of searchableText.matchAll(TERMINAL_STATE_RESTORE_SEQUENCE_PATTERN)) {
const raw = match[0];
if (shouldPreserveTerminalStateRestore(raw)) {
preserved += raw;
}
}
return {
preserved,
pending,
droppedBytes: Math.max(0, byteLength(rawText) - byteLength(preserved) - byteLength(pending)),
};
}
function takePendingDisplayControl(gate) {
const pending = gate.pendingDisplayControl || "";
gate.pendingDisplayControl = "";
return pending;
}
function finalizeAcceptedTextAfterPendingDisplayControl(pending, text) {
const rawText = String(text || "");
if (!pending) return { data: rawText, droppedBytes: 0 };
const combined = `${pending}${rawText}`;
TERMINAL_STATE_RESTORE_SEQUENCE_PATTERN.lastIndex = 0;
const restoreMatch = TERMINAL_STATE_RESTORE_SEQUENCE_PATTERN.exec(combined);
TERMINAL_STATE_RESTORE_SEQUENCE_PATTERN.lastIndex = 0;
if (restoreMatch?.index === 0 && restoreMatch[0].length > pending.length) {
const raw = restoreMatch[0];
const remainder = combined.slice(raw.length);
if (shouldPreserveTerminalStateRestore(raw)) {
return { data: `${raw}${remainder}`, droppedBytes: 0 };
}
return { data: remainder, droppedBytes: byteLength(raw) };
}
const oscPattern = new RegExp(`${ESC}\\][\\s\\S]*?(?:\\x07|${ESC}\\\\)`, "g");
const oscMatch = oscPattern.exec(combined);
if (oscMatch?.index === 0 && oscMatch[0].length > pending.length) {
return { data: combined, droppedBytes: 0 };
}
// Held password-prompt prefixes (plain or SGR-styled). Keep them when
// quiet/max-drain resumes so a split "Pass"+"word: " is not lost.
if (isProbablePasswordPromptPrefix(pending)) {
return { data: combined, droppedBytes: 0 };
}
return { data: rawText, droppedBytes: byteLength(pending) };
}
function isCompletePasswordPrompt(candidate) {
const trimmed = String(candidate || "").trimEnd();
if (!trimmed) return false;
// Align with terminalSudoAutofill's Kylin coverage (#1293): prompts may end
// with 密码/口令 and no colon (e.g. "用户 的密码"). Still require a password
// keyword so ordinary lines like "Password authentication failed" stay out.
return (
/(?:\bpassword\b|密\s*码|口\s*令)/i.test(trimmed)
&& (
/[:]\s*$/.test(trimmed)
|| /\[sudo/i.test(trimmed)
|| /(?:密\s*码|口\s*令)\s*$/.test(trimmed)
|| /^input\s+password\s*$/i.test(trimmed)
|| /^password\s*$/i.test(trimmed)
)
);
}
function isProbablePasswordPromptPrefix(candidate) {
// Strip SGR/OSC for matching so styled chunks like "\x1b[31mPass" still hold,
// while callers keep the raw pending bytes for display.
let trimmed = stripAnsi(String(candidate || "")).trimEnd();
// Incomplete CSI/OSC left after stripAnsi (e.g. trailing "\x1b[") must not
// prevent matching a held password prefix.
const trailingControl = getTrailingDisplayControlPrefix(trimmed);
if (trailingControl) {
trimmed = trimmed.slice(0, -trailingControl.length).trimEnd();
}
if (!trimmed || trimmed.length > 160) return false;
if (/[\r\n]/.test(trimmed)) return false;
if (isCompletePasswordPrompt(trimmed)) return false;
const lower = trimmed.toLowerCase();
const prefixTargets = [
"password",
"password:",
"password",
"[sudo",
"密码",
"密码:",
"口令",
"口令:",
"输入密码",
"输入密码:",
"input password",
"input password:",
];
if (prefixTargets.some((target) => target.startsWith(lower))) return true;
// Allow an unfinished "[sudo…]" tag, or "[sudo…] " + a password-word prefix.
const sudoTag = trimmed.match(/^\[sudo[^\]]*\]?\s*/i);
if (sudoTag) {
const remainder = trimmed.slice(sudoTag[0].length);
if (!remainder) {
if (/^\[sudo(?:[^\]]*)\]?\s*$/i.test(trimmed)) return true;
} else {
const remLower = remainder.toLowerCase();
const remTargets = [
"password",
"password:",
"password",
"密码",
"密码:",
"口令",
"口令:",
"输入密码",
"输入密码:",
"input password",
"input password:",
];
if (remTargets.some((target) => target.startsWith(remLower))) return true;
}
}
// Prompts with leading text split mid-keyword, e.g. `alice@host's pass` +
// `word:` or `用户 的密` + `码`. Hold when a trailing suffix is a real
// password-keyword prefix at a word boundary.
return hasTrailingPasswordKeywordPrefix(trimmed);
}
function hasTrailingPasswordKeywordPrefix(trimmed) {
const lower = String(trimmed || "").toLowerCase();
const keywordTargets = [
"password",
"password:",
"password",
"密码",
"密码:",
"口令",
"口令:",
"输入密码",
"输入密码:",
"input password",
"input password:",
];
for (const target of keywordTargets) {
const maxLen = Math.min(lower.length, target.length);
// ASCII keywords: require >= 3 chars ("pas"/"pass") so lone "p"/"pa" mid-line
// noise is not held. CJK keywords can match a single character ("密").
// Ignore punctuation/spaces (incl. full-width ) so "password" stays ASCII minLen.
const keywordBody = target.replace(/[:\s]/g, "");
let isAsciiKeyword = true;
for (let i = 0; i < keywordBody.length; i += 1) {
if (keywordBody.charCodeAt(i) > 0x7f) {
isAsciiKeyword = false;
break;
}
}
const minLen = isAsciiKeyword ? 3 : 1;
for (let len = maxLen; len >= minLen; len -= 1) {
const suffix = lower.slice(-len);
if (!target.startsWith(suffix)) continue;
const before = lower.slice(0, -len);
if (before.length === 0) return true;
const prev = before[before.length - 1];
if (!/[a-z0-9]/i.test(prev)) return true;
}
}
return false;
}
function getTrailingPasswordPromptPrefix(text) {
const raw = String(text || "");
const lastBreak = Math.max(raw.lastIndexOf("\n"), raw.lastIndexOf("\r"));
const trailing = raw.slice(lastBreak + 1);
if (!trailing) return "";
return isProbablePasswordPromptPrefix(trailing) ? trailing : "";
}
/**
* Restore sequences already counted in `preserved` can also sit at the start of
* the trailing password-prefix line (e.g. "stale\n\x1b[?1049lPass", or multiple
* restores "\x1b[?25hstale\n\x1b[?1049lPass"). Strip only complete leading
* restore sequences that are a real suffix of `preserved` — never peel chars
* like the final "l" of "\x1b[?1049l" off "login pass".
*/
function stripLeadingPreservedOverlap(passwordPending, preserved) {
let pending = String(passwordPending || "");
let keep = String(preserved || "");
if (!pending || !keep) return pending;
if (pending.startsWith(keep)) return pending.slice(keep.length);
while (pending && keep) {
TERMINAL_STATE_RESTORE_SEQUENCE_PATTERN.lastIndex = 0;
const match = TERMINAL_STATE_RESTORE_SEQUENCE_PATTERN.exec(pending);
TERMINAL_STATE_RESTORE_SEQUENCE_PATTERN.lastIndex = 0;
if (!match || match.index !== 0) break;
const seq = match[0];
if (!shouldPreserveTerminalStateRestore(seq) || !keep.endsWith(seq)) break;
pending = pending.slice(seq.length);
keep = keep.slice(0, -seq.length);
}
return pending;
}
/**
* When discarding a held prefix that ends mid-CSI, also drop the CSI final
* byte(s) from the next chunk so "Pass\x1b[0" + "m$ " does not leak as "m$ ".
*/
function consumeTrailingCsiCompletion(pending, text) {
const control = getTrailingDisplayControlPrefix(pending);
if (!control || !control.startsWith(`${ESC}[`)) {
return { text, extraDroppedBytes: 0 };
}
let i = 0;
const raw = String(text || "");
while (i < raw.length) {
const code = raw.charCodeAt(i);
// CSI parameter bytes 0? and intermediate bytes SP/
if ((code >= 0x30 && code <= 0x3f) || (code >= 0x20 && code <= 0x2f)) {
i += 1;
continue;
}
// CSI final byte @~
if (code >= 0x40 && code <= 0x7e) {
return { text: raw.slice(i + 1), extraDroppedBytes: i + 1 };
}
break;
}
return { text: raw, extraDroppedBytes: 0 };
}
function isStandaloneHoldableControlPrefix(pending) {
const raw = String(pending || "");
if (!raw) return false;
if (raw === ESC || raw === `${ESC}[`) return true;
// Incomplete private-mode restore CSI and OSC title prefixes are safe to hold
// alone; incomplete SGR CSI (ESC[31) is not — it can leak into "$ ".
if (raw.startsWith(`${ESC}[?`) && TRAILING_RESTORE_CONTROL_PREFIX_PATTERN.test(raw)) {
return true;
}
if (raw.startsWith(`${ESC}]`)) return true;
return false;
}
function extractDrainHold(text, options = {}) {
const restoreControls = extractTerminalStateRestoreControls(text, options);
if (!options.holdTrailingPartial) {
return restoreControls;
}
// A styled prompt can split mid-CSI, e.g. "\x1b[31mPass\x1b[" + "0mword: ".
// Keep both the password-prefix body and the trailing control prefix so the
// next chunk can still complete "Password:" (#2010 Codex follow-up).
const controlPending = restoreControls.pending;
const textWithoutControl = controlPending
? String(text || "").slice(0, -controlPending.length)
: String(text || "");
let passwordPending = getTrailingPasswordPromptPrefix(textWithoutControl);
passwordPending = stripLeadingPreservedOverlap(
passwordPending,
restoreControls.preserved,
);
if (!passwordPending || !isProbablePasswordPromptPrefix(passwordPending)) {
// Incomplete SGR CSI (ESC[31) must not be held alone — otherwise the next
// shell prompt can be accepted as "\x1b[31$ " and leak stale color bytes.
// Restore/OSC prefixes stay held as before.
if (controlPending && !isStandaloneHoldableControlPrefix(controlPending)) {
return {
preserved: restoreControls.preserved,
pending: "",
droppedBytes: Math.max(
0,
byteLength(text) - byteLength(restoreControls.preserved),
),
};
}
return restoreControls;
}
const pending = `${passwordPending}${controlPending}`;
return {
preserved: restoreControls.preserved,
pending,
droppedBytes: Math.max(
0,
byteLength(text) - byteLength(restoreControls.preserved) - byteLength(pending),
),
};
}
function getPromptCandidateSuffix(text) {
const raw = String(text || "");
const normalized = stripAnsi(raw).replace(/\r/g, "\n");
const lastLineStart = normalized.lastIndexOf("\n") + 1;
const candidate = normalized.slice(lastLineStart).trimEnd();
if (!candidate) return null;
if (candidate.length > 160) return null;
// Password prompts are interactive resume points too. Without this, Ctrl+C
// drain treats "[sudo] password for …:" as stale flood and drops it, so the
// remote waits for a password while the terminal shows nothing (#2010).
const looksLikePrompt = (
isCompletePasswordPrompt(candidate)
|| /^[#$>%]\s*$/.test(candidate)
|| /^[^ \t\r\n<>]{1,80}[#$>%]\s*$/.test(candidate)
|| /^[^\r\n<>]{1,120}[#$>%]\s*$/.test(candidate)
|| /^<[^>\r\n]{1,80}>\s*$/.test(candidate)
|| /^\[[^\]\r\n]{1,120}\]\s*[#$>%]\s*$/.test(candidate)
);
if (!looksLikePrompt) return null;
const rawLastBreak = Math.max(raw.lastIndexOf("\n"), raw.lastIndexOf("\r"));
return raw.slice(rawLastBreak + 1);
}
function shouldArmTerminalInterruptOutputGate(session) {
if (!session?.stream) return false;
const flowState = session.flowState;
return Boolean(
getStreamPaused(session.stream)
|| flowState?.appliedPause
|| flowState?.rendererPaused
|| (Number(flowState?.unackedBytes) || 0) >= OUTPUT_GATE_UNACKED_THRESHOLD
);
}
function armTerminalInterruptOutputGate(session, options = {}) {
if (!session) return false;
session._interruptOutputGate = {
active: true,
startedAt: nowFromOptions(options),
lastDroppedAt: 0,
quietMs: Number.isFinite(options.quietMs) ? options.quietMs : DEFAULT_QUIET_MS,
promptQuietMs: Number.isFinite(options.promptQuietMs) ? options.promptQuietMs : DEFAULT_PROMPT_QUIET_MS,
maxDrainMs: Number.isFinite(options.maxDrainMs) ? options.maxDrainMs : DEFAULT_MAX_DRAIN_MS,
promptCandidateBytes: Number.isFinite(options.promptCandidateBytes)
? options.promptCandidateBytes
: DEFAULT_PROMPT_CANDIDATE_BYTES,
droppedBytes: 0,
droppedChunks: 0,
pendingInterruptCaret: false,
pendingDisplayControl: "",
};
return true;
}
function disarmTerminalInterruptOutputGate(session) {
if (session?._interruptOutputGate) {
session._interruptOutputGate.active = false;
}
}
function mergeInterruptOutputMeta(first, second) {
const droppedOutputMayAffectTerminalState = Boolean(
first?.droppedOutputMayAffectTerminalState
|| second?.droppedOutputMayAffectTerminalState
);
const droppedOutputAlternateScreenAction = second?.droppedOutputMayAffectTerminalState
? second?.droppedOutputAlternateScreenAction
: (second?.droppedOutputAlternateScreenAction ?? first?.droppedOutputAlternateScreenAction);
if (!droppedOutputMayAffectTerminalState && !droppedOutputAlternateScreenAction) {
return undefined;
}
return {
...(droppedOutputMayAffectTerminalState ? { droppedOutputMayAffectTerminalState: true } : {}),
...(droppedOutputAlternateScreenAction ? { droppedOutputAlternateScreenAction } : {}),
};
}
function stashPendingInterruptOutputMeta(session, meta) {
if (!session || !meta) return;
session._pendingInterruptOutputMeta = mergeInterruptOutputMeta(
session._pendingInterruptOutputMeta,
meta,
);
}
function takePendingInterruptOutputMeta(session, meta) {
if (!session) return meta;
const pending = session._pendingInterruptOutputMeta;
delete session._pendingInterruptOutputMeta;
return mergeInterruptOutputMeta(pending, meta);
}
function clearPendingInterruptOutputMeta(session) {
if (session) {
delete session._pendingInterruptOutputMeta;
}
}
function isPasswordPrefixPending(pending) {
return Boolean(pending) && isProbablePasswordPromptPrefix(pending);
}
function getLastVisibleLine(text) {
const normalized = stripAnsi(String(text || "")).replace(/\r/g, "\n");
const lastLineStart = normalized.lastIndexOf("\n") + 1;
return normalized.slice(lastLineStart).trimEnd();
}
/**
* A held "Pass" / "[sudo] pass" must only survive when the next chunk continues
* or completes a password prompt. Otherwise discard it before the generic
* shell-prompt matcher can accept junk like "Pass$ " (#2010 Codex follow-up).
*/
function resolveHeldPasswordPrefix(pending, text) {
if (!isPasswordPrefixPending(pending)) {
return { pending, text, droppedPendingBytes: 0 };
}
// Held prefixes must continue on the same line. A leading line break means
// the next chunk is a fresh line (e.g. "Pass" then "\nPassword: "), not a
// completion of the held prefix — discard so quiet-gap still applies.
if (/^[\r\n]/.test(text)) {
return {
pending: "",
text,
droppedPendingBytes: byteLength(pending),
};
}
const combined = `${pending}${text}`;
const lastLine = getLastVisibleLine(combined);
if (isCompletePasswordPrompt(lastLine) || isProbablePasswordPromptPrefix(lastLine)) {
return { pending: "", text: combined, droppedPendingBytes: 0 };
}
const consumed = consumeTrailingCsiCompletion(pending, text);
return {
pending: "",
text: consumed.text,
droppedPendingBytes: byteLength(pending) + consumed.extraDroppedBytes,
};
}
function filterTerminalInterruptOutput(session, data, options = {}) {
const gate = session?._interruptOutputGate;
const incomingText = String(data || "");
if (!gate?.active) {
return { accepted: true, data: incomingText, droppedBytes: 0, reason: "inactive" };
}
const now = nowFromOptions(options);
const rawPendingDisplayControl = takePendingDisplayControl(gate);
const hadHeldPasswordPrefix = isPasswordPrefixPending(rawPendingDisplayControl);
const resolvedPasswordPrefix = resolveHeldPasswordPrefix(
rawPendingDisplayControl,
incomingText,
);
const prefixDropBytes = resolvedPasswordPrefix.droppedPendingBytes;
// Only treat the held prefix as continued when it was merged into this chunk
// (not discarded across a line break / non-prompt continuation).
const heldPasswordPrefixContinued = (
hadHeldPasswordPrefix
&& prefixDropBytes === 0
&& resolvedPasswordPrefix.pending === ""
);
if (prefixDropBytes > 0) {
gate.droppedBytes += prefixDropBytes;
gate.droppedChunks += 1;
}
const pendingDisplayControl = resolvedPasswordPrefix.pending;
const text = resolvedPasswordPrefix.text;
const combinedText = `${pendingDisplayControl}${text}`;
const bytes = byteLength(combinedText);
const quietGapMs = gate.lastDroppedAt > 0 ? now - gate.lastDroppedAt : 0;
const withPrefixDrop = (droppedBytes) => droppedBytes + prefixDropBytes;
if (gate.pendingInterruptCaret) {
gate.pendingInterruptCaret = false;
if (text.startsWith("C")) {
const restoreControls = extractTerminalStateRestoreControls(pendingDisplayControl);
const droppedBytes = restoreControls.droppedBytes;
gate.droppedBytes += droppedBytes;
gate.droppedChunks += droppedBytes > 0 ? 1 : 0;
disarmTerminalInterruptOutputGate(session);
return {
accepted: true,
data: `${restoreControls.preserved}^${text}`,
droppedBytes: withPrefixDrop(droppedBytes),
reason: "interrupt-echo",
};
}
}
const interruptEchoIndex = combinedText.indexOf("^C");
if (interruptEchoIndex >= 0) {
const droppedPrefix = combinedText.slice(0, interruptEchoIndex);
const restoreControls = extractTerminalStateRestoreControls(droppedPrefix);
const droppedBytes = restoreControls.droppedBytes;
gate.droppedBytes += droppedBytes;
gate.droppedChunks += droppedBytes > 0 ? 1 : 0;
disarmTerminalInterruptOutputGate(session);
return {
accepted: true,
data: `${restoreControls.preserved}${combinedText.slice(interruptEchoIndex)}`,
droppedBytes: withPrefixDrop(droppedBytes),
reason: "interrupt-echo",
};
}
// Complete password prompts resume immediately — including one-chunk prompts
// before promptQuietMs, held-prefix completions, and last-line prompts that
// arrive after a large stale prefix. Unlike shell prompts, password prompts
// often emit nothing further until the user types; dropping them leaves a
// blank terminal while the remote waits (#2010). Detect the last-line
// password candidate independently of the whole-chunk promptCandidateBytes
// cap used for ordinary shell prompts.
{
const passwordPromptCandidate = getPromptCandidateSuffix(combinedText);
if (
passwordPromptCandidate
&& isCompletePasswordPrompt(stripAnsi(passwordPromptCandidate))
) {
const droppedPrefix = combinedText.slice(
0,
combinedText.length - passwordPromptCandidate.length,
);
const restoreControls = extractTerminalStateRestoreControls(droppedPrefix);
const droppedBytes = restoreControls.droppedBytes;
gate.droppedBytes += droppedBytes;
gate.droppedChunks += droppedBytes > 0 ? 1 : 0;
disarmTerminalInterruptOutputGate(session);
return {
accepted: true,
data: `${restoreControls.preserved}${passwordPromptCandidate}`,
droppedBytes: withPrefixDrop(droppedBytes),
reason: heldPasswordPrefixContinued ? "prompt-gap" : "password-prompt",
};
}
}
if (gate.droppedBytes === 0 && bytes <= gate.promptCandidateBytes) {
const promptCandidate = getPromptCandidateSuffix(combinedText);
if (promptCandidate) {
const droppedPrefix = combinedText.slice(0, combinedText.length - promptCandidate.length);
const restoreControls = extractTerminalStateRestoreControls(droppedPrefix);
const droppedBytes = restoreControls.droppedBytes;
gate.droppedBytes += droppedBytes;
gate.droppedChunks += droppedBytes > 0 ? 1 : 0;
disarmTerminalInterruptOutputGate(session);
return {
accepted: true,
data: `${restoreControls.preserved}${promptCandidate}`,
droppedBytes: withPrefixDrop(droppedBytes),
reason: "prompt-candidate",
};
}
}
if (quietGapMs >= gate.promptQuietMs && bytes <= gate.promptCandidateBytes) {
const promptCandidate = getPromptCandidateSuffix(combinedText);
if (promptCandidate) {
const droppedPrefix = combinedText.slice(0, combinedText.length - promptCandidate.length);
const restoreControls = extractTerminalStateRestoreControls(droppedPrefix);
const droppedBytes = restoreControls.droppedBytes;
gate.droppedBytes += droppedBytes;
gate.droppedChunks += droppedBytes > 0 ? 1 : 0;
disarmTerminalInterruptOutputGate(session);
return {
accepted: true,
data: `${restoreControls.preserved}${promptCandidate}`,
droppedBytes: withPrefixDrop(droppedBytes),
reason: "prompt-gap",
};
}
}
if (quietGapMs >= gate.quietMs) {
const accepted = finalizeAcceptedTextAfterPendingDisplayControl(pendingDisplayControl, text);
gate.droppedBytes += accepted.droppedBytes;
gate.droppedChunks += accepted.droppedBytes > 0 ? 1 : 0;
disarmTerminalInterruptOutputGate(session);
return {
accepted: true,
data: accepted.data,
droppedBytes: withPrefixDrop(accepted.droppedBytes),
reason: "quiet-gap",
};
}
if (now - gate.startedAt >= gate.maxDrainMs) {
const accepted = finalizeAcceptedTextAfterPendingDisplayControl(pendingDisplayControl, text);
gate.droppedBytes += accepted.droppedBytes;
gate.droppedChunks += accepted.droppedBytes > 0 ? 1 : 0;
disarmTerminalInterruptOutputGate(session);
return {
accepted: true,
data: accepted.data,
droppedBytes: withPrefixDrop(accepted.droppedBytes),
reason: "max-drain",
};
}
const restoreControls = extractDrainHold(combinedText, {
holdTrailingPartial: true,
});
const droppedBytes = restoreControls.droppedBytes;
gate.pendingDisplayControl = restoreControls.pending;
gate.pendingInterruptCaret = text.endsWith("^");
gate.lastDroppedAt = now;
gate.droppedBytes += droppedBytes;
gate.droppedChunks += droppedBytes > 0 ? 1 : 0;
if (restoreControls.preserved) {
return {
accepted: true,
data: restoreControls.preserved,
droppedBytes: withPrefixDrop(droppedBytes),
reason: "draining",
};
}
return {
accepted: false,
data: "",
droppedBytes: withPrefixDrop(droppedBytes),
reason: "draining",
};
}
module.exports = {
armTerminalInterruptOutputGate,
clearPendingInterruptOutputMeta,
disarmTerminalInterruptOutputGate,
filterTerminalInterruptOutput,
shouldArmTerminalInterruptOutputGate,
stashPendingInterruptOutputMeta,
takePendingInterruptOutputMeta,
};