[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:
638
electron/terminalWorker/aiExec.cjs
Normal file
638
electron/terminalWorker/aiExec.cjs
Normal file
@@ -0,0 +1,638 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const {
|
||||
execViaPty,
|
||||
startPtyJob,
|
||||
execViaChannel,
|
||||
execViaRawPty,
|
||||
} = require("../bridges/ai/ptyExec.cjs");
|
||||
const { getFreshIdlePrompt, formatSyntheticEcho } = require("../bridges/ai/shellUtils.cjs");
|
||||
const {
|
||||
ensureSessionShellKind,
|
||||
ensureSessionShellKindForExec,
|
||||
} = require("../bridges/ai/sessionShellKind.cjs");
|
||||
const {
|
||||
checkBlocklistForShell,
|
||||
resolveSessionBlocklistShellKind,
|
||||
} = require("../bridges/ai/commandSafety.cjs");
|
||||
|
||||
const DEFAULT_BACKGROUND_JOB_TIMEOUT_MS = 60 * 60 * 1000;
|
||||
const DEFAULT_BACKGROUND_JOB_POLL_INTERVAL_MS = 30 * 1000;
|
||||
const BACKGROUND_JOB_RETENTION_MS = 10 * 60 * 1000;
|
||||
const MAX_BACKGROUND_JOB_OUTPUT_CHARS = 256 * 1024;
|
||||
|
||||
function cancelPtyExecsForSession(activePtyExecs, chatSessionId) {
|
||||
if (!chatSessionId) return;
|
||||
for (const [marker, entry] of activePtyExecs) {
|
||||
if (entry.chatSessionId !== chatSessionId) continue;
|
||||
try {
|
||||
if (typeof entry.cancel === "function") entry.cancel();
|
||||
else entry.cleanup?.();
|
||||
} catch {
|
||||
// Ignore cancellation races while the worker session is shutting down.
|
||||
}
|
||||
activePtyExecs.delete(marker);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelWorkerBackgroundJobsForSession(backgroundJobs, chatSessionId) {
|
||||
if (!chatSessionId) return;
|
||||
for (const [, job] of backgroundJobs) {
|
||||
if (job.chatSessionId !== chatSessionId) continue;
|
||||
if (job.status !== "running") continue;
|
||||
try {
|
||||
job.handle?.cancel?.();
|
||||
job.status = "stopping";
|
||||
job.error = "Cancellation requested";
|
||||
job.updatedAt = Date.now();
|
||||
} catch {
|
||||
// Ignore cancellation races while the worker session is shutting down.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createWorkerBackgroundJobId() {
|
||||
return `job_${Date.now().toString(36)}_${crypto.randomBytes(6).toString("hex")}`;
|
||||
}
|
||||
|
||||
function readWorkerJobSnapshot(job) {
|
||||
if (!job) {
|
||||
return {
|
||||
stdout: "",
|
||||
outputBaseOffset: 0,
|
||||
totalOutputChars: 0,
|
||||
outputTruncated: false,
|
||||
};
|
||||
}
|
||||
if (job.status === "running" || job.status === "stopping") {
|
||||
const snapshot = job.handle?.getSnapshot?.();
|
||||
if (snapshot) {
|
||||
const stdout = String(snapshot.stdout || "");
|
||||
const outputBaseOffset = Math.max(0, Number(snapshot.outputBaseOffset) || 0);
|
||||
const totalOutputChars = Math.max(outputBaseOffset + stdout.length, Number(snapshot.totalOutputChars) || 0);
|
||||
return {
|
||||
stdout,
|
||||
outputBaseOffset,
|
||||
totalOutputChars,
|
||||
outputTruncated: Boolean(snapshot.outputTruncated),
|
||||
};
|
||||
}
|
||||
}
|
||||
const stdout = String(job.stdout || "");
|
||||
const outputBaseOffset = Math.max(0, Number(job.outputBaseOffset) || 0);
|
||||
const totalOutputChars = Math.max(outputBaseOffset + stdout.length, Number(job.totalOutputChars) || 0);
|
||||
return {
|
||||
stdout,
|
||||
outputBaseOffset,
|
||||
totalOutputChars,
|
||||
outputTruncated: Boolean(job.outputTruncated),
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkerOutputWindow(stdout) {
|
||||
const fullText = String(stdout || "");
|
||||
const totalOutputChars = fullText.length;
|
||||
const outputBaseOffset = Math.max(0, totalOutputChars - MAX_BACKGROUND_JOB_OUTPUT_CHARS);
|
||||
return {
|
||||
stdout: outputBaseOffset > 0 ? fullText.slice(outputBaseOffset) : fullText,
|
||||
outputBaseOffset,
|
||||
totalOutputChars,
|
||||
outputTruncated: outputBaseOffset > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function refreshRunningWorkerJobSnapshot(job) {
|
||||
if (!job || (job.status !== "running" && job.status !== "stopping")) return;
|
||||
const snapshot = readWorkerJobSnapshot(job);
|
||||
job.stdout = snapshot.stdout;
|
||||
job.outputBaseOffset = snapshot.outputBaseOffset;
|
||||
job.totalOutputChars = snapshot.totalOutputChars;
|
||||
job.outputTruncated = snapshot.outputTruncated;
|
||||
}
|
||||
|
||||
function storeCompletedWorkerJobOutput(job, stdout, metadata = null) {
|
||||
if (metadata && typeof metadata === "object") {
|
||||
const normalizedStdout = String(metadata.stdout ?? stdout ?? "");
|
||||
const outputBaseOffset = Math.max(0, Number(metadata.outputBaseOffset) || 0);
|
||||
const totalOutputChars = Math.max(outputBaseOffset + normalizedStdout.length, Number(metadata.totalOutputChars) || 0);
|
||||
job.stdout = normalizedStdout;
|
||||
job.outputBaseOffset = outputBaseOffset;
|
||||
job.totalOutputChars = totalOutputChars;
|
||||
job.outputTruncated = Boolean(metadata.outputTruncated);
|
||||
job.handle = null;
|
||||
return;
|
||||
}
|
||||
const window = createWorkerOutputWindow(stdout);
|
||||
job.stdout = window.stdout;
|
||||
job.outputBaseOffset = window.outputBaseOffset;
|
||||
job.totalOutputChars = window.totalOutputChars;
|
||||
job.outputTruncated = window.outputTruncated;
|
||||
job.handle = null;
|
||||
}
|
||||
|
||||
function pruneCompletedWorkerJobs(backgroundJobs, now = Date.now()) {
|
||||
for (const [jobId, job] of backgroundJobs) {
|
||||
if (job.status === "running" || job.status === "stopping") continue;
|
||||
const updatedAt = Number(job.updatedAt) || 0;
|
||||
if (updatedAt > 0 && now - updatedAt > BACKGROUND_JOB_RETENTION_MS) {
|
||||
backgroundJobs.delete(jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collapseCarriageReturns(text) {
|
||||
if (!text || text.indexOf("\r") === -1) return text;
|
||||
let result = "";
|
||||
let crPending = false;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
if (ch === "\r") {
|
||||
crPending = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\n") {
|
||||
crPending = false;
|
||||
result += ch;
|
||||
continue;
|
||||
}
|
||||
if (crPending) {
|
||||
const lastNl = result.lastIndexOf("\n");
|
||||
result = lastNl >= 0 ? result.slice(0, lastNl + 1) : "";
|
||||
crPending = false;
|
||||
}
|
||||
result += ch;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function serializeWorkerJob(job, offset = 0) {
|
||||
if (job.status === "running" || job.status === "stopping") {
|
||||
refreshRunningWorkerJobSnapshot(job);
|
||||
}
|
||||
const stdout = job.stdout || "";
|
||||
const outputBaseOffset = job.outputBaseOffset || 0;
|
||||
const totalOutputChars = Math.max(outputBaseOffset + stdout.length, job.totalOutputChars || 0);
|
||||
const numericOffset = Math.max(0, Number(offset) || 0);
|
||||
const relativeOffset = numericOffset <= outputBaseOffset
|
||||
? 0
|
||||
: Math.min(numericOffset - outputBaseOffset, stdout.length);
|
||||
return {
|
||||
ok: true,
|
||||
jobId: job.id,
|
||||
sessionId: job.sessionId,
|
||||
command: job.command,
|
||||
status: job.status,
|
||||
completed: job.status !== "running" && job.status !== "stopping",
|
||||
exitCode: job.exitCode,
|
||||
error: job.error,
|
||||
startedAt: job.startedAt,
|
||||
updatedAt: job.updatedAt,
|
||||
output: collapseCarriageReturns(stdout.slice(relativeOffset)),
|
||||
nextOffset: totalOutputChars,
|
||||
totalOutputChars,
|
||||
outputBaseOffset,
|
||||
outputTruncated: Boolean(job.outputTruncated),
|
||||
recommendedPollIntervalMs: DEFAULT_BACKGROUND_JOB_POLL_INTERVAL_MS,
|
||||
};
|
||||
}
|
||||
|
||||
function getScopedWorkerJob(backgroundJobs, jobId, chatSessionId) {
|
||||
const job = backgroundJobs.get(jobId);
|
||||
if (!job) return null;
|
||||
if (job.chatSessionId) {
|
||||
if (!chatSessionId || job.chatSessionId !== chatSessionId) return null;
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
function getActiveWorkerSessionJobError(activeSessionJobs, sessionId) {
|
||||
if (!activeSessionJobs?.has(sessionId)) return null;
|
||||
return {
|
||||
ok: false,
|
||||
error: "Session already has a long-running command in progress. Wait for it to finish or stop it before starting another command.",
|
||||
};
|
||||
}
|
||||
|
||||
function isNetworkDeviceSession(session, sessionMeta = {}) {
|
||||
const sessionProtocol = session.protocol || session.type || sessionMeta.protocol || "";
|
||||
const isSshOrSerial = sessionProtocol === "ssh" || sessionProtocol === "serial";
|
||||
return {
|
||||
sessionProtocol,
|
||||
isNetworkDevice: (sessionMeta.deviceType === "network" && isSshOrSerial) || sessionProtocol === "serial",
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkerAiExecHandler({
|
||||
sessions,
|
||||
activePtyExecs = new Map(),
|
||||
activeSessionJobs = new Map(),
|
||||
}) {
|
||||
return async function handleWorkerAiExec(event, payload = {}) {
|
||||
const {
|
||||
sessionId,
|
||||
command,
|
||||
chatSessionId,
|
||||
commandTimeoutMs,
|
||||
sessionMeta,
|
||||
enforceWallTimeout,
|
||||
commandBlocklist,
|
||||
} = payload;
|
||||
const session = sessions?.get(sessionId);
|
||||
if (!session) {
|
||||
return { ok: false, error: "Session not found" };
|
||||
}
|
||||
const busy = getActiveWorkerSessionJobError(activeSessionJobs, sessionId);
|
||||
if (busy) return busy;
|
||||
|
||||
const meta = sessionMeta || {};
|
||||
const { sessionProtocol, isNetworkDevice } = isNetworkDeviceSession(session, meta);
|
||||
const timeoutMs = Number.isFinite(commandTimeoutMs) ? commandTimeoutMs : 60000;
|
||||
|
||||
if ((session.protocol === "local" || session.type === "local") && session.shellKind === "unknown") {
|
||||
return {
|
||||
ok: false,
|
||||
error: "AI execution is not supported for this local shell executable. Configure the local terminal to use bash/zsh/sh, fish, PowerShell/pwsh, or cmd.exe.",
|
||||
};
|
||||
}
|
||||
|
||||
const ptyStream = session.stream || session.pty || session.proc;
|
||||
|
||||
if (isNetworkDevice && ptyStream && typeof ptyStream.write === "function") {
|
||||
return execViaRawPty(ptyStream, command, {
|
||||
timeoutMs,
|
||||
trackForCancellation: activePtyExecs,
|
||||
chatSessionId,
|
||||
encoding: sessionProtocol === "serial" ? (session.serialEncoding || "utf8") : "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
if (ptyStream && typeof ptyStream.write === "function") {
|
||||
// Remote sessions may not set shellKind at connect time; probe once so
|
||||
// fish login shells get the fish wrapper (issue #1854). Cancellable so
|
||||
// Stop during the probe window does not still type the command.
|
||||
const probed = await ensureSessionShellKindForExec(session, {
|
||||
trackForCancellation: activePtyExecs,
|
||||
chatSessionId,
|
||||
});
|
||||
if (!probed.ok) return probed;
|
||||
const safety = checkBlocklistForShell(
|
||||
command,
|
||||
resolveSessionBlocklistShellKind(session),
|
||||
commandBlocklist,
|
||||
);
|
||||
if (safety.blocked) {
|
||||
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
return execViaPty(ptyStream, command, {
|
||||
stripMarkers: true,
|
||||
trackForCancellation: activePtyExecs,
|
||||
timeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
loginShellHint: session._loginShellKind,
|
||||
probeLiveShell: true,
|
||||
onProbeAborted: (marker) => {
|
||||
event?.sender?.send?.("netcatty:data", {
|
||||
sessionId,
|
||||
data: `${marker}_R\n`,
|
||||
});
|
||||
},
|
||||
chatSessionId,
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => {
|
||||
event?.sender?.send?.("netcatty:data", {
|
||||
sessionId,
|
||||
data: formatSyntheticEcho(rawCommand),
|
||||
syntheticEcho: true,
|
||||
});
|
||||
},
|
||||
enforceWallTimeout: enforceWallTimeout === true,
|
||||
});
|
||||
}
|
||||
|
||||
if (isNetworkDevice) {
|
||||
return { ok: false, error: "Network device session has no writable PTY stream for command execution" };
|
||||
}
|
||||
|
||||
const sshClient = session.sshClient || session.conn;
|
||||
if (sshClient && typeof sshClient.exec === "function") {
|
||||
const probed = await ensureSessionShellKindForExec(session, {
|
||||
trackForCancellation: activePtyExecs,
|
||||
chatSessionId,
|
||||
});
|
||||
if (!probed.ok) return probed;
|
||||
const safety = checkBlocklistForShell(
|
||||
command,
|
||||
resolveSessionBlocklistShellKind(session),
|
||||
commandBlocklist,
|
||||
);
|
||||
if (safety.blocked) {
|
||||
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
return execViaChannel(sshClient, command, {
|
||||
timeoutMs,
|
||||
trackForCancellation: activePtyExecs,
|
||||
chatSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
if (session.protocol === "serial" && session.serialPort && typeof session.serialPort.write === "function") {
|
||||
if (session.ymodemActive || session.zmodemSentry?.isActive?.()) {
|
||||
return { ok: false, error: "Serial file transfer is already in progress" };
|
||||
}
|
||||
return execViaRawPty(session.serialPort, command, {
|
||||
timeoutMs,
|
||||
trackForCancellation: activePtyExecs,
|
||||
chatSessionId,
|
||||
encoding: session.serialEncoding || "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: false, error: "No terminal stream or SSH client available for this session" };
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkerAiJobStartHandler({
|
||||
sessions,
|
||||
backgroundJobs = new Map(),
|
||||
activeSessionJobs = new Map(),
|
||||
}) {
|
||||
return async function handleWorkerAiJobStart(event, payload = {}) {
|
||||
const {
|
||||
sessionId,
|
||||
command,
|
||||
chatSessionId,
|
||||
commandTimeoutMs,
|
||||
sessionMeta,
|
||||
commandBlocklist,
|
||||
} = payload;
|
||||
if (!sessionId || !command) {
|
||||
return { ok: false, error: "sessionId and command are required" };
|
||||
}
|
||||
if (typeof command !== "string" || !command.trim()) {
|
||||
return { ok: false, error: "Invalid command", exitCode: 1 };
|
||||
}
|
||||
pruneCompletedWorkerJobs(backgroundJobs);
|
||||
|
||||
const session = sessions?.get(sessionId);
|
||||
if (!session) {
|
||||
return { ok: false, error: "Session not found" };
|
||||
}
|
||||
const busy = getActiveWorkerSessionJobError(activeSessionJobs, sessionId);
|
||||
if (busy) return busy;
|
||||
|
||||
if ((session.protocol === "local" || session.type === "local") && session.shellKind === "unknown") {
|
||||
return {
|
||||
ok: false,
|
||||
error: "AI execution is not supported for this local shell executable. Configure the local terminal to use bash/zsh/sh, fish, PowerShell/pwsh, or cmd.exe.",
|
||||
};
|
||||
}
|
||||
|
||||
const meta = sessionMeta || {};
|
||||
const { sessionProtocol, isNetworkDevice } = isNetworkDeviceSession(session, meta);
|
||||
if (isNetworkDevice || sessionProtocol === "serial") {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Background execution currently supports shell-backed PTY sessions only.",
|
||||
};
|
||||
}
|
||||
|
||||
const ptyStream = session.stream || session.pty || session.proc;
|
||||
if (!ptyStream || typeof ptyStream.write !== "function") {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Background execution requires a writable PTY-backed terminal session.",
|
||||
};
|
||||
}
|
||||
|
||||
const jobId = createWorkerBackgroundJobId();
|
||||
const startedAt = Date.now();
|
||||
activeSessionJobs.set(sessionId, jobId);
|
||||
|
||||
// Insert into backgroundJobs *before* the shell-kind probe so
|
||||
// netcatty:ai:catty:cancel / cancelWorkerBackgroundJobsForSession can
|
||||
// latch cancellation while we await. Without this, the first job on an
|
||||
// unprobed remote session has no map entry during the probe and still
|
||||
// writes to the PTY after chat cancel (Codex P2 on #2061).
|
||||
let probeCancelRequested = false;
|
||||
const job = {
|
||||
id: jobId,
|
||||
sessionId,
|
||||
chatSessionId: chatSessionId || null,
|
||||
command,
|
||||
status: "running",
|
||||
startedAt,
|
||||
updatedAt: startedAt,
|
||||
exitCode: null,
|
||||
error: null,
|
||||
stdout: "",
|
||||
outputBaseOffset: 0,
|
||||
totalOutputChars: 0,
|
||||
outputTruncated: false,
|
||||
pendingShellProbe: true,
|
||||
handle: {
|
||||
cancel: () => {
|
||||
probeCancelRequested = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
backgroundJobs.set(jobId, job);
|
||||
|
||||
// Same shellKind probe as foreground exec so background jobs on fish
|
||||
// remote shells are not wrapped as posix (issue #1854). Session is
|
||||
// reserved above so concurrent starts cannot pass the busy check.
|
||||
try {
|
||||
await ensureSessionShellKind(session);
|
||||
} catch (err) {
|
||||
job.status = "failed";
|
||||
job.error = err?.message || String(err);
|
||||
job.updatedAt = Date.now();
|
||||
job.pendingShellProbe = false;
|
||||
if (activeSessionJobs.get(sessionId) === jobId) activeSessionJobs.delete(sessionId);
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
|
||||
if (probeCancelRequested || job.status === "stopping") {
|
||||
job.status = "cancelled";
|
||||
job.error = "Cancelled";
|
||||
job.updatedAt = Date.now();
|
||||
job.pendingShellProbe = false;
|
||||
if (activeSessionJobs.get(sessionId) === jobId) activeSessionJobs.delete(sessionId);
|
||||
return {
|
||||
ok: false,
|
||||
error: "Cancelled",
|
||||
jobId,
|
||||
sessionId,
|
||||
status: "cancelled",
|
||||
};
|
||||
}
|
||||
|
||||
const safety = checkBlocklistForShell(
|
||||
command,
|
||||
resolveSessionBlocklistShellKind(session),
|
||||
commandBlocklist,
|
||||
);
|
||||
if (safety.blocked) {
|
||||
job.status = "failed";
|
||||
job.error = `Command blocked by safety policy. Pattern: ${safety.matchedPattern}`;
|
||||
job.updatedAt = Date.now();
|
||||
job.pendingShellProbe = false;
|
||||
backgroundJobs.delete(jobId);
|
||||
if (activeSessionJobs.get(sessionId) === jobId) activeSessionJobs.delete(sessionId);
|
||||
return { ok: false, error: job.error };
|
||||
}
|
||||
|
||||
const timeoutMs = Math.max(
|
||||
Number.isFinite(commandTimeoutMs) ? commandTimeoutMs : 60000,
|
||||
DEFAULT_BACKGROUND_JOB_TIMEOUT_MS,
|
||||
);
|
||||
let handle;
|
||||
try {
|
||||
handle = startPtyJob(ptyStream, command, {
|
||||
timeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
loginShellHint: session._loginShellKind,
|
||||
probeLiveShell: true,
|
||||
onProbeAborted: (marker) => {
|
||||
event?.sender?.send?.("netcatty:data", {
|
||||
sessionId,
|
||||
data: `${marker}_R\n`,
|
||||
});
|
||||
},
|
||||
chatSessionId,
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => {
|
||||
event?.sender?.send?.("netcatty:data", {
|
||||
sessionId,
|
||||
data: formatSyntheticEcho(rawCommand),
|
||||
syntheticEcho: true,
|
||||
});
|
||||
},
|
||||
maxBufferedChars: MAX_BACKGROUND_JOB_OUTPUT_CHARS,
|
||||
normalizeFinalOutput: false,
|
||||
});
|
||||
} catch (err) {
|
||||
job.status = "failed";
|
||||
job.error = err?.message || String(err);
|
||||
job.updatedAt = Date.now();
|
||||
job.pendingShellProbe = false;
|
||||
if (activeSessionJobs.get(sessionId) === jobId) activeSessionJobs.delete(sessionId);
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
|
||||
job.handle = handle;
|
||||
job.pendingShellProbe = false;
|
||||
|
||||
handle.resultPromise.then((result) => {
|
||||
job.updatedAt = Date.now();
|
||||
job.exitCode = result.exitCode ?? null;
|
||||
storeCompletedWorkerJobOutput(job, result.stdout || "", result);
|
||||
const isForcedCancel = typeof result.error === "string" && result.error.includes("forced");
|
||||
if (result.error === "Cancelled" || isForcedCancel) {
|
||||
job.status = "cancelled";
|
||||
job.error = result.error;
|
||||
if (activeSessionJobs.get(sessionId) === jobId) activeSessionJobs.delete(sessionId);
|
||||
return;
|
||||
}
|
||||
if (result.error) {
|
||||
job.status = "failed";
|
||||
job.error = result.error;
|
||||
if (activeSessionJobs.get(sessionId) === jobId) activeSessionJobs.delete(sessionId);
|
||||
return;
|
||||
}
|
||||
if (typeof result.exitCode === "number" && result.exitCode !== 0) {
|
||||
job.status = "failed";
|
||||
job.error = `Command exited with code ${result.exitCode}`;
|
||||
if (activeSessionJobs.get(sessionId) === jobId) activeSessionJobs.delete(sessionId);
|
||||
return;
|
||||
}
|
||||
job.status = "completed";
|
||||
if (activeSessionJobs.get(sessionId) === jobId) activeSessionJobs.delete(sessionId);
|
||||
}).catch((err) => {
|
||||
job.updatedAt = Date.now();
|
||||
job.status = "failed";
|
||||
job.error = err?.message || String(err);
|
||||
storeCompletedWorkerJobOutput(job, job.stdout || "");
|
||||
if (activeSessionJobs.get(sessionId) === jobId) activeSessionJobs.delete(sessionId);
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
jobId,
|
||||
sessionId,
|
||||
command,
|
||||
status: "running",
|
||||
startedAt,
|
||||
outputMode: "foreground-mirrored",
|
||||
recommendedPollIntervalMs: DEFAULT_BACKGROUND_JOB_POLL_INTERVAL_MS,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkerAiJobPollHandler({ backgroundJobs = new Map() }) {
|
||||
return function handleWorkerAiJobPoll(_event, payload = {}) {
|
||||
const { jobId, offset = 0, chatSessionId } = payload || {};
|
||||
if (!jobId) return { ok: false, error: "jobId is required" };
|
||||
const job = getScopedWorkerJob(backgroundJobs, jobId, chatSessionId || null);
|
||||
if (!job) return { ok: false, error: "Background job not found" };
|
||||
return serializeWorkerJob(job, offset);
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkerAiJobStopHandler({ backgroundJobs = new Map() }) {
|
||||
return function handleWorkerAiJobStop(_event, payload = {}) {
|
||||
const { jobId, chatSessionId } = payload || {};
|
||||
if (!jobId) return { ok: false, error: "jobId is required" };
|
||||
const job = getScopedWorkerJob(backgroundJobs, jobId, chatSessionId || null);
|
||||
if (!job) return { ok: false, error: "Background job not found" };
|
||||
if (job.status === "running") {
|
||||
try {
|
||||
job.handle?.cancel?.();
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
job.status = "stopping";
|
||||
job.error = "Cancellation requested";
|
||||
job.updatedAt = Date.now();
|
||||
}
|
||||
return serializeWorkerJob(job, 0);
|
||||
};
|
||||
}
|
||||
|
||||
function registerWorkerAiExecHandlers(ipcMain, { sessions }) {
|
||||
const activePtyExecs = new Map();
|
||||
const backgroundJobs = new Map();
|
||||
const activeSessionJobs = new Map();
|
||||
ipcMain.handle("netcatty:ai:exec", createWorkerAiExecHandler({
|
||||
sessions,
|
||||
activePtyExecs,
|
||||
activeSessionJobs,
|
||||
}));
|
||||
ipcMain.handle("netcatty:ai:jobStart", createWorkerAiJobStartHandler({
|
||||
sessions,
|
||||
backgroundJobs,
|
||||
activeSessionJobs,
|
||||
}));
|
||||
ipcMain.handle("netcatty:ai:jobPoll", createWorkerAiJobPollHandler({
|
||||
backgroundJobs,
|
||||
}));
|
||||
ipcMain.handle("netcatty:ai:jobStop", createWorkerAiJobStopHandler({
|
||||
backgroundJobs,
|
||||
}));
|
||||
ipcMain.on("netcatty:ai:catty:cancel", (_event, payload = {}) => {
|
||||
cancelPtyExecsForSession(activePtyExecs, payload.chatSessionId);
|
||||
cancelWorkerBackgroundJobsForSession(backgroundJobs, payload.chatSessionId);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cancelWorkerBackgroundJobsForSession,
|
||||
cancelPtyExecsForSession,
|
||||
createWorkerAiExecHandler,
|
||||
createWorkerAiJobStartHandler,
|
||||
createWorkerAiJobPollHandler,
|
||||
createWorkerAiJobStopHandler,
|
||||
registerWorkerAiExecHandlers,
|
||||
};
|
||||
Reference in New Issue
Block a user