858 lines
27 KiB
JavaScript
858 lines
27 KiB
JavaScript
|
|
"use strict";
|
||
|
|
|
||
|
|
const path = require("node:path");
|
||
|
|
const { randomUUID } = require("node:crypto");
|
||
|
|
const { createScriptRuntime } = require("../scripts/scriptRuntime.cjs");
|
||
|
|
const { stepsToJavaScript } = require("../scripts/scriptCodegen.cjs");
|
||
|
|
const {
|
||
|
|
appendSessionOutput,
|
||
|
|
getOrCreateBuffer,
|
||
|
|
removeSessionBuffer,
|
||
|
|
} = require("../scripts/sessionOutputBuffer.cjs");
|
||
|
|
const { shellPromptPatterns } = require("../scripts/shellPromptPatterns.cjs");
|
||
|
|
const {
|
||
|
|
appendRetainedRunLog,
|
||
|
|
pruneCompletedRuns,
|
||
|
|
} = require("../scripts/scriptRunRetention.cjs");
|
||
|
|
const { addTerminalDataTap } = require("../bridges/emitTerminalSessionData.cjs");
|
||
|
|
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||
|
|
|
||
|
|
const MAX_SCRIPT_RECORDING_STEPS = 10_000;
|
||
|
|
const MAX_SCRIPT_RECORDING_BYTES = 2 * 1024 * 1024;
|
||
|
|
const SCRIPT_RECORDING_LIMIT_ERROR = "Recording stopped because it reached the safety limit";
|
||
|
|
|
||
|
|
let sessions = null;
|
||
|
|
let electronModule = null;
|
||
|
|
let terminalBridge = null;
|
||
|
|
let terminalWorkerManager = null;
|
||
|
|
let getMainWindow = null;
|
||
|
|
|
||
|
|
/** @type {Map<string, object>} */
|
||
|
|
const runs = new Map();
|
||
|
|
/** @type {Map<string, object>} */
|
||
|
|
const recordings = new Map();
|
||
|
|
/** @type {Map<string, { resolve, reject, type, runId?: string }>} */
|
||
|
|
const pendingDialogs = new Map();
|
||
|
|
/** @type {Map<string, { resolve, reject, sessionId, runId?: string }>} */
|
||
|
|
const pendingScreenSnapshots = new Map();
|
||
|
|
/** @type {Map<string, { runId: string, token: symbol }>} */
|
||
|
|
const scriptLogTokens = new Map();
|
||
|
|
/** @type {Map<string, Promise<void>>} */
|
||
|
|
const sessionRunChains = new Map();
|
||
|
|
/** @type {Map<string, number>} */
|
||
|
|
const queuedRunGenerations = new Map();
|
||
|
|
/** @type {Map<string, number>} */
|
||
|
|
const cancelledQueuedRunGenerations = new Map();
|
||
|
|
/** @type {Map<string, { abort: (reason?: Error) => void }>} */
|
||
|
|
const runAbortControls = new Map();
|
||
|
|
/** @type {Map<string, { connected?: boolean, name?: string, hostname?: string, username?: string }>} */
|
||
|
|
const rendererSessionMetaById = new Map();
|
||
|
|
let scriptRuntimeGeneration = 0;
|
||
|
|
let disposeTerminalDataTap = null;
|
||
|
|
let disposeWorkerOutputTap = null;
|
||
|
|
let disposeWorkerSessionClosed = null;
|
||
|
|
|
||
|
|
function disposeSubscription(subscription) {
|
||
|
|
if (typeof subscription === "function") {
|
||
|
|
subscription();
|
||
|
|
} else {
|
||
|
|
subscription?.dispose?.();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function releaseSessionResources(sessionId) {
|
||
|
|
if (!sessionId) return;
|
||
|
|
removeSessionBuffer(sessionId);
|
||
|
|
rendererSessionMetaById.delete(sessionId);
|
||
|
|
recordings.delete(sessionId);
|
||
|
|
}
|
||
|
|
|
||
|
|
function enqueueSessionRun(sessionId, task) {
|
||
|
|
const previous = sessionRunChains.get(sessionId) || Promise.resolve();
|
||
|
|
const next = previous
|
||
|
|
.catch(() => {})
|
||
|
|
.then(() => task());
|
||
|
|
const settled = next.then(() => {}, () => {});
|
||
|
|
sessionRunChains.set(sessionId, settled);
|
||
|
|
settled.finally(() => {
|
||
|
|
if (sessionRunChains.get(sessionId) === settled) {
|
||
|
|
sessionRunChains.delete(sessionId);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
return next;
|
||
|
|
}
|
||
|
|
|
||
|
|
function init(deps) {
|
||
|
|
scriptRuntimeGeneration += 1;
|
||
|
|
queuedRunGenerations.clear();
|
||
|
|
cancelledQueuedRunGenerations.clear();
|
||
|
|
for (const [runId, control] of runAbortControls.entries()) {
|
||
|
|
const run = runs.get(runId);
|
||
|
|
if (run && !run.endedAt) {
|
||
|
|
run.aborted = true;
|
||
|
|
run.paused = false;
|
||
|
|
run.status = "failed";
|
||
|
|
run.error = "Script runtime reinitialized";
|
||
|
|
run.endedAt = Date.now();
|
||
|
|
getOrCreateBuffer(run.sessionId).abortWaiters(run.error);
|
||
|
|
settlePendingRunRequests(runId, { reject: false, reason: new Error(run.error) });
|
||
|
|
}
|
||
|
|
control.abort(new Error("Script runtime reinitialized"));
|
||
|
|
}
|
||
|
|
disposeSubscription(disposeTerminalDataTap);
|
||
|
|
disposeSubscription(disposeWorkerOutputTap);
|
||
|
|
disposeSubscription(disposeWorkerSessionClosed);
|
||
|
|
disposeTerminalDataTap = null;
|
||
|
|
disposeWorkerOutputTap = null;
|
||
|
|
disposeWorkerSessionClosed = null;
|
||
|
|
|
||
|
|
sessions = deps.sessions;
|
||
|
|
electronModule = deps.electronModule;
|
||
|
|
terminalBridge = deps.terminalBridge;
|
||
|
|
terminalWorkerManager = deps.terminalWorkerManager || null;
|
||
|
|
getMainWindow = deps.getMainWindow;
|
||
|
|
|
||
|
|
disposeTerminalDataTap = addTerminalDataTap((sessionId, data) => {
|
||
|
|
appendSessionOutput(sessionId, data);
|
||
|
|
});
|
||
|
|
disposeWorkerOutputTap = terminalWorkerManager?.addOutputTap?.((sessionId, data) => {
|
||
|
|
appendSessionOutput(sessionId, data);
|
||
|
|
}) ?? null;
|
||
|
|
disposeWorkerSessionClosed = terminalWorkerManager?.onSessionClosed?.(({ sessionId }) => {
|
||
|
|
releaseSessionResources(sessionId);
|
||
|
|
}) ?? null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function broadcastRuns() {
|
||
|
|
const win = getMainWindow?.();
|
||
|
|
if (!win?.webContents) return;
|
||
|
|
win.webContents.send("netcatty:script:runs-updated", {
|
||
|
|
runs: Array.from(runs.values()).map(serializeRun),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function serializeRun(run) {
|
||
|
|
const now = Date.now();
|
||
|
|
const elapsedMs = run.endedAt
|
||
|
|
? run.endedAt - run.startedAt
|
||
|
|
: Math.max(0, now - run.startedAt);
|
||
|
|
return {
|
||
|
|
runId: run.runId,
|
||
|
|
scriptId: run.scriptId,
|
||
|
|
scriptLabel: run.scriptLabel,
|
||
|
|
sessionId: run.sessionId,
|
||
|
|
status: run.status,
|
||
|
|
startedAt: run.startedAt,
|
||
|
|
endedAt: run.endedAt,
|
||
|
|
currentStep: run.currentStep,
|
||
|
|
stepIndex: run.stepIndex,
|
||
|
|
totalSteps: run.totalSteps,
|
||
|
|
progressMode: run.progressMode || "activity",
|
||
|
|
activityLabel: run.activityLabel,
|
||
|
|
progressLabel: run.progressLabel,
|
||
|
|
progressCurrent: run.progressCurrent,
|
||
|
|
progressTotal: run.progressTotal,
|
||
|
|
elapsedMs,
|
||
|
|
waitingFor: run.waitingFor,
|
||
|
|
logs: run.logs.slice(-200),
|
||
|
|
error: run.error,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function isWorkerSessionOpen(sessionId) {
|
||
|
|
return Boolean(sessionId && terminalWorkerManager?.hasOpenSession?.(sessionId));
|
||
|
|
}
|
||
|
|
|
||
|
|
function rememberRendererSessionMeta(sessionId, meta) {
|
||
|
|
if (!sessionId || !meta || typeof meta !== "object") return;
|
||
|
|
const prev = rendererSessionMetaById.get(sessionId) || {};
|
||
|
|
rendererSessionMetaById.set(sessionId, { ...prev, ...meta });
|
||
|
|
}
|
||
|
|
|
||
|
|
function hasActiveOutputBuffer(sessionId) {
|
||
|
|
try {
|
||
|
|
return Boolean(String(getOrCreateBuffer(sessionId).getText() || "").trim());
|
||
|
|
} catch {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function isSessionConnected(sessionId) {
|
||
|
|
const session = sessions?.get(sessionId);
|
||
|
|
if (session) {
|
||
|
|
return session.status !== "disconnected";
|
||
|
|
}
|
||
|
|
const rendererMeta = rendererSessionMetaById.get(sessionId);
|
||
|
|
return Boolean(
|
||
|
|
rendererMeta?.connected
|
||
|
|
|| isWorkerSessionOpen(sessionId)
|
||
|
|
|| hasActiveOutputBuffer(sessionId),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getSessionMeta(sessionId) {
|
||
|
|
const session = sessions?.get(sessionId);
|
||
|
|
const rendererMeta = rendererSessionMetaById.get(sessionId);
|
||
|
|
if (session) {
|
||
|
|
return {
|
||
|
|
connected: session.status !== "disconnected",
|
||
|
|
name: rendererMeta?.name || session.label || session.hostLabel || "",
|
||
|
|
hostname: session.hostname || session.hostLabel || rendererMeta?.hostname || "",
|
||
|
|
username: session.username || rendererMeta?.username || "",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
if (isSessionConnected(sessionId)) {
|
||
|
|
return {
|
||
|
|
connected: true,
|
||
|
|
name: rendererMeta?.name || "",
|
||
|
|
hostname: rendererMeta?.hostname || "",
|
||
|
|
username: rendererMeta?.username || "",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
return { connected: false, name: "", hostname: "", username: "" };
|
||
|
|
}
|
||
|
|
|
||
|
|
function notifyScriptSessionInput(sessionId, data) {
|
||
|
|
const session = sessions?.get(sessionId);
|
||
|
|
const webContents = session?.webContentsId
|
||
|
|
? electronModule.webContents.fromId(session.webContentsId)
|
||
|
|
: getMainWindow?.()?.webContents;
|
||
|
|
webContents?.send("netcatty:script:session-input", { sessionId, data });
|
||
|
|
}
|
||
|
|
|
||
|
|
function writeToSession(sessionId, data, options = {}) {
|
||
|
|
const payload = {
|
||
|
|
sessionId,
|
||
|
|
data,
|
||
|
|
automated: options.automated !== false,
|
||
|
|
...(options.sensitive === true ? { sensitive: true } : {}),
|
||
|
|
};
|
||
|
|
const webContentsId = getMainWindow?.()?.webContents?.id;
|
||
|
|
if (terminalWorkerManager) {
|
||
|
|
// Mirror input-based log rewrites into the main-process stream manager
|
||
|
|
// (see the netcatty:write forwarder in terminalBridge.registerHandlers);
|
||
|
|
// the real write handler runs in the terminal worker process.
|
||
|
|
sessionLogStreamManager.registerSudoAutofillInput(sessionId, data);
|
||
|
|
terminalWorkerManager.send("netcatty:write", payload, { webContentsId });
|
||
|
|
} else {
|
||
|
|
terminalBridge?.writeToSession?.(
|
||
|
|
{ sender: getMainWindow?.()?.webContents },
|
||
|
|
payload,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
// sendLine may emit body and CR as two writes. Only the final write should
|
||
|
|
// invalidate the startup seed — otherwise prompt text that arrives in the
|
||
|
|
// gap is marked consumed and the next wait never sees it (#1960).
|
||
|
|
if (options.automated !== false && data && data !== "\x03") {
|
||
|
|
if (options.invalidateStartupSeed !== false) {
|
||
|
|
getOrCreateBuffer(sessionId).invalidateStartupSeed();
|
||
|
|
}
|
||
|
|
notifyScriptSessionInput(sessionId, data);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function bufferFallbackSnapshot(sessionId) {
|
||
|
|
return {
|
||
|
|
rows: 24,
|
||
|
|
cols: 80,
|
||
|
|
currentRow: 0,
|
||
|
|
lines: getOrCreateBuffer(sessionId).getText().split("\n"),
|
||
|
|
// Not a real terminal viewport — full script buffer / scrollback only.
|
||
|
|
source: "buffer-fallback",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function isViewportSnapshot(snapshot) {
|
||
|
|
return snapshot?.source !== "buffer-fallback";
|
||
|
|
}
|
||
|
|
|
||
|
|
function stripTrailingBlankLines(text) {
|
||
|
|
return String(text || "").replace(/(?:[ \t]*\r?\n)*$/u, "");
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeNewlines(text) {
|
||
|
|
return String(text || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Prefer viewport when it is the live screen suffix (drops scrolled-off
|
||
|
|
* scrollback — #1821). Prefer live buffer when the viewport is empty or a
|
||
|
|
* lagging/incomplete paint of the menu already on the tap path (#1960).
|
||
|
|
*/
|
||
|
|
function resolveStartupSeedText(viewportText, bufferText) {
|
||
|
|
const viewportRaw = String(viewportText || "");
|
||
|
|
const bufferRaw = String(bufferText || "");
|
||
|
|
const viewportCore = stripTrailingBlankLines(normalizeNewlines(viewportRaw));
|
||
|
|
const bufferCore = stripTrailingBlankLines(normalizeNewlines(bufferRaw));
|
||
|
|
|
||
|
|
if (!viewportCore) return bufferRaw;
|
||
|
|
if (!bufferCore) return viewportRaw;
|
||
|
|
if (viewportCore === bufferCore) return viewportRaw;
|
||
|
|
if (bufferCore.endsWith(viewportCore)) return viewportRaw;
|
||
|
|
if (bufferCore.startsWith(viewportCore) || bufferCore.includes(viewportCore)) {
|
||
|
|
return bufferRaw;
|
||
|
|
}
|
||
|
|
return viewportRaw;
|
||
|
|
}
|
||
|
|
|
||
|
|
function seedBufferFromScreen(buffer, screenText, syncStartText) {
|
||
|
|
const currentText = buffer.getText();
|
||
|
|
const trailingFresh = currentText.startsWith(syncStartText)
|
||
|
|
? currentText.slice(syncStartText.length)
|
||
|
|
: "";
|
||
|
|
const seedText = resolveStartupSeedText(screenText, syncStartText);
|
||
|
|
if (!seedText && !trailingFresh) return;
|
||
|
|
buffer.replaceWithVisibleScreen(seedText || "", trailingFresh, syncStartText);
|
||
|
|
}
|
||
|
|
|
||
|
|
function defaultDialogValue(type) {
|
||
|
|
if (type === "confirm") return false;
|
||
|
|
if (type === "prompt") return "";
|
||
|
|
if (type === "form") return {};
|
||
|
|
if (type === "waitForTimeout") return "abort";
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function settlePendingRunRequests(runId, options = {}) {
|
||
|
|
const reject = options.reject === true;
|
||
|
|
const reason = options.reason || new Error("Stopped by user");
|
||
|
|
for (const [requestId, pending] of pendingScreenSnapshots.entries()) {
|
||
|
|
if (pending.runId !== runId) continue;
|
||
|
|
pendingScreenSnapshots.delete(requestId);
|
||
|
|
if (reject) {
|
||
|
|
pending.reject(reason);
|
||
|
|
} else {
|
||
|
|
pending.resolve(bufferFallbackSnapshot(pending.sessionId));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for (const [requestId, pending] of pendingDialogs.entries()) {
|
||
|
|
if (pending.runId !== runId) continue;
|
||
|
|
pendingDialogs.delete(requestId);
|
||
|
|
if (reject) {
|
||
|
|
pending.reject(reason);
|
||
|
|
} else {
|
||
|
|
pending.resolve(defaultDialogValue(pending.type));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function requestScreenSnapshot(sessionId, runId) {
|
||
|
|
const session = sessions?.get(sessionId);
|
||
|
|
const webContents = session?.webContentsId
|
||
|
|
? electronModule.webContents.fromId(session.webContentsId)
|
||
|
|
: getMainWindow?.()?.webContents;
|
||
|
|
if (!webContents) {
|
||
|
|
return bufferFallbackSnapshot(sessionId);
|
||
|
|
}
|
||
|
|
|
||
|
|
const requestId = randomUUID();
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const timer = setTimeout(() => {
|
||
|
|
pendingScreenSnapshots.delete(requestId);
|
||
|
|
resolve(bufferFallbackSnapshot(sessionId));
|
||
|
|
}, 3000);
|
||
|
|
pendingScreenSnapshots.set(requestId, {
|
||
|
|
sessionId,
|
||
|
|
runId,
|
||
|
|
resolve: (value) => {
|
||
|
|
clearTimeout(timer);
|
||
|
|
resolve(value);
|
||
|
|
},
|
||
|
|
reject: (err) => {
|
||
|
|
clearTimeout(timer);
|
||
|
|
reject(err);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
webContents.send("netcatty:script:screen-snapshot-request", { requestId, sessionId });
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function showDialog(type, message, defaultValue, extras = {}, runId) {
|
||
|
|
const win = getMainWindow?.();
|
||
|
|
const webContents = win?.webContents;
|
||
|
|
if (!webContents) {
|
||
|
|
if (type === "confirm") return Promise.resolve(false);
|
||
|
|
if (type === "prompt") return Promise.resolve(defaultValue || "");
|
||
|
|
if (type === "form") return Promise.resolve({});
|
||
|
|
if (type === "waitForTimeout") return Promise.resolve("abort");
|
||
|
|
return Promise.resolve(undefined);
|
||
|
|
}
|
||
|
|
const requestId = randomUUID();
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const timer = setTimeout(() => {
|
||
|
|
pendingDialogs.delete(requestId);
|
||
|
|
reject(new Error("Dialog timed out"));
|
||
|
|
}, 120000);
|
||
|
|
pendingDialogs.set(requestId, {
|
||
|
|
type,
|
||
|
|
runId,
|
||
|
|
resolve: (value) => {
|
||
|
|
clearTimeout(timer);
|
||
|
|
resolve(value);
|
||
|
|
},
|
||
|
|
reject: (err) => {
|
||
|
|
clearTimeout(timer);
|
||
|
|
reject(err);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
webContents.send("netcatty:script:dialog-request", {
|
||
|
|
requestId,
|
||
|
|
type,
|
||
|
|
message,
|
||
|
|
defaultValue,
|
||
|
|
...extras,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function showWaitForTimeoutDialog(pattern, timeoutMs, runId) {
|
||
|
|
return showDialog(
|
||
|
|
"waitForTimeout",
|
||
|
|
`Timed out waiting for "${pattern}" after ${timeoutMs}ms`,
|
||
|
|
undefined,
|
||
|
|
{ pattern, timeoutMs },
|
||
|
|
runId,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function stopScriptSessionLog(sessionId, runId) {
|
||
|
|
const entry = scriptLogTokens.get(sessionId);
|
||
|
|
if (!entry || (runId && entry.runId !== runId)) return;
|
||
|
|
scriptLogTokens.delete(sessionId);
|
||
|
|
await sessionLogStreamManager.stopStream(sessionId, entry.token);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function syncOutputBufferFromSnapshot(sessionId, runId, isAborted = () => false) {
|
||
|
|
const buffer = getOrCreateBuffer(sessionId);
|
||
|
|
const syncStartText = buffer.getText();
|
||
|
|
try {
|
||
|
|
const snapshot = await requestScreenSnapshot(sessionId, runId);
|
||
|
|
if (isAborted()) return;
|
||
|
|
const screenText = (snapshot.lines || []).join("\n");
|
||
|
|
|
||
|
|
// #1960: never mark the startup buffer as fully consumed. Bastion menus
|
||
|
|
// already on screen must stay waitable. Prefer a real viewport when it is
|
||
|
|
// the live screen (drops scrolled-off scrollback for #1821); otherwise seed
|
||
|
|
// the live main-process buffer (empty / lagging / buffer-fallback).
|
||
|
|
if (isAborted()) return;
|
||
|
|
if (isViewportSnapshot(snapshot) && stripTrailingBlankLines(screenText)) {
|
||
|
|
seedBufferFromScreen(buffer, screenText, syncStartText);
|
||
|
|
} else {
|
||
|
|
seedBufferFromScreen(buffer, syncStartText, syncStartText);
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
if (isAborted()) return;
|
||
|
|
// Snapshot failed: still seed whatever the script buffer already has.
|
||
|
|
if (syncStartText) {
|
||
|
|
seedBufferFromScreen(buffer, "", syncStartText);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function runScriptOnSession({
|
||
|
|
runId,
|
||
|
|
scriptId,
|
||
|
|
scriptLabel,
|
||
|
|
sessionId,
|
||
|
|
content,
|
||
|
|
permissionMode = "auto",
|
||
|
|
sessionMeta,
|
||
|
|
}) {
|
||
|
|
rememberRendererSessionMeta(sessionId, sessionMeta);
|
||
|
|
const run = {
|
||
|
|
runId,
|
||
|
|
scriptId,
|
||
|
|
scriptLabel,
|
||
|
|
sessionId,
|
||
|
|
status: "running",
|
||
|
|
startedAt: Date.now(),
|
||
|
|
endedAt: undefined,
|
||
|
|
currentStep: undefined,
|
||
|
|
stepIndex: 0,
|
||
|
|
totalSteps: undefined,
|
||
|
|
progressMode: "activity",
|
||
|
|
activityLabel: undefined,
|
||
|
|
progressLabel: undefined,
|
||
|
|
progressCurrent: undefined,
|
||
|
|
progressTotal: undefined,
|
||
|
|
waitingFor: undefined,
|
||
|
|
logs: [],
|
||
|
|
paused: false,
|
||
|
|
aborted: false,
|
||
|
|
};
|
||
|
|
runs.set(runId, run);
|
||
|
|
broadcastRuns();
|
||
|
|
|
||
|
|
let abortRun = () => {};
|
||
|
|
const abortPromise = new Promise((_, reject) => {
|
||
|
|
abortRun = (reason) => reject(reason || new Error("Script stopped"));
|
||
|
|
});
|
||
|
|
abortPromise.catch(() => {});
|
||
|
|
runAbortControls.set(runId, { abort: abortRun });
|
||
|
|
|
||
|
|
const runtime = createScriptRuntime({
|
||
|
|
sessionId,
|
||
|
|
runId,
|
||
|
|
appVersion: electronModule?.app?.getVersion?.(),
|
||
|
|
appendLog: (id, message) => {
|
||
|
|
const entry = runs.get(id);
|
||
|
|
if (!entry || entry.aborted || entry.endedAt) return;
|
||
|
|
appendRetainedRunLog(entry, { at: Date.now(), message });
|
||
|
|
broadcastRuns();
|
||
|
|
},
|
||
|
|
writeToSession: (sid, data, options) => {
|
||
|
|
if (run.aborted || run.endedAt) return;
|
||
|
|
writeToSession(sid, data, options);
|
||
|
|
},
|
||
|
|
getOutputBuffer: getOrCreateBuffer,
|
||
|
|
getScreenSnapshot: (sid) => requestScreenSnapshot(sid, runId),
|
||
|
|
getSessionMeta,
|
||
|
|
showDialog: (type, message, defaultValue, extras) => showDialog(type, message, defaultValue, extras, runId),
|
||
|
|
showWaitForTimeoutDialog: (pattern, timeoutMs) => showWaitForTimeoutDialog(pattern, timeoutMs, runId),
|
||
|
|
disconnectSession: async (sid) => {
|
||
|
|
if (terminalWorkerManager) {
|
||
|
|
terminalWorkerManager.send("netcatty:close", { sessionId: sid });
|
||
|
|
} else {
|
||
|
|
terminalBridge?.closeSession?.({ sender: {} }, { sessionId: sid });
|
||
|
|
}
|
||
|
|
},
|
||
|
|
startSessionLog: async (sid, logPath) => {
|
||
|
|
const session = sessions?.get(sid);
|
||
|
|
const defaultDir = electronModule?.app?.getPath?.("documents") || process.cwd();
|
||
|
|
const filePath = logPath
|
||
|
|
? path.resolve(String(logPath))
|
||
|
|
: path.join(defaultDir, `netcatty-script-${Date.now()}.log`);
|
||
|
|
const result = sessionLogStreamManager.startStreamToFile(sid, {
|
||
|
|
filePath,
|
||
|
|
hostLabel: session?.hostname || session?.hostLabel || "script",
|
||
|
|
format: "raw",
|
||
|
|
stopRequiresToken: true,
|
||
|
|
});
|
||
|
|
if (!result.ok) {
|
||
|
|
throw new Error(result.error || "Failed to start script log");
|
||
|
|
}
|
||
|
|
scriptLogTokens.set(sid, { runId, token: result.token });
|
||
|
|
},
|
||
|
|
stopSessionLog: async (sid) => {
|
||
|
|
await stopScriptSessionLog(sid, runId);
|
||
|
|
},
|
||
|
|
isPaused: () => Boolean(runs.get(runId)?.paused),
|
||
|
|
isAborted: () => {
|
||
|
|
const entry = runs.get(runId);
|
||
|
|
return Boolean(entry?.aborted || entry?.endedAt);
|
||
|
|
},
|
||
|
|
permissionMode,
|
||
|
|
startedAt: run.startedAt,
|
||
|
|
onStatusChange: (id, patch) => {
|
||
|
|
const entry = runs.get(id);
|
||
|
|
if (!entry || entry.aborted || entry.endedAt) return;
|
||
|
|
Object.assign(entry, patch);
|
||
|
|
broadcastRuns();
|
||
|
|
},
|
||
|
|
});
|
||
|
|
runAbortControls.set(runId, {
|
||
|
|
abort: (reason) => {
|
||
|
|
runtime.stop(reason || new Error("Script stopped"));
|
||
|
|
abortRun(reason);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
try {
|
||
|
|
const operationPromise = (async () => {
|
||
|
|
await syncOutputBufferFromSnapshot(sessionId, runId, () => run.aborted);
|
||
|
|
await runtime.execute(content);
|
||
|
|
})();
|
||
|
|
operationPromise.catch(() => {});
|
||
|
|
await Promise.race([
|
||
|
|
operationPromise,
|
||
|
|
abortPromise,
|
||
|
|
]);
|
||
|
|
if (run.aborted) {
|
||
|
|
run.status = "failed";
|
||
|
|
run.error = run.error || "Stopped by user";
|
||
|
|
} else {
|
||
|
|
run.status = "completed";
|
||
|
|
}
|
||
|
|
run.endedAt = Date.now();
|
||
|
|
run.progressMode = "activity";
|
||
|
|
run.progressLabel = undefined;
|
||
|
|
run.progressCurrent = undefined;
|
||
|
|
run.progressTotal = undefined;
|
||
|
|
} catch (err) {
|
||
|
|
run.status = "failed";
|
||
|
|
run.endedAt = Date.now();
|
||
|
|
run.progressMode = "activity";
|
||
|
|
run.progressLabel = undefined;
|
||
|
|
run.progressCurrent = undefined;
|
||
|
|
run.progressTotal = undefined;
|
||
|
|
run.error = err?.message || String(err);
|
||
|
|
appendRetainedRunLog(run, { at: Date.now(), message: run.error });
|
||
|
|
} finally {
|
||
|
|
settlePendingRunRequests(runId, {
|
||
|
|
reject: run.aborted,
|
||
|
|
reason: new Error("Stopped by user"),
|
||
|
|
});
|
||
|
|
await stopScriptSessionLog(sessionId, runId);
|
||
|
|
runAbortControls.delete(runId);
|
||
|
|
pruneCompletedRuns(runs);
|
||
|
|
broadcastRuns();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleScriptRun(_event, payload = {}) {
|
||
|
|
const {
|
||
|
|
runId: requestedRunId,
|
||
|
|
returnWhenQueued = false,
|
||
|
|
scriptId,
|
||
|
|
scriptLabel,
|
||
|
|
content,
|
||
|
|
sessionId,
|
||
|
|
sessionIds,
|
||
|
|
mode = "parallel",
|
||
|
|
permissionMode = "auto",
|
||
|
|
} = payload;
|
||
|
|
const targets = Array.isArray(sessionIds) && sessionIds.length > 0
|
||
|
|
? sessionIds
|
||
|
|
: sessionId
|
||
|
|
? [sessionId]
|
||
|
|
: [];
|
||
|
|
if (targets.length === 0) {
|
||
|
|
throw new Error("No target session for script run");
|
||
|
|
}
|
||
|
|
if (!content || !String(content).trim()) {
|
||
|
|
throw new Error("Script content is empty");
|
||
|
|
}
|
||
|
|
if (requestedRunId !== undefined && targets.length !== 1) {
|
||
|
|
throw new Error("A caller-provided runId requires exactly one target session");
|
||
|
|
}
|
||
|
|
if (returnWhenQueued && targets.length !== 1) {
|
||
|
|
throw new Error("returnWhenQueued requires exactly one target session");
|
||
|
|
}
|
||
|
|
|
||
|
|
const runIds = [];
|
||
|
|
const queueRun = (sid) => {
|
||
|
|
const generation = scriptRuntimeGeneration;
|
||
|
|
const runId = requestedRunId === undefined ? randomUUID() : String(requestedRunId).trim();
|
||
|
|
if (!runId || runId.length > 128) {
|
||
|
|
throw new Error("Invalid script run id");
|
||
|
|
}
|
||
|
|
if (runs.has(runId) || queuedRunGenerations.has(runId)) {
|
||
|
|
throw new Error(`Script run id already exists: ${runId}`);
|
||
|
|
}
|
||
|
|
runIds.push(runId);
|
||
|
|
queuedRunGenerations.set(runId, generation);
|
||
|
|
return enqueueSessionRun(sid, () => {
|
||
|
|
if (queuedRunGenerations.get(runId) === generation) {
|
||
|
|
queuedRunGenerations.delete(runId);
|
||
|
|
}
|
||
|
|
if (generation !== scriptRuntimeGeneration) return undefined;
|
||
|
|
if (cancelledQueuedRunGenerations.get(runId) === generation) {
|
||
|
|
cancelledQueuedRunGenerations.delete(runId);
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
return runScriptOnSession({
|
||
|
|
runId,
|
||
|
|
scriptId,
|
||
|
|
scriptLabel,
|
||
|
|
sessionId: sid,
|
||
|
|
content,
|
||
|
|
permissionMode,
|
||
|
|
sessionMeta: payload.sessionMeta,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
if (returnWhenQueued) {
|
||
|
|
void queueRun(targets[0]).catch(() => {});
|
||
|
|
return { runIds, runId: runIds[0] };
|
||
|
|
}
|
||
|
|
|
||
|
|
if (mode === "sequential") {
|
||
|
|
for (const sid of targets) {
|
||
|
|
await queueRun(sid);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
await Promise.all(targets.map((sid) => queueRun(sid)));
|
||
|
|
}
|
||
|
|
|
||
|
|
return { runIds, runId: runIds[0] };
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleScriptStop(_event, payload = {}) {
|
||
|
|
const run = runs.get(payload.runId);
|
||
|
|
if (!run) {
|
||
|
|
const generation = queuedRunGenerations.get(payload.runId);
|
||
|
|
if (generation === undefined) return { ok: false };
|
||
|
|
cancelledQueuedRunGenerations.set(payload.runId, generation);
|
||
|
|
return { ok: true };
|
||
|
|
}
|
||
|
|
run.aborted = true;
|
||
|
|
run.paused = false;
|
||
|
|
run.status = "failed";
|
||
|
|
run.error = "Stopped by user";
|
||
|
|
run.endedAt = Date.now();
|
||
|
|
run.waitingFor = undefined;
|
||
|
|
getOrCreateBuffer(run.sessionId).abortWaiters("Stopped by user");
|
||
|
|
settlePendingRunRequests(run.runId, {
|
||
|
|
reject: false,
|
||
|
|
reason: new Error("Stopped by user"),
|
||
|
|
});
|
||
|
|
runAbortControls.get(run.runId)?.abort(new Error("Stopped by user"));
|
||
|
|
broadcastRuns();
|
||
|
|
return { ok: true };
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleScriptPause(_event, payload = {}) {
|
||
|
|
const run = runs.get(payload.runId);
|
||
|
|
if (!run) return { ok: false };
|
||
|
|
run.paused = true;
|
||
|
|
run.status = "paused";
|
||
|
|
broadcastRuns();
|
||
|
|
return { ok: true };
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleScriptResume(_event, payload = {}) {
|
||
|
|
const run = runs.get(payload.runId);
|
||
|
|
if (!run) return { ok: false };
|
||
|
|
run.paused = false;
|
||
|
|
run.status = "running";
|
||
|
|
broadcastRuns();
|
||
|
|
return { ok: true };
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleScriptGetRuns(_event, payload = {}) {
|
||
|
|
const all = Array.from(runs.values()).map(serializeRun);
|
||
|
|
if (payload.sessionId) {
|
||
|
|
return all.filter((run) => run.sessionId === payload.sessionId);
|
||
|
|
}
|
||
|
|
return all;
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleScriptDialogResponse(_event, payload = {}) {
|
||
|
|
const pending = pendingDialogs.get(payload.requestId);
|
||
|
|
if (!pending) return { ok: false };
|
||
|
|
pendingDialogs.delete(payload.requestId);
|
||
|
|
if (payload.cancelled) {
|
||
|
|
pending.reject(new Error("Dialog cancelled"));
|
||
|
|
return { ok: true };
|
||
|
|
}
|
||
|
|
if (pending.type === "confirm") {
|
||
|
|
pending.resolve(Boolean(payload.value));
|
||
|
|
} else if (pending.type === "prompt") {
|
||
|
|
pending.resolve(typeof payload.value === "string" ? payload.value : "");
|
||
|
|
} else if (pending.type === "form") {
|
||
|
|
pending.resolve(payload.value && typeof payload.value === "object" ? payload.value : {});
|
||
|
|
} else if (pending.type === "waitForTimeout") {
|
||
|
|
pending.resolve(typeof payload.value === "string" ? payload.value : "abort");
|
||
|
|
} else {
|
||
|
|
pending.resolve(undefined);
|
||
|
|
}
|
||
|
|
return { ok: true };
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleScriptScreenSnapshotResponse(_event, payload = {}) {
|
||
|
|
const pending = pendingScreenSnapshots.get(payload.requestId);
|
||
|
|
if (!pending) return { ok: false };
|
||
|
|
pendingScreenSnapshots.delete(payload.requestId);
|
||
|
|
pending.resolve(payload.snapshot || bufferFallbackSnapshot(pending.sessionId));
|
||
|
|
return { ok: true };
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleRecordingStart(_event, payload = {}) {
|
||
|
|
const { sessionId } = payload;
|
||
|
|
if (!sessionId) throw new Error("sessionId required");
|
||
|
|
recordings.set(sessionId, {
|
||
|
|
sessionId,
|
||
|
|
startedAt: Date.now(),
|
||
|
|
steps: [],
|
||
|
|
totalBytes: 0,
|
||
|
|
lastTimestamp: Date.now(),
|
||
|
|
});
|
||
|
|
return { ok: true };
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleRecordingStop(_event, payload = {}) {
|
||
|
|
const { sessionId } = payload;
|
||
|
|
const recording = recordings.get(sessionId);
|
||
|
|
if (!recording) {
|
||
|
|
return { steps: [], code: "" };
|
||
|
|
}
|
||
|
|
recordings.delete(sessionId);
|
||
|
|
const code = stepsToJavaScript(recording.steps, new Date(recording.startedAt).toISOString());
|
||
|
|
return { steps: recording.steps, code };
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleRecordingAppendStep(_event, payload = {}) {
|
||
|
|
const { sessionId, step } = payload;
|
||
|
|
const recording = recordings.get(sessionId);
|
||
|
|
if (!recording) return { ok: false, error: "Recording not started" };
|
||
|
|
if (!step) return { ok: false, error: "Recording step is required" };
|
||
|
|
const now = Date.now();
|
||
|
|
const gap = now - recording.lastTimestamp;
|
||
|
|
const additions = [];
|
||
|
|
if (gap > 1000 && step.type === "send") {
|
||
|
|
additions.push({ type: "sleep", value: gap });
|
||
|
|
}
|
||
|
|
additions.push(step);
|
||
|
|
let additionBytes = 0;
|
||
|
|
try {
|
||
|
|
for (const addition of additions) {
|
||
|
|
additionBytes += Buffer.byteLength(JSON.stringify(addition), "utf8");
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
return { ok: false, error: "Invalid recording step" };
|
||
|
|
}
|
||
|
|
const nextStepCount = recording.steps.length + additions.length;
|
||
|
|
const nextTotalBytes = recording.totalBytes + additionBytes;
|
||
|
|
const exceedsLimit = nextStepCount > MAX_SCRIPT_RECORDING_STEPS
|
||
|
|
|| nextTotalBytes > MAX_SCRIPT_RECORDING_BYTES;
|
||
|
|
if (!exceedsLimit) {
|
||
|
|
recording.steps.push(...additions);
|
||
|
|
recording.totalBytes = nextTotalBytes;
|
||
|
|
recording.lastTimestamp = now;
|
||
|
|
}
|
||
|
|
if (
|
||
|
|
exceedsLimit
|
||
|
|
|| recording.steps.length >= MAX_SCRIPT_RECORDING_STEPS
|
||
|
|
|| recording.totalBytes >= MAX_SCRIPT_RECORDING_BYTES
|
||
|
|
) {
|
||
|
|
recordings.delete(sessionId);
|
||
|
|
const retainedSteps = recording.steps;
|
||
|
|
return {
|
||
|
|
ok: false,
|
||
|
|
stopped: true,
|
||
|
|
reason: "limit",
|
||
|
|
error: SCRIPT_RECORDING_LIMIT_ERROR,
|
||
|
|
steps: retainedSteps,
|
||
|
|
code: retainedSteps.length > 0
|
||
|
|
? stepsToJavaScript(retainedSteps, new Date(recording.startedAt).toISOString())
|
||
|
|
: "",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
return { ok: true };
|
||
|
|
}
|
||
|
|
|
||
|
|
function registerHandlers(ipcMain) {
|
||
|
|
ipcMain.handle("netcatty:script:run", handleScriptRun);
|
||
|
|
ipcMain.handle("netcatty:script:stop", handleScriptStop);
|
||
|
|
ipcMain.handle("netcatty:script:pause", handleScriptPause);
|
||
|
|
ipcMain.handle("netcatty:script:resume", handleScriptResume);
|
||
|
|
ipcMain.handle("netcatty:script:get-runs", handleScriptGetRuns);
|
||
|
|
ipcMain.handle("netcatty:script:dialog-response", handleScriptDialogResponse);
|
||
|
|
ipcMain.handle("netcatty:script:screen-snapshot-response", handleScriptScreenSnapshotResponse);
|
||
|
|
ipcMain.handle("netcatty:script:recording:start", handleRecordingStart);
|
||
|
|
ipcMain.handle("netcatty:script:recording:stop", handleRecordingStop);
|
||
|
|
ipcMain.handle("netcatty:script:recording:append-step", handleRecordingAppendStep);
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
MAX_SCRIPT_RECORDING_STEPS,
|
||
|
|
MAX_SCRIPT_RECORDING_BYTES,
|
||
|
|
init,
|
||
|
|
registerHandlers,
|
||
|
|
appendSessionOutput,
|
||
|
|
removeSessionBuffer,
|
||
|
|
releaseSessionResources,
|
||
|
|
resolveStartupSeedText,
|
||
|
|
};
|