[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
857
electron/bridges/scriptBridge.cjs
Normal file
857
electron/bridges/scriptBridge.cjs
Normal file
@@ -0,0 +1,857 @@
|
||||
"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,
|
||||
};
|
||||
Reference in New Issue
Block a user