Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
804 lines
27 KiB
JavaScript
804 lines
27 KiB
JavaScript
/* 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,
|
|
};
|