feat(terminal): add system manager side panel for processes, tmux, and Docker

Introduce workspace-aware System side panel with remote process/tmux/Docker management, terminal popup for interactive attach, capability warmup, review-hardened IPC, performance optimizations, toast action errors, and SSH channel recovery on reconnect.
This commit is contained in:
陈大猫
2026-06-11 04:19:21 +08:00
committed by GitHub
parent 5e323f1f8f
commit 36267717ac
249 changed files with 10201 additions and 22 deletions

View File

@@ -1083,4 +1083,5 @@ module.exports = {
// derives the preferred default key from findAllDefaultPrivateKeys()[0]).
_findDefaultPrivateKey: findDefaultPrivateKey,
_findAllDefaultPrivateKeys: findAllDefaultPrivateKeys,
ensureMoshStatsConnection,
};

View File

@@ -0,0 +1,302 @@
/* eslint-disable no-undef */
function shQuote(str) {
return `'${String(str).replace(/'/g, `'\"'\"'`)}'`;
}
function sanitizeDockerId(id) {
return String(id || "").replace(/[^a-zA-Z0-9]/g, "").slice(0, 64);
}
function sanitizeContainerName(name) {
const trimmed = String(name || "").trim().slice(0, 128);
if (!trimmed) return null;
return trimmed.replace(/[^a-zA-Z0-9_.-]/g, "") || null;
}
function sanitizeImageRef(ref) {
const trimmed = String(ref || "").trim().slice(0, 256);
return trimmed || null;
}
function parseDockerContainers(stdout) {
const containers = [];
for (const line of (stdout || "").split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const row = JSON.parse(trimmed);
containers.push({
id: row.ID || row.Id || "",
name: (row.Names || row.Name || "").replace(/^\//, ""),
image: row.Image || "",
status: row.Status || row.State || "",
state: row.State || "",
ports: row.Ports || "",
createdAt: row.CreatedAt || row.Created || "",
});
} catch {
// skip malformed line
}
}
return containers;
}
function parseDockerStats(stdout) {
const stats = [];
for (const line of (stdout || "").split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const row = JSON.parse(trimmed);
stats.push({
id: row.ID || row.Container || "",
name: row.Name || "",
cpuPercent: parseFloat(String(row.CPUPerc || "0").replace("%", "")) || 0,
memUsage: row.MemUsage || "",
memPercent: parseFloat(String(row.MemPerc || "0").replace("%", "")) || 0,
netIO: row.NetIO || "",
blockIO: row.BlockIO || "",
pids: Number(row.PIDs || row.Pids || 0) || 0,
});
} catch {
// skip
}
}
return stats;
}
function parseDockerImages(stdout) {
const images = [];
for (const line of (stdout || "").split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const row = JSON.parse(trimmed);
const repository = row.Repository || "";
const tag = row.Tag || "";
images.push({
id: row.ID || row.Id || "",
repository,
tag,
size: row.Size || "",
createdAt: row.CreatedAt || row.CreatedSince || "",
digest: row.Digest || "",
name: repository && tag ? `${repository}:${tag}` : repository || tag || row.ID || "",
});
} catch {
// skip
}
}
return images;
}
function summarizeImageInspect(info) {
if (!info) return null;
return {
id: info.Id,
repoTags: info.RepoTags,
repoDigests: info.RepoDigests,
created: info.Created,
size: info.Size,
architecture: info.Architecture,
os: info.Os,
config: {
env: info.Config?.Env,
cmd: info.Config?.Cmd,
entrypoint: info.Config?.Entrypoint,
workingDir: info.Config?.WorkingDir,
exposedPorts: info.Config?.ExposedPorts,
labels: info.Config?.Labels,
},
rootfs: info.RootFS,
history: Array.isArray(info.History) ? info.History.slice(0, 5) : undefined,
};
}
function summarizeContainerInspect(info) {
if (!info) return null;
return {
id: info.Id,
name: info.Name,
image: info.Config?.Image,
state: info.State,
network: info.NetworkSettings,
mounts: info.Mounts,
env: info.Config?.Env,
labels: info.Config?.Labels,
created: info.Created,
path: info.Path,
args: info.Args,
restartPolicy: info.HostConfig?.RestartPolicy,
};
}
function createDockerOpsApi({ execOnSession }) {
async function runDocker(event, sessionId, args, timeoutMs = 15000) {
const cmd = `docker ${args}`;
const result = await execOnSession(event, sessionId, cmd, timeoutMs);
if (!result.success) return result;
if (result.code !== 0 && result.code !== null && result.code !== undefined) {
return {
success: false,
error: (result.stderr || "").trim() || `docker exited with code ${result.code}`,
stderr: result.stderr,
};
}
return result;
}
async function listContainers(event, sessionId) {
const result = await execOnSession(
event,
sessionId,
"docker ps -a --format '{{json .}}'",
12000,
);
if (!result.success) return { success: false, error: result.error };
return { success: true, containers: parseDockerContainers(result.stdout) };
}
async function listImages(event, sessionId) {
const result = await execOnSession(
event,
sessionId,
"docker images --format '{{json .}}'",
12000,
);
if (!result.success) return { success: false, error: result.error };
return { success: true, images: parseDockerImages(result.stdout) };
}
async function getStats(event, payload) {
const sessionId = payload?.sessionId;
if (!sessionId) return { success: false, error: "Missing sessionId" };
const ids = Array.isArray(payload?.ids) ? payload.ids.filter(Boolean) : [];
const idArg = ids.map((id) => sanitizeDockerId(id)).filter(Boolean).join(" ");
const result = await execOnSession(
event,
sessionId,
`docker stats --no-stream --format '{{json .}}' ${idArg}`.trim(),
15000,
);
if (!result.success) return { success: false, error: result.error };
return { success: true, stats: parseDockerStats(result.stdout) };
}
async function inspectContainer(event, payload) {
const { sessionId, containerId } = payload || {};
if (!sessionId || !containerId) return { success: false, error: "Missing params" };
const safeId = sanitizeDockerId(containerId);
const result = await execOnSession(event, sessionId, `docker inspect ${safeId}`, 10000);
if (!result.success) return { success: false, error: result.error };
try {
const parsed = JSON.parse(result.stdout || "[]");
const info = Array.isArray(parsed) ? parsed[0] : parsed;
return { success: true, inspect: summarizeContainerInspect(info) };
} catch {
return { success: false, error: "Failed to parse inspect output" };
}
}
async function inspectImage(event, payload) {
const { sessionId, imageId } = payload || {};
if (!sessionId || !imageId) return { success: false, error: "Missing params" };
const safeId = sanitizeDockerId(imageId);
const result = await execOnSession(event, sessionId, `docker image inspect ${safeId}`, 10000);
if (!result.success) return { success: false, error: result.error };
try {
const parsed = JSON.parse(result.stdout || "[]");
const info = Array.isArray(parsed) ? parsed[0] : parsed;
return { success: true, inspect: summarizeImageInspect(info) };
} catch {
return { success: false, error: "Failed to parse image inspect output" };
}
}
async function containerAction(event, payload) {
const { sessionId, containerId, action, newName } = payload || {};
if (!sessionId || !containerId || !action) return { success: false, error: "Missing params" };
const safeId = sanitizeDockerId(containerId);
switch (action) {
case "start":
return runDocker(event, sessionId, `start ${safeId}`);
case "stop":
return runDocker(event, sessionId, `stop ${safeId}`);
case "restart":
return runDocker(event, sessionId, `restart ${safeId}`);
case "rm":
return runDocker(event, sessionId, `rm -f ${safeId}`);
case "pause":
return runDocker(event, sessionId, `pause ${safeId}`);
case "unpause":
return runDocker(event, sessionId, `unpause ${safeId}`);
case "kill":
return runDocker(event, sessionId, `kill ${safeId}`);
case "rename": {
const next = sanitizeContainerName(newName);
if (!next) return { success: false, error: "Invalid container name" };
return runDocker(event, sessionId, `rename ${safeId} ${shQuote(next)}`);
}
default:
return { success: false, error: `Invalid container action: ${action}` };
}
}
async function imageAction(event, payload) {
const { sessionId, action, imageRef, imageId, force, all, repository, tag } = payload || {};
if (!sessionId || !action) return { success: false, error: "Missing params" };
switch (action) {
case "pull": {
const ref = sanitizeImageRef(imageRef);
if (!ref) return { success: false, error: "Missing image reference" };
return runDocker(event, sessionId, `pull ${shQuote(ref)}`, 600000);
}
case "rm": {
const safeId = sanitizeDockerId(imageId);
if (!safeId) return { success: false, error: "Missing image id" };
const forceFlag = force ? " -f" : "";
return runDocker(event, sessionId, `rmi${forceFlag} ${safeId}`);
}
case "prune": {
const allFlag = all ? " -a" : "";
return runDocker(event, sessionId, `image prune${allFlag} -f`, 120000);
}
case "tag": {
const safeId = sanitizeDockerId(imageId);
const repo = sanitizeImageRef(repository);
const tagName = String(tag || "").trim().slice(0, 128) || "latest";
if (!safeId || !repo) return { success: false, error: "Missing params" };
return runDocker(
event,
sessionId,
`tag ${safeId} ${shQuote(`${repo}:${tagName}`)}`,
);
}
default:
return { success: false, error: `Invalid image action: ${action}` };
}
}
return {
listContainers,
listImages,
getStats,
inspectContainer,
inspectImage,
containerAction,
imageAction,
parseDockerContainers,
parseDockerStats,
parseDockerImages,
};
}
module.exports = {
createDockerOpsApi,
parseDockerContainers,
parseDockerStats,
parseDockerImages,
};

View File

@@ -0,0 +1,27 @@
"use strict";
/** Best-effort check that an ssh2 Client transport is still usable. */
function isSshConnAlive(conn) {
if (!conn) return false;
const sock = conn._sock;
if (sock && sock.destroyed) return false;
return true;
}
/** True when conn.exec failed because the underlying transport/channel is gone. */
function isTransportExecError(message) {
const msg = String(message || "").toLowerCase();
return (
msg.includes("not connected")
|| msg.includes("connection lost")
|| msg.includes("socket hang up")
|| msg.includes("econnreset")
|| msg.includes("closed")
|| msg.includes("destroyed")
|| msg.includes("channel open failure")
|| msg.includes("unable to exec")
|| msg.includes("no response")
);
}
module.exports = { isSshConnAlive, isTransportExecError };

View File

@@ -0,0 +1,33 @@
"use strict";
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const { isSshConnAlive, isTransportExecError } = require("./execConnHealth.cjs");
describe("isSshConnAlive", () => {
it("returns false for missing conn", () => {
assert.equal(isSshConnAlive(null), false);
});
it("returns false when socket is destroyed", () => {
assert.equal(isSshConnAlive({ _sock: { destroyed: true } }), false);
});
it("returns true when socket is alive", () => {
assert.equal(isSshConnAlive({ _sock: { destroyed: false } }), true);
assert.equal(isSshConnAlive({}), true);
});
});
describe("isTransportExecError", () => {
it("detects common ssh2 transport failures", () => {
assert.equal(isTransportExecError("Not connected"), true);
assert.equal(isTransportExecError("Channel open failure: open failed"), true);
assert.equal(isTransportExecError("read ECONNRESET"), true);
});
it("ignores unrelated command errors", () => {
assert.equal(isTransportExecError("docker: no such container"), false);
assert.equal(isTransportExecError("permission denied"), false);
});
});

View File

@@ -0,0 +1,219 @@
/* eslint-disable no-undef */
const { isSshConnAlive, isTransportExecError } = require("./execConnHealth.cjs");
function createExecOnSessionApi(ctx) {
with (ctx) {
/** Serialize remote exec per session to avoid SSH channel storms. */
const execQueues = new Map();
function getSession(sessionId) {
return sessions?.get?.(sessionId) ?? null;
}
function enqueueExec(sessionId, task) {
let state = execQueues.get(sessionId);
if (!state) {
state = { running: false, pending: [] };
execQueues.set(sessionId, state);
}
return new Promise((resolve) => {
state.pending.push({ task, resolve });
void drainExecQueue(sessionId);
});
}
async function drainExecQueue(sessionId) {
const state = execQueues.get(sessionId);
if (!state || state.running) return;
state.running = true;
while (state.pending.length > 0) {
const job = state.pending.shift();
if (!job) continue;
try {
const result = await job.task();
job.resolve(result);
} catch (err) {
job.resolve({ success: false, error: err?.message || String(err) });
}
}
state.running = false;
if (state.pending.length === 0) {
execQueues.delete(sessionId);
}
}
async function ensureMoshCompanion(session, sessionId, event) {
if (session?.type !== "mosh" || typeof ensureMoshStatsConnection !== "function") {
return;
}
if (session.moshStatsConn && isSshConnAlive(session.moshStatsConn)) {
return;
}
if (session.moshStatsConn && !isSshConnAlive(session.moshStatsConn)) {
session.moshStatsConn = null;
}
if (!session.moshStatsConn && !session.moshStatsConnFailed) {
await ensureMoshStatsConnection(session, sessionId, event?.sender);
}
}
async function resolveExecConnection(session, sessionId, event) {
if (!session) return null;
await ensureMoshCompanion(session, sessionId, event);
const conn = session.conn || session.moshStatsConn;
if (!conn) return null;
if (!isSshConnAlive(conn)) {
if (session.moshStatsConn === conn) {
session.moshStatsConn = null;
await ensureMoshCompanion(session, sessionId, event);
return session.conn || session.moshStatsConn;
}
return null;
}
return conn;
}
function execOnConnection(conn, command, timeoutMs) {
return new Promise((resolve) => {
let settled = false;
let activeStream = null;
const settle = (result) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(result);
};
const timer = setTimeout(() => {
settle({ success: false, error: "Command timeout" });
try { if (activeStream) activeStream.close(); } catch { /* ignore */ }
}, timeoutMs);
try {
conn.exec(command, (err, stream) => {
if (err) {
settle({ success: false, error: err.message || String(err) });
return;
}
activeStream = stream;
let stdout = "";
let stderr = "";
stream.on("data", (chunk) => { stdout += chunk.toString(); });
if (stream.stderr) {
stream.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
}
stream.on("close", (code) => {
settle({ success: true, stdout, stderr, code: code ?? 0 });
});
});
} catch (err) {
settle({ success: false, error: err?.message || String(err) });
}
});
}
async function execOnSshSession(session, sessionId, command, timeoutMs, event, allowCompanionRetry = true) {
if (session?.type === "et") {
if (typeof execOnEtSession !== "function") {
return { success: false, error: "ET command executor unavailable" };
}
return execOnEtSession(session, command, timeoutMs, {
requireTrustedHost: true,
knownHosts: session.etStatsAuth?.knownHosts,
});
}
const conn = await resolveExecConnection(session, sessionId, event);
if (!conn) {
if (session?.type === "mosh" && !session.moshStatsAuth && !session.moshStatsConnFailed) {
return { success: false, pending: true, error: "Mosh handshake in progress" };
}
return { success: false, error: "Session not found or not connected" };
}
const result = await execOnConnection(conn, command, timeoutMs);
if (
allowCompanionRetry
&& !result.success
&& session.moshStatsConn
&& isTransportExecError(result.error)
) {
session.moshStatsConn = null;
return execOnSshSession(session, sessionId, command, timeoutMs, event, false);
}
return result;
}
async function execOnLocalMachine(command, timeoutMs) {
const { execFile } = require("node:child_process");
const platform = process.platform;
if (platform === "win32") {
return new Promise((resolve) => {
execFile(
"powershell.exe",
["-NoProfile", "-NonInteractive", "-Command", command],
{ timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 },
(err, stdout, stderr) => {
if (err && !stdout) {
resolve({ success: false, error: err.message || String(err), stdout: "", stderr: String(stderr || "") });
return;
}
resolve({ success: true, stdout: String(stdout || ""), stderr: String(stderr || ""), code: err?.code ?? 0 });
},
);
});
}
return new Promise((resolve) => {
execFile(
"sh",
["-c", command],
{ timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 },
(err, stdout, stderr) => {
if (err && !stdout) {
resolve({ success: false, error: err.message || String(err), stdout: "", stderr: String(stderr || "") });
return;
}
resolve({ success: true, stdout: String(stdout || ""), stderr: String(stderr || ""), code: err?.code ?? 0 });
},
);
});
}
async function execOnSessionInner(event, sessionId, command, timeoutMs = 8000) {
const session = getSession(sessionId);
if (!session) {
execQueues.delete(sessionId);
return { success: false, error: "Session not found" };
}
if (session.protocol === "local" || session.type === "local") {
return execOnLocalMachine(command, timeoutMs);
}
if (session.conn || session.type === "mosh" || session.type === "et") {
return execOnSshSession(session, sessionId, command, timeoutMs, event);
}
return { success: false, error: "Session not supported for system management" };
}
async function execOnSession(event, sessionId, command, timeoutMs = 8000) {
return enqueueExec(sessionId, () => execOnSessionInner(event, sessionId, command, timeoutMs));
}
function isLocalSession(sessionId) {
const session = getSession(sessionId);
return !!(session?.protocol === "local" || session?.type === "local");
}
return { execOnSession, execOnLocalMachine, isLocalSession, getSession };
}
}
module.exports = { createExecOnSessionApi };

View File

@@ -0,0 +1,194 @@
/* eslint-disable no-undef */
function shQuote(str) {
return `'${String(str).replace(/'/g, `'\"'\"'`)}'`;
}
function wrapLoginShell(command) {
const oneLine = String(command || "")
.replace(/\r\n/g, "\n")
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.join("; ");
return `bash -lc ${JSON.stringify(oneLine)}`;
}
function wrapShExec(command) {
const oneLine = String(command || "")
.replace(/\r\n/g, "\n")
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.join("; ");
return `exec sh -c ${JSON.stringify(oneLine)}`;
}
function stripAnsi(text) {
return String(text || "").replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
}
function parseTmuxVersionString(text) {
const match = stripAnsi(text).match(/tmux\s+(\d+)\.(\d+)([a-z0-9]*)/i);
if (!match) {
return { raw: stripAnsi(text).trim(), major: 0, minor: 0, patch: "" };
}
return {
raw: match[0],
major: Number(match[1]) || 0,
minor: Number(match[2]) || 0,
patch: match[3] || "",
};
}
function getListSessionsFormat(version) {
const major = version?.major ?? 0;
const minor = version?.minor ?? 0;
if (major < 2) return null;
const fields = ["#{session_name}", "#{session_windows}", "#{session_attached}"];
if (major >= 3 || (major === 2 && minor >= 1)) {
fields.push("#{session_created}", "#{session_activity}");
}
if (major > 3 || (major === 3 && minor >= 2)) {
fields.push("#{session_group}");
}
return fields.join("\\t");
}
// Single-line script — multiline strings break when passed through bash -lc JSON quoting.
const TMUX_DETECT_SCRIPT = [
"uid=$(id -u 2>/dev/null || echo 0)",
"echo \"__TMUX_VERSION__=$(tmux -V 2>/dev/null || true)\"",
"echo \"__TMUX_BIN__=$(command -v tmux 2>/dev/null || which tmux 2>/dev/null || true)\"",
"for d in \"${TMUX_TMPDIR:-/tmp}/tmux-$uid\" \"/tmp/tmux-$uid\"; do",
"[ -d \"$d\" ] || continue",
"for s in \"$d\"/*; do [ -S \"$s\" ] && echo \"__SOCKET__=$s\"; done",
"done",
].join("; ");
function parseDetectScriptOutput(stdout) {
const info = {
version: { raw: "", major: 0, minor: 0, patch: "" },
binary: "",
sockets: [],
};
for (const line of stripAnsi(stdout).split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith("__TMUX_VERSION__=")) {
info.version = parseTmuxVersionString(trimmed.slice("__TMUX_VERSION__=".length));
continue;
}
if (trimmed.startsWith("__TMUX_BIN__=")) {
info.binary = trimmed.slice("__TMUX_BIN__=".length).trim();
continue;
}
if (trimmed.startsWith("__SOCKET__=")) {
info.sockets.push(trimmed.slice("__SOCKET__=".length).trim());
}
}
info.sockets = [...new Set(info.sockets.filter(Boolean))];
return info;
}
function normalizeExecResult(result) {
if (!result) return { success: false, error: "No exec result" };
const stdout = stripAnsi(result.stdout || "");
const stderr = stripAnsi(result.stderr || "");
const combined = [stderr, stdout].filter(Boolean).join("\n").trim();
if (!result.success && combined) {
return {
...result,
success: true,
stdout: combined,
stderr,
code: result.code ?? 1,
};
}
return { ...result, stdout: combined || stdout, stderr };
}
function buildTmuxInvocation(binary, socketPath, args) {
const bin = binary || "tmux";
const socketFlag = socketPath ? `-S ${shQuote(socketPath)} ` : "";
return `${bin} ${socketFlag}${args}`.replace(/\s+/g, " ").trim();
}
// tmux diagnostics that must never be mistaken for session names — a stale
// socket makes `tmux ls 2>&1` print "error connecting to /tmp/tmux-0/default
// (No such file or directory)", which the bare-name fallback below would
// otherwise turn into a phantom session row.
const TMUX_DIAGNOSTIC_LINE = /^(error connecting to|no server running|no current client|can't find|lost server|server exited|failed to connect|protocol version mismatch|open terminal failed|invalid option|usage:|unknown command)/i;
function isTmuxDiagnosticLine(line) {
return TMUX_DIAGNOSTIC_LINE.test(String(line || "").trim());
}
function parseListOutput(stdout) {
const text = stripAnsi(stdout);
const plain = [];
const lines = text.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (isTmuxDiagnosticLine(trimmed)) continue;
const match = trimmed.match(/^([^:]+):\s*(\d+)\s+windows?\b/i);
if (match) {
plain.push({
name: match[1].trim(),
windows: Number(match[2]) || 0,
attached: /\battached\b/i.test(trimmed),
created: 0,
activity: "",
group: "",
});
continue;
}
const parts = trimmed.split("\t");
if (parts.length >= 4) {
plain.push({
name: parts[0].trim(),
windows: Number(parts[1]) || 0,
attached: parts[2] === "1",
created: Number(parts[3]) || 0,
activity: parts[4] || "",
group: parts[5] || "",
});
continue;
}
if (parts.length === 1 && !trimmed.includes(":")) {
plain.push({
name: trimmed,
windows: 0,
attached: false,
created: 0,
activity: "",
group: "",
});
}
}
return plain;
}
module.exports = {
shQuote,
wrapLoginShell,
wrapShExec,
stripAnsi,
parseTmuxVersionString,
getListSessionsFormat,
TMUX_DETECT_SCRIPT,
parseDetectScriptOutput,
normalizeExecResult,
buildTmuxInvocation,
parseListOutput,
isTmuxDiagnosticLine,
TMUX_DIAGNOSTIC_LINE,
};

View File

@@ -0,0 +1,272 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {
parseTmuxVersionString,
getListSessionsFormat,
parseDetectScriptOutput,
normalizeExecResult,
parseListOutput,
wrapLoginShell,
} = require("./tmuxEnv.cjs");
test("parseTmuxVersionString handles tmux 3.0a", () => {
const v = parseTmuxVersionString("tmux 3.0a");
assert.equal(v.major, 3);
assert.equal(v.minor, 0);
assert.equal(v.patch, "a");
});
test("wrapLoginShell flattens multiline scripts", () => {
const wrapped = wrapLoginShell("echo one\necho two");
assert.ok(!wrapped.includes("\\n"));
assert.ok(wrapped.includes("; echo two"));
});
test("parseListOutput parses default tmux ls line", () => {
const sample = "test-session: 1 windows (created Thu Jun 11 00:38:14 2026)\n";
const sessions = parseListOutput(sample);
assert.equal(sessions.length, 1);
assert.equal(sessions[0].name, "test-session");
assert.equal(sessions[0].windows, 1);
});
test("getListSessionsFormat omits session_group before tmux 3.2", () => {
const fmt = getListSessionsFormat({ major: 3, minor: 0 });
assert.ok(fmt.includes("session_name"));
assert.ok(!fmt.includes("session_group"));
});
test("parseDetectScriptOutput reads version and sockets", () => {
const stdout = [
"__TMUX_VERSION__=tmux 3.0a",
"__TMUX_BIN__=/usr/bin/tmux",
"__SOCKET__=/tmp/tmux-0/default",
].join("\n");
const parsed = parseDetectScriptOutput(stdout);
assert.equal(parsed.version.major, 3);
assert.equal(parsed.binary, "/usr/bin/tmux");
assert.deepEqual(parsed.sockets, ["/tmp/tmux-0/default"]);
});
test("parseListOutput ignores tmux diagnostic lines", () => {
const sample = [
"error connecting to /tmp/tmux-0/default (No such file or directory)",
"no server running on /private/tmp/tmux-501/default",
"can't find session: missing",
"test-session: 1 windows (created Thu Jun 11 00:38:14 2026)",
].join("\n");
const sessions = parseListOutput(sample);
assert.equal(sessions.length, 1);
assert.equal(sessions[0].name, "test-session");
});
test("parseListOutput returns nothing for a lone stale-socket error", () => {
const sessions = parseListOutput(
"error connecting to /tmp/tmux-0/default (No such file or directory)\n",
);
assert.deepEqual(sessions, []);
});
test("isNoTmuxServerMessage matches stale-socket connect errors", () => {
const { isNoTmuxServerMessage } = require("./tmuxOps.cjs");
assert.equal(
isNoTmuxServerMessage("error connecting to /tmp/tmux-0/default (No such file or directory)", 1),
true,
);
assert.equal(isNoTmuxServerMessage("no server running on /tmp/tmux-501/default", 1), true);
assert.equal(isNoTmuxServerMessage("test-session: 1 windows", 1), false);
assert.equal(isNoTmuxServerMessage("error connecting to /tmp/tmux-0/default (No such file or directory)", 0), false);
});
test("mutating tmux commands execute exactly once on silent success", async () => {
const { createTmuxOpsApi } = require("./tmuxOps.cjs");
const executed = [];
const api = createTmuxOpsApi({
execOnSession: async (_event, _sessionId, command) => {
executed.push(command);
// Silent success, the normal result for kill-session/send-keys/split-window.
return { success: true, stdout: "", stderr: "", code: 0 };
},
});
const result = await api.tmuxAction(null, {
sessionId: "s1",
action: "killSession",
sessionName: "demo",
});
assert.equal(result.success, true);
const killRuns = executed.filter((cmd) => cmd.includes("kill-session"));
assert.equal(killRuns.length, 1, `kill-session ran ${killRuns.length} times: ${executed.join(" | ")}`);
});
test("parseTmuxWindowsPlain parses default tmux list-windows output", () => {
const { parseTmuxWindowsPlain } = require("./tmuxOps.cjs");
const sample = [
"0: bash* (2 panes) [160x40] [b33d,1]",
"1: zsh (1 pane) [160x40] [b33d,2]",
].join("\n");
const windows = parseTmuxWindowsPlain(sample);
assert.equal(windows.length, 2);
assert.equal(windows[0].name, "bash");
assert.equal(windows[0].panes, 2);
assert.equal(windows[0].active, true);
assert.equal(windows[1].name, "zsh");
});
test("parseTmuxWindows falls back to plain output when -F tabs are missing", () => {
const { parseTmuxWindows } = require("./tmuxOps.cjs");
const windows = parseTmuxWindows("0: main* (2 panes) [80x24]");
assert.equal(windows.length, 1);
assert.equal(windows[0].panes, 2);
});
test("parseTmuxWindows reads list-windows output from stderr", () => {
const { parseTmuxWindows } = require("./tmuxOps.cjs");
const windows = parseTmuxWindows("0: main* (2 panes) [80x24]");
assert.equal(windows.length, 1);
assert.equal(windows[0].name, "main");
});
test("list-windows tries alternate socket when default returns empty", async () => {
const { createTmuxOpsApi } = require("./tmuxOps.cjs");
const api = createTmuxOpsApi({
execOnSession: async (_event, _sessionId, command) => {
if (command.includes("TMUX_DETECT") || command.includes("__SOCKET__") || command.includes("__TMUX_")) {
return {
success: true,
stdout: "__TMUX_VERSION__=tmux 3.0a\n__SOCKET__=/tmp/tmux-0/custom\n",
stderr: "",
code: 0,
};
}
if (command.includes("-S '/tmp/tmux-0/custom'") && command.includes("list-windows")) {
return { success: true, stdout: "0: main* (2 panes) [80x24]", stderr: "", code: 0 };
}
if (command.includes("list-windows")) {
return { success: true, stdout: "", stderr: "", code: 0 };
}
return { success: true, stdout: "tmux 3.0a", stderr: "", code: 0 };
},
});
const result = await api.listWindows(null, { sessionId: "s1", sessionName: "test-session" });
assert.equal(result.success, true);
assert.equal(result.windows.length, 1);
assert.equal(result.windows[0].panes, 2);
});
test("parseTmuxWindowsAllPlain parses list-windows -a default output", () => {
const { parseTmuxWindowsAllPlain } = require("./tmuxOps.cjs");
const sample = [
"test-session: 0: bash* (2 panes) [80x24]",
"test-session: 1: zsh (1 pane) [80x24]",
"other: 0: vim (1 pane) [80x24]",
].join("\n");
const windows = parseTmuxWindowsAllPlain(sample, "test-session");
assert.equal(windows.length, 2);
assert.equal(windows[0].name, "bash");
assert.equal(windows[1].index, 1);
});
test("list-windows falls back to list-windows -a when -t target misses", async () => {
const { createTmuxOpsApi } = require("./tmuxOps.cjs");
const api = createTmuxOpsApi({
execOnSession: async (_event, _sessionId, command) => {
if (command.includes("for d in") || command.includes("__TMUX_")) {
return { success: true, stdout: "__TMUX_VERSION__=tmux 3.0a\n", stderr: "", code: 0 };
}
if (command.includes("list-windows -a")) {
return {
success: true,
stdout: "test-session: 0: main* (2 panes) [80x24]\ntest-session: 1: aux (1 pane) [80x24]",
stderr: "",
code: 0,
};
}
if (command.includes("list-windows")) {
return { success: true, stdout: "can't find session: test-session", stderr: "", code: 1 };
}
return { success: true, stdout: "tmux 3.0a", stderr: "", code: 0 };
},
});
const result = await api.listWindows(null, { sessionId: "s1", sessionName: "test-session" });
assert.equal(result.success, true);
assert.equal(result.windows.length, 2);
});
test("list-windows parses output delivered on stderr", async () => {
const { createTmuxOpsApi } = require("./tmuxOps.cjs");
const api = createTmuxOpsApi({
execOnSession: async (_event, _sessionId, command) => {
if (command.includes("TMUX_DETECT") || command.includes("__SOCKET__") || command.includes("__TMUX_") || command.includes("for d in")) {
return { success: true, stdout: "__TMUX_VERSION__=tmux 3.0a\n", stderr: "", code: 0 };
}
if (command.includes("list-windows")) {
return {
success: true,
stdout: "",
stderr: "0: remote* (2 panes) [80x24]\n",
code: 0,
};
}
return { success: true, stdout: "tmux 3.0a", stderr: "", code: 0 };
},
});
const result = await api.listWindows(null, { sessionId: "s1", sessionName: "test-session" });
assert.equal(result.success, true);
assert.equal(result.windows.length, 1);
assert.equal(result.windows[0].name, "remote");
});
test("parseTmuxPanes splits literal \\t when remote printf fails", () => {
const { parseTmuxPanes } = require("./tmuxOps.cjs");
const sample = "0\\tRainYun-0tWTeTRw\\tbash\\t\\t\\t2232702\\t80\\t24";
const panes = parseTmuxPanes(sample);
assert.equal(panes.length, 1);
assert.equal(panes[0].title, "RainYun-0tWTeTRw");
assert.equal(panes[0].command, "bash");
assert.equal(panes[0].pid, 2232702);
assert.equal(panes[0].width, 80);
assert.equal(panes[0].height, 24);
});
test("parseTmuxPanesPlain parses default list-panes output", () => {
const { parseTmuxPanesPlain } = require("./tmuxOps.cjs");
const panes = parseTmuxPanesPlain("0: [80x24]\n1: [80x24] (active)");
assert.equal(panes.length, 2);
assert.equal(panes[1].active, true);
});
test("list-windows routes tab separators through printf (tmux does not expand \\t in -F)", async () => {
const { createTmuxOpsApi } = require("./tmuxOps.cjs");
const commands = [];
const api = createTmuxOpsApi({
execOnSession: async (_event, _sessionId, command) => {
commands.push(command);
if (command.includes("list-windows")) {
// Real tab characters, as printf would produce on the remote host.
return { success: true, stdout: "0\tmain\t2\t1\tlayout", stderr: "", code: 0 };
}
return { success: true, stdout: "tmux 3.0a", stderr: "", code: 0 };
},
});
const result = await api.listWindows(null, { sessionId: "s1", sessionName: "demo" });
assert.equal(result.success, true);
assert.equal(result.windows.length, 1);
assert.equal(result.windows[0].name, "main");
assert.equal(result.windows[0].panes, 2);
const listCmd = commands.find((cmd) => cmd.includes("list-windows"));
assert.ok(listCmd.includes("$(printf '"), `expected printf-wrapped format, got: ${listCmd}`);
});
test("normalizeExecResult keeps stdout from failed ET-style exec", () => {
const normalized = normalizeExecResult({
success: false,
error: "Command failed",
stdout: "",
stderr: "test-session: 1 windows (created Thu Jun 11 00:38:14 2026)",
code: 1,
});
assert.equal(normalized.success, true);
assert.equal(parseListOutput(normalized.stdout).length, 1);
});

View File

@@ -0,0 +1,803 @@
/* eslint-disable no-undef */
const {
shQuote,
wrapLoginShell,
wrapShExec,
parseTmuxVersionString,
getListSessionsFormat,
TMUX_DETECT_SCRIPT,
parseDetectScriptOutput,
normalizeExecResult,
buildTmuxInvocation,
parseListOutput,
isTmuxDiagnosticLine,
} = require("./tmuxEnv.cjs");
function shQuoteLocal(str) {
return shQuote(str);
}
function tmuxTarget(sessionName, windowIndex, paneIndex) {
const sessionRef = shQuoteLocal(sessionName);
if (windowIndex === undefined || windowIndex === null) return sessionRef;
const win = Number(windowIndex);
if (paneIndex === undefined || paneIndex === null) return `${sessionRef}:${win}`;
return `${sessionRef}:${win}.${Number(paneIndex)}`;
}
function sanitizeNewSessionName(name) {
const trimmed = String(name || "").trim();
if (!trimmed) return null;
return trimmed.slice(0, 64);
}
function parseTmuxSessions(stdout) {
const sessions = [];
for (const line of (stdout || "").split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const parts = trimmed.split("\t");
if (parts.length < 4) continue;
sessions.push({
name: parts[0],
windows: Number(parts[1]) || 0,
attached: parts[2] === "1",
created: Number(parts[3]) || 0,
activity: parts[4] || "",
group: parts[5] || "",
});
}
return sessions;
}
/** Fallback parser for default `tmux list-sessions` / `tmux ls` output. */
function parseTmuxSessionsPlain(stdout) {
const sessions = [];
for (const line of (stdout || "").split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = trimmed.match(/^([^:]+):\s*(\d+)\s+windows?\b/i);
if (!match) continue;
sessions.push({
name: match[1].trim(),
windows: Number(match[2]) || 0,
attached: /\battached\b/i.test(trimmed),
created: 0,
activity: "",
group: "",
});
}
return sessions;
}
function parseTmuxSessionNames(stdout) {
return (stdout || "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((name) => ({
name,
windows: 0,
attached: false,
created: 0,
activity: "",
group: "",
}));
}
function isNoTmuxServerMessage(text, code) {
if (code !== 1) return false;
const msg = String(text || "").toLowerCase();
if (msg.includes("no server running")) return true;
// Stale socket file: "error connecting to /tmp/tmux-0/default (No such file or directory)"
return msg.includes("error connecting to") && msg.includes("no such file or directory");
}
/** Split tmux -F rows on real tabs or literal `\t` when remote printf fails. */
function splitTmuxFields(line) {
const text = String(line || "");
if (text.includes("\t")) return text.split("\t");
if (text.includes("\\t")) return text.split("\\t");
return [text];
}
/** Default `tmux list-windows` lines, e.g. `0: bash* (2 panes) [80x24]`. */
function parseTmuxWindowsPlain(stdout) {
const windows = [];
for (const line of (stdout || "").split("\n")) {
const trimmed = line.trim();
if (!trimmed || isTmuxDiagnosticLine(trimmed)) continue;
let match = trimmed.match(/^(\d+):\s*(.+?)(\*)?\s+\((\d+)\s+panes?\)/i);
if (match) {
windows.push({
index: Number(match[1]),
name: match[2].trim(),
panes: Number(match[4]) || 0,
active: match[3] === "*",
layout: "",
});
continue;
}
match = trimmed.match(/^(\d+):\s*(.*?)(\*)?(?:\s+\[[^\]]+\]|\s*$)/);
if (!match) continue;
windows.push({
index: Number(match[1]),
name: match[2].trim(),
panes: 0,
active: match[3] === "*",
layout: "",
});
}
return windows;
}
function parseTmuxWindows(stdout) {
const windows = [];
for (const line of (stdout || "").split("\n")) {
const trimmed = line.trim();
if (!trimmed || isTmuxDiagnosticLine(trimmed)) continue;
const parts = splitTmuxFields(trimmed);
if (parts.length < 4) continue;
windows.push({
index: Number(parts[0]),
name: parts[1],
panes: Number(parts[2]) || 0,
active: parts[3] === "1",
layout: parts[4] || "",
});
}
if (windows.length > 0) return windows;
return parseTmuxWindowsPlain(stdout);
}
function parseTmuxWindowsAll(stdout) {
const windows = [];
for (const line of (stdout || "").split("\n")) {
const trimmed = line.trim();
if (!trimmed || isTmuxDiagnosticLine(trimmed)) continue;
const parts = splitTmuxFields(trimmed);
if (parts.length < 5) continue;
windows.push({
session: parts[0].trim(),
index: Number(parts[1]),
name: parts[2],
panes: Number(parts[3]) || 0,
active: parts[4] === "1",
layout: parts[5] || "",
});
}
return windows;
}
/** Plain `tmux list-windows -a` lines, e.g. `test-session: 0: bash* (2 panes)`. */
function parseTmuxWindowsAllPlain(stdout, sessionName) {
const name = String(sessionName || "").trim();
const windows = [];
for (const line of (stdout || "").split("\n")) {
const trimmed = line.trim();
if (!trimmed || isTmuxDiagnosticLine(trimmed)) continue;
const match = trimmed.match(/^([^:]+):\s*(\d+):\s*(.+)$/);
if (!match || match[1].trim() !== name) continue;
const parsed = parseTmuxWindowsPlain(`${match[2]}: ${match[3]}`);
if (parsed.length > 0) windows.push(parsed[0]);
}
return windows;
}
function filterWindowsForSession(rows, sessionName) {
const name = String(sessionName || "").trim();
return rows
.filter((row) => row.session === name)
.map(({ session, ...window }) => window);
}
function parseTmuxPaneRow(parts) {
if (parts.length < 5) return null;
return {
index: Number(parts[0]),
title: parts[1] || "",
command: parts[2] || "",
active: parts[3] === "1" || (parts.length >= 7 && parts[parts.length - 4] === "1"),
pid: Number(parts[4]) || (parts.length >= 7 ? Number(parts[parts.length - 3]) : 0) || 0,
width: Number(parts[parts.length >= 7 ? parts.length - 2 : 5]) || 0,
height: Number(parts[parts.length >= 7 ? parts.length - 1 : 6]) || 0,
};
}
/** Default `tmux list-panes` lines, e.g. `0: [80x24]` or `1: [80x24] (active)`. */
function parseTmuxPanesPlain(stdout) {
const panes = [];
for (const line of (stdout || "").split("\n")) {
const trimmed = line.trim();
if (!trimmed || isTmuxDiagnosticLine(trimmed)) continue;
const match = trimmed.match(/^(\d+):\s*\[(\d+)x(\d+)\]/);
if (!match) continue;
panes.push({
index: Number(match[1]),
title: "",
command: "",
active: /\bactive\b/i.test(trimmed) || trimmed.includes("*"),
pid: 0,
width: Number(match[2]) || 0,
height: Number(match[3]) || 0,
});
}
return panes;
}
function parseTmuxPanes(stdout) {
const panes = [];
for (const line of (stdout || "").split("\n")) {
const trimmed = line.trim();
if (!trimmed || isTmuxDiagnosticLine(trimmed)) continue;
const row = parseTmuxPaneRow(splitTmuxFields(trimmed));
if (row) panes.push(row);
}
if (panes.length > 0) return panes;
return parseTmuxPanesPlain(stdout);
}
function parseTmuxClients(stdout, sessionName) {
const clients = [];
for (const line of (stdout || "").split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const parts = trimmed.split("\t");
if (parts.length < 4) continue;
if (sessionName && parts[3] !== sessionName) continue;
clients.push({
name: parts[0],
tty: parts[1],
activity: parts[2],
session: parts[3],
});
}
return clients;
}
// Legacy export kept for tests — prefer getListSessionsFormat(version).
const TMUX_LIST_SESSIONS_FMT = getListSessionsFormat({ major: 3, minor: 0 });
const TMUX_LIST_WINDOWS_FMT = "#{window_index}\\t#{window_name}\\t#{window_panes}\\t#{window_active}\\t#{window_layout}";
const TMUX_LIST_ALL_WINDOWS_FMT = "#{session_name}\\t#{window_index}\\t#{window_name}\\t#{window_panes}\\t#{window_active}\\t#{window_layout}";
const TMUX_LIST_PANES_FMT = "#{pane_index}\\t#{pane_title}\\t#{pane_current_command}\\t#{pane_active}\\t#{pane_pid}\\t#{pane_width}\\t#{pane_height}";
const TMUX_LIST_CLIENTS_FMT = "#{client_name}\\t#{client_tty}\\t#{client_activity}\\t#{client_session}";
/**
* tmux does NOT expand \t inside -F format strings — quoting the format
* directly emits a literal backslash-t and the tab-split parsers see one
* giant field. Route the format through printf on the remote side so the
* separators become real tab characters.
*/
function tmuxFormatArg(format) {
return `"$(printf '${format}')"`;
}
function createTmuxOpsApi({ execOnSession }) {
/** @type {Map<string, { version: object, binary: string, sockets: string[], detectedAt: number }>} */
const envCache = new Map();
const ENV_TTL_MS = 60_000;
async function execShell(event, sessionId, script, timeoutMs = 8000, options = {}) {
// retryOnEmptyOutput exists for READ commands where empty stdout means the
// wrapper swallowed the output. Mutating commands (kill-session, send-keys,
// split-window…) succeed silently — retrying them re-executes the mutation,
// so they must pass retryOnEmptyOutput: false.
const { retryOnEmptyOutput = true } = options;
const attempts = [
wrapShExec(script),
wrapLoginShell(script),
script,
];
let last = { success: false, error: "No exec result", stdout: "", stderr: "" };
for (const cmd of attempts) {
last = normalizeExecResult(await execOnSession(event, sessionId, cmd, timeoutMs));
if (last.success && (!retryOnEmptyOutput || String(last.stdout || "").trim())) return last;
}
return last;
}
async function detectTmuxEnv(event, sessionId, force = false) {
const cached = envCache.get(sessionId);
if (!force && cached && Date.now() - cached.detectedAt < ENV_TTL_MS) {
return cached;
}
const env = {
version: { raw: "", major: 0, minor: 0, patch: "" },
binary: "tmux",
sockets: [],
preferredSocket: cached?.preferredSocket ?? null,
detectedAt: Date.now(),
};
const versionResult = await execShell(event, sessionId, "tmux -V 2>&1", 5000);
if (versionResult.success && versionResult.stdout) {
env.version = parseTmuxVersionString(versionResult.stdout);
}
const binResult = await execShell(event, sessionId, "command -v tmux 2>/dev/null || which tmux 2>/dev/null", 5000);
if (binResult.success && binResult.stdout) {
const bin = binResult.stdout.split("\n").map((l) => l.trim()).find(Boolean);
if (bin) env.binary = bin;
}
const socketResult = await execShell(
event,
sessionId,
TMUX_DETECT_SCRIPT,
8000,
);
if (socketResult.success) {
const parsed = parseDetectScriptOutput(socketResult.stdout);
if (parsed.version.raw) env.version = parsed.version;
if (parsed.binary) env.binary = parsed.binary;
env.sockets = parsed.sockets;
}
envCache.set(sessionId, env);
return env;
}
function buildSocketOrder(env) {
const order = [];
if (env.preferredSocket) order.push(env.preferredSocket);
order.push(null);
for (const socket of env.sockets || []) {
if (socket && !order.includes(socket)) order.push(socket);
}
return order;
}
function rememberPreferredSocket(sessionId, env, socketPath) {
const next = {
...env,
preferredSocket: socketPath ?? env.preferredSocket ?? null,
detectedAt: Date.now(),
};
envCache.set(sessionId, next);
return next;
}
async function queryTmuxRows(event, sessionId, buildArgVariants, parseRows) {
const env = await detectTmuxEnv(event, sessionId);
let lastError = "Cannot read tmux data";
let lastOutput = "";
const tried = [];
for (const socketPath of buildSocketOrder(env)) {
for (const args of buildArgVariants()) {
const cmd = buildTmuxInvocation(env.binary, socketPath, args);
tried.push(cmd);
const result = await execShell(event, sessionId, cmd, 8000);
const output = String(result.stdout || result.stderr || "").trim();
if (output) lastOutput = output.slice(0, 500);
if (isNoTmuxServerMessage(output, result.code)) continue;
if (!result.success && !output) {
lastError = (result.error || result.stderr || lastError).slice(0, 240);
continue;
}
const rows = parseRows(output);
if (rows.length > 0) {
rememberPreferredSocket(sessionId, env, socketPath);
return { success: true, rows };
}
if (output) lastError = output.slice(0, 240);
}
}
return {
success: false,
error: lastError,
debug: {
lastOutput,
tried: tried.slice(-8),
sockets: buildSocketOrder(env),
},
};
}
async function execTmux(event, sessionId, args, timeoutMs = 8000, options = {}) {
const env = options.env || await detectTmuxEnv(event, sessionId);
const shellOptions = { retryOnEmptyOutput: options.retryOnEmptyOutput ?? true };
if (Object.prototype.hasOwnProperty.call(options, "socketPath")) {
const cmd = buildTmuxInvocation(env.binary, options.socketPath, args);
const result = await execShell(event, sessionId, cmd, timeoutMs, shellOptions);
return { ...result, socketPath: options.socketPath ?? null, env };
}
const attempts = buildSocketOrder(env);
let lastResult = null;
for (const socketPathResolved of attempts) {
const cmd = buildTmuxInvocation(env.binary, socketPathResolved, args);
const result = await execShell(event, sessionId, cmd, timeoutMs, shellOptions);
lastResult = result;
if (!result.success) continue;
const combined = `${result.stderr || ""}\n${result.stdout || ""}`;
if (isNoTmuxServerMessage(combined, result.code)) continue;
const hasOutput = Boolean((result.stdout || "").trim());
if (hasOutput || result.code === 0) {
if (hasOutput) rememberPreferredSocket(sessionId, env, socketPathResolved);
return { ...result, socketPath: socketPathResolved, env };
}
}
return lastResult || { success: false, error: "tmux command failed" };
}
async function runTmux(event, sessionId, args, timeoutMs = 8000) {
// No empty-output retry here: runTmux carries every mutating tmux command,
// and list-* commands routed through it may legitimately print nothing.
const result = await execTmux(event, sessionId, args, timeoutMs, { retryOnEmptyOutput: false });
if (!result.success) return result;
if (result.code !== 0 && result.code !== null && result.code !== undefined) {
return {
success: false,
error: (result.stderr || result.stdout || "").trim() || `tmux exited with code ${result.code}`,
stderr: result.stderr,
};
}
return result;
}
async function listSessions(event, sessionId) {
const env = await detectTmuxEnv(event, sessionId, true);
const socketPaths = buildSocketOrder(env);
let lastOutput = "";
const buildListCommands = (binary, socketPath) => {
const inv = (args) => buildTmuxInvocation(binary, socketPath, args);
const cmds = [inv("list-sessions 2>&1"), inv("list-sessions")];
const format = getListSessionsFormat(env.version);
if (format) cmds.push(inv(`list-sessions -F ${tmuxFormatArg(format)}`));
cmds.push(inv("list-sessions -F '#{session_name}'"));
return cmds;
};
for (const socketPath of socketPaths) {
for (const cmd of buildListCommands(env.binary, socketPath)) {
const result = await execShell(event, sessionId, cmd, 8000);
const output = String(result.stdout || result.stderr || "").trim();
if (output) lastOutput = output;
if (!result.success) continue;
if (isNoTmuxServerMessage(output, result.code)) continue;
const sessions = parseListOutput(output);
if (sessions.length > 0) {
rememberPreferredSocket(sessionId, env, socketPath);
return {
success: true,
sessions,
tmuxVersion: env.version.raw || undefined,
};
}
}
}
if (isNoTmuxServerMessage(lastOutput, 1)) {
return { success: true, sessions: [], tmuxVersion: env.version.raw || undefined };
}
const diag = [
env.version.raw || "tmux version unknown",
env.binary ? `bin=${env.binary}` : null,
env.sockets.length ? `sockets=${env.sockets.join(",")}` : "sockets=none",
lastOutput ? `last=${lastOutput.slice(0, 240)}` : "last=empty",
].filter(Boolean).join("; ");
return {
success: false,
error: `Cannot list tmux sessions (${diag})`,
tmuxVersion: env.version.raw || undefined,
};
}
async function createSession(event, payload) {
const { sessionId, name, command } = payload || {};
if (!sessionId || !name) return { success: false, error: "Missing sessionId or name" };
const safeName = sanitizeNewSessionName(name);
if (!safeName) return { success: false, error: "Invalid session name" };
envCache.delete(sessionId);
const result = await runTmux(event, sessionId, `new-session -d -s ${shQuoteLocal(safeName)}`, 8000);
if (!result.success) return { success: false, error: result.error || result.stderr };
const cmd = String(command || "").trim();
if (cmd) {
const sendResult = await runTmux(
event,
sessionId,
`send-keys -t ${shQuoteLocal(safeName)} ${shQuoteLocal(cmd)} C-m`,
8000,
);
if (!sendResult.success) {
return { success: false, error: sendResult.error || sendResult.stderr };
}
}
envCache.delete(sessionId);
return { success: true, name: safeName };
}
async function listWindows(event, payload) {
const { sessionId, sessionName } = payload || {};
if (!sessionId || !sessionName) return { success: false, error: "Missing params" };
const name = String(sessionName).trim();
const target = tmuxTarget(name);
const targetExact = tmuxTarget(`=${name}`);
// Mirror listSessions: force-refresh env and walk the same socket order.
const env = await detectTmuxEnv(event, sessionId, true);
const socketPaths = buildSocketOrder(env);
let lastOutput = "";
let lastError = "Cannot list tmux windows";
const tried = [];
const buildCommands = (binary, socketPath) => {
const inv = (args) => buildTmuxInvocation(binary, socketPath, args);
return [
inv(`list-windows -t ${target} -F ${tmuxFormatArg(TMUX_LIST_WINDOWS_FMT)} 2>&1`),
inv(`list-windows -t ${target} 2>&1`),
inv(`list-windows -t ${targetExact} -F ${tmuxFormatArg(TMUX_LIST_WINDOWS_FMT)} 2>&1`),
inv(`list-windows -t ${targetExact} 2>&1`),
inv(`list-windows -a -F ${tmuxFormatArg(TMUX_LIST_ALL_WINDOWS_FMT)} 2>&1`),
inv(`list-windows -a 2>&1`),
inv(`list-windows -t ${target} -F ${tmuxFormatArg(TMUX_LIST_WINDOWS_FMT)}`),
inv(`list-windows -t ${target}`),
];
};
for (const socketPath of socketPaths) {
for (const cmd of buildCommands(env.binary, socketPath)) {
tried.push(cmd);
const result = await execShell(event, sessionId, cmd, 8000);
const output = String(result.stdout || result.stderr || "").trim();
if (output) lastOutput = output.slice(0, 500);
if (!result.success && !output) {
lastError = (result.error || result.stderr || lastError).slice(0, 240);
continue;
}
if (isNoTmuxServerMessage(output, result.code)) continue;
let windows = [];
if (cmd.includes("list-windows -a")) {
const formatted = parseTmuxWindowsAll(output);
windows = formatted.length > 0
? filterWindowsForSession(formatted, name)
: parseTmuxWindowsAllPlain(output, name);
} else {
windows = parseTmuxWindows(output);
}
if (windows.length > 0) {
rememberPreferredSocket(sessionId, env, socketPath);
return { success: true, windows };
}
if (output) lastError = output.slice(0, 240);
}
}
return {
success: false,
error: lastError,
debug: { lastOutput, tried: tried.slice(-8), sockets: socketPaths },
};
}
async function listPanes(event, payload) {
const { sessionId, sessionName, windowIndex } = payload || {};
if (!sessionId || !sessionName || windowIndex === undefined) {
return { success: false, error: "Missing params" };
}
const name = String(sessionName).trim();
const target = tmuxTarget(name, windowIndex);
const env = await detectTmuxEnv(event, sessionId, true);
const socketPaths = buildSocketOrder(env);
let lastOutput = "";
let lastError = "Cannot list tmux panes";
const tried = [];
const buildCommands = (binary, socketPath) => {
const inv = (args) => buildTmuxInvocation(binary, socketPath, args);
return [
inv(`list-panes -t ${target} -F ${tmuxFormatArg(TMUX_LIST_PANES_FMT)} 2>&1`),
inv(`list-panes -t ${target} 2>&1`),
inv(`list-panes -t ${target} -F ${tmuxFormatArg(TMUX_LIST_PANES_FMT)}`),
inv(`list-panes -t ${target}`),
];
};
for (const socketPath of socketPaths) {
for (const cmd of buildCommands(env.binary, socketPath)) {
tried.push(cmd);
const result = await execShell(event, sessionId, cmd, 8000);
const output = String(result.stdout || result.stderr || "").trim();
if (output) lastOutput = output.slice(0, 500);
if (!result.success && !output) {
lastError = (result.error || result.stderr || lastError).slice(0, 240);
continue;
}
if (isNoTmuxServerMessage(output, result.code)) continue;
const panes = parseTmuxPanes(output);
if (panes.length > 0) {
rememberPreferredSocket(sessionId, env, socketPath);
return { success: true, panes };
}
if (output) lastError = output.slice(0, 240);
}
}
return {
success: false,
error: lastError,
debug: { lastOutput, tried: tried.slice(-8), sockets: socketPaths },
};
}
async function listClients(event, payload) {
const { sessionId, sessionName } = payload || {};
if (!sessionId) return { success: false, error: "Missing sessionId" };
const result = await runTmux(
event,
sessionId,
`list-clients -F ${tmuxFormatArg(TMUX_LIST_CLIENTS_FMT)}`,
8000,
);
if (!result.success) return { success: false, error: result.error };
return {
success: true,
clients: parseTmuxClients(result.stdout, sessionName || undefined),
};
}
async function tmuxAction(event, payload) {
const { sessionId, action } = payload || {};
if (!sessionId || !action) return { success: false, error: "Missing sessionId or action" };
switch (action) {
case "killSession": {
const { sessionName } = payload;
if (!sessionName) return { success: false, error: "Missing sessionName" };
return runTmux(event, sessionId, `kill-session -t ${tmuxTarget(sessionName)}`, 8000);
}
case "renameSession": {
const { sessionName, newName } = payload;
const next = sanitizeNewSessionName(newName);
if (!sessionName || !next) return { success: false, error: "Missing params" };
return runTmux(
event,
sessionId,
`rename-session -t ${tmuxTarget(sessionName)} ${shQuote(next)}`,
8000,
);
}
case "detachSession": {
const { sessionName } = payload;
if (!sessionName) return { success: false, error: "Missing sessionName" };
return runTmux(event, sessionId, `detach-client -s ${tmuxTarget(sessionName)}`, 8000);
}
case "createWindow": {
const { sessionName, windowName } = payload;
if (!sessionName) return { success: false, error: "Missing sessionName" };
const nameArg = windowName && String(windowName).trim()
? ` -n ${shQuote(String(windowName).trim().slice(0, 64))}`
: "";
return runTmux(
event,
sessionId,
`new-window -t ${tmuxTarget(sessionName)}${nameArg}`,
8000,
);
}
case "killWindow": {
const { sessionName, windowIndex } = payload;
if (!sessionName || windowIndex === undefined) return { success: false, error: "Missing params" };
return runTmux(
event,
sessionId,
`kill-window -t ${tmuxTarget(sessionName, windowIndex)}`,
8000,
);
}
case "renameWindow": {
const { sessionName, windowIndex, newName } = payload;
const next = String(newName || "").trim().slice(0, 64);
if (!sessionName || windowIndex === undefined || !next) {
return { success: false, error: "Missing params" };
}
return runTmux(
event,
sessionId,
`rename-window -t ${tmuxTarget(sessionName, windowIndex)} ${shQuote(next)}`,
8000,
);
}
case "killPane": {
const { sessionName, windowIndex, paneIndex } = payload;
if (!sessionName || windowIndex === undefined || paneIndex === undefined) {
return { success: false, error: "Missing params" };
}
return runTmux(
event,
sessionId,
`kill-pane -t ${tmuxTarget(sessionName, windowIndex, paneIndex)}`,
8000,
);
}
case "splitPane": {
const { sessionName, windowIndex, paneIndex, direction } = payload;
if (!sessionName || windowIndex === undefined) {
return { success: false, error: "Missing params" };
}
const flag = direction === "vertical" ? "-v" : "-h";
const target = paneIndex !== undefined && paneIndex !== null
? tmuxTarget(sessionName, windowIndex, paneIndex)
: tmuxTarget(sessionName, windowIndex);
return runTmux(event, sessionId, `split-window -t ${target} ${flag}`, 8000);
}
case "sendKeys": {
const { sessionName, windowIndex, paneIndex, keys, enter } = payload;
if (!sessionName || windowIndex === undefined || paneIndex === undefined) {
return { success: false, error: "Missing params" };
}
const keyText = String(keys ?? "");
const enterSuffix = enter !== false ? " C-m" : "";
return runTmux(
event,
sessionId,
`send-keys -t ${tmuxTarget(sessionName, windowIndex, paneIndex)} ${shQuote(keyText)}${enterSuffix}`,
8000,
);
}
case "selectWindow": {
const { sessionName, windowIndex } = payload;
if (!sessionName || windowIndex === undefined) return { success: false, error: "Missing params" };
return runTmux(
event,
sessionId,
`select-window -t ${tmuxTarget(sessionName, windowIndex)}`,
8000,
);
}
case "killServer": {
return runTmux(event, sessionId, "kill-server", 8000);
}
default:
return { success: false, error: `Unknown tmux action: ${action}` };
}
}
return {
listSessions,
createSession,
listWindows,
listPanes,
listClients,
tmuxAction,
shQuote,
tmuxTarget,
};
}
module.exports = {
createTmuxOpsApi,
shQuote,
tmuxTarget,
parseTmuxSessions,
parseTmuxSessionsPlain,
parseTmuxSessionNames,
parseTmuxWindows,
parseTmuxWindowsPlain,
parseTmuxWindowsAll,
parseTmuxWindowsAllPlain,
filterWindowsForSession,
splitTmuxFields,
parseTmuxPaneRow,
parseTmuxPanesPlain,
parseTmuxPanes,
parseTmuxClients,
isNoTmuxServerMessage,
};

View File

@@ -0,0 +1,304 @@
"use strict";
const { createExecOnSessionApi } = require("./systemManager/execOnSession.cjs");
const { createTmuxOpsApi } = require("./systemManager/tmuxOps.cjs");
const { createDockerOpsApi } = require("./systemManager/dockerOps.cjs");
const CAPABILITY_SCRIPT_POSIX = [
"exec sh -c ",
"'",
'printf "%s\\n" "__NC_OS__=$(uname -s)"; ',
'command -v tmux >/dev/null 2>&1 && printf "%s\\n" __NC_TMUX__=1; ',
'docker info >/dev/null 2>&1 && printf "%s\\n" __NC_DOCKER__=1',
"'",
].join("");
const PROCESS_LIST_SCRIPT_POSIX = [
"exec sh -c ",
"'",
"ps -eo pid=,ppid=,user=,stat=,pcpu=,pmem=,rss=,vsz=,etime=,args= 2>/dev/null | head -n 200",
"'",
].join("");
function parseCapabilities(stdout, isLocal, localPlatform) {
const text = stdout || "";
let targetOs = "unknown";
if (isLocal) {
if (localPlatform === "linux") targetOs = "linux";
else if (localPlatform === "darwin") targetOs = "darwin";
else if (localPlatform === "win32") targetOs = "win32";
} else {
const osMatch = text.match(/__NC_OS__=([^\r\n]+)/);
const uname = (osMatch?.[1] || "").trim().toLowerCase();
if (uname.includes("linux")) targetOs = "linux";
else if (uname.includes("darwin")) targetOs = "darwin";
else if (uname.includes("windows") || uname.includes("mingw")) targetOs = "win32";
}
const hasTmux = text.includes("__NC_TMUX__=1");
const hasDocker = text.includes("__NC_DOCKER__=1");
return { targetOs, hasTmux, hasDocker, probedAt: Date.now() };
}
function parseProcessLines(stdout) {
const processes = [];
for (const line of (stdout || "").split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const m = trimmed.match(/^(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+([\d.]+)\s+([\d.]+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);
if (!m) continue;
processes.push({
pid: Number(m[1]),
ppid: Number(m[2]),
user: m[3],
stat: m[4],
cpuPercent: Number(m[5]),
memPercent: Number(m[6]),
rssKb: Number(m[7]),
vszKb: Number(m[8]),
elapsed: m[9],
command: m[10],
});
}
return processes;
}
const ALLOWED_SIGNALS = new Set([
"TERM", "KILL", "STOP", "CONT", "HUP", "INT", "USR1", "USR2",
"1", "2", "9", "15", "18", "19",
]);
function buildProcessSignalCommand(pid, signal, nice) {
if (nice !== undefined && nice !== null) {
const n = Number(nice);
if (!Number.isFinite(n) || n < -20 || n > 19) {
return { error: "Invalid nice value" };
}
return { command: `renice ${Math.trunc(n)} -p ${Number(pid)}` };
}
const sig = String(signal || "TERM").toUpperCase();
if (!ALLOWED_SIGNALS.has(sig)) {
return { error: "Invalid signal" };
}
const numericPid = Number(pid);
if (!Number.isFinite(numericPid) || numericPid <= 0) {
return { error: "Invalid pid" };
}
if (sig === "KILL" || sig === "9") {
return { command: `kill -9 ${numericPid}` };
}
if (sig === "TERM" || sig === "15") {
return { command: `kill -15 ${numericPid}` };
}
if (/^\d+$/.test(sig)) {
return { command: `kill -${sig} ${numericPid}` };
}
return { command: `kill -s ${sig} ${numericPid}` };
}
function createSystemManagerBridge(deps) {
const {
getSessions,
execOnEtSession,
ensureMoshStatsConnection,
process,
} = deps;
const execApi = createExecOnSessionApi({
sessions: { get: (id) => getSessions()?.get(id) },
execOnEtSession,
ensureMoshStatsConnection,
});
const { execOnSession, execOnLocalMachine, isLocalSession } = execApi;
const tmuxOps = createTmuxOpsApi({ execOnSession });
const dockerOps = createDockerOpsApi({ execOnSession });
async function probeCapabilities(event, payload) {
const sessionId = payload?.sessionId;
if (!sessionId) return { success: false, error: "Missing sessionId" };
if (isLocalSession(sessionId)) {
const platform = process.platform;
let script = CAPABILITY_SCRIPT_POSIX;
if (platform === "win32") {
const result = await execOnLocalMachine(
"$os=[System.Environment]::OSVersion.Platform; Write-Output \"__NC_OS__=Windows\"; if (Get-Command tmux -ErrorAction SilentlyContinue) { Write-Output '__NC_TMUX__=1' }; docker info 2>$null; if ($LASTEXITCODE -eq 0) { Write-Output '__NC_DOCKER__=1' }",
8000,
);
if (!result.success) return { success: false, error: result.error || "Probe failed" };
return { success: true, capabilities: parseCapabilities(result.stdout, true, platform) };
}
const result = await execOnLocalMachine(
script.replace(/^exec sh -c '/, "").replace(/'$/, ""),
8000,
);
if (!result.success) {
const fallback = await execOnLocalMachine("uname -s; command -v tmux; docker info >/dev/null 2>&1 && echo docker_ok", 8000);
if (!fallback.success) return { success: false, error: fallback.error || "Probe failed" };
const text = fallback.stdout || "";
return {
success: true,
capabilities: {
targetOs: platform === "linux" ? "linux" : platform === "darwin" ? "darwin" : "unknown",
hasTmux: text.includes("tmux") && !text.includes("not found"),
hasDocker: text.includes("docker_ok"),
probedAt: Date.now(),
},
};
}
return { success: true, capabilities: parseCapabilities(result.stdout, true, platform) };
}
const result = await execOnSession(event, sessionId, CAPABILITY_SCRIPT_POSIX, 8000);
if (result.pending) return { success: false, pending: true };
if (!result.success) return { success: false, error: result.error || "Probe failed" };
return {
success: true,
capabilities: parseCapabilities(result.stdout, false, process.platform),
};
}
async function listProcesses(event, payload) {
const sessionId = payload?.sessionId;
if (!sessionId) return { success: false, error: "Missing sessionId" };
if (isLocalSession(sessionId) && process.platform === "win32") {
const result = await execOnLocalMachine(
"Get-CimInstance Win32_Process | Sort-Object KernelModeTime -Descending | Select-Object -First 200 ProcessId,ParentProcessId,Name,WorkingSetSize | ConvertTo-Json -Compress",
10000,
);
if (!result.success) return { success: false, error: result.error };
try {
const raw = JSON.parse(result.stdout || "[]");
const list = Array.isArray(raw) ? raw : [raw];
const processes = list.map((p) => ({
pid: Number(p.ProcessId),
ppid: Number(p.ParentProcessId) || 0,
user: "",
stat: "R",
cpuPercent: 0,
memPercent: 0,
rssKb: Math.round((Number(p.WorkingSetSize) || 0) / 1024),
vszKb: 0,
elapsed: "",
command: String(p.Name || ""),
}));
return { success: true, processes };
} catch {
return { success: false, error: "Failed to parse process list" };
}
}
const result = await execOnSession(event, sessionId, PROCESS_LIST_SCRIPT_POSIX, 12000);
if (result.pending) return { success: false, pending: true };
if (!result.success) return { success: false, error: result.error || "Failed to list processes" };
return { success: true, processes: parseProcessLines(result.stdout) };
}
async function signalProcess(event, payload) {
const { sessionId, pid, signal = "TERM", nice } = payload || {};
if (!sessionId || !pid) return { success: false, error: "Missing sessionId or pid" };
const built = buildProcessSignalCommand(pid, signal, nice);
if (built.error) return { success: false, error: built.error };
const result = await execOnSession(event, sessionId, `exec sh -c ${JSON.stringify(built.command)}`, 5000);
if (!result.success) return { success: false, error: result.error };
return { success: true, code: result.code };
}
async function listTmuxSessions(event, payload) {
const sessionId = typeof payload === "string" ? payload : payload?.sessionId;
if (!sessionId) return { success: false, error: "Missing sessionId" };
return tmuxOps.listSessions(event, sessionId);
}
async function createTmuxSession(event, payload) {
return tmuxOps.createSession(event, payload);
}
async function listTmuxWindows(event, payload) {
return tmuxOps.listWindows(event, payload);
}
async function listTmuxPanes(event, payload) {
return tmuxOps.listPanes(event, payload);
}
async function listTmuxClients(event, payload) {
return tmuxOps.listClients(event, payload);
}
async function tmuxAction(event, payload) {
const result = await tmuxOps.tmuxAction(event, payload);
if (result.success === false && result.error) {
return { success: false, error: result.error || result.stderr };
}
if (result.success === false) {
return { success: false, error: result.stderr || "tmux command failed" };
}
return { success: true };
}
async function listDockerContainers(event, payload) {
const sessionId = payload?.sessionId;
if (!sessionId) return { success: false, error: "Missing sessionId" };
return dockerOps.listContainers(event, sessionId);
}
async function listDockerImages(event, payload) {
const sessionId = payload?.sessionId;
if (!sessionId) return { success: false, error: "Missing sessionId" };
return dockerOps.listImages(event, sessionId);
}
async function dockerStats(event, payload) {
return dockerOps.getStats(event, payload);
}
async function dockerInspect(event, payload) {
return dockerOps.inspectContainer(event, payload);
}
async function dockerImageInspect(event, payload) {
return dockerOps.inspectImage(event, payload);
}
async function dockerAction(event, payload) {
const result = await dockerOps.containerAction(event, payload);
if (result.success === false) {
return { success: false, error: result.error || result.stderr || "docker command failed" };
}
return { success: true };
}
async function dockerImageAction(event, payload) {
const result = await dockerOps.imageAction(event, payload);
if (result.success === false) {
return { success: false, error: result.error || result.stderr || "docker command failed" };
}
return { success: true, output: result.stdout };
}
function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:system:probeCapabilities", probeCapabilities);
ipcMain.handle("netcatty:system:listProcesses", listProcesses);
ipcMain.handle("netcatty:system:signalProcess", signalProcess);
ipcMain.handle("netcatty:system:listTmuxSessions", listTmuxSessions);
ipcMain.handle("netcatty:system:createTmuxSession", createTmuxSession);
ipcMain.handle("netcatty:system:listTmuxWindows", listTmuxWindows);
ipcMain.handle("netcatty:system:listTmuxPanes", listTmuxPanes);
ipcMain.handle("netcatty:system:listTmuxClients", listTmuxClients);
ipcMain.handle("netcatty:system:tmuxAction", tmuxAction);
ipcMain.handle("netcatty:system:listDockerContainers", listDockerContainers);
ipcMain.handle("netcatty:system:listDockerImages", listDockerImages);
ipcMain.handle("netcatty:system:dockerStats", dockerStats);
ipcMain.handle("netcatty:system:dockerInspect", dockerInspect);
ipcMain.handle("netcatty:system:dockerImageInspect", dockerImageInspect);
ipcMain.handle("netcatty:system:dockerAction", dockerAction);
ipcMain.handle("netcatty:system:dockerImageAction", dockerImageAction);
}
return { registerHandlers, probeCapabilities, listProcesses };
}
module.exports = { createSystemManagerBridge };

View File

@@ -661,9 +661,15 @@ main();
windowsHide: true,
}, (err, stdout, stderr) => {
if (err) {
resolve({ success: false, error: err.message, stdout, stderr });
resolve({
success: false,
error: err.message,
stdout: stdout || "",
stderr: stderr || "",
code: typeof err.code === "number" && err.code !== 0 ? err.code : 1,
});
} else {
resolve({ success: true, stdout: stdout || "", stderr: stderr || "" });
resolve({ success: true, stdout: stdout || "", stderr: stderr || "", code: 0 });
}
});
});

View File

@@ -894,6 +894,22 @@ const {
prewarmSettingsWindow,
} = settingsWindowApi;
const { createTerminalPopupWindowApi } = require("./windowManager/terminalPopupWindow.cjs");
const terminalPopupWindowApi = createTerminalPopupWindowApi({
get mainWindow() { return mainWindow; },
get currentTheme() { return currentTheme; },
V8_CACHE_OPTIONS,
__dirname,
resolveFrontendBackgroundColor,
createExternalOnlyWindowOpenHandler,
getDevRendererBaseUrl,
applyWindowOpacityToWindow,
sendWhenRendererReady,
showAndFocusWindow,
resolveSettingsWindowBounds,
});
const { openTerminalPopupWindow, closeTerminalPopupWindow } = terminalPopupWindowApi;
/**
* Register window control IPC handlers (only once)
*/
@@ -1174,6 +1190,8 @@ module.exports = {
createWindow,
openSettingsWindow,
closeSettingsWindow,
openTerminalPopupWindow,
closeTerminalPopupWindow,
prewarmSettingsWindow,
buildAppMenu,
getMainWindow,

View File

@@ -0,0 +1,125 @@
/* eslint-disable no-undef */
function createTerminalPopupWindowApi(ctx) {
with (ctx) {
const terminalPopupWindows = new Map();
function isLiveWindow(win) {
return Boolean(win && typeof win.isDestroyed === "function" && !win.isDestroyed());
}
async function openTerminalPopupWindow(electronModule, options, payload) {
const { BrowserWindow, shell } = electronModule;
const { preload, devServerUrl, isDev, appIcon, isMac, electronDir, sourceWindow } = options;
const osTheme = electronModule?.nativeTheme?.shouldUseDarkColors ? "dark" : "light";
const effectiveTheme = currentTheme === "dark" || currentTheme === "light" ? currentTheme : osTheme;
const frontendBackground = resolveFrontendBackgroundColor(electronDir || __dirname, effectiveTheme);
const backgroundColor = frontendBackground || "#1a1a1a";
const popupWidth = 920;
const popupHeight = 580;
const { x: popupX, y: popupY } = resolveSettingsWindowBounds(electronModule, {
sourceWindow: sourceWindow || mainWindow,
settingsWidth: popupWidth,
settingsHeight: popupHeight,
});
const title = typeof payload?.title === "string" && payload.title.trim()
? payload.title.trim()
: "Terminal";
const win = new BrowserWindow({
title,
width: popupWidth,
height: popupHeight,
...(popupX !== undefined && popupY !== undefined ? { x: popupX, y: popupY } : {}),
minWidth: 480,
minHeight: 320,
backgroundColor,
icon: appIcon,
show: false,
frame: isMac,
titleBarStyle: isMac ? "hiddenInset" : undefined,
trafficLightPosition: isMac ? { x: 12, y: 12 } : undefined,
webPreferences: {
preload,
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
v8CacheOptions: V8_CACHE_OPTIONS,
},
});
const popupId = String(payload?.popupId || Date.now());
terminalPopupWindows.set(popupId, win);
try {
win.webContents?.setWindowOpenHandler?.(
createExternalOnlyWindowOpenHandler(shell),
);
} catch {
// ignore
}
win.on("closed", () => {
terminalPopupWindows.delete(popupId);
});
win.on("page-title-updated", (e) => { e.preventDefault(); });
try {
win.setBackgroundColor(backgroundColor);
} catch {
// ignore
}
applyWindowOpacityToWindow(win);
const popupPath = "/#/terminal-popup";
if (isDev) {
try {
const baseUrl = getDevRendererBaseUrl(devServerUrl);
await win.loadURL(`${baseUrl}${popupPath}`);
} catch (e) {
console.warn("[TerminalPopup] Dev server not reachable", e);
await win.loadURL(`app://netcatty/index.html${popupPath}`);
}
} else {
await win.loadURL(`app://netcatty/index.html${popupPath}`);
}
const delivery = await sendWhenRendererReady(
win,
"netcatty:window:terminalPopupConfig",
{ ...payload, popupId },
{ timeoutMs: 10000 },
);
if (!delivery.success) {
try { win.destroy(); } catch { /* ignore */ }
terminalPopupWindows.delete(popupId);
return { success: false, error: delivery.error || "Popup failed to receive config" };
}
showAndFocusWindow(win);
return { success: true, popupId };
}
function closeTerminalPopupWindow(popupId) {
const win = terminalPopupWindows.get(popupId);
if (isLiveWindow(win)) {
try { win.close(); } catch { /* ignore */ }
}
terminalPopupWindows.delete(popupId);
}
return {
openTerminalPopupWindow,
closeTerminalPopupWindow,
};
}
}
module.exports = { createTerminalPopupWindowApi };

View File

@@ -159,6 +159,15 @@ function createBridgeRegistrar(context) {
transferBridge.registerHandlers(ipcMain);
portForwardingBridge.registerHandlers(ipcMain);
terminalBridge.registerHandlers(ipcMain);
const { createSystemManagerBridge } = require("../bridges/systemManagerBridge.cjs");
const systemManagerBridge = createSystemManagerBridge({
getSessions: () => sessions,
execOnEtSession: (...args) => terminalBridge.execOnEtSession(...args),
ensureMoshStatsConnection: (...args) => sshBridge.ensureMoshStatsConnection(...args),
process,
});
systemManagerBridge.registerHandlers(ipcMain);
oauthBridge.setupOAuthBridge(ipcMain);
githubAuthBridge.registerHandlers(ipcMain);
googleAuthBridge.registerHandlers(ipcMain, electronModule);
@@ -345,6 +354,27 @@ function createBridgeRegistrar(context) {
return { success: false, error: err?.message || "Failed to open new window" };
}
});
ipcMain.handle("netcatty:window:openTerminalPopup", async (event, payload) => {
try {
if (!payload || typeof payload !== "object") {
return { success: false, error: "Invalid popup payload" };
}
const sourceWindow = BrowserWindow.fromWebContents(event.sender);
return await getWindowManager().openTerminalPopupWindow(electronModule, {
preload,
devServerUrl: effectiveDevServerUrl,
isDev,
appIcon,
isMac,
electronDir,
sourceWindow,
}, payload);
} catch (err) {
console.error("[Main] Failed to open terminal popup:", err);
return { success: false, error: err?.message || "Failed to open terminal popup" };
}
});
// Cloud sync master password (stored in-memory + persisted via safeStorage)
ipcMain.handle("netcatty:cloudSync:session:setPassword", async (_event, password) => {

View File

@@ -74,6 +74,62 @@ function createPreloadApi(ctx) {
getServerStats: async (sessionId) => {
return ipcRenderer.invoke("netcatty:ssh:stats", { sessionId });
},
probeSystemCapabilities: async (sessionId) => {
return ipcRenderer.invoke("netcatty:system:probeCapabilities", { sessionId });
},
listSystemProcesses: async (sessionId) => {
return ipcRenderer.invoke("netcatty:system:listProcesses", { sessionId });
},
signalSystemProcess: async (options) => {
return ipcRenderer.invoke("netcatty:system:signalProcess", options);
},
listTmuxSessions: async (sessionId) => {
return ipcRenderer.invoke("netcatty:system:listTmuxSessions", { sessionId });
},
createTmuxSession: async (options) => {
return ipcRenderer.invoke("netcatty:system:createTmuxSession", options);
},
listTmuxWindows: async (options) => {
return ipcRenderer.invoke("netcatty:system:listTmuxWindows", options);
},
listTmuxPanes: async (options) => {
return ipcRenderer.invoke("netcatty:system:listTmuxPanes", options);
},
listTmuxClients: async (options) => {
return ipcRenderer.invoke("netcatty:system:listTmuxClients", options);
},
tmuxAction: async (options) => {
return ipcRenderer.invoke("netcatty:system:tmuxAction", options);
},
listDockerContainers: async (sessionId) => {
return ipcRenderer.invoke("netcatty:system:listDockerContainers", { sessionId });
},
listDockerImages: async (sessionId) => {
return ipcRenderer.invoke("netcatty:system:listDockerImages", { sessionId });
},
getDockerStats: async (options) => {
return ipcRenderer.invoke("netcatty:system:dockerStats", options);
},
dockerInspect: async (options) => {
return ipcRenderer.invoke("netcatty:system:dockerInspect", options);
},
dockerImageInspect: async (options) => {
return ipcRenderer.invoke("netcatty:system:dockerImageInspect", options);
},
dockerAction: async (options) => {
return ipcRenderer.invoke("netcatty:system:dockerAction", options);
},
dockerImageAction: async (options) => {
return ipcRenderer.invoke("netcatty:system:dockerImageAction", options);
},
openTerminalPopup: async (payload) => {
return ipcRenderer.invoke("netcatty:window:openTerminalPopup", payload);
},
onTerminalPopupConfig: (cb) => {
const handler = (_event, payload) => cb(payload);
ipcRenderer.on("netcatty:window:terminalPopupConfig", handler);
return () => ipcRenderer.removeListener("netcatty:window:terminalPopupConfig", handler);
},
readRemoteHistory: async (sessionId, limit) => {
return ipcRenderer.invoke("netcatty:ssh:readRemoteHistory", { sessionId, limit });
},