mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
6b9b71ac8a
* feat(scripts): add terminal script automation, recording, and side panel Introduce nct script runtime in the main process with terminal-side recording, execution overlay, Monaco editor, connect-script queues, and scripts side panel integration. Fix recording to wire scriptRecorderRef into xterm, use waitForPrompt for root/user shells, and save recorded scripts through the global editor dialog. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): address Bugbot review findings for recording and side panel Refresh script run state in side panel memo, guard toolbar recording against multi-session conflicts, reset connect scripts on reconnect, resolve focused session via getActiveTerminalSessionId, and avoid wiping host edit form when snippets list updates. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): prevent pending script re-run and improve workspace run feedback Track one-shot pendingScript execution separately from onConnect queue so reconnects do not replay Run-now scripts. Fall back to focused session when running on all tabs outside a workspace, and toast when every tab is still connecting. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): consume connect-script window once and filter serial hosts Mark the onConnect script evaluation as consumed even when the queue is empty so later snippet/host edits do not rerun automation mid-session. Restore serial-host filtering in SelectHostPanel confirm handler. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): grace period before giving up on connect-script queue Only mark onConnect automation as consumed after scripts run or a short post-connect grace window expires, so late-loading snippets still trigger while mid-session Vault edits do not. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): gate connect-script consumption on vault hydration Replace the grace timer with vault-init and unresolved host script binding checks so late-loading snippets still run onConnect automation while mid-session edits stay suppressed. Clear pending script run state on disconnect so reconnect retries workspace pending scripts. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): honor run targets and retry failed connect automation Route QuickScriptEditorDialog Run through onRunSnippet for multi-host workspaces. Defer connect/pending script completion markers until each script succeeds and retry the queue after failures. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): stabilize pending runs and global connect queue sync Keep pending script run state across reconnects, run onConnect scripts before one-shot workspace pending scripts, and strip per-host queue entries when an onConnect script targets all hosts. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): defer host queue snippet sync and guard connect runs Persist snippet target links only when the host panel is saved, block duplicate connect-script scheduling while a batch is running, and require runnable hosts before showing Run in the snippets editor footer. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): retry connect failures and surface output-trigger errors Use a retry tick in connect-script effect deps, toast automation failures from output triggers, and require runnable hosts before QuickScript Run. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): track id-less pending runs and re-arm output triggers Remember anonymous pendingScript objects after first run and allow onOutput triggers to fire again when new terminal output arrives. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): unblock paused connect runs and pass session meta Resolve waitForScriptRun on pause, run workspace scripts per session with hostname context, defer output-trigger arming until runs start, and add noRunnableHosts i18n for QuickScript Run. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): serialize output triggers and defer empty connect finalize Fire at most one onOutput trigger per scan while a run is active, and only finalize an empty connect queue after vault snippets are loaded. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): guard output launch race and script run waits Block overlapping onOutput launches, timeout stuck waitForScriptRun promises, and disable vault Run when no connectable targets exist. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): match output triggers on new chunks only Scan onOutput patterns against recent terminal output instead of buffer length dedupe, and restore overflow-visible on SelectHostPanel new-host flow. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): await sequential workspace runs and output launch Wait for each tab script to finish in sequential workspace mode, and hold output-trigger launch guard until the session run becomes active. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): wait through pause and hold output trigger until done Keep connect and sequential workspace queues blocked until scripts complete or fail, and retain output-trigger guard until the launched run finishes. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): harden output trigger wait helpers Add explicit timeouts to output-trigger wait helpers and stop rescanning the buffer after script completion to avoid sticky-match rerun loops. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): allow empty connect queue and match output in new chunks Treat an empty connectScriptIds array as an explicit cleared queue instead of re-migrating from snippet targets, and only fire onOutput triggers when the latest terminal output contains the pattern. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): require fresh output overlap for onOutput triggers Only fire output-matched scripts when the regex match extends into the latest terminal chunk, preventing reruns from stale buffer text. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): promote manual scripts added to host connect queue Diff connect queue changes from raw connectScriptIds so saving a host updates manual scripts to onConnect when they are newly queued. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): dedupe output triggers by match position Track the buffer end offset of each output trigger match and ignore stale re-scans, with a longer wait for run registration before retry. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): record output trigger matches only after run starts Propagate automation launch failures to output triggers and commit match dedupe only once the session run is registered as active. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): address Codex review on wait cursor and sensitive codegen Track scan offset so waitFor/waitForAny only match output after the previous wait, and emit sendLine after sensitive prompt in recorded scripts. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): harden onOutput triggers against PTY echo and local input Filter server output before pattern matching so onOutput fires only after Enter on real stdout, not on keystroke/paste echo, OSC/SGR sequences, or alternate-screen apps. Add dev diagnostics and scope triggers to the active session when no explicit host targets are configured. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(scripts): remove onOutput trigger debug logging Drop temporary diagnostics module and console traces now that PTY echo filtering is verified; keep the production trigger pipeline unchanged. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
462 lines
14 KiB
JavaScript
462 lines
14 KiB
JavaScript
"use strict";
|
|
|
|
const vm = require("node:vm");
|
|
const { shellPromptPatterns } = require("./shellPromptPatterns.cjs");
|
|
|
|
function wrapScriptSource(source) {
|
|
const trimmed = String(source || "").trim();
|
|
if (!trimmed) {
|
|
throw new Error("Script content is empty");
|
|
}
|
|
|
|
if (/^\(\s*async\s*\(\s*\)\s*=>/m.test(trimmed) || /^\(\s*async\s+function\s*\(\s*\)\s*\{/m.test(trimmed)) {
|
|
return trimmed;
|
|
}
|
|
|
|
const hasMainFunction = /\basync\s+function\s+main\s*\(/m.test(trimmed) || /\bfunction\s+main\s*\(/m.test(trimmed);
|
|
if (hasMainFunction) {
|
|
const body = trimmed.replace(/\n\s*;?\s*await\s+main\s*\(\s*\)\s*;?\s*$/m, "").trimEnd();
|
|
const invokeMain = /\basync\s+function\s+main\s*\(/m.test(body)
|
|
? "await main();"
|
|
: "await Promise.resolve(main());";
|
|
return `(async () => {\n${body}\n${invokeMain}\n})();`;
|
|
}
|
|
|
|
return `(async () => {\n${trimmed}\n})();`;
|
|
}
|
|
|
|
function truncateActivityLabel(value, max = 80) {
|
|
const text = String(value ?? "");
|
|
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
}
|
|
|
|
function createScriptRuntime(deps) {
|
|
const {
|
|
sessionId,
|
|
runId,
|
|
appendLog,
|
|
writeToSession,
|
|
getOutputBuffer,
|
|
getScreenSnapshot,
|
|
getSessionMeta,
|
|
showDialog,
|
|
showWaitForTimeoutDialog,
|
|
disconnectSession,
|
|
startSessionLog,
|
|
stopSessionLog,
|
|
onStatusChange,
|
|
isPaused,
|
|
permissionMode = "auto",
|
|
startedAt = Date.now(),
|
|
} = deps;
|
|
|
|
let stepIndex = 0;
|
|
let progressMode = "activity";
|
|
let progressLabel;
|
|
let progressCurrent = 0;
|
|
let progressTotal = 0;
|
|
|
|
let screenSnapshot = {
|
|
rows: 24,
|
|
cols: 80,
|
|
currentRow: 0,
|
|
lines: [],
|
|
};
|
|
|
|
function emitStatus(patch = {}) {
|
|
onStatusChange?.(runId, {
|
|
progressMode,
|
|
progressLabel: progressMode === "determinate" ? progressLabel : undefined,
|
|
progressCurrent: progressMode === "determinate" ? progressCurrent : undefined,
|
|
progressTotal: progressMode === "determinate" ? progressTotal : undefined,
|
|
elapsedMs: Math.max(0, Date.now() - startedAt),
|
|
stepIndex,
|
|
status: "running",
|
|
...patch,
|
|
});
|
|
}
|
|
|
|
function assertWriteAllowed(operation) {
|
|
if (permissionMode !== "observer") return;
|
|
throw new Error(`Observer mode: ${operation} is disabled. Switch to Confirm or Auto mode.`);
|
|
}
|
|
|
|
async function trackStep(label) {
|
|
stepIndex += 1;
|
|
const activityLabel = truncateActivityLabel(label);
|
|
emitStatus({
|
|
activityLabel,
|
|
currentStep: activityLabel,
|
|
});
|
|
}
|
|
|
|
async function refreshScreenSnapshot() {
|
|
if (typeof getScreenSnapshot === "function") {
|
|
try {
|
|
screenSnapshot = await getScreenSnapshot(sessionId);
|
|
} catch {
|
|
// fall back to output buffer text
|
|
}
|
|
}
|
|
return screenSnapshot;
|
|
}
|
|
|
|
async function waitForPromptWithRecovery(timeoutMs = 60000) {
|
|
let stepTracked = false;
|
|
while (true) {
|
|
if (deps.isAborted?.()) {
|
|
throw new Error("Script stopped");
|
|
}
|
|
if (!stepTracked) {
|
|
await trackStep("waitForPrompt");
|
|
stepTracked = true;
|
|
}
|
|
onStatusChange?.(runId, { waitingFor: "shell prompt", status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
|
|
try {
|
|
return await getOutputBuffer(sessionId).waitForAny(
|
|
shellPromptPatterns(),
|
|
timeoutMs,
|
|
() => Boolean(deps.isAborted?.()),
|
|
);
|
|
} catch (err) {
|
|
if (!String(err?.message || err).includes("timed out")) {
|
|
throw err;
|
|
}
|
|
onStatusChange?.(runId, { status: "paused", waitingFor: "shell prompt", elapsedMs: Math.max(0, Date.now() - startedAt) });
|
|
const action = await showWaitForTimeoutDialog?.("shell prompt", timeoutMs);
|
|
onStatusChange?.(runId, { status: "running" });
|
|
if (action === "retry") {
|
|
continue;
|
|
}
|
|
if (action === "skip") {
|
|
return -1;
|
|
}
|
|
throw new Error("Script stopped by user");
|
|
} finally {
|
|
onStatusChange?.(runId, { waitingFor: undefined, status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
|
|
}
|
|
}
|
|
}
|
|
|
|
async function waitForAnyWithRecovery(patterns, timeoutMs = 30000) {
|
|
const label = Array.isArray(patterns)
|
|
? patterns.map((pattern) => (pattern instanceof RegExp ? pattern.source : String(pattern))).join(" | ")
|
|
: String(patterns);
|
|
let stepTracked = false;
|
|
while (true) {
|
|
if (deps.isAborted?.()) {
|
|
throw new Error("Script stopped");
|
|
}
|
|
if (!stepTracked) {
|
|
await trackStep(`waitForAny ${truncateActivityLabel(label, 60)}`);
|
|
stepTracked = true;
|
|
}
|
|
onStatusChange?.(runId, { waitingFor: label, status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
|
|
try {
|
|
return await getOutputBuffer(sessionId).waitForAny(
|
|
patterns,
|
|
timeoutMs,
|
|
() => Boolean(deps.isAborted?.()),
|
|
);
|
|
} catch (err) {
|
|
if (!String(err?.message || err).includes("timed out")) {
|
|
throw err;
|
|
}
|
|
onStatusChange?.(runId, { status: "paused", waitingFor: label, elapsedMs: Math.max(0, Date.now() - startedAt) });
|
|
const action = await showWaitForTimeoutDialog?.(label, timeoutMs);
|
|
onStatusChange?.(runId, { status: "running" });
|
|
if (action === "retry") {
|
|
continue;
|
|
}
|
|
if (action === "skip") {
|
|
return -1;
|
|
}
|
|
throw new Error("Script stopped by user");
|
|
} finally {
|
|
onStatusChange?.(runId, { waitingFor: undefined, status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
|
|
}
|
|
}
|
|
}
|
|
|
|
async function waitForWithRecovery(pattern, timeoutMs = 30000) {
|
|
const patternLabel = pattern instanceof RegExp ? pattern.source : String(pattern);
|
|
let stepTracked = false;
|
|
while (true) {
|
|
if (deps.isAborted?.()) {
|
|
throw new Error("Script stopped");
|
|
}
|
|
if (!stepTracked) {
|
|
await trackStep(`waitFor ${truncateActivityLabel(patternLabel, 60)}`);
|
|
stepTracked = true;
|
|
}
|
|
onStatusChange?.(runId, { waitingFor: patternLabel, status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
|
|
try {
|
|
return await getOutputBuffer(sessionId).waitFor(
|
|
pattern,
|
|
timeoutMs,
|
|
() => Boolean(deps.isAborted?.()),
|
|
);
|
|
} catch (err) {
|
|
if (!String(err?.message || err).includes("timed out")) {
|
|
throw err;
|
|
}
|
|
onStatusChange?.(runId, { status: "paused", waitingFor: patternLabel, elapsedMs: Math.max(0, Date.now() - startedAt) });
|
|
const action = await showWaitForTimeoutDialog?.(patternLabel, timeoutMs);
|
|
onStatusChange?.(runId, { status: "running" });
|
|
if (action === "retry") {
|
|
continue;
|
|
}
|
|
if (action === "skip") {
|
|
return "";
|
|
}
|
|
throw new Error("Script stopped by user");
|
|
} finally {
|
|
onStatusChange?.(runId, { waitingFor: undefined, status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
|
|
}
|
|
}
|
|
}
|
|
|
|
const progressApi = {
|
|
start(label, total) {
|
|
progressMode = "determinate";
|
|
progressLabel = truncateActivityLabel(label || "Progress", 60);
|
|
progressTotal = Math.max(1, Number(total) || 1);
|
|
progressCurrent = 0;
|
|
emitStatus({
|
|
progressMode,
|
|
progressLabel,
|
|
progressCurrent,
|
|
progressTotal,
|
|
activityLabel: progressLabel,
|
|
});
|
|
},
|
|
set(current, detail) {
|
|
if (progressMode !== "determinate") return;
|
|
progressCurrent = Math.max(0, Math.min(progressTotal, Number(current) || 0));
|
|
const patch = {
|
|
progressCurrent,
|
|
progressTotal,
|
|
progressLabel,
|
|
};
|
|
if (detail !== undefined && detail !== null && detail !== "") {
|
|
patch.activityLabel = truncateActivityLabel(detail);
|
|
}
|
|
emitStatus(patch);
|
|
},
|
|
step(detail) {
|
|
if (progressMode !== "determinate") return;
|
|
progressCurrent = Math.min(progressTotal, progressCurrent + 1);
|
|
const patch = {
|
|
progressCurrent,
|
|
progressTotal,
|
|
progressLabel,
|
|
};
|
|
if (detail !== undefined && detail !== null && detail !== "") {
|
|
patch.activityLabel = truncateActivityLabel(detail);
|
|
}
|
|
emitStatus(patch);
|
|
},
|
|
done() {
|
|
if (progressMode !== "determinate") return;
|
|
progressCurrent = progressTotal;
|
|
emitStatus({
|
|
progressCurrent,
|
|
progressTotal,
|
|
progressLabel,
|
|
});
|
|
progressMode = "activity";
|
|
progressLabel = undefined;
|
|
progressCurrent = 0;
|
|
progressTotal = 0;
|
|
emitStatus({
|
|
progressMode: "activity",
|
|
progressLabel: undefined,
|
|
progressCurrent: undefined,
|
|
progressTotal: undefined,
|
|
});
|
|
},
|
|
};
|
|
|
|
const sessionApi = {
|
|
get connected() {
|
|
const meta = getSessionMeta?.(sessionId);
|
|
return Boolean(meta?.connected);
|
|
},
|
|
get hostname() {
|
|
return getSessionMeta?.(sessionId)?.hostname || "";
|
|
},
|
|
get username() {
|
|
return getSessionMeta?.(sessionId)?.username || "";
|
|
},
|
|
sleep(ms) {
|
|
const delay = Math.max(0, Number(ms) || 0);
|
|
return trackStep(`sleep ${delay}ms`).then(() => interruptibleSleep(delay, deps.isAborted));
|
|
},
|
|
async startLog(path) {
|
|
assertWriteAllowed("session.startLog");
|
|
await trackStep("startLog");
|
|
await startSessionLog?.(sessionId, path);
|
|
},
|
|
async stopLog() {
|
|
await trackStep("stopLog");
|
|
await stopSessionLog?.(sessionId);
|
|
},
|
|
async disconnect() {
|
|
assertWriteAllowed("session.disconnect");
|
|
await trackStep("disconnect");
|
|
await disconnectSession?.(sessionId);
|
|
},
|
|
};
|
|
|
|
const screenApi = {
|
|
async send(text) {
|
|
assertWriteAllowed("screen.send");
|
|
await waitIfPaused();
|
|
const payload = String(text ?? "");
|
|
await trackStep(`send: ${truncateActivityLabel(formatScriptInputForLog(payload), 60)}`);
|
|
appendLog(runId, `→ ${formatScriptInputForLog(payload)}`);
|
|
writeToSession(sessionId, payload, { automated: true });
|
|
},
|
|
async sendLine(text) {
|
|
assertWriteAllowed("screen.sendLine");
|
|
await waitIfPaused();
|
|
const line = String(text ?? "");
|
|
await trackStep(`sendLine: ${truncateActivityLabel(line, 60)}`);
|
|
appendLog(runId, `→ ${line}`);
|
|
writeToSession(sessionId, `${line}\r`, { automated: true });
|
|
},
|
|
waitFor(pattern, timeoutMs = 30000) {
|
|
return waitForWithRecovery(pattern, timeoutMs);
|
|
},
|
|
waitForPrompt(timeoutMs = 60000) {
|
|
return waitForPromptWithRecovery(timeoutMs);
|
|
},
|
|
async waitForAny(patterns, timeoutMs = 30000) {
|
|
return waitForAnyWithRecovery(patterns, timeoutMs);
|
|
},
|
|
async getText(startRow, endRow) {
|
|
await refreshScreenSnapshot();
|
|
const lines = screenSnapshot.lines || [];
|
|
const start = typeof startRow === "number" ? Math.max(0, startRow) : 0;
|
|
const end = typeof endRow === "number" ? Math.min(lines.length - 1, endRow) : lines.length - 1;
|
|
if (lines.length === 0) {
|
|
return getOutputBuffer(sessionId).getText();
|
|
}
|
|
return lines.slice(start, end + 1).join("\n");
|
|
},
|
|
get currentRow() {
|
|
return screenSnapshot.currentRow ?? 0;
|
|
},
|
|
get rows() {
|
|
return screenSnapshot.rows ?? 24;
|
|
},
|
|
get cols() {
|
|
return screenSnapshot.cols ?? 80;
|
|
},
|
|
async clear() {
|
|
assertWriteAllowed("screen.clear");
|
|
await trackStep("clear");
|
|
writeToSession(sessionId, "\x1b[2J\x1b[H", { automated: true });
|
|
},
|
|
};
|
|
|
|
const dialogApi = {
|
|
alert(message) {
|
|
return showDialog("alert", String(message ?? ""));
|
|
},
|
|
confirm(message) {
|
|
return showDialog("confirm", String(message ?? ""));
|
|
},
|
|
prompt(message, defaultValue = "") {
|
|
return showDialog("prompt", String(message ?? ""), String(defaultValue ?? ""));
|
|
},
|
|
};
|
|
|
|
const nct = {
|
|
session: sessionApi,
|
|
screen: screenApi,
|
|
dialog: dialogApi,
|
|
progress: progressApi,
|
|
version: deps.appVersion || "0.0.0",
|
|
sleep: sessionApi.sleep.bind(sessionApi),
|
|
log(message) {
|
|
stepIndex += 1;
|
|
emitStatus({
|
|
activityLabel: "log",
|
|
currentStep: "log",
|
|
});
|
|
appendLog(runId, String(message ?? ""));
|
|
},
|
|
};
|
|
|
|
async function waitIfPaused() {
|
|
while (isPaused?.()) {
|
|
if (deps.isAborted?.()) {
|
|
throw new Error("Script stopped");
|
|
}
|
|
onStatusChange?.(runId, { status: "paused", elapsedMs: Math.max(0, Date.now() - startedAt) });
|
|
await interruptibleSleep(100, deps.isAborted);
|
|
}
|
|
onStatusChange?.(runId, { status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
|
|
}
|
|
|
|
async function execute(source) {
|
|
if (deps.isAborted?.()) {
|
|
throw new Error("Script stopped");
|
|
}
|
|
const wrapped = wrapScriptSource(source);
|
|
const sandbox = {
|
|
nct,
|
|
console: {
|
|
log: (...args) => {
|
|
appendLog(runId, args.map((arg) => String(arg)).join(" "));
|
|
},
|
|
},
|
|
};
|
|
vm.createContext(sandbox);
|
|
const script = new vm.Script(wrapped, { filename: `script-${runId}.js` });
|
|
const result = script.runInContext(sandbox, { displayErrors: true });
|
|
if (result && typeof result.then === "function") {
|
|
await result;
|
|
}
|
|
}
|
|
|
|
return { execute, nct };
|
|
}
|
|
|
|
function interruptibleSleep(ms, isAborted) {
|
|
const delay = Math.max(0, Number(ms) || 0);
|
|
if (!isAborted) {
|
|
return new Promise((resolve) => setTimeout(resolve, delay));
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
const startedAt = Date.now();
|
|
const timer = setInterval(() => {
|
|
if (isAborted()) {
|
|
clearInterval(timer);
|
|
reject(new Error("Script stopped"));
|
|
return;
|
|
}
|
|
if (Date.now() - startedAt >= delay) {
|
|
clearInterval(timer);
|
|
resolve(undefined);
|
|
}
|
|
}, 50);
|
|
});
|
|
}
|
|
|
|
function formatScriptInputForLog(data) {
|
|
return String(data ?? "")
|
|
.replace(/\r/g, "\\r")
|
|
.replace(/\n/g, "\\n")
|
|
.replace(/\x03/g, "^C")
|
|
.replace(/\x1b/g, "\\e");
|
|
}
|
|
|
|
module.exports = {
|
|
createScriptRuntime,
|
|
wrapScriptSource,
|
|
interruptibleSleep,
|
|
formatScriptInputForLog,
|
|
};
|