mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: convert tmux monitoring into a plugin (B10)
This commit is contained in:
1 parent
79d77678c9
commit
708eedb861
145 files changed
+30308
-4458
No files matched your search
@@ -1,190 +0,0 @@
|
||||
import type { Client, ClientChannel } from "ssh2";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
|
||||
export interface TmuxSessionInfo {
|
||||
name: string;
|
||||
created: number;
|
||||
lastActivity: number;
|
||||
windows: number;
|
||||
attachedClients: number;
|
||||
}
|
||||
|
||||
export interface TmuxDetectionResult {
|
||||
available: boolean;
|
||||
sessions: TmuxSessionInfo[];
|
||||
}
|
||||
|
||||
const TMUX_PATH_DIRS = [
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/opt/bin",
|
||||
"/usr/pkg/bin",
|
||||
];
|
||||
|
||||
export function withTmuxPath(command: string): string {
|
||||
const script = `PATH=${TMUX_PATH_DIRS.join(":")}:"$PATH"; export PATH; ${command}`;
|
||||
return `/bin/sh -c ${shellEscape(script)}`;
|
||||
}
|
||||
|
||||
export function tmuxCommand(args: string): string {
|
||||
return withTmuxPath(`tmux -u ${args}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command on the remote host via a separate exec channel.
|
||||
* Returns stdout as a string. Does not pollute the interactive shell.
|
||||
*/
|
||||
export function execCommand(conn: Client, command: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
conn.exec(command, (err, stream) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
stream.on("data", (data: Buffer) => {
|
||||
stdout += data.toString("utf-8");
|
||||
});
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString("utf-8");
|
||||
});
|
||||
stream.on("error", (err: Error) => {
|
||||
reject(err);
|
||||
});
|
||||
stream.on("close", (code: number) => {
|
||||
if (code !== 0 && stdout === "") {
|
||||
reject(
|
||||
new Error(stderr.trim() || `Command exited with code ${code}`),
|
||||
);
|
||||
} else {
|
||||
resolve(stdout.trim());
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether tmux is installed and list all existing sessions with details.
|
||||
*/
|
||||
export async function detectTmux(conn: Client): Promise<TmuxDetectionResult> {
|
||||
try {
|
||||
await execCommand(conn, tmuxCommand("-V"));
|
||||
} catch {
|
||||
return { available: false, sessions: [] };
|
||||
}
|
||||
|
||||
let sessions: TmuxSessionInfo[] = [];
|
||||
try {
|
||||
const output = await execCommand(
|
||||
conn,
|
||||
tmuxCommand(
|
||||
`list-sessions -F "#{session_name}|#{session_created}|#{session_activity}|#{session_windows}|#{session_attached}" 2>/dev/null`,
|
||||
),
|
||||
);
|
||||
if (output) {
|
||||
sessions = output
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => {
|
||||
const [name, created, activity, windows, attached] = line.split("|");
|
||||
return {
|
||||
name,
|
||||
created: parseInt(created, 10) || 0,
|
||||
lastActivity: parseInt(activity, 10) || 0,
|
||||
windows: parseInt(windows, 10) || 0,
|
||||
attachedClients: parseInt(attached, 10) || 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// tmux server not running yet -- no sessions exist
|
||||
}
|
||||
|
||||
return { available: true, sessions };
|
||||
}
|
||||
|
||||
// tmux options applied on every attach/create:
|
||||
// - mouse on: enables mouse wheel / touch scrollback through tmux history
|
||||
// - history-limit: deep scrollback buffer on the remote host
|
||||
// - set-clipboard on: use OSC 52 to sync tmux selections to the client clipboard
|
||||
// - mode-keys vi: use vi-style keys in copy mode
|
||||
// - MouseDragEnd: stop the selection but keep it highlighted so the user can
|
||||
// adjust and press Enter to copy (or drag again)
|
||||
// - Enter: copy the (possibly adjusted) selection and exit copy mode
|
||||
// - pane-mode-changed hook: on copy-mode entry, show a brief hint so users
|
||||
// know to press Enter to copy the selection
|
||||
// Using -q on set/set-hook to suppress errors on older tmux versions that don't support
|
||||
// a particular option (e.g. set-clipboard on tmux < 2.5). Note: set-hook doesn't support -q.
|
||||
const TMUX_OPTS =
|
||||
`set -gq mouse on` +
|
||||
` \\; set -gq history-limit 50000` +
|
||||
` \\; set -gq set-clipboard on` +
|
||||
` \\; set -gq aggressive-resize on` +
|
||||
` \\; set -gq mode-keys vi` +
|
||||
` \\; bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X stop-selection` +
|
||||
` \\; bind-key -T copy-mode-vi Enter send-keys -X copy-selection-and-cancel` +
|
||||
` \\; set-hook -g pane-mode-changed` +
|
||||
` 'if -F "#{pane_in_mode}"` +
|
||||
` "display-message -d 2500 \\"Adjust selection and press Enter to copy\\""'`;
|
||||
|
||||
/**
|
||||
* Wait for a tmux session to appear by polling via exec channel.
|
||||
* Returns the session name once found, or null on timeout.
|
||||
*/
|
||||
export async function waitForTmuxSession(
|
||||
conn: Client,
|
||||
sessionName: string,
|
||||
timeoutMs = 5000,
|
||||
intervalMs = 100,
|
||||
): Promise<string | null> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await execCommand(
|
||||
conn,
|
||||
tmuxCommand(`has-session -t ${shellEscape(sessionName)} 2>/dev/null`),
|
||||
);
|
||||
return sessionName;
|
||||
} catch {
|
||||
// session not ready yet
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write tmux attach or new-session command to the interactive shell stream.
|
||||
* Uses && exit so the shell only closes if tmux started successfully.
|
||||
*/
|
||||
export function attachOrCreateTmuxSession(
|
||||
stream: ClientChannel,
|
||||
existingSessionName?: string,
|
||||
newSessionName?: string,
|
||||
): void {
|
||||
let command: string;
|
||||
if (existingSessionName) {
|
||||
command = `${tmuxCommand(`${TMUX_OPTS} \\; attach-session -t ${shellEscape(existingSessionName)}`)} && exit\r`;
|
||||
} else {
|
||||
const nameFlag = newSessionName ? ` -s ${shellEscape(newSessionName)}` : "";
|
||||
command = `${tmuxCommand(`${TMUX_OPTS} \\; new-session${nameFlag}`)} && exit\r`;
|
||||
}
|
||||
|
||||
sshLogger.info("Writing tmux command to shell", {
|
||||
operation: "tmux_attach_or_create",
|
||||
sessionName: existingSessionName || "(auto)",
|
||||
isReattach: !!existingSessionName,
|
||||
});
|
||||
|
||||
stream.write(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the name of the most recently created tmux session via exec channel.
|
||||
*/
|
||||
|
||||
function shellEscape(s: string): string {
|
||||
return "'" + s.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
@@ -1,767 +0,0 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import express from "express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import type { Client } from "ssh2";
|
||||
import { connectHost, getConnectionPoolKey } from "../connect/connect-host.js";
|
||||
import type { SshConnectHost } from "../connect/types.js";
|
||||
import { createCorsMiddleware } from "../../utils/cors-config.js";
|
||||
import { createCompressionMiddleware } from "../../utils/compression-config.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import {
|
||||
createCurrentTmuxSessionTagRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import { resolveHostById, checkHostAccess } from "../host-resolver.js";
|
||||
import type { HostAction } from "../../utils/permission-manager.js";
|
||||
import { withConnection } from "../ssh-connection-pool.js";
|
||||
import { execCommand, tmuxCommand } from "./helper.js";
|
||||
import {
|
||||
SEP,
|
||||
parseSessions,
|
||||
parseWindows,
|
||||
parsePanes,
|
||||
parsePsOutput,
|
||||
parseGpuOutput,
|
||||
buildPaneMetrics,
|
||||
attachPanesToWindows,
|
||||
shellEscape,
|
||||
type RawPane,
|
||||
type TmuxSessionSummary,
|
||||
type TmuxWindow,
|
||||
type PaneMetrics,
|
||||
} from "./monitor-helpers.js";
|
||||
import type { SSHHost, AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { listenOnServicePort } from "../../utils/service-listen.js";
|
||||
|
||||
const PANE_ID_RE = /^%\d+$/;
|
||||
// tmux session names cannot contain ":" or "."; keep to a conservative
|
||||
// printable subset so the name is safe as a tmux target everywhere.
|
||||
const SESSION_NAME_RE = /^[A-Za-z0-9_@%+=-]{1,64}$/;
|
||||
const MAX_SEARCH_PANES = 100;
|
||||
const MAX_MATCHES_PER_PANE = 50;
|
||||
const SEARCH_HISTORY_LINES = 2000;
|
||||
const SEARCH_CONCURRENCY = 4;
|
||||
|
||||
interface TmuxSessionOverview extends TmuxSessionSummary {
|
||||
windows: TmuxWindow[];
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
// SSH connection
|
||||
|
||||
export function connectToHost(host: SSHHost): () => Promise<Client> {
|
||||
return async () => {
|
||||
const connection = await connectHost(host as unknown as SshConnectHost, {
|
||||
userId: host.userId || "",
|
||||
purpose: "tmux",
|
||||
});
|
||||
return connection.client;
|
||||
};
|
||||
}
|
||||
|
||||
function getPoolKey(host: SSHHost): string {
|
||||
return getConnectionPoolKey(
|
||||
"tmux-monitor",
|
||||
host as unknown as SshConnectHost,
|
||||
);
|
||||
}
|
||||
|
||||
async function withHostConnection<T>(
|
||||
host: SSHHost,
|
||||
fn: (client: Client) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return withConnection(getPoolKey(host), connectToHost(host), fn);
|
||||
}
|
||||
|
||||
// tmux queries
|
||||
|
||||
async function tmuxAvailable(conn: Client): Promise<boolean> {
|
||||
try {
|
||||
await execCommand(conn, tmuxCommand("-V"));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runTmuxList(conn: Client, command: string): Promise<string> {
|
||||
try {
|
||||
return await execCommand(conn, command);
|
||||
} catch {
|
||||
return ""; // tmux server not running -- no sessions
|
||||
}
|
||||
}
|
||||
|
||||
function listSessionsCmd(): string {
|
||||
return tmuxCommand(
|
||||
`list-sessions -F "#{session_name}${SEP}#{session_created}${SEP}#{session_activity}${SEP}#{session_attached}" 2>/dev/null`,
|
||||
);
|
||||
}
|
||||
|
||||
function listWindowsCmd(): string {
|
||||
return tmuxCommand(
|
||||
`list-windows -a -F "#{session_name}${SEP}#{window_index}${SEP}#{window_active}${SEP}#{window_name}" 2>/dev/null`,
|
||||
);
|
||||
}
|
||||
|
||||
function listPanesCmd(): string {
|
||||
return tmuxCommand(
|
||||
`list-panes -a -F "#{session_name}${SEP}#{window_index}${SEP}#{pane_id}${SEP}#{pane_index}${SEP}#{pane_pid}${SEP}#{pane_active}${SEP}#{pane_width}${SEP}#{pane_height}${SEP}#{pane_current_command}${SEP}#{pane_current_path}${SEP}#{pane_title}" 2>/dev/null`,
|
||||
);
|
||||
}
|
||||
|
||||
async function listPanesRaw(conn: Client): Promise<RawPane[]> {
|
||||
return parsePanes(await runTmuxList(conn, listPanesCmd()));
|
||||
}
|
||||
|
||||
async function fetchSessionTags(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<Map<string, string[]>> {
|
||||
return createCurrentTmuxSessionTagRepository().listByUserAndHost(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
}
|
||||
|
||||
async function collectPaneMetrics(
|
||||
conn: Client,
|
||||
panes: RawPane[],
|
||||
): Promise<PaneMetrics[]> {
|
||||
let psOutput = "";
|
||||
try {
|
||||
psOutput = await execCommand(
|
||||
conn,
|
||||
"ps -eo pid=,ppid=,pcpu=,pmem=,rss=,comm= 2>/dev/null",
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
// GPU memory per pid (best effort; nvidia-smi may not exist)
|
||||
let gpuOutput = "";
|
||||
try {
|
||||
gpuOutput = await execCommand(
|
||||
conn,
|
||||
"command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi --query-compute-apps=pid,used_gpu_memory --format=csv,noheader,nounits 2>/dev/null || true",
|
||||
);
|
||||
} catch {
|
||||
// no GPU on host
|
||||
}
|
||||
|
||||
return buildPaneMetrics(
|
||||
panes,
|
||||
parsePsOutput(psOutput),
|
||||
parseGpuOutput(gpuOutput),
|
||||
);
|
||||
}
|
||||
|
||||
// Express app
|
||||
|
||||
const app = express();
|
||||
app.set("trust proxy", "loopback");
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
app.use(createCompressionMiddleware());
|
||||
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
next();
|
||||
});
|
||||
app.use(authManager.createAuthMiddleware());
|
||||
|
||||
/**
|
||||
* Resolve the host for a request and verify the user can access it.
|
||||
* Sends the error response and returns null when access is denied.
|
||||
*/
|
||||
async function requireHost(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
permission: HostAction = "connect",
|
||||
): Promise<SSHHost | null> {
|
||||
const userId = (req as unknown as AuthenticatedRequest).userId;
|
||||
const hostId = parseInt(String(req.params.hostId), 10);
|
||||
if (isNaN(hostId)) {
|
||||
res.status(400).json({ error: "Invalid host ID" });
|
||||
return null;
|
||||
}
|
||||
if (DataCrypto.getUserDataKey(userId) === null) {
|
||||
res.status(401).json({ error: "User data is locked" });
|
||||
return null;
|
||||
}
|
||||
|
||||
let host: SSHHost | null = null;
|
||||
try {
|
||||
host = await resolveHostById(hostId, userId);
|
||||
} catch (err) {
|
||||
sshLogger.error(`Failed to resolve host ${hostId} for tmux monitor`, err);
|
||||
}
|
||||
if (!host) {
|
||||
res.status(404).json({ error: "Host not found" });
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasAccess = await checkHostAccess(
|
||||
hostId,
|
||||
userId,
|
||||
host.userId || userId,
|
||||
permission,
|
||||
);
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({ error: "Access denied" });
|
||||
return null;
|
||||
}
|
||||
|
||||
// The monitor is opt-in per host (same pattern as enableDocker in
|
||||
// docker.ts): hiding the UI is not enough, the API must refuse too.
|
||||
if (!host.enableTmuxMonitor) {
|
||||
res
|
||||
.status(403)
|
||||
.json({ error: "Tmux Monitor is not enabled for this host" });
|
||||
return null;
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
function toErrorMessage(err: unknown): string {
|
||||
return getErrorMessage(err);
|
||||
}
|
||||
|
||||
// Destructive tmux actions terminate processes on the remote host, so they
|
||||
// land in the audit log like other host-level mutations (see host.ts).
|
||||
async function auditTmuxAction(
|
||||
req: express.Request,
|
||||
host: SSHHost,
|
||||
action: string,
|
||||
resourceName: string,
|
||||
details?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const userId = (req as unknown as AuthenticatedRequest).userId;
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
let username = userId;
|
||||
try {
|
||||
const actor = await createCurrentUserRepository().findById(userId);
|
||||
username = actor?.username ?? userId;
|
||||
} catch {
|
||||
// fall back to the raw user id
|
||||
}
|
||||
await logAudit({
|
||||
userId,
|
||||
username,
|
||||
action,
|
||||
resourceType: "host",
|
||||
resourceId: String(host.id),
|
||||
resourceName,
|
||||
details: details ? JSON.stringify(details) : undefined,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Typed error codes so the frontend can render a helpful state instead of a
|
||||
// raw 500 (same pattern as SESSION_EXPIRED handling in main-axios).
|
||||
type TmuxErrorCode =
|
||||
"TMUX_NOT_INSTALLED" | "TMUX_NO_SERVER" | "HOST_UNREACHABLE" | "TMUX_ERROR";
|
||||
|
||||
function classifyTmuxError(err: unknown): TmuxErrorCode {
|
||||
const msg = getErrorMessage(err, "");
|
||||
if (/command not found|exited with code 127/i.test(msg))
|
||||
return "TMUX_NOT_INSTALLED";
|
||||
if (/no server running|lost server/i.test(msg)) return "TMUX_NO_SERVER";
|
||||
if (
|
||||
/timeout|timed out|econnrefused|ehostunreach|enotfound|enetunreach|econnreset|authentication|handshake|keepalive/i.test(
|
||||
msg,
|
||||
)
|
||||
)
|
||||
return "HOST_UNREACHABLE";
|
||||
return "TMUX_ERROR";
|
||||
}
|
||||
|
||||
function sendTmuxError(
|
||||
res: express.Response,
|
||||
err: unknown,
|
||||
context: string,
|
||||
hostId: number,
|
||||
): void {
|
||||
const code = classifyTmuxError(err);
|
||||
const status = code === "TMUX_ERROR" ? 500 : 503;
|
||||
const error =
|
||||
code === "TMUX_NOT_INSTALLED"
|
||||
? "tmux is not installed on this host"
|
||||
: code === "TMUX_NO_SERVER"
|
||||
? "No tmux server is running on this host"
|
||||
: code === "HOST_UNREACHABLE"
|
||||
? "Could not connect to the host"
|
||||
: toErrorMessage(err);
|
||||
sshLogger.error(`tmux ${context} failed for host ${hostId}`, err);
|
||||
res.status(status).json({ error, code });
|
||||
}
|
||||
|
||||
app.get("/tmux_monitor/:hostId/overview", async (req, res) => {
|
||||
const userId = (req as unknown as AuthenticatedRequest).userId;
|
||||
const host = await requireHost(req, res);
|
||||
if (!host) return;
|
||||
|
||||
try {
|
||||
const result = await withHostConnection(host, async (conn) => {
|
||||
if (!(await tmuxAvailable(conn))) {
|
||||
return { available: false, sessions: [] as TmuxSessionOverview[] };
|
||||
}
|
||||
const [sessionsOut, windowsOut, panesOut] = await Promise.all([
|
||||
runTmuxList(conn, listSessionsCmd()),
|
||||
runTmuxList(conn, listWindowsCmd()),
|
||||
runTmuxList(conn, listPanesCmd()),
|
||||
]);
|
||||
const sessions = parseSessions(sessionsOut);
|
||||
const windows = parseWindows(windowsOut);
|
||||
attachPanesToWindows(windows, parsePanes(panesOut));
|
||||
|
||||
const tags = await fetchSessionTags(userId, host.id);
|
||||
const full: TmuxSessionOverview[] = sessions.map((s) => ({
|
||||
...s,
|
||||
windows: windows.get(s.name) || [],
|
||||
tags: tags.get(s.name) || [],
|
||||
}));
|
||||
return { available: true, sessions: full };
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
sendTmuxError(res, err, "overview", host.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Focus a pane: select its window and pane on the server so every attached
|
||||
// client (including the monitor's embedded terminal) switches to it.
|
||||
app.post("/tmux_monitor/:hostId/focus", async (req, res) => {
|
||||
const host = await requireHost(req, res, "connect");
|
||||
if (!host) return;
|
||||
|
||||
const paneId = String((req.body as { paneId?: string })?.paneId || "");
|
||||
if (!PANE_ID_RE.test(paneId)) {
|
||||
return res.status(400).json({ error: "Invalid pane ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
await withHostConnection(host, (conn) =>
|
||||
// A pane id is a valid window target: tmux resolves it to the window
|
||||
// containing the pane.
|
||||
execCommand(
|
||||
conn,
|
||||
tmuxCommand(
|
||||
`select-window -t ${shellEscape(paneId)} \\; select-pane -t ${shellEscape(paneId)}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
sendTmuxError(res, err, "focus", host.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Create a detached session. Starts the tmux server if none is running.
|
||||
app.post("/tmux_monitor/:hostId/sessions", async (req, res) => {
|
||||
const host = await requireHost(req, res, "connect");
|
||||
if (!host) return;
|
||||
|
||||
const name = String((req.body as { name?: string })?.name || "").trim();
|
||||
if (!SESSION_NAME_RE.test(name)) {
|
||||
return res.status(400).json({ error: "Invalid session name" });
|
||||
}
|
||||
|
||||
try {
|
||||
await withHostConnection(host, (conn) =>
|
||||
execCommand(conn, tmuxCommand(`new-session -d -s ${shellEscape(name)}`)),
|
||||
);
|
||||
sshLogger.info("tmux session created", {
|
||||
operation: "tmux_session_create",
|
||||
hostId: host.id,
|
||||
sessionName: name,
|
||||
});
|
||||
res.json({ ok: true, name });
|
||||
} catch (err) {
|
||||
if (/duplicate session/i.test(toErrorMessage(err))) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: "A session with this name already exists" });
|
||||
}
|
||||
sendTmuxError(res, err, "create session", host.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Create a window in an existing session. The session name comes from tmux's
|
||||
// own listing, so it is only checked for characters that would change the
|
||||
// target's meaning (":" and "." are window/pane separators in tmux targets);
|
||||
// "=" prefixes the target for an exact-name match.
|
||||
app.post("/tmux_monitor/:hostId/windows", async (req, res) => {
|
||||
const host = await requireHost(req, res, "connect");
|
||||
if (!host) return;
|
||||
|
||||
const sessionName = String(
|
||||
(req.body as { sessionName?: string })?.sessionName || "",
|
||||
).trim();
|
||||
if (!sessionName || /[:.\n]/.test(sessionName)) {
|
||||
return res.status(400).json({ error: "Invalid session name" });
|
||||
}
|
||||
|
||||
try {
|
||||
await withHostConnection(host, (conn) =>
|
||||
execCommand(
|
||||
conn,
|
||||
tmuxCommand(`new-window -t ${shellEscape(`=${sessionName}`)}`),
|
||||
),
|
||||
);
|
||||
sshLogger.info("tmux window created", {
|
||||
operation: "tmux_window_create",
|
||||
hostId: host.id,
|
||||
sessionName,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (/can't find session|no such session/i.test(toErrorMessage(err))) {
|
||||
return res.status(404).json({ error: "Session not found" });
|
||||
}
|
||||
sendTmuxError(res, err, "create window", host.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Rename a session. Saved tags follow the session to its new name (for every
|
||||
// user — the session itself is shared on the host).
|
||||
app.post("/tmux_monitor/:hostId/rename", async (req, res) => {
|
||||
const host = await requireHost(req, res, "connect");
|
||||
if (!host) return;
|
||||
|
||||
const body = req.body as { sessionName?: string; newName?: string };
|
||||
const sessionName = String(body?.sessionName || "").trim();
|
||||
const newName = String(body?.newName || "").trim();
|
||||
if (!sessionName || /[:.\n]/.test(sessionName)) {
|
||||
return res.status(400).json({ error: "Invalid session name" });
|
||||
}
|
||||
if (!SESSION_NAME_RE.test(newName)) {
|
||||
return res.status(400).json({ error: "Invalid new session name" });
|
||||
}
|
||||
|
||||
try {
|
||||
await withHostConnection(host, (conn) =>
|
||||
execCommand(
|
||||
conn,
|
||||
tmuxCommand(
|
||||
`rename-session -t ${shellEscape(`=${sessionName}`)} ${shellEscape(newName)}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
await createCurrentTmuxSessionTagRepository().renameSessionForHost(
|
||||
host.id,
|
||||
sessionName,
|
||||
newName,
|
||||
);
|
||||
sshLogger.info("tmux session renamed", {
|
||||
operation: "tmux_session_rename",
|
||||
hostId: host.id,
|
||||
sessionName,
|
||||
newName,
|
||||
});
|
||||
await auditTmuxAction(req, host, "tmux_session_rename", sessionName, {
|
||||
newName,
|
||||
});
|
||||
res.json({ ok: true, name: newName });
|
||||
} catch (err) {
|
||||
if (/can't find session|no such session/i.test(toErrorMessage(err))) {
|
||||
return res.status(404).json({ error: "Session not found" });
|
||||
}
|
||||
if (/duplicate session/i.test(toErrorMessage(err))) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: "A session with this name already exists" });
|
||||
}
|
||||
sendTmuxError(res, err, "rename session", host.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Kill a session and drop its saved tags.
|
||||
app.post("/tmux_monitor/:hostId/kill", async (req, res) => {
|
||||
const host = await requireHost(req, res, "connect");
|
||||
if (!host) return;
|
||||
|
||||
const sessionName = String(
|
||||
(req.body as { sessionName?: string })?.sessionName || "",
|
||||
).trim();
|
||||
if (!sessionName || /[:.\n]/.test(sessionName)) {
|
||||
return res.status(400).json({ error: "Invalid session name" });
|
||||
}
|
||||
|
||||
try {
|
||||
await withHostConnection(host, (conn) =>
|
||||
execCommand(
|
||||
conn,
|
||||
tmuxCommand(`kill-session -t ${shellEscape(`=${sessionName}`)}`),
|
||||
),
|
||||
);
|
||||
await createCurrentTmuxSessionTagRepository().deleteSessionForHost(
|
||||
host.id,
|
||||
sessionName,
|
||||
);
|
||||
sshLogger.info("tmux session killed", {
|
||||
operation: "tmux_session_kill",
|
||||
hostId: host.id,
|
||||
sessionName,
|
||||
});
|
||||
await auditTmuxAction(req, host, "tmux_session_kill", sessionName);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (/can't find session|no such session/i.test(toErrorMessage(err))) {
|
||||
return res.status(404).json({ error: "Session not found" });
|
||||
}
|
||||
sendTmuxError(res, err, "kill session", host.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Kill a window (and every pane in it). Killing the last window of a session
|
||||
// ends the session — tmux semantics.
|
||||
app.post("/tmux_monitor/:hostId/kill-window", async (req, res) => {
|
||||
const host = await requireHost(req, res, "connect");
|
||||
if (!host) return;
|
||||
|
||||
const body = req.body as { sessionName?: string; windowIndex?: number };
|
||||
const sessionName = String(body?.sessionName || "").trim();
|
||||
const windowIndex = Number(body?.windowIndex);
|
||||
if (!sessionName || /[:.\n]/.test(sessionName)) {
|
||||
return res.status(400).json({ error: "Invalid session name" });
|
||||
}
|
||||
if (!Number.isInteger(windowIndex) || windowIndex < 0) {
|
||||
return res.status(400).json({ error: "Invalid window index" });
|
||||
}
|
||||
|
||||
try {
|
||||
await withHostConnection(host, (conn) =>
|
||||
execCommand(
|
||||
conn,
|
||||
tmuxCommand(
|
||||
`kill-window -t ${shellEscape(`=${sessionName}:${windowIndex}`)}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
sshLogger.info("tmux window killed", {
|
||||
operation: "tmux_window_kill",
|
||||
hostId: host.id,
|
||||
sessionName,
|
||||
windowIndex,
|
||||
});
|
||||
await auditTmuxAction(req, host, "tmux_window_kill", sessionName, {
|
||||
windowIndex,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (
|
||||
/can't find window|no such window|can't find session/i.test(
|
||||
toErrorMessage(err),
|
||||
)
|
||||
) {
|
||||
return res.status(404).json({ error: "Window not found" });
|
||||
}
|
||||
sendTmuxError(res, err, "kill window", host.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Kill a single pane. Killing the last pane of a window closes the window,
|
||||
// and the last window of a session ends the session — tmux semantics.
|
||||
app.post("/tmux_monitor/:hostId/kill-pane", async (req, res) => {
|
||||
const host = await requireHost(req, res, "connect");
|
||||
if (!host) return;
|
||||
|
||||
const paneId = String((req.body as { paneId?: string })?.paneId || "");
|
||||
if (!PANE_ID_RE.test(paneId)) {
|
||||
return res.status(400).json({ error: "Invalid pane ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
await withHostConnection(host, (conn) =>
|
||||
execCommand(conn, tmuxCommand(`kill-pane -t ${shellEscape(paneId)}`)),
|
||||
);
|
||||
sshLogger.info("tmux pane killed", {
|
||||
operation: "tmux_pane_kill",
|
||||
hostId: host.id,
|
||||
paneId,
|
||||
});
|
||||
await auditTmuxAction(req, host, "tmux_pane_kill", paneId);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (/can't find pane|no such pane/i.test(toErrorMessage(err))) {
|
||||
return res.status(404).json({ error: "Pane not found" });
|
||||
}
|
||||
sendTmuxError(res, err, "kill pane", host.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Split the window containing a pane. "h" places the new pane to the right,
|
||||
// "v" below — matching tmux's own -h/-v semantics. The new pane starts in the
|
||||
// source pane's working directory.
|
||||
app.post("/tmux_monitor/:hostId/split", async (req, res) => {
|
||||
const host = await requireHost(req, res, "connect");
|
||||
if (!host) return;
|
||||
|
||||
const body = req.body as { paneId?: string; direction?: string };
|
||||
const paneId = String(body?.paneId || "");
|
||||
const direction = body?.direction === "v" ? "-v" : "-h";
|
||||
if (!PANE_ID_RE.test(paneId)) {
|
||||
return res.status(400).json({ error: "Invalid pane ID" });
|
||||
}
|
||||
if (body?.direction !== "h" && body?.direction !== "v") {
|
||||
return res.status(400).json({ error: "Invalid split direction" });
|
||||
}
|
||||
|
||||
try {
|
||||
await withHostConnection(host, (conn) =>
|
||||
execCommand(
|
||||
conn,
|
||||
tmuxCommand(
|
||||
`split-window ${direction} -t ${shellEscape(paneId)} -c ${shellEscape("#{pane_current_path}")}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
sshLogger.info("tmux pane split", {
|
||||
operation: "tmux_pane_split",
|
||||
hostId: host.id,
|
||||
paneId,
|
||||
direction: body.direction,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
sendTmuxError(res, err, "split", host.id);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/tmux_monitor/:hostId/search", async (req, res) => {
|
||||
const host = await requireHost(req, res);
|
||||
if (!host) return;
|
||||
|
||||
const query = String(req.query.q || "").trim();
|
||||
if (!query) {
|
||||
return res.status(400).json({ error: "Missing search query" });
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await withHostConnection(host, async (conn) => {
|
||||
const allPanes = await listPanesRaw(conn);
|
||||
const panes = allPanes.slice(0, MAX_SEARCH_PANES);
|
||||
// Flips to true whenever a limit was hit, so the UI can tell the user
|
||||
// the results are partial instead of silently truncating.
|
||||
let truncated = allPanes.length > MAX_SEARCH_PANES;
|
||||
const matches: Array<{
|
||||
paneId: string;
|
||||
sessionName: string;
|
||||
windowIndex: number;
|
||||
line: number;
|
||||
text: string;
|
||||
}> = [];
|
||||
|
||||
// Bounded concurrency; each search runs capture+grep remotely so only
|
||||
// matching lines travel back over the wire.
|
||||
for (let i = 0; i < panes.length; i += SEARCH_CONCURRENCY) {
|
||||
const batch = panes.slice(i, i + SEARCH_CONCURRENCY);
|
||||
await Promise.all(
|
||||
batch.map(async (pane) => {
|
||||
try {
|
||||
const output = await execCommand(
|
||||
conn,
|
||||
tmuxCommand(
|
||||
`capture-pane -p -J -t ${shellEscape(pane.id)} -S -${SEARCH_HISTORY_LINES} 2>/dev/null | grep -n -i -F -- ${shellEscape(query)} | head -${MAX_MATCHES_PER_PANE}`,
|
||||
),
|
||||
);
|
||||
const lines = output.split("\n").filter(Boolean);
|
||||
if (lines.length >= MAX_MATCHES_PER_PANE) truncated = true;
|
||||
for (const line of lines) {
|
||||
const sep = line.indexOf(":");
|
||||
if (sep === -1) continue;
|
||||
matches.push({
|
||||
paneId: pane.id,
|
||||
sessionName: pane.sessionName,
|
||||
windowIndex: pane.windowIndex,
|
||||
line: parseInt(line.slice(0, sep), 10) || 0,
|
||||
text: line.slice(sep + 1).slice(0, 500),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// grep exits non-zero when there are no matches -- not an error
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
return { matches, truncated };
|
||||
});
|
||||
res.json({
|
||||
query,
|
||||
matches: results.matches,
|
||||
truncated: results.truncated,
|
||||
searchedLines: SEARCH_HISTORY_LINES,
|
||||
maxPanes: MAX_SEARCH_PANES,
|
||||
});
|
||||
} catch (err) {
|
||||
sendTmuxError(res, err, "search", host.id);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/tmux_monitor/:hostId/metrics", async (req, res) => {
|
||||
const host = await requireHost(req, res);
|
||||
if (!host) return;
|
||||
|
||||
try {
|
||||
const metrics = await withHostConnection(host, async (conn) => {
|
||||
const panes = await listPanesRaw(conn);
|
||||
if (panes.length === 0) return [];
|
||||
return collectPaneMetrics(conn, panes);
|
||||
});
|
||||
res.json({ panes: metrics });
|
||||
} catch (err) {
|
||||
sendTmuxError(res, err, "metrics", host.id);
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/tmux_monitor/:hostId/tags", async (req, res) => {
|
||||
const userId = (req as unknown as AuthenticatedRequest).userId;
|
||||
const host = await requireHost(req, res);
|
||||
if (!host) return;
|
||||
|
||||
const { sessionName, tags } = req.body as {
|
||||
sessionName?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
if (!sessionName || typeof sessionName !== "string") {
|
||||
return res.status(400).json({ error: "Missing session name" });
|
||||
}
|
||||
if (!Array.isArray(tags) || tags.some((t) => typeof t !== "string")) {
|
||||
return res.status(400).json({ error: "Tags must be an array of strings" });
|
||||
}
|
||||
const cleanTags = [
|
||||
...new Set(tags.map((t) => t.trim().slice(0, 64)).filter(Boolean)),
|
||||
].slice(0, 20);
|
||||
|
||||
try {
|
||||
await createCurrentTmuxSessionTagRepository().replaceForUserHostSession(
|
||||
userId,
|
||||
host.id,
|
||||
sessionName,
|
||||
cleanTags,
|
||||
);
|
||||
res.json({ sessionName, tags: cleanTags });
|
||||
} catch (err) {
|
||||
sshLogger.error(
|
||||
`Failed to save tmux session tags for host ${host.id}`,
|
||||
err,
|
||||
);
|
||||
res.status(500).json({ error: toErrorMessage(err) });
|
||||
}
|
||||
});
|
||||
|
||||
const PORT = 30010;
|
||||
listenOnServicePort({
|
||||
app,
|
||||
port: PORT,
|
||||
logger: sshLogger,
|
||||
serviceName: "tmux",
|
||||
});
|
||||
@@ -1,250 +0,0 @@
|
||||
// Pure parsing/aggregation helpers for the tmux monitor module.
|
||||
// Kept free of SSH/Express dependencies so they can be unit-tested.
|
||||
|
||||
// Field separator used in tmux -F format strings. Session names, pane titles
|
||||
// and paths may contain "|", and tmux sanitizes control characters (and, under
|
||||
// non-UTF-8 locales, multibyte characters) in format output to "_", so a
|
||||
// printable ASCII token is the only separator that survives everywhere.
|
||||
export const SEP = "<<TMX>>";
|
||||
|
||||
export interface TmuxPane {
|
||||
id: string;
|
||||
index: number;
|
||||
pid: number;
|
||||
active: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
command: string;
|
||||
path: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface TmuxWindow {
|
||||
index: number;
|
||||
name: string;
|
||||
active: boolean;
|
||||
panes: TmuxPane[];
|
||||
}
|
||||
|
||||
export interface TmuxSessionSummary {
|
||||
name: string;
|
||||
created: number;
|
||||
lastActivity: number;
|
||||
attachedClients: number;
|
||||
}
|
||||
|
||||
export interface RawPane extends TmuxPane {
|
||||
sessionName: string;
|
||||
windowIndex: number;
|
||||
}
|
||||
|
||||
export interface ProcessInfo {
|
||||
pid: number;
|
||||
ppid: number;
|
||||
cpu: number;
|
||||
mem: number;
|
||||
rss: number;
|
||||
comm: string;
|
||||
}
|
||||
|
||||
export interface PaneMetrics {
|
||||
paneId: string;
|
||||
sessionName: string;
|
||||
pid: number;
|
||||
processCount: number;
|
||||
cpuPercent: number;
|
||||
memRssKb: number;
|
||||
gpuMemMb: number;
|
||||
topCommand: string | null;
|
||||
}
|
||||
|
||||
export function parseSessions(output: string): TmuxSessionSummary[] {
|
||||
return output
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [name, created, activity, attached] = line.split(SEP);
|
||||
return {
|
||||
name,
|
||||
created: parseInt(created, 10) || 0,
|
||||
lastActivity: parseInt(activity, 10) || 0,
|
||||
attachedClients: parseInt(attached, 10) || 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function parseWindows(output: string): Map<string, TmuxWindow[]> {
|
||||
const bySession = new Map<string, TmuxWindow[]>();
|
||||
for (const line of output.split("\n").filter(Boolean)) {
|
||||
const [session, index, active, name] = line.split(SEP);
|
||||
if (!bySession.has(session)) bySession.set(session, []);
|
||||
bySession.get(session)!.push({
|
||||
index: parseInt(index, 10) || 0,
|
||||
name: name || "",
|
||||
active: active === "1",
|
||||
panes: [],
|
||||
});
|
||||
}
|
||||
return bySession;
|
||||
}
|
||||
|
||||
export function parsePanes(output: string): RawPane[] {
|
||||
return output
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [
|
||||
sessionName,
|
||||
windowIndex,
|
||||
id,
|
||||
index,
|
||||
pid,
|
||||
active,
|
||||
width,
|
||||
height,
|
||||
command,
|
||||
path,
|
||||
title,
|
||||
] = line.split(SEP);
|
||||
return {
|
||||
sessionName,
|
||||
windowIndex: parseInt(windowIndex, 10) || 0,
|
||||
id,
|
||||
index: parseInt(index, 10) || 0,
|
||||
pid: parseInt(pid, 10) || 0,
|
||||
active: active === "1",
|
||||
width: parseInt(width, 10) || 0,
|
||||
height: parseInt(height, 10) || 0,
|
||||
command: command || "",
|
||||
path: path || "",
|
||||
title: title || "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function parsePsOutput(output: string): ProcessInfo[] {
|
||||
const processes: ProcessInfo[] = [];
|
||||
for (const line of output.split("\n")) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length < 6) continue;
|
||||
const pid = parseInt(parts[0], 10);
|
||||
const ppid = parseInt(parts[1], 10);
|
||||
if (isNaN(pid) || isNaN(ppid)) continue;
|
||||
processes.push({
|
||||
pid,
|
||||
ppid,
|
||||
cpu: parseFloat(parts[2]) || 0,
|
||||
mem: parseFloat(parts[3]) || 0,
|
||||
rss: parseInt(parts[4], 10) || 0,
|
||||
comm: parts.slice(5).join(" "),
|
||||
});
|
||||
}
|
||||
return processes;
|
||||
}
|
||||
|
||||
export function parseGpuOutput(output: string): Map<number, number> {
|
||||
const gpuByPid = new Map<number, number>();
|
||||
for (const line of output.split("\n").filter(Boolean)) {
|
||||
const [pid, mem] = line.split(",").map((s) => s.trim());
|
||||
const pidNum = parseInt(pid, 10);
|
||||
const memNum = parseInt(mem, 10);
|
||||
if (!isNaN(pidNum) && !isNaN(memNum)) {
|
||||
gpuByPid.set(pidNum, (gpuByPid.get(pidNum) || 0) + memNum);
|
||||
}
|
||||
}
|
||||
return gpuByPid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map each pane's shell pid to its descendant process tree and aggregate
|
||||
* CPU/RAM/GPU usage per pane.
|
||||
*/
|
||||
export function buildPaneMetrics(
|
||||
panes: RawPane[],
|
||||
processes: ProcessInfo[],
|
||||
gpuByPid: Map<number, number>,
|
||||
): PaneMetrics[] {
|
||||
const byPid = new Map<number, ProcessInfo>();
|
||||
const childrenOf = new Map<number, number[]>();
|
||||
for (const p of processes) {
|
||||
byPid.set(p.pid, p);
|
||||
if (!childrenOf.has(p.ppid)) childrenOf.set(p.ppid, []);
|
||||
childrenOf.get(p.ppid)!.push(p.pid);
|
||||
}
|
||||
|
||||
return panes.map((pane) => {
|
||||
// Walk the descendant tree starting at (and including) the pane's shell
|
||||
const treePids: number[] = [];
|
||||
const queue = [pane.pid];
|
||||
const seen = new Set<number>();
|
||||
for (let cursor = 0; cursor < queue.length; cursor++) {
|
||||
const pid = queue[cursor];
|
||||
if (seen.has(pid)) continue;
|
||||
seen.add(pid);
|
||||
if (byPid.has(pid)) treePids.push(pid);
|
||||
for (const child of childrenOf.get(pid) || []) queue.push(child);
|
||||
}
|
||||
|
||||
let cpuPercent = 0;
|
||||
let memRssKb = 0;
|
||||
let gpuMemMb = 0;
|
||||
let topCommand: string | null = null;
|
||||
let topCpu = -1;
|
||||
for (const pid of treePids) {
|
||||
const p = byPid.get(pid)!;
|
||||
cpuPercent += p.cpu;
|
||||
memRssKb += p.rss;
|
||||
gpuMemMb += gpuByPid.get(pid) || 0;
|
||||
// The pane shell itself is rarely the interesting process
|
||||
if (p.cpu > topCpu && pid !== pane.pid) {
|
||||
topCpu = p.cpu;
|
||||
topCommand = p.comm;
|
||||
}
|
||||
}
|
||||
if (topCommand === null && treePids.length > 0) {
|
||||
topCommand = byPid.get(treePids[0])!.comm;
|
||||
}
|
||||
|
||||
return {
|
||||
paneId: pane.id,
|
||||
sessionName: pane.sessionName,
|
||||
pid: pane.pid,
|
||||
processCount: treePids.length,
|
||||
cpuPercent: Math.round(cpuPercent * 10) / 10,
|
||||
memRssKb,
|
||||
gpuMemMb,
|
||||
topCommand,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Group panes into their windows (mutates the window objects' pane arrays).
|
||||
*/
|
||||
export function attachPanesToWindows(
|
||||
windows: Map<string, TmuxWindow[]>,
|
||||
panes: RawPane[],
|
||||
): void {
|
||||
const windowsBySessionAndIndex = new Map<string, Map<number, TmuxWindow>>();
|
||||
for (const [sessionName, sessionWindows] of windows) {
|
||||
const byIndex = new Map<number, TmuxWindow>();
|
||||
for (const window of sessionWindows) {
|
||||
if (!byIndex.has(window.index)) byIndex.set(window.index, window);
|
||||
}
|
||||
windowsBySessionAndIndex.set(sessionName, byIndex);
|
||||
}
|
||||
|
||||
for (const pane of panes) {
|
||||
const window = windowsBySessionAndIndex
|
||||
.get(pane.sessionName)
|
||||
?.get(pane.windowIndex);
|
||||
if (window) {
|
||||
const { sessionName: _s, windowIndex: _w, ...paneFields } = pane;
|
||||
window.panes.push(paneFields);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function shellEscape(s: string): string {
|
||||
return "'" + s.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
Reference in new issue
Block a user