Files
SenMeshNet/electron/bridges/terminalBridge.cjs
zhaolei 2f9f8da48e
Some checks failed
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
Initial commit: NetMesh smart ops platform with 3D topology, inspection reports, RCA and AI ops assistant
2026-10-15 13:53:04 +08:00

2593 lines
94 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Terminal Bridge - Handles local shell, telnet/mosh, and serial port sessions
* Extracted from main.cjs for single responsibility
*/
const os = require("node:os");
const fs = require("node:fs");
const net = require("node:net");
const { randomUUID } = require("node:crypto");
const { execFile, execFileSync } = require("node:child_process");
const path = require("node:path");
const { promisify } = require("node:util");
const { StringDecoder } = require("node:string_decoder");
const { ensureNodePtySpawnHelperExecutable } = require("./nodePtySpawnHelperPermissions.cjs");
const { createTerminalFlowPauseArbiter } = require("./terminalFlowPauseArbiter.cjs");
ensureNodePtySpawnHelperExecutable();
const terminalFlowPauseArbiter = createTerminalFlowPauseArbiter();
const trackedFlowPauseSenders = new WeakSet();
const pty = require("node-pty");
const { SerialPort } = require("serialport");
const {
configureTerminalSessionDataEmitter,
emitTerminalSessionData,
} = require("./emitTerminalSessionData.cjs");
const {
getRecentInterruptTrace,
getSessionSnapshot,
logTerminalFlowAckSample,
logTerminalFlowPauseSample,
logTerminalInterruptDebug,
normalizeTrace,
rememberInterruptTrace,
resetTerminalFlowAckSample,
} = require("./terminalInterruptDiagnostics.cjs");
const {
clearSessionFlowState,
setBufferedOutputBytes,
setRendererFlowPaused,
shouldAcceptSessionOutput,
shouldProcessSessionOutput,
trackAck,
} = require("./terminalFlowAck.cjs");
const {
armTerminalInterruptOutputGate,
disarmTerminalInterruptOutputGate,
filterTerminalInterruptOutput,
shouldArmTerminalInterruptOutputGate,
stashPendingInterruptOutputMeta,
takePendingInterruptOutputMeta,
} = require("./terminalInterruptOutputGate.cjs");
const iconv = require("iconv-lite");
const ptyProcessTree = require("./ptyProcessTree.cjs");
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
const { detectShellKind } = require("./ai/ptyExec.cjs");
const { stripAnsi, trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
const { createZmodemSentry } = require("./zmodemHelper.cjs");
const { discoverShells } = require("./shellDiscovery.cjs");
const { isWindowsAppExecutionAliasPath } = require("../../lib/localShell.cjs");
const moshHandshake = require("./moshHandshake.cjs");
const tempDirBridge = require("./tempDirBridge.cjs");
const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs");
const telnetProtocol = require("./telnetProtocol.cjs");
const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs");
const { enableTcpNoDelay } = require("./tcpNoDelay.cjs");
const { releaseConnectionRef } = require("./sshConnectionPool.cjs");
const { normalizeTerminalEncoding, encodeTerminalInput } = require("./terminalEncoding.cjs");
const { isTerminalReportSequence } = require("./terminalReportSequence.cjs");
const { receiveYmodemFiles, sendYmodemCancel, sendYmodemFile } = require("./ymodemTransfer.cjs");
const {
getNativeOpenSshAgentSocket,
getNativeOpenSshForwardingAgentSocket,
prepareSystemSshAgentForAuth,
} = require("./sshAuthHelper.cjs");
const execFileAsync = promisify(execFile);
// Shared references
let sessions = null;
let electronModule = null;
let terminalOutputChannel = null;
let selectZmodemUploadFiles = null;
let selectZmodemDownloadDirectory = null;
let reportOpenedSessionActivity = null;
let terminalDataPipeline = null;
const terminalInputPipelineBarriers = new Map();
const DEFAULT_UTF8_LOCALE = "en_US.UTF-8";
const LOGIN_SHELLS = new Set(["bash", "zsh", "fish", "ksh"]);
const POWERSHELL_SHELLS = new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"]);
function expandHomePath(targetPath) {
if (!targetPath) return targetPath;
if (targetPath === "~") return os.homedir();
if (targetPath.startsWith("~/")) return path.join(os.homedir(), targetPath.slice(2));
return targetPath;
}
function normalizeExecutablePath(targetPath) {
const expanded = expandHomePath(targetPath);
if (!expanded) return expanded;
if (expanded.includes(path.sep) || expanded.startsWith(".")) {
return path.resolve(expanded);
}
return expanded;
}
const getLoginShellArgs = (shellPath) => {
if (!shellPath || process.platform === "win32") return [];
const shellName = path.basename(shellPath);
return LOGIN_SHELLS.has(shellName) ? ["-l"] : [];
};
/**
* Initialize the terminal bridge with dependencies
*/
function init(deps) {
sessions = deps.sessions;
electronModule = deps.electronModule;
terminalOutputChannel = deps.terminalOutputChannel || null;
selectZmodemUploadFiles = deps.selectZmodemUploadFiles || null;
selectZmodemDownloadDirectory = deps.selectZmodemDownloadDirectory || null;
reportOpenedSessionActivity = typeof deps.reportOpenedSessionActivity === "function"
? deps.reportOpenedSessionActivity
: null;
terminalDataPipeline = deps.terminalDataPipeline || null;
terminalInputPipelineBarriers.clear();
configureTerminalSessionDataEmitter({
getSession: (sessionId) => sessions?.get(sessionId),
outputChannel: terminalOutputChannel,
onSessionActivity: reportOpenedSessionActivity,
});
cleanupStaleEtTempDirs();
}
function openTerminalOutputSession(sessionId, webContents) {
const generation = webContents?.claimSessionGeneration?.(sessionId);
const session = sessions?.get?.(sessionId);
if (session && Number.isSafeInteger(generation)) {
session._terminalSessionGeneration = generation;
}
terminalOutputChannel?.openSession?.(sessionId, webContents);
}
function closeTerminalOutputSession(sessionId) {
terminalOutputChannel?.closeSession?.(sessionId);
}
/** @type {Map<string, { resolve: (value: any) => void, timeout: NodeJS.Timeout }>} */
const pendingTerminalSnapshots = new Map();
const pendingTerminalSnapshotApplies = new Map();
const pendingTerminalOutputDrains = new Map();
const pendingAttachedOutputRestores = new Map();
const TERMINAL_SNAPSHOT_TIMEOUT_MS = 2000;
/** In-process (non-worker) attach home mapping: sessionId -> webContentsId */
const attachHomeWebContentsIds = new Map();
const {
markAttachPopupClosePrepared,
retryPendingAttachedSessionOutput,
releaseAttachedSessionState,
setRestoreAttachedSessionOutput,
setAttachHomeLookup,
setFanoutSessionExit,
validateAttachPopupAuthorization,
} = require("./terminalAttachRestore.cjs");
function isAuthorizedAttachIpc(event, payload, sessionId) {
return validateAttachPopupAuthorization(
payload?.authorization,
sessionId,
event?.sender?.id,
);
}
function resolveSessionHomeWebContentsId(sessionId, terminalWorkerManager = null) {
if (!sessionId) return null;
if (terminalWorkerManager?.getSessionWebContentsId) {
const id = terminalWorkerManager.getSessionWebContentsId(sessionId);
if (typeof id === "number") return id;
}
const session = sessions?.get?.(sessionId);
if (typeof session?.webContentsId === "number") return session.webContentsId;
return null;
}
function normalizeKittyKeyboardModeState(value) {
if (!value || typeof value !== "object") return undefined;
const flags = (input) => Number.isFinite(input) ? Math.max(0, Math.floor(input)) & 31 : 0;
const stack = (input) => Array.isArray(input) ? input.slice(-32).map(flags) : [];
return {
mainFlags: flags(value.mainFlags),
alternateFlags: flags(value.alternateFlags),
mainStack: stack(value.mainStack),
alternateStack: stack(value.alternateStack),
alternateScreenActive: value.alternateScreenActive === true,
};
}
/**
* Capture a serialize snapshot from the home renderer before rebinding output
* to an observe popup, so the popup is not an empty shell.
*/
function requestTerminalSessionSnapshot(event, payload, terminalWorkerManager = null) {
const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : "";
if (!sessionId) {
return Promise.resolve({ success: false, snapshot: "", error: "Missing sessionId" });
}
if (!isAuthorizedAttachIpc(event, payload, sessionId)) {
return Promise.resolve({ success: false, snapshot: "", error: "Unauthorized attach request" });
}
const homeId = resolveSessionHomeWebContentsId(sessionId, terminalWorkerManager);
if (typeof homeId !== "number" || !electronModule?.webContents?.fromId) {
return Promise.resolve({ success: false, snapshot: "", error: "Home renderer not found" });
}
let home;
try {
home = electronModule.webContents.fromId(homeId);
} catch {
home = null;
}
if (!home || home.isDestroyed?.()) {
return Promise.resolve({ success: false, snapshot: "", error: "Home renderer destroyed" });
}
const requestId = randomUUID();
return new Promise((resolve) => {
const timeout = setTimeout(() => {
pendingTerminalSnapshots.delete(requestId);
resolve({ success: false, snapshot: "", error: "timeout" });
}, TERMINAL_SNAPSHOT_TIMEOUT_MS);
pendingTerminalSnapshots.set(requestId, { resolve, timeout, webContentsId: home.id });
try {
home.send("netcatty:terminal:snapshot-request", { sessionId, requestId });
} catch (err) {
clearTimeout(timeout);
pendingTerminalSnapshots.delete(requestId);
resolve({ success: false, snapshot: "", error: err?.message || String(err) });
}
});
}
function handleTerminalSessionSnapshotResponse(event, payload) {
const requestId = typeof payload?.requestId === "string" ? payload.requestId : "";
if (!requestId) return;
const pending = pendingTerminalSnapshots.get(requestId);
if (!pending) return;
if (pending.webContentsId !== event?.sender?.id) return;
clearTimeout(pending.timeout);
pendingTerminalSnapshots.delete(requestId);
pending.resolve({
success: true,
snapshot: typeof payload?.snapshot === "string" ? payload.snapshot : "",
kittyKeyboardModeState: normalizeKittyKeyboardModeState(payload?.kittyKeyboardModeState),
kittyKeyboardProtocolEnabled: typeof payload?.kittyKeyboardProtocolEnabled === "boolean"
? payload.kittyKeyboardProtocolEnabled
: undefined,
passwordPromptActive: typeof payload?.passwordPromptActive === "boolean"
? payload.passwordPromptActive
: undefined,
cwd: payload?.cwd === null ? null : typeof payload?.cwd === "string" ? payload.cwd : undefined,
title: payload?.title === null ? null : typeof payload?.title === "string" ? payload.title : undefined,
});
}
function requestTerminalOutputDrain(sessionId, terminalWorkerManager = null) {
const targetId = resolveSessionHomeWebContentsId(sessionId, terminalWorkerManager);
if (typeof targetId !== "number") {
return Promise.resolve({ success: false, error: "Display renderer not found" });
}
const requestId = randomUUID();
return new Promise((resolve) => {
const timeout = setTimeout(() => {
pendingTerminalOutputDrains.delete(requestId);
resolve({ success: false, error: "timeout" });
}, TERMINAL_SNAPSHOT_TIMEOUT_MS);
pendingTerminalOutputDrains.set(requestId, { resolve, timeout, webContentsId: targetId });
const sent = terminalWorkerManager
? terminalWorkerManager.drainOutputSession?.(sessionId, requestId)
: terminalOutputChannel?.drainSession?.(sessionId, requestId);
if (!sent) {
clearTimeout(timeout);
pendingTerminalOutputDrains.delete(requestId);
resolve({ success: false, error: "Output drain unavailable" });
}
});
}
function handleTerminalOutputDrainResponse(event, payload) {
const pending = pendingTerminalOutputDrains.get(payload?.requestId);
if (!pending || pending.webContentsId !== event?.sender?.id) return;
clearTimeout(pending.timeout);
pendingTerminalOutputDrains.delete(payload.requestId);
pending.resolve({ success: true });
}
/**
* Push the observe-popup terminal state back to the home renderer before
* restoring the display route, so reopen/attach doesn't show a stale view.
*/
function applyTerminalSessionSnapshot(event, payload, terminalWorkerManager = null) {
const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : "";
const hasSnapshot = typeof payload?.snapshot === "string";
const snapshot = hasSnapshot ? payload.snapshot : "";
const hasContextSnapshot = typeof payload?.contextSnapshot === "string";
const contextSnapshot = hasContextSnapshot ? payload.contextSnapshot : "";
const hasContextViewportSnapshot = typeof payload?.contextViewportSnapshot === "string";
const contextViewportSnapshot = hasContextViewportSnapshot ? payload.contextViewportSnapshot : "";
const hasContextScrollbackSnapshot = typeof payload?.contextScrollbackSnapshot === "string";
const contextScrollbackSnapshot = hasContextScrollbackSnapshot ? payload.contextScrollbackSnapshot : "";
const hasAlternateScreen = typeof payload?.alternateScreen === "boolean";
const alternateScreen = payload?.alternateScreen === true;
const kittyKeyboardModeState = normalizeKittyKeyboardModeState(payload?.kittyKeyboardModeState);
const kittyKeyboardProtocolEnabled = typeof payload?.kittyKeyboardProtocolEnabled === "boolean"
? payload.kittyKeyboardProtocolEnabled
: undefined;
const passwordPromptActive = typeof payload?.passwordPromptActive === "boolean"
? payload.passwordPromptActive
: undefined;
const cwd = payload?.cwd === null ? null : typeof payload?.cwd === "string" ? payload.cwd : undefined;
const title = payload?.title === null ? null : typeof payload?.title === "string" ? payload.title : undefined;
if (
!sessionId
|| !hasSnapshot
|| !hasContextSnapshot
|| !hasContextViewportSnapshot
|| !hasContextScrollbackSnapshot
|| !hasAlternateScreen
) {
return Promise.resolve({ success: false, error: "Missing sessionId or snapshot" });
}
if (!isAuthorizedAttachIpc(event, payload, sessionId)) {
return Promise.resolve({ success: false, error: "Unauthorized attach request" });
}
let homeId = null;
if (terminalWorkerManager?.getAttachHomeWebContentsId) {
homeId = terminalWorkerManager.getAttachHomeWebContentsId(sessionId);
}
if (homeId == null) {
homeId = attachHomeWebContentsIds.get(sessionId) ?? null;
}
if (typeof homeId !== "number") {
return Promise.resolve({ success: false, error: "Home renderer not found" });
}
try {
const home = findRegisteredMainWebContents(homeId);
if (!home) {
return Promise.resolve({ success: false, error: "Home renderer unavailable" });
}
const requestId = randomUUID();
return new Promise((resolve) => {
const timeout = setTimeout(() => {
pendingTerminalSnapshotApplies.delete(requestId);
resolve({ success: false, error: "timeout" });
}, TERMINAL_SNAPSHOT_TIMEOUT_MS);
pendingTerminalSnapshotApplies.set(requestId, { resolve, timeout, webContentsId: home.id });
try {
home.send("netcatty:terminal:apply-snapshot", {
sessionId,
snapshot,
contextSnapshot,
contextViewportSnapshot,
contextScrollbackSnapshot,
alternateScreen,
kittyKeyboardModeState,
kittyKeyboardProtocolEnabled,
passwordPromptActive,
cwd,
title,
requestId,
});
} catch (err) {
clearTimeout(timeout);
pendingTerminalSnapshotApplies.delete(requestId);
resolve({ success: false, error: err?.message || String(err) });
}
});
} catch (err) {
return Promise.resolve({ success: false, error: err?.message || String(err) });
}
}
function handleTerminalSessionApplySnapshotResponse(event, payload) {
const pending = pendingTerminalSnapshotApplies.get(payload?.requestId);
if (!pending || pending.webContentsId !== event?.sender?.id) return;
clearTimeout(pending.timeout);
pendingTerminalSnapshotApplies.delete(payload.requestId);
pending.resolve(payload?.success === false
? { success: false, error: payload?.error || "Snapshot apply failed" }
: { success: true });
}
/**
* Rebind a live session's output MessagePort to another renderer (e.g. AI
* silent-session observe popup). Keeps the same PTY/stream; only the display
* route moves. Returns the previous webContentsId so the caller can restore.
*
* Worker mode: sessions live in the utilityProcess; display routing is owned
* by terminalWorkerManager (sessionWebContentsIds + output ports).
* In-process mode: sessions Map in this bridge owns webContentsId.
*/
async function rebindTerminalSessionOutput(event, payload, terminalWorkerManager = null) {
const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : "";
if (!sessionId) {
return { success: false, error: "Missing sessionId" };
}
const sender = event?.sender;
if (!sender || sender.isDestroyed?.()) {
return { success: false, error: "Invalid sender" };
}
if (!isAuthorizedAttachIpc(event, payload, sessionId)) {
return { success: false, error: "Unauthorized attach request" };
}
if (terminalWorkerManager) {
try {
const result = await terminalWorkerManager.rebindOutputSession(sessionId, sender.id);
if (result?.success && sender.isDestroyed?.()) {
await restoreAttachedSessionOutput(sessionId, terminalWorkerManager);
return { success: false, error: "Attach window closed during rebind" };
}
return result;
} catch (err) {
return { success: false, error: err?.message || String(err) };
}
}
const session = sessions?.get?.(sessionId);
if (!session) {
return { success: false, error: "Session not found" };
}
const previousWebContentsId =
typeof session.webContentsId === "number" ? session.webContentsId : null;
try {
if (
previousWebContentsId != null
&& previousWebContentsId !== sender.id
&& !attachHomeWebContentsIds.has(sessionId)
) {
attachHomeWebContentsIds.set(sessionId, previousWebContentsId);
}
openTerminalOutputSession(sessionId, sender);
session.webContentsId = sender.id;
if (sender.isDestroyed?.()) {
await restoreAttachedSessionOutput(sessionId, terminalWorkerManager);
return { success: false, error: "Attach window closed during rebind" };
}
return {
success: true,
previousWebContentsId,
webContentsId: sender.id,
};
} catch (err) {
return { success: false, error: err?.message || String(err) };
}
}
function fanoutSessionLifecycleEvent(
sessionId,
primaryWebContentsId,
channel,
payload,
terminalSessionGeneration,
) {
const targets = new Set();
if (typeof primaryWebContentsId === "number") targets.add(primaryWebContentsId);
const homeId = attachHomeWebContentsIds.get(sessionId);
if (typeof homeId === "number") targets.add(homeId);
for (const id of targets) {
try {
const contents = electronModule?.webContents?.fromId?.(id);
contents?.send?.(channel, Number.isSafeInteger(terminalSessionGeneration)
? { ...payload, _terminalSessionGeneration: terminalSessionGeneration }
: payload);
} catch {
// ignore destroyed renderers
}
}
attachHomeWebContentsIds.delete(sessionId);
}
function findRegisteredMainWebContents(preferredId) {
try {
const wm = require("./windowManager.cjs");
const mains = typeof wm.getMainWindows === "function"
? wm.getMainWindows()
: (typeof wm.getMainWindow === "function" ? [wm.getMainWindow()].filter(Boolean) : []);
const liveContents = [];
for (const win of mains) {
const contents = win?.webContents;
if (contents && !contents.isDestroyed?.()) liveContents.push(contents);
}
if (typeof preferredId === "number") {
const preferred = liveContents.find((contents) => contents.id === preferredId);
if (preferred) return preferred;
}
return liveContents[0] || null;
} catch {
// ignore unavailable window manager during isolated tests/startup
}
return null;
}
async function restoreAttachedSessionOutputOnce(
sessionId,
terminalWorkerManager = null,
preferredHomeWebContentsId = null,
) {
if (!sessionId) return { success: false, restored: false };
const restorePauseOwner = "main:attach-restore";
const paused = terminalFlowPauseArbiter.setDirectPaused(
sessionId,
restorePauseOwner,
true,
);
await applyEffectiveSessionFlowPauseAndWait(
{ sender: { id: undefined } },
{ sessionId, paused },
terminalWorkerManager,
);
let result;
if (terminalWorkerManager?.restoreAttachHome) {
result = await terminalWorkerManager.restoreAttachHome(sessionId, preferredHomeWebContentsId);
} else {
const homeId = attachHomeWebContentsIds.get(sessionId);
if (homeId == null) {
result = { success: true, restored: false };
} else {
const session = sessions?.get?.(sessionId);
if (!session) {
attachHomeWebContentsIds.delete(sessionId);
result = { success: true, restored: false };
} else {
try {
const home = findRegisteredMainWebContents(preferredHomeWebContentsId ?? homeId);
if (!home) {
result = { success: false, restored: false, error: "Home renderer unavailable" };
} else {
openTerminalOutputSession(sessionId, home);
session.webContentsId = home.id;
attachHomeWebContentsIds.delete(sessionId);
result = { success: true, restored: true, webContentsId: home.id };
}
} catch (err) {
result = { success: false, restored: false, error: err?.message || String(err) };
}
}
}
}
// Resume only after output has a live destination. If no main renderer is
// currently available, keep the source paused and the home mapping intact so
// a later attach/recovery can safely reclaim the session.
if (result?.success) {
const effectivePaused = terminalFlowPauseArbiter.setDirectPaused(
sessionId,
restorePauseOwner,
false,
);
applyEffectiveSessionFlowPause(
{ sender: { id: undefined } },
{ sessionId, paused: effectivePaused },
terminalWorkerManager,
);
}
return result;
}
function restoreAttachedSessionOutput(
sessionId,
terminalWorkerManager = null,
preferredHomeWebContentsId = null,
) {
if (!sessionId) return Promise.resolve({ success: false, restored: false });
const existing = pendingAttachedOutputRestores.get(sessionId);
if (existing) return existing;
const operation = restoreAttachedSessionOutputOnce(
sessionId,
terminalWorkerManager,
preferredHomeWebContentsId,
);
pendingAttachedOutputRestores.set(sessionId, operation);
const clearPendingRestore = () => {
if (pendingAttachedOutputRestores.get(sessionId) === operation) {
pendingAttachedOutputRestores.delete(sessionId);
}
};
void operation.then(clearPendingRestore, clearPendingRestore);
return operation;
}
/**
* Restore output to a previous renderer after an attach popup closes.
* Falls back to the first live main-ish window if the home webContents is gone.
*/
async function restoreTerminalSessionOutput(event, payload, terminalWorkerManager = null) {
const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : "";
if (!sessionId) {
return { success: false, error: "Missing sessionId" };
}
if (!isAuthorizedAttachIpc(event, payload, sessionId)) {
return { success: false, error: "Unauthorized attach request" };
}
const homeId = payload?.webContentsId;
const target = findRegisteredMainWebContents(homeId);
if (!target) {
return { success: false, error: "No live renderer to restore output to" };
}
if (terminalWorkerManager) {
if (!terminalWorkerManager.hasOpenSession?.(sessionId)) {
// Session already closed — nothing to restore.
return { success: true, restored: false };
}
try {
const result = await terminalWorkerManager.rebindOutputSession(sessionId, target.id);
if (!result?.success) {
return { success: false, error: result?.error || "Failed to restore session output" };
}
terminalWorkerManager.clearAttachHome?.(sessionId);
return { success: true, restored: true, webContentsId: target.id };
} catch (err) {
return { success: false, error: err?.message || String(err) };
}
}
const session = sessions?.get?.(sessionId);
if (!session) {
// Session already closed — nothing to restore.
return { success: true, restored: false };
}
try {
openTerminalOutputSession(sessionId, target);
session.webContentsId = target.id;
attachHomeWebContentsIds.delete(sessionId);
return { success: true, restored: true, webContentsId: target.id };
} catch (err) {
return { success: false, error: err?.message || String(err) };
}
}
/**
* Locate an executable on POSIX systems by name.
*
* macOS GUI Electron apps inherit launchd's minimal PATH
* (`/usr/bin:/bin:/usr/sbin:/sbin`), missing Homebrew and other common
* package-manager directories. `pty.spawn(name)` then either fails
* synchronously with ENOENT or spawns a child that immediately exits
* with no useful error surfaced to the renderer (see issue #842 for the
* Mosh case).
*
* Returns the absolute path on success, or null when the binary cannot
* be located anywhere we know to look. Win32 callers should keep using
* findExecutable() which handles `where.exe` + Windows-specific paths.
*/
const POSIX_EXTRA_PATH_DIRS = [
"/opt/homebrew/bin",
"/opt/homebrew/sbin",
"/usr/local/bin",
"/usr/local/sbin",
"/opt/local/bin",
"/opt/local/sbin",
"/usr/bin",
"/bin",
"/usr/sbin",
"/sbin",
];
function isExecutableFile(candidate) {
try {
const st = fs.statSync(candidate);
if (!st.isFile()) return false;
// Windows has no POSIX execute bit — Node returns mode 0o100666 even for
// .exe / .bat / .cmd files, so 0o111 is unreliable there. Treat any
// regular file as executable on Win32 and let spawn-time PATHEXT /
// extension handling reject non-executables.
if (process.platform === "win32") return true;
return (st.mode & 0o111) !== 0;
} catch {
return false;
}
}
function resolvePosixExecutable(name, opts = {}) {
if (process.platform === "win32") return null;
if (!name || typeof name !== "string") return null;
// Already an absolute or relative path: validate as-is.
if (name.includes("/")) {
return isExecutableFile(name) ? name : null;
}
if (!/^[a-zA-Z0-9._+-]+$/.test(name)) return null;
const seen = new Set();
const dirs = [];
// 1. Honor the caller-supplied PATH first so callers that have already
// merged a host-level environmentVariables.PATH override don't see the
// fallback decline a binary that the spawned process would have found.
// Falls back to the main process PATH when no override is provided.
const pathOverride = Object.prototype.hasOwnProperty.call(opts, "pathOverride")
? opts.pathOverride
: process.env.PATH;
for (const dir of (pathOverride || "").split(":")) {
if (dir && !seen.has(dir)) {
seen.add(dir);
dirs.push(dir);
}
}
// 2. Add directories the GUI launcher's PATH typically misses on macOS/Linux.
for (const dir of POSIX_EXTRA_PATH_DIRS) {
if (!seen.has(dir)) {
seen.add(dir);
dirs.push(dir);
}
}
// 3. User-scoped install locations (nix-profile, cargo, ~/.local).
const home = process.env.HOME;
if (home) {
for (const sub of [".nix-profile/bin", ".cargo/bin", ".local/bin"]) {
const dir = path.join(home, sub);
if (!seen.has(dir)) {
seen.add(dir);
dirs.push(dir);
}
}
}
for (const dir of dirs) {
const candidate = path.join(dir, name);
if (isExecutableFile(candidate)) return candidate;
}
return null;
}
/**
* Find executable path on Windows
*/
function findExecutable(name, opts = {}) {
if (process.platform !== "win32") return name;
const { execFileSync } = require("child_process");
try {
const pathOverride = Object.prototype.hasOwnProperty.call(opts, "pathOverride")
? opts.pathOverride
: process.env.PATH;
const env = { ...process.env, PATH: pathOverride || "" };
const whereExe = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "where.exe");
const result = execFileSync(fs.existsSync(whereExe) ? whereExe : "where.exe", [name], {
encoding: "utf8",
env,
maxBuffer: 1024 * 1024,
timeout: 3_000,
windowsHide: true,
});
const candidates = result
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
// Windows App Execution Aliases (MSIX/Store installs, e.g. winget pwsh 7)
// fail fs.existsSync() because they are reparse points, yet ConPTY can
// spawn the alias path directly. Keep the first alias as a last resort
// behind any regular executable found on the PATH.
let aliasCandidate = null;
for (const candidate of candidates) {
if (isWindowsAppExecutionAliasPath(candidate)) {
aliasCandidate ??= candidate;
continue;
}
if (!fs.existsSync(candidate)) continue;
return candidate;
}
if (aliasCandidate) return aliasCandidate;
} catch (err) {
console.warn(`Could not find ${name} via where.exe:`, err.message);
}
if (!/^[a-zA-Z0-9._-]+$/.test(name)) return name;
const commonPaths = [];
if (name === "pwsh") {
commonPaths.push(
path.join(process.env.ProgramFiles || "C:\\Program Files", "PowerShell", "7", "pwsh.exe"),
path.join(process.env.ProgramW6432 || "C:\\Program Files", "PowerShell", "7", "pwsh.exe"),
);
}
if (name === "powershell") {
commonPaths.push(
path.join(
process.env.SystemRoot || "C:\\Windows",
"System32",
"WindowsPowerShell",
"v1.0",
"powershell.exe",
),
);
}
commonPaths.push(
path.join(process.env.SystemRoot || "C:\\Windows", "System32", "OpenSSH", `${name}.exe`),
path.join(process.env.ProgramFiles || "C:\\Program Files", "Git", "usr", "bin", `${name}.exe`),
path.join(process.env.ProgramFiles || "C:\\Program Files", "OpenSSH", `${name}.exe`),
);
for (const p of commonPaths) {
if (fs.existsSync(p)) return p;
}
return name;
}
function getDefaultLocalShell() {
if (process.platform !== "win32") {
return process.env.SHELL || "/bin/bash";
}
const pwsh = findExecutable("pwsh");
if (pwsh && pwsh.toLowerCase() !== "pwsh") {
return pwsh;
}
const powershell = findExecutable("powershell");
if (powershell && powershell.toLowerCase() !== "powershell") {
return powershell;
}
return "powershell.exe";
}
function getLocalShellArgs(shellPath) {
if (!shellPath) return [];
if (process.platform !== "win32") {
return getLoginShellArgs(shellPath);
}
const shellName = path.basename(shellPath).toLowerCase();
if (POWERSHELL_SHELLS.has(shellName)) {
return ["-NoLogo"];
}
return [];
}
const isUtf8Locale = (value) => typeof value === "string" && /utf-?8/i.test(value);
const isEmptyLocale = (value) => {
if (value === undefined || value === null) return true;
const trimmed = String(value).trim();
if (!trimmed) return true;
return trimmed === "C" || trimmed === "POSIX";
};
const applyLocaleDefaults = (env) => {
const hasUtf8 =
isUtf8Locale(env.LC_ALL) || isUtf8Locale(env.LC_CTYPE) || isUtf8Locale(env.LANG);
if (hasUtf8) return env;
const hasAnyLocale =
!isEmptyLocale(env.LC_ALL) || !isEmptyLocale(env.LC_CTYPE) || !isEmptyLocale(env.LANG);
if (hasAnyLocale) return env;
return {
...env,
LANG: DEFAULT_UTF8_LOCALE,
LC_CTYPE: DEFAULT_UTF8_LOCALE,
LC_ALL: DEFAULT_UTF8_LOCALE,
};
};
/**
* Start a local terminal session
*/
function startLocalSession(event, payload) {
const sessionId = payload?.sessionId || randomUUID();
const defaultShell = getDefaultLocalShell();
// payload.shell may be a discovered shell ID (e.g., "wsl-ubuntu") — resolve it
let resolvedShell = payload?.shell;
let resolvedArgs = payload?.shellArgs;
if (resolvedShell && !/[/\\]/.test(resolvedShell)) {
// Looks like a shell ID, not a path — try to resolve from discovery cache
const shells = discoverShells();
const match = shells.find((s) => s.id === resolvedShell);
if (match) {
resolvedShell = match.command;
resolvedArgs = resolvedArgs ?? match.args;
}
}
const shell = normalizeExecutablePath(resolvedShell) || defaultShell;
const shellArgs = resolvedArgs ?? getLocalShellArgs(shell);
const shellKind = detectShellKind(shell);
const { buildTerminalProcessEnv } = require("./httpNetworkProxyBridge.cjs");
const env = applyLocaleDefaults({
...buildTerminalProcessEnv(process.env),
...(payload?.env || {}),
TERM: "xterm-256color",
COLORTERM: "truecolor",
});
// Determine the starting directory
// Default to home directory if not specified or if specified path is invalid
const defaultCwd = os.homedir();
let cwd = defaultCwd;
if (payload?.cwd) {
try {
// Resolve to absolute path and check if it exists and is a directory
const resolvedPath = path.resolve(expandHomePath(payload.cwd));
if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isDirectory()) {
cwd = resolvedPath;
} else {
console.warn(`[Terminal] Specified cwd "${payload.cwd}" is not a valid directory, using home directory`);
}
} catch (err) {
console.warn(`[Terminal] Error validating cwd "${payload.cwd}":`, err.message);
}
}
const proc = pty.spawn(shell, shellArgs, {
name: env.TERM || "xterm-256color",
cols: payload?.cols || 80,
rows: payload?.rows || 24,
env,
cwd,
encoding: null, // Return Buffer for ZMODEM binary support
// node-pty can only clear ConPTY through its bundled conpty.dll.
useConptyDll: process.platform === "win32",
});
const session = {
proc,
pty: proc,
type: "local",
protocol: "local",
webContentsId: event.sender.id,
hostname: "localhost",
username: (() => {
try {
return os.userInfo().username || "local";
} catch {
return "local";
}
})(),
label: "Local Terminal",
shellExecutable: shell,
shellKind,
flushPendingData: null,
lastIdlePrompt: "",
lastIdlePromptAt: 0,
_promptTrackTail: "",
};
const { claimSessionSlot } = require("./sessionBootEpoch.cjs");
const claim = claimSessionSlot(sessions, sessionId, session, payload?.bootEpoch);
if (!claim.ok) {
try { proc.kill(); } catch { /* ignore */ }
const supersededError = new Error("Local session superseded by a newer reconnect");
supersededError.code = "NETCATTY_BOOT_SUPERSEDED";
throw supersededError;
}
openTerminalOutputSession(sessionId, event.sender);
ptyProcessTree.registerPid(sessionId, proc.pid);
// Start real-time session log stream if configured. The token returned
// by startStream is captured so the corresponding stopStream below only
// tears down THIS stream — a stale exit event from a previous session
// that reused this sessionId would no-op instead of killing a freshly
// started stream after a "Restart" reconnect (issue #916).
let logStreamToken = null;
if (payload?.sessionLog?.enabled && payload?.sessionLog?.directory) {
logStreamToken = sessionLogStreamManager.startStream(sessionId, {
hostLabel: "Local",
hostname: "localhost",
directory: payload.sessionLog.directory,
format: payload.sessionLog.format || "txt",
timestampsEnabled: Boolean(payload.sessionLog.timestampsEnabled),
startTime: Date.now(),
});
session.logStreamToken = logStreamToken;
}
const {
bufferData: bufferLocalData,
flushPaced: flushLocalPaced,
takePendingEntry: takePendingLocal,
discard: discardLocal,
} = createPtyOutputBuffer((data, meta) => {
const contents = electronModule.webContents.fromId(session.webContentsId);
emitTerminalSessionData(contents, sessionId, data, {
session,
cols: session.cols,
rows: session.rows,
meta,
});
}, {
onPendingBytesChange: (bytes) => setBufferedOutputBytes(session, bytes),
shouldAcceptOutput: () => shouldAcceptSessionOutput(session),
});
session.flushPendingData = flushLocalPaced;
session.takePendingData = takePendingLocal;
session.discardPendingData = discardLocal;
// On Windows, node-pty ignores encoding: null and still emits UTF-8
// strings, making raw-byte ZMODEM impossible for local PTY sessions.
// Only wire up the sentry on platforms where encoding: null works.
if (process.platform !== "win32") {
const localDecoder = new StringDecoder("utf8");
const zmodemSentry = createZmodemSentry({
sessionId,
onData(buf) {
const str = localDecoder.write(buf);
if (!str) return;
trackSessionIdlePrompt(session, str);
bufferLocalData(str);
sessionLogStreamManager.appendData(sessionId, str);
},
writeToRemote(buf) {
try { return proc.write(buf); } catch { return true; }
},
getWebContents() {
return electronModule.webContents.fromId(session.webContentsId);
},
selectUploadFiles: selectZmodemUploadFiles
? () => selectZmodemUploadFiles(session.webContentsId, sessionId)
: undefined,
selectDownloadDirectory: selectZmodemDownloadDirectory
? () => selectZmodemDownloadDirectory(session.webContentsId, sessionId)
: undefined,
label: "Local",
});
session.zmodemSentry = zmodemSentry;
proc.onData((data) => {
if (sessions.get(sessionId) !== session) return;
if (!shouldProcessSessionOutput(session, zmodemSentry)) return;
zmodemSentry.consume(data);
});
} else {
proc.onData((data) => {
if (sessions.get(sessionId) !== session) return;
if (!shouldProcessSessionOutput(session)) return;
trackSessionIdlePrompt(session, data);
bufferLocalData(data);
sessionLogStreamManager.appendData(sessionId, data);
});
}
let localExitFinalized = false;
proc.onExit((evt) => {
const finalizeExit = () => {
if (localExitFinalized) return;
localExitFinalized = true;
sessionLogStreamManager.stopStream(sessionId, logStreamToken);
if (sessions.get(sessionId) !== session) return;
ptyProcessTree.unregisterPid(sessionId);
sessions.delete(sessionId);
if (session.closed) return;
// Signal present = killed externally (show disconnected UI).
// No signal = the process exited and the renderer decides whether to
// auto-close based on the reported exit code.
const reason = evt.signal ? "error" : "exited";
fanoutSessionLifecycleEvent(
sessionId,
session.webContentsId,
"netcatty:exit",
{ sessionId, ...evt, reason },
session._terminalSessionGeneration,
);
};
flushLocalPaced(finalizeExit);
});
return { sessionId };
}
/**
* Start a Telnet session using native Node.js net module
*/
const { createTelnetSessionApi } = require("./terminalBridge/telnetSession.cjs");
const telnetSessionApi = createTelnetSessionApi({
get sessions() { return sessions; },
get electronModule() { return electronModule; },
net, randomUUID, StringDecoder, iconv, Buffer, console, setTimeout, clearTimeout,
normalizeTerminalEncoding, encodeTerminalInput, createTelnetAutoLogin, telnetProtocol,
createPtyOutputBuffer, sessionLogStreamManager, createZmodemSentry, ptyProcessTree,
enableTcpNoDelay, trackSessionIdlePrompt, stripAnsi, clearPendingAutomatedWrites,
openTerminalOutputSession, closeTerminalOutputSession,
get selectZmodemUploadFiles() { return selectZmodemUploadFiles; },
get selectZmodemDownloadDirectory() { return selectZmodemDownloadDirectory; },
});
const { startTelnetSession } = telnetSessionApi;
/**
* Resolve Netcatty's bundled bare `mosh-client` binary.
*
* Returns the absolute path or null.
*/
const { createMoshSessionApi } = require("./terminalBridge/moshSession.cjs");
const moshSessionApi = createMoshSessionApi({
get sessions() { return sessions; },
get electronModule() { return electronModule; },
os, fs, net, path, pty, iconv, Buffer, StringDecoder, process, console,
setTimeout, clearTimeout, setInterval, clearInterval,
randomUUID, execFileAsync, ptyProcessTree, sessionLogStreamManager,
stripAnsi, trackSessionIdlePrompt, createZmodemSentry, moshHandshake, tempDirBridge,
createPtyOutputBuffer, enableTcpNoDelay, normalizeTerminalEncoding,
resolvePosixExecutable, findExecutable, isExecutableFile,
openTerminalOutputSession, closeTerminalOutputSession,
get selectZmodemUploadFiles() { return selectZmodemUploadFiles; },
get selectZmodemDownloadDirectory() { return selectZmodemDownloadDirectory; },
ensureMoshStatsConnection: (...args) => require("./sshBridge.cjs").ensureMoshStatsConnection(...args),
getAvailableAgentSocket: getNativeOpenSshAgentSocket,
getAvailableForwardingAgentSocket: getNativeOpenSshForwardingAgentSocket,
prepareSystemSshAgentForAuth,
bundledMoshClient: (...args) => bundledMoshClient(...args),
});
const {
resolveBareMoshClient,
addBundledMoshRuntimeEnv,
createMoshUtf8Decoder,
buildMoshSshAuthArgs,
cleanupMoshAuthTempFiles,
startMoshSessionViaHandshake,
swapToMoshClient,
resolveLangFromCharsetForMosh,
startMoshSession,
} = moshSessionApi;
/**
* EternalTerminal session API. `et` is a self-contained client that performs
* its own SSH bootstrap + ET protocol handshake, so Netcatty just spawns the
* bundled `et` binary as a PTY (no Node handshake wrapper like Mosh needs).
*/
const { createEtSessionApi } = require("./terminalBridge/etSession.cjs");
const etSessionApi = createEtSessionApi({
get sessions() { return sessions; },
get electronModule() { return electronModule; },
os, fs, path, pty, process, console,
randomUUID, execFile, execFileSync, StringDecoder,
sessionLogStreamManager, tempDirBridge,
createZmodemSentry, trackSessionIdlePrompt, createPtyOutputBuffer,
findExecutable,
openTerminalOutputSession, closeTerminalOutputSession,
getAvailableAgentSocket: getNativeOpenSshAgentSocket,
getAvailableForwardingAgentSocket: getNativeOpenSshForwardingAgentSocket,
prepareSystemSshAgentForAuth,
get selectZmodemUploadFiles() { return selectZmodemUploadFiles; },
get selectZmodemDownloadDirectory() { return selectZmodemDownloadDirectory; },
bundledEtClient: (...args) => bundledEtClient(...args),
});
const {
startEtSession,
execOnEtSession,
cleanupStaleEtTempDirs,
cleanupSessionExternalAuthArtifacts,
} = etSessionApi;
/**
* List available serial ports (hardware only)
*/
async function listSerialPorts() {
try {
const ports = await SerialPort.list();
return ports.map(port => ({
path: port.path,
manufacturer: port.manufacturer || '',
serialNumber: port.serialNumber || '',
vendorId: port.vendorId || '',
productId: port.productId || '',
pnpId: port.pnpId || '',
type: 'hardware',
}));
} catch (err) {
console.error("[Serial] Failed to list ports:", err.message);
return [];
}
}
/**
* Start a serial port session (supports both hardware serial ports and PTY devices)
* Note: SerialPort library can open PTY devices directly, they just won't appear in list()
*/
async function startSerialSession(event, options) {
const sessionId = options.sessionId || randomUUID();
const portPath = options.path;
const baudRate = options.baudRate || 115200;
const dataBits = options.dataBits || 8;
const stopBits = options.stopBits || 1;
const parity = options.parity || 'none';
const flowControl = options.flowControl || 'none';
console.log(`[Serial] Starting connection to ${portPath} at ${baudRate} baud`);
return new Promise((resolve, reject) => {
// Token for the log stream we open on this connection. Captured here so
// the close/error handlers can pass it to stopStream and avoid
// tearing down a freshly started stream after a "Restart" reconnect on
// the same sessionId (issue #916).
let logStreamToken = null;
const {
registerPendingBootAbort,
clearPendingBootAbort,
} = require("./sessionBootEpoch.cjs");
const pendingBootAbort = registerPendingBootAbort(sessionId, options?.bootEpoch);
let settled = false;
const settleReject = (err) => {
if (settled) return;
settled = true;
clearPendingBootAbort(sessionId, pendingBootAbort);
reject(err);
};
const settleResolve = (value) => {
if (settled) return;
settled = true;
clearPendingBootAbort(sessionId, pendingBootAbort);
resolve(value);
};
try {
const serialPort = new SerialPort({
path: portPath,
baudRate: baudRate,
dataBits: dataBits,
stopBits: stopBits,
parity: parity,
rtscts: flowControl === 'rts/cts',
xon: flowControl === 'xon/xoff',
xoff: flowControl === 'xon/xoff',
autoOpen: false,
});
const abortPendingOpen = () => {
try { serialPort.close(); } catch { /* ignore */ }
const supersededError = new Error("Connection superseded by a newer reconnect");
supersededError.code = "NETCATTY_BOOT_SUPERSEDED";
settleReject(supersededError);
};
if (pendingBootAbort.signal.aborted) {
abortPendingOpen();
return;
}
pendingBootAbort.signal.addEventListener("abort", abortPendingOpen, { once: true });
serialPort.open((err) => {
if (settled) {
try { serialPort.close(); } catch { /* ignore */ }
return;
}
if (pendingBootAbort.signal.aborted) {
abortPendingOpen();
return;
}
if (err) {
console.error(`[Serial] Failed to open port ${portPath}:`, err.message);
settleReject(new Error(`Failed to open serial port: ${err.message}`));
return;
}
console.log(`[Serial] Connected to ${portPath}`);
const initialSerialEncoding = normalizeTerminalEncoding(options.charset);
const serialDecoderRef = { current: iconv.getDecoder(initialSerialEncoding) };
const session = {
serialPort,
type: 'serial',
protocol: 'serial',
shellKind: 'raw',
encoding: initialSerialEncoding,
// Kept for backward compatibility with aiBridge / mcpServerBridge
// which read session.serialEncoding for exec calls.
serialEncoding: initialSerialEncoding,
decoderRef: serialDecoderRef,
webContentsId: event.sender.id,
};
{
const { claimSessionSlot } = require("./sessionBootEpoch.cjs");
const claim = claimSessionSlot(sessions, sessionId, session, options?.bootEpoch);
if (!claim.ok) {
try { serialPort.close(); } catch { /* ignore */ }
settleReject(new Error("Connection superseded by a newer reconnect"));
return;
}
}
openTerminalOutputSession(sessionId, event.sender);
// Start real-time session log stream if configured
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
logStreamToken = sessionLogStreamManager.startStream(sessionId, {
hostLabel: options.label || portPath,
hostname: portPath,
directory: options.sessionLog.directory,
format: options.sessionLog.format || "txt",
timestampsEnabled: Boolean(options.sessionLog.timestampsEnabled),
startTime: Date.now(),
});
session.logStreamToken = logStreamToken;
}
const serialZmodemSentry = createZmodemSentry({
sessionId,
onData(buf) {
const decoded = serialDecoderRef.current.write(buf);
if (!decoded) return;
const contents = electronModule.webContents.fromId(session.webContentsId);
emitTerminalSessionData(contents, sessionId, decoded, {
session,
cols: session.cols,
rows: session.rows,
});
sessionLogStreamManager.appendData(sessionId, decoded);
},
writeToRemote(buf) {
try { return serialPort.write(buf); } catch { return true; }
},
getWebContents() {
return electronModule.webContents.fromId(session.webContentsId);
},
selectUploadFiles: selectZmodemUploadFiles
? () => selectZmodemUploadFiles(session.webContentsId, sessionId)
: undefined,
selectDownloadDirectory: selectZmodemDownloadDirectory
? () => selectZmodemDownloadDirectory(session.webContentsId, sessionId)
: undefined,
label: "Serial",
});
session.zmodemSentry = serialZmodemSentry;
serialPort.on('data', (data) => {
if (sessions.get(sessionId) !== session) return;
if (session.ymodemActive) return;
if (!shouldProcessSessionOutput(session, serialZmodemSentry)) return;
// data is already Buffer from serialport — feed to sentry
serialZmodemSentry.consume(data);
});
let serialExitFinalized = false;
const finalizeSerialExit = ({ exitCode, error, reason }) => {
if (serialExitFinalized || sessions.get(sessionId) !== session) return;
serialExitFinalized = true;
session.zmodemSentry?.cancel();
session.ymodemAbortController?.abort();
sessionLogStreamManager.stopStream(sessionId, logStreamToken);
const primaryId = session.webContentsId;
ptyProcessTree.unregisterPid(sessionId);
sessions.delete(sessionId);
if (session.closed) return;
fanoutSessionLifecycleEvent(
sessionId,
primaryId,
"netcatty:exit",
{ sessionId, exitCode, ...(error ? { error } : {}), reason },
session._terminalSessionGeneration,
);
};
serialPort.on('error', (err) => {
console.error(`[Serial] Port error: ${err.message}`);
finalizeSerialExit({ exitCode: 1, error: err.message, reason: "error" });
});
serialPort.on('close', () => {
console.log(`[Serial] Port closed`);
finalizeSerialExit({ exitCode: 0, reason: "closed" });
});
settleResolve({ sessionId });
});
} catch (err) {
console.error("[Serial] Failed to start serial session:", err.message);
settleReject(err);
}
});
}
/**
* Write data to a session
*/
function cancelActiveYmodemSession(session) {
if (!session?.ymodemActive) return;
void sendYmodemCancel(session.serialPort);
session.ymodemAbortController?.abort();
}
function pauseSshOutputForInterrupt(session, trace) {
const stream = session?.stream;
if (!stream || typeof stream.pause !== "function") return false;
const flowState = session.flowState;
let alreadyPaused = Boolean(flowState?.appliedPause || flowState?.rendererPaused);
try {
if (typeof stream.isPaused === "function") {
alreadyPaused = alreadyPaused || stream.isPaused();
}
} catch {
// Treat unreadable pause state as not paused; a best-effort pause is fine.
}
if (alreadyPaused) return false;
logTerminalInterruptDebug("interrupt-output-pause-before-write-start", {
session: getSessionSnapshot(session),
}, trace);
try {
stream.pause();
logTerminalInterruptDebug("interrupt-output-pause-before-write-done", {
session: getSessionSnapshot(session),
}, trace);
return true;
} catch (err) {
logTerminalInterruptDebug("interrupt-output-pause-before-write-failed", {
error: err?.message || String(err),
code: err?.code,
session: getSessionSnapshot(session),
}, trace);
return false;
}
}
function clearPendingAutomatedWrites(session) {
const timers = session?.pendingAutomatedWriteTimers;
if (!Array.isArray(timers) || timers.length === 0) return;
for (const timer of timers) clearTimeout(timer);
session.pendingAutomatedWriteTimers = [];
}
function splitTerminalInputIntoLineWrites(data) {
if (typeof data !== "string") return [data];
const chunks = [];
let line = "";
for (let index = 0; index < data.length; index += 1) {
const char = data[index];
if (char === "\r" || char === "\n") {
if (char === "\r" && data[index + 1] === "\n") index += 1;
chunks.push(`${line}\r`);
line = "";
continue;
}
line += char;
}
if (line.length > 0) chunks.push(line);
return chunks.length > 0 ? chunks : [data];
}
function getAutomatedLineDelayMs(payload) {
if (!payload?.automated) return 0;
const lineDelayMs = Number(payload.lineDelayMs);
return Number.isFinite(lineDelayMs) && lineDelayMs > 0 ? Math.min(lineDelayMs, 2000) : 0;
}
function shouldBlockSessionInput(session, data) {
if (session.ymodemActive) {
if (data === '\x03') {
cancelActiveYmodemSession(session);
}
return true;
}
// During ZMODEM transfer, block terminal input (Ctrl+C cancels the transfer)
if (session.zmodemSentry?.isActive()) {
if (data === '\x03') {
session.zmodemSentry.cancel();
}
return true;
}
return false;
}
function writeToSessionNow(payload, data, logRewrite = payload.logRewrite) {
const session = sessions.get(payload.sessionId);
const trace = payload.interruptTrace || null;
if (!session) {
logTerminalInterruptDebug("write-session-missing", {
sessionId: payload.sessionId,
dataCode: data === "\x03" ? "ETX" : undefined,
}, trace);
return;
}
if (shouldBlockSessionInput(session, data)) {
logTerminalInterruptDebug("write-session-blocked-by-transfer", {
sessionId: payload.sessionId,
dataCode: data === "\x03" ? "ETX" : undefined,
session: getSessionSnapshot(session),
}, trace);
return;
}
if (data !== "\x03" && !payload.automated && !isTerminalReportSequence(data)) {
disarmTerminalInterruptOutputGate(session);
}
try {
if (session.type === 'telnet-native' && !payload.automated) {
session.autoLogin?.handleUserInput();
}
// Encode keystrokes with the SAME charset the output path decodes with so
// input and output stay symmetric on non-UTF-8 devices (issue #1216).
// session.encoding is the normalized iconv identifier; it is only set on
// sessions whose output is iconv-decoded (SSH / telnet / serial). Mosh and
// local PTY leave it unset, so encodeTerminalInput returns the original
// UTF-8 string for them. For UTF-8 it also returns the string unchanged, so
// the transport's native string serialization keeps handling that case.
sessionLogStreamManager.registerSudoAutofillInput(payload.sessionId, data);
sessionLogStreamManager.registerProgrammaticCommandLogRewrite(payload.sessionId, logRewrite);
const inputData = session.type === 'telnet-native'
? telnetProtocol.normalizeNvtNewlines(data)
: data;
const outgoing = encodeTerminalInput(inputData, session.encoding);
if (session.stream) {
const shouldLogInterruptWrite = data === "\x03" || trace;
if (shouldLogInterruptWrite) {
logTerminalInterruptDebug("ssh-stream-write-start", {
outgoingBytes: Buffer.isBuffer(outgoing) ? outgoing.length : Buffer.byteLength(String(outgoing)),
dataCode: data === "\x03" ? "ETX" : undefined,
session: getSessionSnapshot(session),
}, trace);
}
const writeResult = session.stream.write(outgoing);
if (
session.blockUntargetedCwdProbe
&& !payload.automated
&& payload.sensitive !== true
&& !isTerminalReportSequence(data)
&& /[\r\n]/.test(String(data || ""))
) {
// Arm recovery only after this generation's interactive stream really
// accepted a submitted user command. Blocked transfers and failed or
// superseded async input never reach this point.
session.pendingCwdRecoveryAfterUserCommand = true;
}
if (shouldLogInterruptWrite) {
logTerminalInterruptDebug("ssh-stream-write-done", {
writeResult,
session: getSessionSnapshot(session),
}, trace);
}
} else if (session.proc) {
session.proc.write(outgoing);
} else if (session.socket) {
// Telnet only: any 0xFF byte going out the wire must be doubled, or
// the peer will treat it as the start of an IAC command sequence and
// eat the next byte (RFC 854 §"Data Stream"). UTF-8 keyboard input
// never produces 0xFF, but paste of binary content and some legacy
// encodings do. Cheap no-op when there is no 0xFF.
let wireData = outgoing;
if (session.type === 'telnet-native' && session.telnetProtocolActive) {
if (typeof wireData === 'string') {
wireData = Buffer.from(wireData, 'utf8');
}
wireData = telnetProtocol.escapeIacForWire(wireData);
}
session.socket.write(wireData);
} else if (session.serialPort) {
session.serialPort.write(outgoing);
}
} catch (err) {
logTerminalInterruptDebug("write-session-error", {
sessionId: payload.sessionId,
error: err?.message || String(err),
code: err?.code,
session: getSessionSnapshot(session),
}, trace);
if (err.code !== 'EPIPE' && err.code !== 'ERR_STREAM_DESTROYED') {
console.warn("Write failed", err);
}
}
}
function writeToSessionWithInterception(
payload,
data,
logRewrite = payload.logRewrite,
expectedSession = sessions.get(payload.sessionId),
) {
const bypass = payload?.sensitive === true || isTerminalReportSequence(data);
const hasInterceptor = Boolean(
terminalDataPipeline?.interceptInput
&& terminalDataPipeline.has?.(payload.sessionId, "input"),
);
const previous = terminalInputPipelineBarriers.get(payload.sessionId);
if (!hasInterceptor && !previous) {
writeToSessionNow(payload, data, logRewrite);
return;
}
const writeIfCurrent = (nextData) => {
const current = sessions.get(payload.sessionId);
if (!current || current !== expectedSession || current.closed) return;
writeToSessionNow(payload, nextData, logRewrite);
};
const write = async () => {
if (!hasInterceptor) {
writeIfCurrent(data);
return;
}
try {
const transformed = await terminalDataPipeline.interceptInput(payload.sessionId, data, {
sensitive: payload?.sensitive === true,
bypass,
});
writeIfCurrent(transformed);
} catch {
writeIfCurrent(data);
}
};
const operation = previous ? previous.then(write, write) : Promise.resolve().then(write);
terminalInputPipelineBarriers.set(payload.sessionId, operation);
void operation.finally(() => {
if (terminalInputPipelineBarriers.get(payload.sessionId) === operation) {
terminalInputPipelineBarriers.delete(payload.sessionId);
}
});
}
function writeToSession(event, payload) {
const session = sessions.get(payload.sessionId);
if (!session) return;
try {
reportOpenedSessionActivity?.({ sessionId: payload.sessionId, phase: "touch" });
} catch {
// Activity tracking must not interfere with terminal input.
}
if (!payload.automated && !isTerminalReportSequence(payload.data)) {
clearPendingAutomatedWrites(session);
}
if (shouldBlockSessionInput(session, payload.data)) {
return;
}
const lineDelayMs = getAutomatedLineDelayMs(payload);
const lineChunks = lineDelayMs > 0 ? splitTerminalInputIntoLineWrites(payload.data) : [payload.data];
if (lineDelayMs > 0 && lineChunks.length > 1) {
clearPendingAutomatedWrites(session);
session.pendingAutomatedWriteTimers = [];
lineChunks.forEach((chunk, index) => {
const sendChunk = () => {
const current = sessions.get(payload.sessionId);
if (!current) return;
writeToSessionWithInterception(
{ ...payload, lineDelayMs: undefined },
chunk,
index === 0 ? payload.logRewrite : undefined,
current,
);
};
if (index === 0) {
sendChunk();
return;
}
const timer = setTimeout(sendChunk, index * lineDelayMs);
session.pendingAutomatedWriteTimers.push(timer);
});
return;
}
writeToSessionWithInterception(payload, payload.data, payload.logRewrite, session);
}
function drainPendingOutputForInterrupt(sessionId, session, trace) {
if (typeof session?.takePendingData !== "function") return;
const pendingEntry = session.takePendingData();
const pending = typeof pendingEntry === "string" ? pendingEntry : pendingEntry?.data;
const pendingMeta = typeof pendingEntry === "string" ? undefined : pendingEntry?.meta;
if (!pending) return;
const output = filterTerminalInterruptOutput(session, pending);
if (!output.accepted || output.droppedBytes > 0) {
logTerminalInterruptDebug("interrupt-pending-output-filtered", {
session: getSessionSnapshot(session),
droppedBytes: output.droppedBytes,
reason: output.reason,
accepted: output.accepted,
}, trace);
}
if (!output.accepted || !output.data) {
stashPendingInterruptOutputMeta(session, pendingMeta);
return;
}
const outputMeta = takePendingInterruptOutputMeta(session, pendingMeta);
const contents = electronModule.webContents.fromId(session.webContentsId);
emitTerminalSessionData(contents, sessionId, output.data, {
session,
cols: session.cols,
rows: session.rows,
meta: outputMeta,
});
}
function interruptSession(event, payload) {
const session = sessions.get(payload.sessionId);
const trace = normalizeTrace(payload);
if (!session) {
logTerminalInterruptDebug("interrupt-session-missing", {
sessionId: payload.sessionId,
senderId: event?.sender?.id,
}, trace);
return;
}
rememberInterruptTrace(session, trace);
resetTerminalFlowAckSample(session);
logTerminalInterruptDebug("interrupt-session-received", {
sessionId: payload.sessionId,
senderId: event?.sender?.id,
rendererPriority: trace?.rendererPriority,
session: getSessionSnapshot(session),
}, trace);
clearPendingAutomatedWrites(session);
const shouldDrainOldOutput = shouldArmTerminalInterruptOutputGate(session);
const pausedForInterrupt = shouldDrainOldOutput
? pauseSshOutputForInterrupt(session, trace)
: false;
if (shouldDrainOldOutput) {
armTerminalInterruptOutputGate(session);
logTerminalInterruptDebug("interrupt-output-drain-armed", {
session: getSessionSnapshot(session),
}, trace);
drainPendingOutputForInterrupt(payload.sessionId, session, trace);
}
logTerminalInterruptDebug("interrupt-clear-flow-start", {
session: getSessionSnapshot(session),
}, trace);
clearSessionFlowState(session, { resume: !shouldDrainOldOutput });
logTerminalInterruptDebug("interrupt-clear-flow-done", {
session: getSessionSnapshot(session),
}, trace);
writeToSessionNow({ sessionId: payload.sessionId, interruptTrace: trace }, "\x03");
if (shouldDrainOldOutput || pausedForInterrupt) {
queueMicrotask(() => {
if (sessions.get(payload.sessionId) !== session) return;
try {
session.stream?.resume?.();
logTerminalInterruptDebug("interrupt-output-resumed-after-write", {
session: getSessionSnapshot(session),
}, trace);
} catch (err) {
logTerminalInterruptDebug("interrupt-output-resume-after-write-failed", {
error: err?.message || String(err),
code: err?.code,
session: getSessionSnapshot(session),
}, trace);
}
});
}
}
async function sendSerialYmodem(_event, payload) {
const session = sessions.get(payload?.sessionId);
if (!session || !session.serialPort || session.type !== 'serial') {
return { success: false, error: "YMODEM send requires an active serial session" };
}
if (session.ymodemActive) {
return { success: false, error: "A YMODEM transfer is already in progress" };
}
if (session.zmodemSentry?.isActive()) {
return { success: false, error: "Another serial file transfer is already in progress" };
}
if (!payload?.filePath || typeof payload.filePath !== "string") {
return { success: false, error: "No file selected" };
}
const abortController = new AbortController();
session.ymodemActive = true;
session.ymodemAbortController = abortController;
try {
const result = await sendYmodemFile(session.serialPort, payload.filePath, {
abortSignal: abortController.signal,
timeoutMs: Number.isFinite(payload.timeoutMs) ? payload.timeoutMs : undefined,
});
return { success: true, ...result };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
code: error?.code,
};
} finally {
session.ymodemActive = false;
session.ymodemAbortController = null;
}
}
async function receiveSerialYmodem(_event, payload) {
const session = sessions.get(payload?.sessionId);
if (!session || !session.serialPort || session.type !== 'serial') {
return { success: false, error: "YMODEM receive requires an active serial session" };
}
if (session.ymodemActive) {
return { success: false, error: "A YMODEM transfer is already in progress" };
}
if (session.zmodemSentry?.isActive()) {
return { success: false, error: "Another serial file transfer is already in progress" };
}
if (!payload?.destinationDir || typeof payload.destinationDir !== "string") {
return { success: false, error: "No destination directory selected" };
}
const abortController = new AbortController();
session.ymodemActive = true;
session.ymodemAbortController = abortController;
try {
const result = await receiveYmodemFiles(session.serialPort, {
destinationDir: payload.destinationDir,
abortSignal: abortController.signal,
timeoutMs: Number.isFinite(payload.timeoutMs) ? payload.timeoutMs : undefined,
});
return { success: true, ...result };
} catch (error) {
if (error?.code !== "YMODEM_CANCELLED" && error?.code !== "YMODEM_REMOTE_CANCELLED") {
await sendYmodemCancel(session.serialPort);
}
return {
success: false,
error: error instanceof Error ? error.message : String(error),
code: error?.code,
};
} finally {
session.ymodemActive = false;
session.ymodemAbortController = null;
}
}
/**
* Pause or resume a session's source stream for output back-pressure.
* The renderer asks for this when its write backlog crosses a watermark, so a
* flooding source can't outrun the terminal renderer. Works across session
* kinds: ssh2 channel (stream), node-pty (proc), telnet socket, serial port —
* all expose pause()/resume().
*/
function setSessionFlowPaused(event, payload) {
const session = sessions.get(payload.sessionId);
if (!session) {
logTerminalInterruptDebug("flow-paused-session-missing", {
sessionId: payload.sessionId,
paused: Boolean(payload.paused),
senderId: event?.sender?.id,
}, normalizeTrace(payload));
return;
}
const trace = getRecentInterruptTrace(session);
setRendererFlowPaused(session, payload.paused);
if (!payload.paused) {
session.flushPendingData?.();
}
if (trace) {
logTerminalFlowPauseSample(session, {
sessionId: payload.sessionId,
paused: Boolean(payload.paused),
senderId: event?.sender?.id,
});
}
}
function applyEffectiveSessionFlowPause(event, payload, terminalWorkerManager) {
if (terminalWorkerManager) {
terminalWorkerManager.send("netcatty:flow", { ...payload, _flowArbitrated: true }, {
webContentsId: event?.sender?.id,
});
return;
}
setSessionFlowPaused(event, payload);
}
async function applyEffectiveSessionFlowPauseAndWait(event, payload, terminalWorkerManager) {
if (terminalWorkerManager) {
await terminalWorkerManager.request("netcatty:terminal:setFlowPausedAndWait", {
...payload,
_flowArbitrated: true,
}, {
webContentsId: event?.sender?.id,
});
return;
}
setSessionFlowPaused(event, payload);
}
function registerFlowPauseSenderCleanup(event, terminalWorkerManager) {
const sender = event?.sender;
if (!sender || trackedFlowPauseSenders.has(sender) || typeof sender.once !== "function") return;
trackedFlowPauseSenders.add(sender);
const senderId = sender.id;
sender.once("destroyed", () => {
for (const change of terminalFlowPauseArbiter.clearSender(senderId)) {
applyEffectiveSessionFlowPause(
{ sender: { id: senderId } },
change,
terminalWorkerManager,
);
}
});
}
function applyRendererFlowPauseRequest(event, payload, terminalWorkerManager) {
const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : "";
if (!sessionId) return;
registerFlowPauseSenderCleanup(event, terminalWorkerManager);
const paused = terminalFlowPauseArbiter.setDirectPaused(
sessionId,
event?.sender?.id,
Boolean(payload.paused),
);
applyEffectiveSessionFlowPause(event, { ...payload, sessionId, paused }, terminalWorkerManager);
}
function ackSessionFlow(event, payload) {
const session = sessions.get(payload.sessionId);
if (!session) return;
logTerminalFlowAckSample(session, {
sessionId: payload.sessionId,
bytes: Number(payload.bytes),
senderId: event?.sender?.id,
});
trackAck(session, Number(payload.bytes), payload.sessionId);
session.flushPendingData?.();
}
/**
* Resize a session terminal
*/
function resizeSession(event, payload) {
const session = sessions.get(payload.sessionId);
if (!session) return;
if (Number.isFinite(payload.cols)) session.cols = payload.cols;
if (Number.isFinite(payload.rows)) session.rows = payload.rows;
try {
if (session.stream) {
session.stream.setWindow(payload.rows, payload.cols, 0, 0);
} else if (session.proc) {
session.proc.resize(payload.cols, payload.rows);
} else if (session.socket && session.type === 'telnet-native') {
session.cols = payload.cols;
session.rows = payload.rows;
// Only push a NAWS update once Telnet is active and the peer has enabled
// NAWS with DO NAWS; partial console wrappers may leak SB payload bytes.
if (session.telnetProtocolActive) {
session.sendTelnetWindowSize?.();
}
}
} catch (err) {
if (err.code !== 'EPIPE' && err.code !== 'ERR_STREAM_DESTROYED') {
console.warn("Resize failed", err);
}
}
}
/**
* Sync ConPTY's internal buffer after the renderer clears the xterm viewport.
* Without this, Windows shells (especially PowerShell) keep the old cursor
* row and the next Enter reprints a tall blank gap. No-op on SSH/Unix PTYs.
*/
function clearSessionPtyBuffer(event, payload) {
const session = sessions.get(payload?.sessionId);
if (!session?.proc || typeof session.proc.clear !== "function") return;
try {
session.proc.clear();
} catch (err) {
if (err?.code !== "EPIPE" && err?.code !== "ERR_STREAM_DESTROYED") {
console.warn("PTY clear failed", err);
}
}
}
function shouldRevokeOpenedSessionOwnership(payload) {
// Disconnect / reconnect tear down the transport but keep the tab and session
// id. Those closes must not drop host_open ownership.
return payload?.retainOwnership !== true;
}
function reportOpenedSessionClosed(sessionId, payload) {
if (!shouldRevokeOpenedSessionOwnership(payload)) return;
try {
reportOpenedSessionActivity?.({ sessionId, phase: "closed" });
} catch {
// Ownership cleanup must not interfere with session teardown.
}
}
/**
* Close a session
*/
function closeSession(event, payload) {
const session = sessions.get(payload.sessionId);
const {
abortPendingBoot,
forgetBootEpoch,
hasNewerBootEpoch,
hasPendingBootAfter,
sessionMatchesBootEpoch,
} = require("./sessionBootEpoch.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
// Abort in-flight SSH passphrase prompts even before a registry slot exists.
abortPendingBoot(payload.sessionId, payload?.bootEpoch);
passphraseHandler.cancelPassphraseRequestsForSession?.(
payload.sessionId,
"session-closed",
payload?.bootEpoch,
);
if (session && !sessionMatchesBootEpoch(session, payload?.bootEpoch)) {
return { skipped: true, reason: "boot-epoch-mismatch" };
}
if (!session) {
// A direct-mode backend can remove a naturally exited session before the
// renderer closes its tab. That later close is still authoritative for AI
// ownership, unless it belongs to an older boot than a pending reconnect.
if (
hasPendingBootAfter(payload.sessionId, payload?.bootEpoch)
|| hasNewerBootEpoch(payload.sessionId, payload?.bootEpoch)
) {
return { skipped: true, reason: "boot-epoch-mismatch" };
}
releaseAttachedSessionState(payload.sessionId);
reportOpenedSessionClosed(payload.sessionId, payload);
forgetBootEpoch(payload.sessionId, payload?.bootEpoch);
return { closed: false, reason: "missing" };
}
releaseAttachedSessionState(payload.sessionId);
terminalFlowPauseArbiter.clearSession(payload.sessionId);
session.closed = true;
fanoutSessionLifecycleEvent(
payload.sessionId,
session.webContentsId,
"netcatty:exit",
{ sessionId: payload.sessionId, exitCode: 0, reason: "closed" },
session._terminalSessionGeneration,
);
terminalInputPipelineBarriers.delete(payload.sessionId);
closeTerminalOutputSession(payload.sessionId);
try {
clearSessionFlowState(session, { resume: false });
cancelActiveYmodemSession(session);
clearPendingAutomatedWrites(session);
session.zmodemSentry?.cancel();
session.discardPendingData?.();
cleanupSessionExternalAuthArtifacts(session);
session.releaseTelnetGeneration?.();
if (session.stream) {
// Snapshot multiplexing state *before* closing the channel: closing the
// stream can synchronously fire its "close" handler, which nulls
// session.connRef (and may already release the shared connection). Reading
// session.connRef afterwards would then wrongly fall into the legacy path
// and end the shared connection a second time.
const isMultiplexed = !!session.connRef;
// Always close this session's own shell channel.
session.stream.close();
if (isMultiplexed) {
// Multiplexed SSH shell (issue #1204): several tabs may share one
// authenticated connection. Closing this tab must only tear the shared
// transport (and jump-host chain) down once the last channel is gone,
// so route teardown through the reference-counted descriptor instead of
// ending the connection directly. releaseConnectionRef is idempotent and
// ends the chain connections itself when the count reaches zero — so it
// is safe even if the stream "close" handler above already released.
releaseConnectionRef(session);
} else {
// Legacy / non-multiplexed path: this session owns its connection.
session.conn?.end();
if (session.chainConnections) {
for (const c of session.chainConnections) {
try { c.end(); } catch {}
}
}
}
} else if (session.proc) {
session.proc.kill();
// Mosh sessions may also carry a companion ssh2 connection opened
// lazily for host-info stats (issue #1198). ET can use the same pattern.
// Close companions here to avoid leaking them.
try { session.moshStatsConn?.end(); } catch { /* ignore */ }
try { session.etStatsConn?.end(); } catch { /* ignore */ }
} else if (session.socket) {
session.socket.destroy();
} else if (session.serialPort) {
session.serialPort.close();
} else if (session.chainConnections) {
// Non-stream session still carrying a jump-host chain (defensive).
for (const c of session.chainConnections) {
try { c.end(); } catch {}
}
}
} catch (err) {
console.warn("Close failed", err);
} finally {
cleanupMoshAuthTempFiles(session.moshAuthTempFiles);
}
ptyProcessTree.unregisterPid(payload.sessionId);
sessions.delete(payload.sessionId);
forgetBootEpoch(payload.sessionId, payload?.bootEpoch);
reportOpenedSessionClosed(payload.sessionId, payload);
return { closed: true };
}
/**
* Set terminal decoder encoding for an active telnet or serial session.
* SSH sessions are handled by sshBridge's own setEncoding IPC — this one
* only responds to sessions that carry a decoderRef (telnet + serial).
*/
function setSessionEncoding(_event, { sessionId, encoding }) {
const session = sessions?.get(sessionId);
if (!session || !session.decoderRef) {
return { ok: false, encoding: encoding || 'utf-8' };
}
const enc = normalizeTerminalEncoding(encoding);
if (!iconv.encodingExists(enc)) {
return { ok: false, encoding: enc };
}
session.encoding = enc;
// Keep serialEncoding mirror in sync so aiBridge / mcpServerBridge exec
// calls pick up the new encoding too.
if (session.type === 'serial') {
session.serialEncoding = enc;
}
// iconv stateful decoders carry partial-byte state from the previous
// encoding, so swap in a fresh decoder rather than reconfiguring.
session.decoderRef.current = iconv.getDecoder(enc);
return { ok: true, encoding: enc };
}
function getTelnetEchoMode(_event, { sessionId }) {
const mode = sessions?.get(sessionId)?.telnetEchoMode;
return mode
? { success: true, ...mode }
: { success: false, error: "Telnet echo mode unavailable" };
}
/**
* Register IPC handlers for terminal operations
*/
function registerWorkerHandle(ipcMain, terminalWorkerManager, channel) {
ipcMain.handle(channel, (event, payload) => {
return terminalWorkerManager.request(channel, payload, {
webContentsId: event?.sender?.id,
});
});
}
function registerWorkerSend(ipcMain, terminalWorkerManager, channel) {
ipcMain.on(channel, (event, payload) => {
terminalWorkerManager.send(channel, payload, {
webContentsId: event?.sender?.id,
});
});
}
function registerHandlers(ipcMain, options = {}) {
const terminalWorkerManager = options.terminalWorkerManager || null;
// Attach/observe popups rebind display routing in the main process even when
// PTY I/O is owned by the terminal worker. Always register these handlers.
ipcMain.handle("netcatty:terminal:rebindOutput", (event, payload) =>
rebindTerminalSessionOutput(event, payload, terminalWorkerManager));
ipcMain.handle("netcatty:terminal:restoreOutput", (event, payload) =>
restoreTerminalSessionOutput(event, payload, terminalWorkerManager));
ipcMain.handle("netcatty:terminal:requestSnapshot", (event, payload) =>
requestTerminalSessionSnapshot(event, payload, terminalWorkerManager));
ipcMain.handle("netcatty:terminal:applySnapshot", (event, payload) =>
applyTerminalSessionSnapshot(event, payload, terminalWorkerManager));
ipcMain.handle("netcatty:terminal:setFlowPausedAndWait", async (event, payload) => {
const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : "";
if (!sessionId) return { success: false, error: "Missing terminal session" };
if (payload?._flowArbitrated === true && !terminalWorkerManager) {
setSessionFlowPaused(event, payload);
return { success: true };
}
registerFlowPauseSenderCleanup(event, terminalWorkerManager);
const paused = terminalFlowPauseArbiter.setDirectPaused(
sessionId,
event?.sender?.id,
Boolean(payload.paused),
);
await applyEffectiveSessionFlowPauseAndWait(
event,
{ ...payload, sessionId, paused },
terminalWorkerManager,
);
if (!payload?.paused) return { success: true };
return requestTerminalOutputDrain(sessionId, terminalWorkerManager);
});
ipcMain.handle("netcatty:terminal:acquireFlowPauseLease", async (event, payload) => {
const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : "";
if (!sessionId) return { success: false, error: "Missing terminal session" };
registerFlowPauseSenderCleanup(event, terminalWorkerManager);
const lease = terminalFlowPauseArbiter.acquire(sessionId, event?.sender?.id);
try {
await applyEffectiveSessionFlowPauseAndWait(
event,
{ sessionId, paused: true },
terminalWorkerManager,
);
} catch (error) {
const rolledBack = terminalFlowPauseArbiter.release(
sessionId,
event?.sender?.id,
lease.leaseId,
);
applyEffectiveSessionFlowPause(
event,
{ sessionId, paused: rolledBack.paused },
terminalWorkerManager,
);
throw error;
}
return { success: true, leaseId: lease.leaseId };
});
ipcMain.handle("netcatty:terminal:waitFlowPauseLease", async (event, payload) => {
const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : "";
const leaseId = typeof payload?.leaseId === "string" ? payload.leaseId : "";
if (!terminalFlowPauseArbiter.owns(sessionId, event?.sender?.id, leaseId)) {
return { success: false, error: "Invalid terminal flow pause lease" };
}
await applyEffectiveSessionFlowPauseAndWait(
event,
{ sessionId, paused: true },
terminalWorkerManager,
);
return requestTerminalOutputDrain(sessionId, terminalWorkerManager);
});
ipcMain.handle("netcatty:terminal:releaseFlowPauseLease", async (event, payload) => {
const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : "";
const leaseId = typeof payload?.leaseId === "string" ? payload.leaseId : "";
const released = terminalFlowPauseArbiter.release(
sessionId,
event?.sender?.id,
leaseId,
{ keepPaused: payload?.keepPaused === true },
);
if (!released.success) {
return { success: false, error: "Invalid terminal flow pause lease" };
}
applyEffectiveSessionFlowPause(
event,
{ sessionId, paused: released.paused },
terminalWorkerManager,
);
return { success: true };
});
ipcMain.handle("netcatty:terminal:markAttachClosePrepared", (event, payload) => {
const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : "";
const success = markAttachPopupClosePrepared(
payload?.authorization,
sessionId,
event?.sender?.id,
);
return success
? { success: true }
: { success: false, error: "Unauthorized attach request" };
});
ipcMain.on("netcatty:terminal:snapshot-response", handleTerminalSessionSnapshotResponse);
ipcMain.on("netcatty:terminal:output-drain-response", handleTerminalOutputDrainResponse);
ipcMain.on("netcatty:terminal:apply-snapshot-response", handleTerminalSessionApplySnapshotResponse);
ipcMain.on("netcatty:terminal:display-ready", (event, payload) => {
const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : "";
const readyMain = findRegisteredMainWebContents(event?.sender?.id);
if (!sessionId || readyMain?.id !== event?.sender?.id) return;
retryPendingAttachedSessionOutput(sessionId, event.sender.id);
});
setRestoreAttachedSessionOutput((sessionId, preferredHomeWebContentsId) =>
restoreAttachedSessionOutput(sessionId, terminalWorkerManager, preferredHomeWebContentsId));
setAttachHomeLookup((sessionId) => {
if (terminalWorkerManager?.getAttachHomeWebContentsId) {
return terminalWorkerManager.getAttachHomeWebContentsId(sessionId);
}
return attachHomeWebContentsIds.get(sessionId) ?? null;
});
setFanoutSessionExit((sessionId, primaryWebContentsId, payload) => {
terminalFlowPauseArbiter.clearSession(sessionId);
fanoutSessionLifecycleEvent(sessionId, primaryWebContentsId, "netcatty:exit", payload);
});
if (terminalWorkerManager) {
terminalWorkerManager.onSessionClosed?.(({ sessionId }) => {
terminalFlowPauseArbiter.clearSession(sessionId);
});
[
"netcatty:local:start",
"netcatty:telnet:start",
"netcatty:mosh:start",
"netcatty:et:start",
"netcatty:serial:start",
"netcatty:serial:list",
"netcatty:serial:ymodem-send",
"netcatty:serial:ymodem-receive",
"netcatty:local:defaultShell",
"netcatty:local:validatePath",
"netcatty:shells:discover",
"netcatty:terminal:setEncoding",
"netcatty:telnet:getEchoMode",
].forEach((channel) => registerWorkerHandle(ipcMain, terminalWorkerManager, channel));
ipcMain.handle("netcatty:close:await", async (event, payload) => {
const result = await terminalWorkerManager.request("netcatty:close:await", payload, {
webContentsId: event?.sender?.id,
});
// A skipped epoch-mismatch close must not drop the replacement's
// flow-pause lease; only clear after the owned epoch was closed (or the
// session was already gone / returned without skipped).
if (!result?.skipped) {
terminalFlowPauseArbiter.clearSession(payload?.sessionId);
}
return result;
});
ipcMain.on("netcatty:write", (event, payload) => {
// Session log streams started in the main process (manual/script logs)
// sanitize sudo-autofill markers and programmatic command echoes based
// on the *input* that produced them. In worker mode the real write
// handler runs in the utilityProcess, so mirror the rewrite
// registrations into the main-process stream manager before
// forwarding. Both calls are no-ops without an active main-process
// stream for the session.
sessionLogStreamManager.registerSudoAutofillInput(payload?.sessionId, payload?.data);
sessionLogStreamManager.registerProgrammaticCommandLogRewrite(payload?.sessionId, payload?.logRewrite);
try {
reportOpenedSessionActivity?.({ sessionId: payload?.sessionId, phase: "touch" });
} catch {
// Activity tracking must not interfere with terminal input.
}
terminalWorkerManager.send("netcatty:write", payload, {
webContentsId: event?.sender?.id,
});
});
[
"netcatty:interrupt",
"netcatty:resize",
"netcatty:pty:clear",
"netcatty:flow:ack",
].forEach((channel) => registerWorkerSend(ipcMain, terminalWorkerManager, channel));
ipcMain.on("netcatty:flow", (event, payload) => {
if (payload?._flowArbitrated === true) {
setSessionFlowPaused(event, payload);
} else {
applyRendererFlowPauseRequest(event, payload, terminalWorkerManager);
}
});
ipcMain.on("netcatty:close", (event, payload) => {
terminalWorkerManager.send("netcatty:close", payload, {
webContentsId: event?.sender?.id,
});
// Epoch-scoped closes may be no-ops in the worker; clearing here would
// orphan the replacement's pause lease. Unscoped closes still clear
// eagerly; owned closes also clear via onSessionClosed.
if (!Number.isFinite(payload?.bootEpoch)) {
terminalFlowPauseArbiter.clearSession(payload?.sessionId);
}
});
return;
}
ipcMain.handle("netcatty:local:start", startLocalSession);
ipcMain.handle("netcatty:telnet:start", startTelnetSession);
ipcMain.handle("netcatty:mosh:start", startMoshSession);
ipcMain.handle("netcatty:et:start", startEtSession);
ipcMain.handle("netcatty:serial:start", startSerialSession);
ipcMain.handle("netcatty:serial:list", listSerialPorts);
ipcMain.handle("netcatty:serial:ymodem-send", sendSerialYmodem);
ipcMain.handle("netcatty:serial:ymodem-receive", receiveSerialYmodem);
ipcMain.handle("netcatty:local:defaultShell", getDefaultShell);
ipcMain.handle("netcatty:local:validatePath", validatePath);
ipcMain.handle("netcatty:shells:discover", () => discoverShells());
ipcMain.handle("netcatty:terminal:setEncoding", setSessionEncoding);
ipcMain.handle("netcatty:telnet:getEchoMode", getTelnetEchoMode);
ipcMain.on("netcatty:write", writeToSession);
ipcMain.on("netcatty:interrupt", interruptSession);
ipcMain.on("netcatty:resize", resizeSession);
ipcMain.on("netcatty:pty:clear", clearSessionPtyBuffer);
ipcMain.on("netcatty:flow", (event, payload) => {
if (payload?._flowArbitrated === true) {
setSessionFlowPaused(event, payload);
} else {
applyRendererFlowPauseRequest(event, payload, null);
}
});
ipcMain.on("netcatty:flow:ack", ackSessionFlow);
ipcMain.on("netcatty:close", closeSession);
ipcMain.handle("netcatty:close:await", closeSession);
}
/**
* Get the default shell for the current platform
*/
const { createPathValidationApi } = require("./terminalBridge/pathValidation.cjs");
const pathValidationApi = createPathValidationApi({
getDefaultLocalShell, expandHomePath, path, fs, process, console, findExecutable,
});
const { getDefaultShell, validatePath } = pathValidationApi;
/**
* Locate the mosh-client binary bundled by electron-builder via
* `extraResources` (see electron-builder.config.cjs and
* binaricat/MoshCatty releases).
*
* Returns an absolute path when the binary is on disk, otherwise null.
* In dev / non-packaged runs the path is computed against the project
* root so the helper is testable without packaging the app.
*
* Note this returns the network-protocol `mosh-client`, not the `mosh`
* wrapper script. Netcatty drives the SSH bootstrap itself and then
* launches this bundled client directly.
*/
function bundledMoshClient(opts = {}) {
const isWin = (opts.platform || process.platform) === "win32";
const basename = isWin ? "mosh-client.exe" : "mosh-client";
// Packaged: <Resources>/mosh/mosh-client[.exe]
const resourcesPath = opts.resourcesPath || process.resourcesPath;
if (resourcesPath) {
const packaged = path.join(resourcesPath, "mosh", basename);
if (fs.existsSync(packaged) && isExecutableFile(packaged)) return packaged;
}
// Dev fallback: resources/mosh/<platform-arch>/mosh-client[.exe] under
// the project root. Useful for `npm run start` after running
// `npm run fetch:mosh` locally.
const projectRoot = opts.projectRoot || path.resolve(__dirname, "..", "..");
const platform = opts.platform || process.platform;
const arch = opts.arch || process.arch;
const candidates = [];
if (platform === "darwin") {
candidates.push(path.join(projectRoot, "resources", "mosh", "darwin-universal", basename));
} else {
candidates.push(path.join(projectRoot, "resources", "mosh", `${platform}-${arch}`, basename));
}
for (const c of candidates) {
if (fs.existsSync(c) && isExecutableFile(c)) return c;
}
return null;
}
/**
* Locate the EternalTerminal `et` client bundled by electron-builder via
* `extraResources` (see electron-builder.config.cjs and
* .github/workflows/build-et-binaries.yml).
*
* Returns an absolute path when the binary is on disk, otherwise null. In
* dev / non-packaged runs the path is computed against the project root so
* the helper is testable without packaging the app.
*
* `et` is a self-contained client that performs its own SSH bootstrap and
* EternalTerminal protocol handshake; Netcatty just spawns it as a PTY.
*/
function bundledEtClient(opts = {}) {
const isWin = (opts.platform || process.platform) === "win32";
const basename = isWin ? "et.exe" : "et";
// Packaged: <Resources>/et/et[.exe]
const resourcesPath = opts.resourcesPath || process.resourcesPath;
if (resourcesPath) {
const packaged = path.join(resourcesPath, "et", basename);
if (fs.existsSync(packaged) && isExecutableFile(packaged)) return packaged;
}
// Dev fallback: resources/et/<platform-arch>/et[.exe] under the project
// root. Useful for `npm run start` after running `npm run fetch:et` locally.
const projectRoot = opts.projectRoot || path.resolve(__dirname, "..", "..");
const platform = opts.platform || process.platform;
const arch = opts.arch || process.arch;
const candidates = [];
if (platform === "darwin") {
candidates.push(path.join(projectRoot, "resources", "et", "darwin-universal", basename));
} else {
candidates.push(path.join(projectRoot, "resources", "et", `${platform}-${arch}`, basename));
}
for (const c of candidates) {
if (fs.existsSync(c) && isExecutableFile(c)) return c;
}
return null;
}
/**
* Cleanup all sessions - call before app quit
*/
function cleanupAllSessions() {
console.log(`[Terminal] Cleaning up ${sessions.size} sessions before quit`);
for (const [sessionId, session] of sessions) {
try {
session.zmodemSentry?.cancel();
cancelActiveYmodemSession(session);
clearPendingAutomatedWrites(session);
clearSessionFlowState(session, { resume: false });
session.discardPendingData?.();
closeTerminalOutputSession(sessionId);
cleanupSessionExternalAuthArtifacts(session);
session.releaseTelnetGeneration?.();
if (session.stream) {
session.stream.close();
session.conn?.end();
} else if (session.proc) {
// For node-pty on Windows, we need to kill more gracefully
try {
session.proc.kill();
} catch (e) {
// Ignore errors during cleanup
}
// Tear down a Mosh stats companion ssh2 connection if one was opened
// (issue #1198), and the equivalent ET companion when present.
try { session.moshStatsConn?.end(); } catch (e) { /* ignore */ }
try { session.etStatsConn?.end(); } catch (e) { /* ignore */ }
} else if (session.socket) {
session.socket.destroy();
} else if (session.serialPort) {
try {
session.serialPort.close();
} catch (e) {
// Ignore errors during cleanup
}
}
if (session.chainConnections) {
for (const c of session.chainConnections) {
try { c.end(); } catch {}
}
}
} catch (err) {
// Ignore cleanup errors
}
}
for (const [sessionId] of sessions) {
ptyProcessTree.unregisterPid(sessionId);
}
sessions.clear();
terminalOutputChannel?.closeAll?.();
}
/**
* 根据主机地址查找会话(用于巡检复用连接)
*/
function findSessionByHost(hostname, port = 22) {
const DEBUG = process.env.INSPECTION_DEBUG === "1";
if (DEBUG) {
console.log("[TerminalBridge] 查找会话,总会话数:", sessions?.size || 0);
}
if (!sessions) {
if (DEBUG) console.log("[TerminalBridge] sessions 未初始化");
return null;
}
for (const session of sessions.values()) {
if (!session) continue;
// SSH 会话才有 conn
if (!session.conn) continue;
// 匹配主机地址(hostname 或 host 字段)
const sessionHost = session.hostname || session.host || "";
if (DEBUG) {
console.log("[TerminalBridge] SSH会话:", sessionHost,
"| port:", session.port, "| 目标:", hostname);
}
if (sessionHost === hostname) {
if (DEBUG) console.log("[TerminalBridge] 匹配到会话:", sessionHost);
return session;
}
}
return null;
}
module.exports = {
init,
registerHandlers,
findExecutable,
getDefaultLocalShell,
startLocalSession,
startTelnetSession,
startMoshSession,
bundledMoshClient,
resolveBareMoshClient,
addBundledMoshRuntimeEnv,
createMoshUtf8Decoder,
startEtSession,
execOnEtSession,
bundledEtClient,
startSerialSession,
sendSerialYmodem,
receiveSerialYmodem,
listSerialPorts,
writeToSession,
setSessionEncoding,
resizeSession,
clearSessionPtyBuffer,
setSessionFlowPaused,
ackSessionFlow,
closeSession,
interruptSession,
cleanupAllSessions,
getDefaultShell,
validatePath,
findSessionByHost,
};