936 lines
32 KiB
JavaScript
936 lines
32 KiB
JavaScript
|
|
"use strict";
|
||
|
|
|
||
|
|
const { shellPromptPatterns } = require("./shellPromptPatterns.cjs");
|
||
|
|
|
||
|
|
const DEFAULT_BUFFER_SIZE = 1024 * 1024;
|
||
|
|
/** Matches within this many bytes of buffer end count as live terminal output. */
|
||
|
|
const FRESH_MATCH_TAIL_SLACK = 512;
|
||
|
|
|
||
|
|
function isFreshTailMatch(textLength, matchEndAbsolute) {
|
||
|
|
return matchEndAbsolute >= textLength - FRESH_MATCH_TAIL_SLACK;
|
||
|
|
}
|
||
|
|
|
||
|
|
function stripTrailingBlankLines(text) {
|
||
|
|
return String(text || "").replace(/(?:[ \t]*\r?\n)*$/u, "");
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Drop any prefix of trailingFresh that the viewport snapshot already shows.
|
||
|
|
* Handles blank-padded full-viewport snapshots and partial overlaps where the
|
||
|
|
* snapshot captured only the start of the sync-race bytes.
|
||
|
|
*
|
||
|
|
* `syncStartText` is the live buffer at snapshot-request time. When the
|
||
|
|
* viewport still matches that pre-sync content, trailingFresh is genuinely new
|
||
|
|
* even if it happens to equal the visible suffix (e.g. a second READY).
|
||
|
|
*/
|
||
|
|
function trimOverlappingTrailingFresh(viewportText, trailingFresh, syncStartText = "") {
|
||
|
|
const trailing = String(trailingFresh || "");
|
||
|
|
if (!trailing) return "";
|
||
|
|
const viewport = String(viewportText || "");
|
||
|
|
const viewportCore = stripTrailingBlankLines(viewport);
|
||
|
|
const trailingCore = stripTrailingBlankLines(trailing);
|
||
|
|
const syncCore = stripTrailingBlankLines(syncStartText);
|
||
|
|
|
||
|
|
// Stale snapshot: still showing pre-sync content → keep all trailingFresh.
|
||
|
|
// Exact match covers a second identical marker while the snapshot IPC is in
|
||
|
|
// flight. Proper-suffix match covers scrollback-backed buffers where the
|
||
|
|
// visible viewport is only the tail of syncStartText (e.g. banner\nREADY vs
|
||
|
|
// READY) so equality alone would miss the stale case and trim a real duplicate.
|
||
|
|
if (
|
||
|
|
syncCore
|
||
|
|
&& viewportCore
|
||
|
|
&& (
|
||
|
|
viewportCore === syncCore
|
||
|
|
|| (syncCore.length > viewportCore.length && syncCore.endsWith(viewportCore))
|
||
|
|
)
|
||
|
|
) {
|
||
|
|
return trailing;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (
|
||
|
|
viewport.endsWith(trailing)
|
||
|
|
|| (trailingCore && viewportCore.endsWith(trailingCore))
|
||
|
|
) {
|
||
|
|
return "";
|
||
|
|
}
|
||
|
|
|
||
|
|
// Partial overlap: prefer the unpadded core so blank-padded snapshots still
|
||
|
|
// trim, then fall back to the full viewport for newline-accurate matches.
|
||
|
|
const candidates = [viewportCore, viewport].filter(Boolean);
|
||
|
|
for (const candidate of candidates) {
|
||
|
|
const max = Math.min(candidate.length, trailing.length);
|
||
|
|
for (let len = max; len > 0; len -= 1) {
|
||
|
|
if (!candidate.endsWith(trailing.slice(0, len))) continue;
|
||
|
|
let remainder = trailing.slice(len);
|
||
|
|
// Avoid turning blank padding + overlapped newline into an extra blank.
|
||
|
|
while (remainder.startsWith("\n") && viewport.endsWith("\n")) {
|
||
|
|
remainder = remainder.slice(1);
|
||
|
|
}
|
||
|
|
return remainder;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return trailing;
|
||
|
|
}
|
||
|
|
|
||
|
|
function isRegExpLike(pattern) {
|
||
|
|
return Boolean(
|
||
|
|
pattern
|
||
|
|
&& typeof pattern === "object"
|
||
|
|
&& typeof pattern.exec === "function"
|
||
|
|
&& typeof pattern.test === "function",
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function escapeRegExp(value) {
|
||
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
|
|
}
|
||
|
|
|
||
|
|
function hasUnescapedCharAt(source, index, char) {
|
||
|
|
if (source[index] !== char) return false;
|
||
|
|
let backslashCount = 0;
|
||
|
|
for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1) {
|
||
|
|
backslashCount += 1;
|
||
|
|
}
|
||
|
|
return backslashCount % 2 === 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
function isRegexCompatibleWaitPattern(pattern) {
|
||
|
|
return pattern instanceof RegExp || isRegExpLike(pattern);
|
||
|
|
}
|
||
|
|
|
||
|
|
function edgeDotRepeatTokenLengthAt(source, index) {
|
||
|
|
if (source[index] !== ".") return 0;
|
||
|
|
const quantifier = source[index + 1];
|
||
|
|
if (quantifier !== "*" && quantifier !== "+") return 0;
|
||
|
|
if (!hasUnescapedCharAt(source, index, ".")) return 0;
|
||
|
|
return source[index + 2] === "?" ? 3 : 2;
|
||
|
|
}
|
||
|
|
|
||
|
|
function stripEdgeDotRepeats(source) {
|
||
|
|
let start = 0;
|
||
|
|
let end = source.length;
|
||
|
|
if (hasUnescapedCharAt(source, start, "^")) {
|
||
|
|
start += 1;
|
||
|
|
}
|
||
|
|
if (end > start && hasUnescapedCharAt(source, end - 1, "$")) {
|
||
|
|
end -= 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
while (start < end) {
|
||
|
|
const tokenLength = edgeDotRepeatTokenLengthAt(source, start);
|
||
|
|
if (tokenLength === 0) break;
|
||
|
|
start += tokenLength;
|
||
|
|
}
|
||
|
|
while (end - start >= 2) {
|
||
|
|
const lazyTokenLength = edgeDotRepeatTokenLengthAt(source, end - 3);
|
||
|
|
if (lazyTokenLength === 3) {
|
||
|
|
end -= 3;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const greedyTokenLength = edgeDotRepeatTokenLengthAt(source, end - 2);
|
||
|
|
if (greedyTokenLength !== 2) break;
|
||
|
|
end -= 2;
|
||
|
|
}
|
||
|
|
return source.slice(start, end);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getRegExpFlags(regex, fallbackFlags = "") {
|
||
|
|
if (typeof regex.flags === "string") return regex.flags;
|
||
|
|
let flags = fallbackFlags;
|
||
|
|
if (regex.global && !flags.includes("g")) flags += "g";
|
||
|
|
if (regex.ignoreCase && !flags.includes("i")) flags += "i";
|
||
|
|
if (regex.multiline && !flags.includes("m")) flags += "m";
|
||
|
|
if (regex.dotAll && !flags.includes("s")) flags += "s";
|
||
|
|
if (regex.unicode && !flags.includes("u")) flags += "u";
|
||
|
|
if (regex.sticky && !flags.includes("y")) flags += "y";
|
||
|
|
return flags;
|
||
|
|
}
|
||
|
|
|
||
|
|
function compilePattern(pattern) {
|
||
|
|
if (pattern instanceof RegExp || isRegExpLike(pattern)) return pattern;
|
||
|
|
if (typeof pattern !== "string") {
|
||
|
|
throw new TypeError("waitFor pattern must be a string or RegExp");
|
||
|
|
}
|
||
|
|
const slashMatch = pattern.match(/^\/(.+)\/([gimsuy]*)$/);
|
||
|
|
if (slashMatch) {
|
||
|
|
return new RegExp(slashMatch[1], slashMatch[2]);
|
||
|
|
}
|
||
|
|
return new RegExp(escapeRegExp(pattern));
|
||
|
|
}
|
||
|
|
|
||
|
|
function compileTextPattern(text) {
|
||
|
|
if (typeof text !== "string") {
|
||
|
|
throw new TypeError("waitForText pattern must be a string");
|
||
|
|
}
|
||
|
|
return new RegExp(escapeRegExp(text));
|
||
|
|
}
|
||
|
|
|
||
|
|
function compileRegexPattern(pattern) {
|
||
|
|
if (pattern instanceof RegExp || isRegExpLike(pattern)) return pattern;
|
||
|
|
if (typeof pattern !== "string") {
|
||
|
|
throw new TypeError("waitForRegex pattern must be a string or RegExp");
|
||
|
|
}
|
||
|
|
const slashMatch = pattern.match(/^\/(.+)\/([gimsuy]*)$/);
|
||
|
|
if (slashMatch) {
|
||
|
|
const flags = slashMatch[2].includes("s") ? slashMatch[2] : `${slashMatch[2]}s`;
|
||
|
|
return new RegExp(slashMatch[1], flags);
|
||
|
|
}
|
||
|
|
return new RegExp(pattern, "s");
|
||
|
|
}
|
||
|
|
|
||
|
|
function compileRegexFreshnessPattern(pattern) {
|
||
|
|
const regex = compileRegexPattern(pattern);
|
||
|
|
const source = typeof regex.source === "string" ? regex.source : String(pattern);
|
||
|
|
const strippedSource = stripEdgeDotRepeats(source);
|
||
|
|
if (!strippedSource || strippedSource === source) return null;
|
||
|
|
const flags = getRegExpFlags(regex).replace(/y/g, "");
|
||
|
|
const globalFlags = flags.includes("g") ? flags : `${flags}g`;
|
||
|
|
try {
|
||
|
|
return new RegExp(strippedSource, globalFlags);
|
||
|
|
} catch {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function tryMatch(text, pattern) {
|
||
|
|
const regex = compilePattern(pattern);
|
||
|
|
const match = regex.exec(text);
|
||
|
|
if (!match) return null;
|
||
|
|
return match[0];
|
||
|
|
}
|
||
|
|
|
||
|
|
function tryMatchWithEnd(text, pattern, compiler = compilePattern) {
|
||
|
|
const regex = compiler(pattern);
|
||
|
|
if (typeof regex.lastIndex === "number") regex.lastIndex = 0;
|
||
|
|
const match = regex.exec(text);
|
||
|
|
if (!match || match.index === undefined) return null;
|
||
|
|
return {
|
||
|
|
value: match[0],
|
||
|
|
endOffset: match.index + match[0].length,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function tryRegexMatchWithEnd(text, pattern) {
|
||
|
|
const regex = compileRegexPattern(pattern);
|
||
|
|
if (typeof regex.lastIndex === "number") regex.lastIndex = 0;
|
||
|
|
const match = regex.exec(text);
|
||
|
|
if (!match || match.index === undefined) return null;
|
||
|
|
|
||
|
|
const value = match[0];
|
||
|
|
let freshStartOffset = match.index;
|
||
|
|
let freshEndOffset = match.index + value.length;
|
||
|
|
const freshnessRegex = compileRegexFreshnessPattern(pattern);
|
||
|
|
if (freshnessRegex) {
|
||
|
|
if (typeof freshnessRegex.lastIndex === "number") freshnessRegex.lastIndex = 0;
|
||
|
|
let freshMatch = freshnessRegex.exec(value);
|
||
|
|
let latestFreshStartOffset = null;
|
||
|
|
let latestFreshEndOffset = null;
|
||
|
|
while (freshMatch && freshMatch.index !== undefined) {
|
||
|
|
const startOffset = freshMatch.index;
|
||
|
|
const endOffset = freshMatch.index + freshMatch[0].length;
|
||
|
|
if (latestFreshEndOffset === null || endOffset >= latestFreshEndOffset) {
|
||
|
|
latestFreshStartOffset = startOffset;
|
||
|
|
}
|
||
|
|
latestFreshEndOffset = Math.max(latestFreshEndOffset ?? 0, endOffset);
|
||
|
|
if (freshMatch[0].length === 0) {
|
||
|
|
freshnessRegex.lastIndex += 1;
|
||
|
|
}
|
||
|
|
freshMatch = freshnessRegex.exec(value);
|
||
|
|
}
|
||
|
|
if (latestFreshEndOffset !== null) {
|
||
|
|
freshStartOffset = match.index + latestFreshStartOffset;
|
||
|
|
freshEndOffset = match.index + latestFreshEndOffset;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
value,
|
||
|
|
startOffset: match.index,
|
||
|
|
endOffset: match.index + value.length,
|
||
|
|
freshStartOffset,
|
||
|
|
freshEndOffset,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function staleAdvanceEndOffset(matched) {
|
||
|
|
return Math.max(Number(matched?.endOffset) || 0, 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
function staleRegexAdvanceEndOffset(matched) {
|
||
|
|
return Math.max((Number(matched?.startOffset) || 0) + 1, 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
function findFreshTailMatchAny(text, patterns) {
|
||
|
|
for (let index = 0; index < patterns.length; index += 1) {
|
||
|
|
const pattern = patterns[index];
|
||
|
|
let offset = 0;
|
||
|
|
while (offset <= text.length) {
|
||
|
|
const matched = tryMatchWithEnd(text.slice(offset), pattern);
|
||
|
|
if (matched === null) break;
|
||
|
|
const absoluteEnd = offset + matched.endOffset;
|
||
|
|
if (isFreshTailMatch(text.length, absoluteEnd)) {
|
||
|
|
return {
|
||
|
|
index,
|
||
|
|
matched: {
|
||
|
|
value: matched.value,
|
||
|
|
endOffset: absoluteEnd,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
offset = Math.max(absoluteEnd, offset + 1);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function findMatchingPreservedTailMatch(text, patterns, preserved) {
|
||
|
|
for (let index = 0; index < patterns.length; index += 1) {
|
||
|
|
const pattern = patterns[index];
|
||
|
|
let offset = 0;
|
||
|
|
while (offset <= text.length) {
|
||
|
|
const matched = tryMatchWithEnd(text.slice(offset), pattern);
|
||
|
|
if (matched === null) break;
|
||
|
|
const absoluteEnd = offset + matched.endOffset;
|
||
|
|
if (absoluteEnd === preserved.endOffset && matched.value === preserved.value) {
|
||
|
|
return {
|
||
|
|
index,
|
||
|
|
matched: {
|
||
|
|
value: matched.value,
|
||
|
|
endOffset: absoluteEnd,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
offset = Math.max(absoluteEnd, offset + 1);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
class SessionOutputBuffer {
|
||
|
|
constructor(sessionId, maxSize = DEFAULT_BUFFER_SIZE) {
|
||
|
|
this.sessionId = sessionId;
|
||
|
|
this.maxSize = maxSize;
|
||
|
|
this.chunks = [];
|
||
|
|
this.totalLength = 0;
|
||
|
|
this.scanOffset = 0;
|
||
|
|
this.waiters = [];
|
||
|
|
this.preservedTailMatch = null;
|
||
|
|
/** @type {number | null} Absolute end of a seeded visible viewport that stays fully waitable. */
|
||
|
|
this.seededLength = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
append(data) {
|
||
|
|
if (!data) return;
|
||
|
|
this.preservedTailMatch = null;
|
||
|
|
this.chunks.push(String(data));
|
||
|
|
this.totalLength += this.chunks[this.chunks.length - 1].length;
|
||
|
|
while (this.totalLength > this.maxSize && this.chunks.length > 1) {
|
||
|
|
const removed = this.chunks.shift();
|
||
|
|
const removedLength = removed.length;
|
||
|
|
this.totalLength -= removedLength;
|
||
|
|
this.scanOffset = Math.max(0, this.scanOffset - removedLength);
|
||
|
|
if (typeof this.seededLength === "number") {
|
||
|
|
this.seededLength = Math.max(0, this.seededLength - removedLength);
|
||
|
|
}
|
||
|
|
for (const waiter of this.waiters) {
|
||
|
|
if (typeof waiter.freshBoundary === "number") {
|
||
|
|
waiter.freshBoundary = Math.max(0, waiter.freshBoundary - removedLength);
|
||
|
|
}
|
||
|
|
if (waiter.custom && typeof waiter.custom.freshBoundary === "number") {
|
||
|
|
waiter.custom.freshBoundary = Math.max(0, waiter.custom.freshBoundary - removedLength);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
this.flushWaiters();
|
||
|
|
}
|
||
|
|
|
||
|
|
getText() {
|
||
|
|
return this.chunks.join("");
|
||
|
|
}
|
||
|
|
|
||
|
|
getPendingText() {
|
||
|
|
return this.getText().slice(this.scanOffset);
|
||
|
|
}
|
||
|
|
|
||
|
|
tryMatchPending(pattern) {
|
||
|
|
return tryMatchWithEnd(this.getPendingText(), pattern);
|
||
|
|
}
|
||
|
|
|
||
|
|
tryMatchPendingText(text) {
|
||
|
|
return tryMatchWithEnd(this.getPendingText(), text, compileTextPattern);
|
||
|
|
}
|
||
|
|
|
||
|
|
tryMatchPendingRegex(pattern) {
|
||
|
|
return tryRegexMatchWithEnd(this.getPendingText(), pattern);
|
||
|
|
}
|
||
|
|
|
||
|
|
currentFreshBoundary() {
|
||
|
|
const textLength = this.getText().length;
|
||
|
|
const normalBoundary = Math.max(this.scanOffset, textLength - FRESH_MATCH_TAIL_SLACK);
|
||
|
|
if (typeof this.seededLength === "number" && this.scanOffset < this.seededLength) {
|
||
|
|
// Startup viewport rows must all be waitable for waitFor / waitForText /
|
||
|
|
// waitForRegex, even when the visible screen is longer than
|
||
|
|
// FRESH_MATCH_TAIL_SLACK (bastion menus, etc.).
|
||
|
|
return this.scanOffset;
|
||
|
|
}
|
||
|
|
if (typeof this.seededLength === "number" && this.scanOffset >= this.seededLength) {
|
||
|
|
this.seededLength = null;
|
||
|
|
}
|
||
|
|
return normalBoundary;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Freshness for waitForPrompt (allowPreservedTailMatch): live tail only, and
|
||
|
|
* never rematch prompts that only exist inside the still-unconsumed seeded
|
||
|
|
* viewport. After live output clears preservedTailMatch, a short seed like
|
||
|
|
* `root# ` must not satisfy waitForPrompt via the normal 512-byte window.
|
||
|
|
* Generic waitForAny uses currentFreshBoundary instead.
|
||
|
|
*/
|
||
|
|
currentTailFreshBoundary() {
|
||
|
|
const textLength = this.getText().length;
|
||
|
|
let boundary = Math.max(this.scanOffset, textLength - FRESH_MATCH_TAIL_SLACK);
|
||
|
|
if (typeof this.seededLength === "number") {
|
||
|
|
if (this.scanOffset >= this.seededLength) {
|
||
|
|
this.seededLength = null;
|
||
|
|
} else {
|
||
|
|
boundary = Math.max(boundary, this.seededLength);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return boundary;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Replace buffer contents with the current visible terminal screen.
|
||
|
|
* The entire seeded viewport is treated as fresh for waitFor / waitForText /
|
||
|
|
* waitForRegex / generic waitForAny. waitForPrompt still uses the live tail
|
||
|
|
* window. `trailingFresh` (bytes that arrived during snapshot sync) stays
|
||
|
|
* outside seededLength so consuming a startup prompt does not discard it.
|
||
|
|
*/
|
||
|
|
replaceWithVisibleScreen(screenText, trailingFresh = "", syncStartText = "") {
|
||
|
|
const normalized = String(screenText || "").endsWith("\n")
|
||
|
|
? String(screenText || "")
|
||
|
|
: `${String(screenText || "")}\n`;
|
||
|
|
// Snapshot and live taps can both observe the same suffix. Full-viewport
|
||
|
|
// snapshots often pad with blank rows, and the snapshot may only include a
|
||
|
|
// prefix of trailingFresh — trim any overlapping prefix before appending.
|
||
|
|
const trailing = trimOverlappingTrailingFresh(normalized, trailingFresh, syncStartText);
|
||
|
|
this.clear();
|
||
|
|
this.append(normalized);
|
||
|
|
// Seed only the visible viewport — not sync-race trailing bytes.
|
||
|
|
this.seededLength = this.getText().length;
|
||
|
|
this.scanOffset = 0;
|
||
|
|
// Re-open pending waiters onto the seeded viewport (#1960).
|
||
|
|
for (const waiter of this.waiters) {
|
||
|
|
if (typeof waiter.freshBoundary === "number") {
|
||
|
|
waiter.freshBoundary = 0;
|
||
|
|
}
|
||
|
|
if (waiter.custom && typeof waiter.custom.freshBoundary === "number") {
|
||
|
|
waiter.custom.freshBoundary = 0;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// Preserve a live-tail shell prompt from the viewport for waitForPrompt,
|
||
|
|
// matching the old markOutputConsumedThrough(preserveTailPatterns) behavior.
|
||
|
|
this.preservedTailMatch = null;
|
||
|
|
const viewportText = this.getText();
|
||
|
|
const fresh = findFreshTailMatchAny(viewportText, shellPromptPatterns());
|
||
|
|
if (trailing) {
|
||
|
|
this.append(trailing);
|
||
|
|
}
|
||
|
|
if (fresh !== null) {
|
||
|
|
this.preservedTailMatch = {
|
||
|
|
textLength: this.getText().length,
|
||
|
|
value: fresh.matched.value,
|
||
|
|
endOffset: fresh.matched.endOffset,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
this.flushWaiters();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* After the script sends automated input, startup snapshot content must not
|
||
|
|
* satisfy later waits (sendLine then waitForPrompt / waitForText). Consume
|
||
|
|
* everything currently buffered — including sync-race trailingFresh that
|
||
|
|
* arrived before the input — so waits require post-command output.
|
||
|
|
*/
|
||
|
|
invalidateStartupSeed() {
|
||
|
|
this.seededLength = null;
|
||
|
|
this.preservedTailMatch = null;
|
||
|
|
this.scanOffset = this.getText().length;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Mark output through `absoluteLength` as already seen, without consuming
|
||
|
|
* anything that arrived after that point. Used by sendLine so peer prompts
|
||
|
|
* that land between body and CR stay waitable (#1960).
|
||
|
|
*/
|
||
|
|
consumeThroughAbsolute(absoluteLength) {
|
||
|
|
this.seededLength = null;
|
||
|
|
this.preservedTailMatch = null;
|
||
|
|
const capped = Math.max(0, Math.min(this.getText().length, Number(absoluteLength) || 0));
|
||
|
|
this.scanOffset = Math.max(this.scanOffset, capped);
|
||
|
|
}
|
||
|
|
|
||
|
|
consumeFreshPendingMatch(pattern, freshBoundary = this.currentFreshBoundary()) {
|
||
|
|
while (true) {
|
||
|
|
const matched = this.tryMatchPending(pattern);
|
||
|
|
if (matched === null) return null;
|
||
|
|
const absoluteEnd = this.scanOffset + matched.endOffset;
|
||
|
|
if (absoluteEnd >= freshBoundary) {
|
||
|
|
return matched;
|
||
|
|
}
|
||
|
|
this.advanceScanOffset(staleAdvanceEndOffset(matched));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
consumeFreshPendingText(text, freshBoundary = this.currentFreshBoundary()) {
|
||
|
|
while (true) {
|
||
|
|
const matched = this.tryMatchPendingText(text);
|
||
|
|
if (matched === null) return null;
|
||
|
|
const absoluteEnd = this.scanOffset + matched.endOffset;
|
||
|
|
if (absoluteEnd >= freshBoundary) {
|
||
|
|
return matched;
|
||
|
|
}
|
||
|
|
this.advanceScanOffset(staleAdvanceEndOffset(matched));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
consumeFreshPendingRegex(pattern, options = {}) {
|
||
|
|
const fallbackBoundary = this.currentFreshBoundary();
|
||
|
|
const text = this.getText();
|
||
|
|
const baseOffset = this.scanOffset;
|
||
|
|
const pendingText = text.slice(baseOffset);
|
||
|
|
let relativeOffset = 0;
|
||
|
|
while (relativeOffset <= pendingText.length) {
|
||
|
|
const matched = tryRegexMatchWithEnd(pendingText.slice(relativeOffset), pattern);
|
||
|
|
if (matched === null) {
|
||
|
|
if (relativeOffset > 0) {
|
||
|
|
this.scanOffset = Math.min(baseOffset + relativeOffset, text.length);
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const minFreshStartAbsolute = Number.isFinite(options.minFreshStartAbsolute)
|
||
|
|
? options.minFreshStartAbsolute
|
||
|
|
: null;
|
||
|
|
const absoluteStart = baseOffset + relativeOffset + matched.freshStartOffset;
|
||
|
|
const matchStartAbsolute = baseOffset + relativeOffset + matched.startOffset;
|
||
|
|
const freshBoundary = minFreshStartAbsolute === null ? fallbackBoundary : minFreshStartAbsolute;
|
||
|
|
if (absoluteStart >= freshBoundary) {
|
||
|
|
let value = matched.value;
|
||
|
|
if (minFreshStartAbsolute !== null && matchStartAbsolute < minFreshStartAbsolute) {
|
||
|
|
const valueFreshStart = matched.freshStartOffset - matched.startOffset;
|
||
|
|
const lineStart = matched.value.lastIndexOf("\n", Math.max(0, valueFreshStart - 1));
|
||
|
|
const valueStart = lineStart >= 0 ? lineStart : Math.max(0, valueFreshStart);
|
||
|
|
value = matched.value.slice(valueStart);
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
...matched,
|
||
|
|
value,
|
||
|
|
endOffset: relativeOffset + matched.endOffset,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
relativeOffset += staleRegexAdvanceEndOffset(matched);
|
||
|
|
}
|
||
|
|
this.scanOffset = Math.min(baseOffset + relativeOffset, text.length);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
consumeFreshPendingMatchAny(patterns, freshBoundary = this.currentFreshBoundary()) {
|
||
|
|
for (let index = 0; index < patterns.length; index += 1) {
|
||
|
|
const pattern = patterns[index];
|
||
|
|
while (true) {
|
||
|
|
const matched = this.tryMatchPending(pattern);
|
||
|
|
if (matched === null) break;
|
||
|
|
const absoluteEnd = this.scanOffset + matched.endOffset;
|
||
|
|
if (absoluteEnd >= freshBoundary) {
|
||
|
|
return { index, matched };
|
||
|
|
}
|
||
|
|
this.advanceScanOffset(staleAdvanceEndOffset(matched));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
advanceScanOffset(endOffset) {
|
||
|
|
const absoluteEnd = this.scanOffset + endOffset;
|
||
|
|
this.scanOffset = Math.min(absoluteEnd, this.getText().length);
|
||
|
|
// Normal waits that consume past a preserved startup prompt invalidate it
|
||
|
|
// (e.g. waitForText matched trailingFresh after the prompt). Baseline via
|
||
|
|
// markOutputConsumedThrough sets scanOffset directly and keeps the prompt.
|
||
|
|
if (this.preservedTailMatch && this.scanOffset > this.preservedTailMatch.endOffset) {
|
||
|
|
this.preservedTailMatch = null;
|
||
|
|
}
|
||
|
|
if (typeof this.seededLength === "number" && this.scanOffset >= this.seededLength) {
|
||
|
|
this.seededLength = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
markCurrentOutputConsumed(options = {}) {
|
||
|
|
this.markOutputConsumedThrough(this.getText().length, options);
|
||
|
|
}
|
||
|
|
|
||
|
|
markOutputConsumedThrough(length, options = {}) {
|
||
|
|
const text = this.getText();
|
||
|
|
const consumedLength = Math.max(0, Math.min(Number(length) || 0, text.length));
|
||
|
|
const consumedText = text.slice(0, consumedLength);
|
||
|
|
this.scanOffset = consumedLength;
|
||
|
|
this.preservedTailMatch = null;
|
||
|
|
if (typeof this.seededLength === "number" && this.scanOffset >= this.seededLength) {
|
||
|
|
this.seededLength = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const preserveTailPatterns = Array.isArray(options.preserveTailPatterns)
|
||
|
|
? options.preserveTailPatterns
|
||
|
|
: [];
|
||
|
|
if (preserveTailPatterns.length === 0 || consumedText.length === 0) return;
|
||
|
|
|
||
|
|
const fresh = findFreshTailMatchAny(consumedText, preserveTailPatterns);
|
||
|
|
if (fresh === null) return;
|
||
|
|
this.preservedTailMatch = {
|
||
|
|
textLength: consumedText.length,
|
||
|
|
value: fresh.matched.value,
|
||
|
|
endOffset: fresh.matched.endOffset,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
consumePreservedTailMatchAny(patterns) {
|
||
|
|
const preserved = this.preservedTailMatch;
|
||
|
|
if (!preserved) return null;
|
||
|
|
const text = this.getText();
|
||
|
|
if (text.length !== preserved.textLength) {
|
||
|
|
this.preservedTailMatch = null;
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const fresh = findMatchingPreservedTailMatch(text, patterns, preserved);
|
||
|
|
if (fresh === null) return null;
|
||
|
|
|
||
|
|
this.preservedTailMatch = null;
|
||
|
|
// Consuming the startup prompt must also consume the whole seeded viewport
|
||
|
|
// from that snapshot. Advancing only to the prompt end leaves later bytes
|
||
|
|
// from the same screen (e.g. `root@host:~# \nold READY`) waitable for the
|
||
|
|
// next waitForText/waitForRegex.
|
||
|
|
const consumeThrough = typeof this.seededLength === "number"
|
||
|
|
? Math.max(fresh.matched.endOffset, this.seededLength)
|
||
|
|
: Math.max(fresh.matched.endOffset, preserved.textLength);
|
||
|
|
this.scanOffset = Math.max(this.scanOffset, consumeThrough);
|
||
|
|
// Sync-race trailingFresh sits past the original seededLength. Keep it fully
|
||
|
|
// waitable even when longer than FRESH_MATCH_TAIL_SLACK; otherwise a marker
|
||
|
|
// near the start of a large burst (READY + long menu) times out after prompt.
|
||
|
|
const textLength = this.getText().length;
|
||
|
|
if (textLength > this.scanOffset) {
|
||
|
|
this.seededLength = textLength;
|
||
|
|
} else {
|
||
|
|
this.seededLength = null;
|
||
|
|
}
|
||
|
|
return fresh;
|
||
|
|
}
|
||
|
|
|
||
|
|
clear() {
|
||
|
|
this.chunks = [];
|
||
|
|
this.totalLength = 0;
|
||
|
|
this.scanOffset = 0;
|
||
|
|
this.preservedTailMatch = null;
|
||
|
|
this.seededLength = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
flushWaiters() {
|
||
|
|
if (this.waiters.length === 0) return;
|
||
|
|
const remaining = [];
|
||
|
|
for (const waiter of this.waiters) {
|
||
|
|
if (waiter.custom) {
|
||
|
|
if (!waiter.custom.check()) {
|
||
|
|
remaining.push(waiter);
|
||
|
|
}
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const matched = this.consumeFreshPendingMatch(
|
||
|
|
waiter.pattern,
|
||
|
|
waiter.freshBoundary ?? this.currentFreshBoundary(),
|
||
|
|
);
|
||
|
|
if (matched !== null) {
|
||
|
|
this.advanceScanOffset(matched.endOffset);
|
||
|
|
clearTimeout(waiter.timer);
|
||
|
|
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
|
||
|
|
waiter.resolve(matched.value);
|
||
|
|
} else {
|
||
|
|
remaining.push(waiter);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
this.waiters = remaining;
|
||
|
|
}
|
||
|
|
|
||
|
|
waitForWithMatcher({ pattern, timeoutMs, shouldAbort, consumeFreshMatch, timeoutLabel, freshBoundary }) {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const waiter = {
|
||
|
|
pattern,
|
||
|
|
freshBoundary,
|
||
|
|
resolve,
|
||
|
|
reject,
|
||
|
|
shouldAbort,
|
||
|
|
timer: null,
|
||
|
|
check: () => {
|
||
|
|
const matched = consumeFreshMatch(waiter.freshBoundary);
|
||
|
|
if (matched === null) return false;
|
||
|
|
this.advanceScanOffset(matched.endOffset);
|
||
|
|
clearTimeout(waiter.timer);
|
||
|
|
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
|
||
|
|
this.waiters = this.waiters.filter((entry) => entry.custom !== waiter);
|
||
|
|
resolve(matched.value);
|
||
|
|
return true;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
waiter.timer = setTimeout(() => {
|
||
|
|
this.waiters = this.waiters.filter((entry) => entry.custom !== waiter);
|
||
|
|
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
|
||
|
|
reject(new Error(`${timeoutLabel} timed out after ${timeoutMs}ms`));
|
||
|
|
}, timeoutMs);
|
||
|
|
if (typeof shouldAbort === "function") {
|
||
|
|
waiter.abortInterval = setInterval(() => {
|
||
|
|
if (!shouldAbort()) return;
|
||
|
|
clearTimeout(waiter.timer);
|
||
|
|
clearInterval(waiter.abortInterval);
|
||
|
|
this.waiters = this.waiters.filter((entry) => entry.custom !== waiter);
|
||
|
|
reject(new Error("Script stopped"));
|
||
|
|
}, 100);
|
||
|
|
}
|
||
|
|
this.waiters.push({
|
||
|
|
pattern,
|
||
|
|
resolve: () => {},
|
||
|
|
reject,
|
||
|
|
timer: waiter.timer,
|
||
|
|
custom: waiter,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
waitFor(pattern, timeoutMs = 30000, shouldAbort) {
|
||
|
|
if (isRegexCompatibleWaitPattern(pattern)) {
|
||
|
|
const minFreshStartAbsolute = this.currentFreshBoundary();
|
||
|
|
const immediate = this.consumeFreshPendingRegex(pattern, { minFreshStartAbsolute });
|
||
|
|
if (immediate !== null) {
|
||
|
|
this.advanceScanOffset(immediate.endOffset);
|
||
|
|
return Promise.resolve(immediate.value);
|
||
|
|
}
|
||
|
|
|
||
|
|
return this.waitForWithMatcher({
|
||
|
|
pattern,
|
||
|
|
timeoutMs,
|
||
|
|
shouldAbort,
|
||
|
|
freshBoundary: minFreshStartAbsolute,
|
||
|
|
consumeFreshMatch: (boundary) => this.consumeFreshPendingRegex(pattern, { minFreshStartAbsolute: boundary }),
|
||
|
|
timeoutLabel: "waitFor",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const freshBoundary = this.currentFreshBoundary();
|
||
|
|
const immediate = this.consumeFreshPendingMatch(pattern, freshBoundary);
|
||
|
|
if (immediate !== null) {
|
||
|
|
this.advanceScanOffset(immediate.endOffset);
|
||
|
|
return Promise.resolve(immediate.value);
|
||
|
|
}
|
||
|
|
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const waiter = {
|
||
|
|
pattern,
|
||
|
|
freshBoundary,
|
||
|
|
resolve,
|
||
|
|
reject,
|
||
|
|
shouldAbort,
|
||
|
|
timer: setTimeout(() => {
|
||
|
|
this.waiters = this.waiters.filter((entry) => entry !== waiter);
|
||
|
|
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
|
||
|
|
reject(new Error(`waitFor timed out after ${timeoutMs}ms`));
|
||
|
|
}, timeoutMs),
|
||
|
|
};
|
||
|
|
if (typeof shouldAbort === "function") {
|
||
|
|
waiter.abortInterval = setInterval(() => {
|
||
|
|
if (!shouldAbort()) return;
|
||
|
|
clearTimeout(waiter.timer);
|
||
|
|
clearInterval(waiter.abortInterval);
|
||
|
|
this.waiters = this.waiters.filter((entry) => entry !== waiter);
|
||
|
|
reject(new Error("Script stopped"));
|
||
|
|
}, 100);
|
||
|
|
}
|
||
|
|
this.waiters.push(waiter);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
waitForText(text, timeoutMs = 30000, shouldAbort) {
|
||
|
|
const freshBoundary = this.currentFreshBoundary();
|
||
|
|
const immediate = this.consumeFreshPendingText(text, freshBoundary);
|
||
|
|
if (immediate !== null) {
|
||
|
|
this.advanceScanOffset(immediate.endOffset);
|
||
|
|
return Promise.resolve(immediate.value);
|
||
|
|
}
|
||
|
|
|
||
|
|
return this.waitForWithMatcher({
|
||
|
|
pattern: text,
|
||
|
|
timeoutMs,
|
||
|
|
shouldAbort,
|
||
|
|
freshBoundary,
|
||
|
|
consumeFreshMatch: (boundary) => this.consumeFreshPendingText(text, boundary),
|
||
|
|
timeoutLabel: "waitForText",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
waitForRegex(pattern, timeoutMs = 30000, shouldAbort) {
|
||
|
|
const minFreshStartAbsolute = this.currentFreshBoundary();
|
||
|
|
const immediate = this.consumeFreshPendingRegex(pattern, { minFreshStartAbsolute });
|
||
|
|
if (immediate !== null) {
|
||
|
|
this.advanceScanOffset(immediate.endOffset);
|
||
|
|
return Promise.resolve(immediate.value);
|
||
|
|
}
|
||
|
|
|
||
|
|
return this.waitForWithMatcher({
|
||
|
|
pattern,
|
||
|
|
timeoutMs,
|
||
|
|
shouldAbort,
|
||
|
|
freshBoundary: minFreshStartAbsolute,
|
||
|
|
consumeFreshMatch: (boundary) => this.consumeFreshPendingRegex(pattern, { minFreshStartAbsolute: boundary }),
|
||
|
|
timeoutLabel: "waitForRegex",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
abortWaiters(reason = "Script stopped") {
|
||
|
|
for (const waiter of this.waiters) {
|
||
|
|
clearTimeout(waiter.timer);
|
||
|
|
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
|
||
|
|
if (waiter.custom?.abortInterval) clearInterval(waiter.custom.abortInterval);
|
||
|
|
if (waiter.custom?.interval) clearInterval(waiter.custom.interval);
|
||
|
|
waiter.reject?.(new Error(reason));
|
||
|
|
}
|
||
|
|
this.waiters = [];
|
||
|
|
}
|
||
|
|
|
||
|
|
async waitForAny(patterns, timeoutMs = 30000, shouldAbort, options = {}) {
|
||
|
|
if (!Array.isArray(patterns) || patterns.length === 0) {
|
||
|
|
throw new TypeError("waitForAny requires a non-empty patterns array");
|
||
|
|
}
|
||
|
|
if (options.allowPreservedTailMatch === true) {
|
||
|
|
const preserved = this.consumePreservedTailMatchAny(patterns);
|
||
|
|
if (preserved !== null) {
|
||
|
|
return preserved.index;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// Generic waitForAny must see the full seeded viewport (menu labels near
|
||
|
|
// the top). waitForPrompt passes allowPreservedTailMatch and stays on the
|
||
|
|
// live-tail window so older visible prompts are not readiness signals.
|
||
|
|
const freshBoundary = options.allowPreservedTailMatch === true
|
||
|
|
? this.currentTailFreshBoundary()
|
||
|
|
: this.currentFreshBoundary();
|
||
|
|
const fresh = this.consumeFreshPendingMatchAny(patterns, freshBoundary);
|
||
|
|
if (fresh !== null) {
|
||
|
|
this.advanceScanOffset(fresh.matched.endOffset);
|
||
|
|
return fresh.index;
|
||
|
|
}
|
||
|
|
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const waiter = {
|
||
|
|
patterns,
|
||
|
|
freshBoundary,
|
||
|
|
resolve,
|
||
|
|
reject,
|
||
|
|
shouldAbort,
|
||
|
|
timer: null,
|
||
|
|
interval: null,
|
||
|
|
check: () => {
|
||
|
|
if (options.allowPreservedTailMatch === true) {
|
||
|
|
const preserved = this.consumePreservedTailMatchAny(patterns);
|
||
|
|
if (preserved !== null) {
|
||
|
|
clearTimeout(waiter.timer);
|
||
|
|
if (waiter.interval) clearInterval(waiter.interval);
|
||
|
|
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
|
||
|
|
this.waiters = this.waiters.filter((entry) => entry.custom !== waiter);
|
||
|
|
resolve(preserved.index);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const fresh = this.consumeFreshPendingMatchAny(patterns, waiter.freshBoundary);
|
||
|
|
if (fresh !== null) {
|
||
|
|
this.advanceScanOffset(fresh.matched.endOffset);
|
||
|
|
clearTimeout(waiter.timer);
|
||
|
|
if (waiter.interval) clearInterval(waiter.interval);
|
||
|
|
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
|
||
|
|
this.waiters = this.waiters.filter((entry) => entry.custom !== waiter);
|
||
|
|
resolve(fresh.index);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
waiter.timer = setTimeout(() => {
|
||
|
|
if (waiter.interval) clearInterval(waiter.interval);
|
||
|
|
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
|
||
|
|
this.waiters = this.waiters.filter((entry) => entry.custom !== waiter);
|
||
|
|
reject(new Error(`waitForAny timed out after ${timeoutMs}ms`));
|
||
|
|
}, timeoutMs);
|
||
|
|
waiter.interval = setInterval(() => {
|
||
|
|
waiter.check();
|
||
|
|
}, 50);
|
||
|
|
if (typeof shouldAbort === "function") {
|
||
|
|
waiter.abortInterval = setInterval(() => {
|
||
|
|
if (!shouldAbort()) return;
|
||
|
|
clearTimeout(waiter.timer);
|
||
|
|
clearInterval(waiter.interval);
|
||
|
|
clearInterval(waiter.abortInterval);
|
||
|
|
this.waiters = this.waiters.filter((entry) => entry.custom !== waiter);
|
||
|
|
reject(new Error("Script stopped"));
|
||
|
|
}, 100);
|
||
|
|
}
|
||
|
|
this.waiters.push({
|
||
|
|
pattern: patterns[0],
|
||
|
|
resolve: () => {},
|
||
|
|
reject: () => {},
|
||
|
|
timer: waiter.timer,
|
||
|
|
custom: waiter,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
dispose() {
|
||
|
|
for (const waiter of this.waiters) {
|
||
|
|
clearTimeout(waiter.timer);
|
||
|
|
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
|
||
|
|
if (waiter.custom?.abortInterval) clearInterval(waiter.custom.abortInterval);
|
||
|
|
if (waiter.custom?.interval) clearInterval(waiter.custom.interval);
|
||
|
|
waiter.reject?.(new Error("Session output buffer disposed"));
|
||
|
|
}
|
||
|
|
this.waiters = [];
|
||
|
|
this.chunks = [];
|
||
|
|
this.totalLength = 0;
|
||
|
|
this.scanOffset = 0;
|
||
|
|
this.preservedTailMatch = null;
|
||
|
|
this.seededLength = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const buffers = new Map();
|
||
|
|
|
||
|
|
function getOrCreateBuffer(sessionId) {
|
||
|
|
if (!buffers.has(sessionId)) {
|
||
|
|
buffers.set(sessionId, new SessionOutputBuffer(sessionId));
|
||
|
|
}
|
||
|
|
return buffers.get(sessionId);
|
||
|
|
}
|
||
|
|
|
||
|
|
function appendSessionOutput(sessionId, data) {
|
||
|
|
getOrCreateBuffer(sessionId).append(data);
|
||
|
|
}
|
||
|
|
|
||
|
|
function removeSessionBuffer(sessionId) {
|
||
|
|
const buffer = buffers.get(sessionId);
|
||
|
|
if (buffer) {
|
||
|
|
buffer.dispose();
|
||
|
|
buffers.delete(sessionId);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
SessionOutputBuffer,
|
||
|
|
appendSessionOutput,
|
||
|
|
getOrCreateBuffer,
|
||
|
|
removeSessionBuffer,
|
||
|
|
tryMatch,
|
||
|
|
compilePattern,
|
||
|
|
};
|