pi-remote-control/extensions/remote-control/tmux/manager.ts

232 lines
5.8 KiB
TypeScript

/**
* tmux session manager.
*
* Spawn, list, kill sessions and read metadata stored via tmux @description
* option. Checks tmux version at startup (requires >= 2.5).
*
* Owner: T-1.1
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export interface TmuxSession {
id: string; // tmux session name (used as our ID)
name: string; // human name (same as id for now; T-1.4 may rename via @description)
description?: string; // from tmux @description option
createdAt: string; // ISO string, from tmux session_created_string
lastActivityAt: string; // ISO string, from tmux session_last_attached
width: number;
height: number;
}
// ---------------------------------------------------------------------------
// Version guard
// ---------------------------------------------------------------------------
let versionChecked = false;
export async function checkTmuxVersion(): Promise<void> {
if (versionChecked) return;
const { stdout } = await execFileAsync("tmux", ["-V"]);
const match = stdout.trim().match(/tmux (\d+)\.(\d+)/);
if (!match) throw new Error(`Cannot parse tmux version: ${stdout.trim()}`);
const major = parseInt(match[1], 10);
const minor = parseInt(match[2], 10);
if (major < 2 || (major === 2 && minor < 5)) {
throw new Error(
`tmux >= 2.5 required (found ${stdout.trim()}). Upgrade tmux to use pi-remote-control.`,
);
}
versionChecked = true;
}
// ---------------------------------------------------------------------------
// Session CRUD
// ---------------------------------------------------------------------------
/**
* Spawn a new detached tmux session.
* Returns the session name (used as stable ID).
*/
export async function spawnSession(opts: {
name: string;
width?: number;
height?: number;
command?: string;
}): Promise<string> {
await checkTmuxVersion();
const { name, width = 80, height = 24, command = "" } = opts;
// Set default-terminal globally so programs inside tmux get xterm-256color
// and emit the escape sequences that SwiftTerm / xterm-compatible clients expect.
await execFileAsync("tmux", [
"set-option",
"-g",
"default-terminal",
"xterm-256color",
]).catch(() => {}); // best-effort; older tmux may not support all options
const args = [
"new-session",
"-d",
"-s",
name,
"-x",
String(width),
"-y",
String(height),
];
if (command) args.push(command);
await execFileAsync("tmux", args);
// Mark as sidecar-managed so listSessions() can filter out unrelated
// tmux sessions (e.g. the pi-sidecar launcher session itself).
await execFileAsync("tmux", [
"set-option",
"-t",
name,
"@pi-remote-managed",
"1",
]).catch(() => {});
return name;
}
/**
* Resize an existing session's window.
* Safe to call at any time; silently ignores unknown sessions.
*/
export async function resizeSession(
name: string,
cols: number,
rows: number,
): Promise<void> {
const c = Math.max(1, Math.min(Math.round(cols), 500));
const r = Math.max(1, Math.min(Math.round(rows), 200));
await execFileAsync("tmux", [
"resize-window",
"-t",
name,
"-x",
String(c),
"-y",
String(r),
]).catch(() => {}); // session may not exist yet; ignore
}
/**
* List all tmux sessions with metadata.
*/
export async function listSessions(): Promise<TmuxSession[]> {
await checkTmuxVersion();
// Use a separator that's unlikely to appear in session names
const SEP = "\x1F"; // ASCII unit separator
const fmt = [
"#{session_name}",
"#{session_created_string}",
"#{session_last_attached_string}",
"#{window_width}",
"#{window_height}",
].join(SEP);
let stdout: string;
try {
({ stdout } = await execFileAsync("tmux", ["list-sessions", "-F", fmt]));
} catch (err: unknown) {
// tmux exits 1 when no sessions exist
if (
err &&
typeof err === "object" &&
"code" in err &&
(err as { code: number }).code === 1
) {
return [];
}
throw err;
}
const sessions: TmuxSession[] = [];
for (const line of stdout.trim().split("\n")) {
if (!line) continue;
const [id, createdAt, lastActivityAt, w, h] = line.split(SEP);
// Only include sessions created by the sidecar.
try {
const r = await execFileAsync("tmux", [
"show-options",
"-t",
id,
"-qv",
"@pi-remote-managed",
]);
if (!r.stdout.trim()) continue; // unmanaged session — skip
} catch {
continue; // can't read options → skip
}
// Fetch @description option separately (may not be set)
let description: string | undefined;
try {
const r = await execFileAsync("tmux", [
"show-options",
"-t",
id,
"-qv",
"@description",
]);
const v = r.stdout.trim();
if (v) description = v;
} catch {
// option not set — that's fine
}
sessions.push({
id,
name: id,
description,
createdAt,
lastActivityAt,
width: parseInt(w, 10) || 120,
height: parseInt(h, 10) || 40,
});
}
return sessions;
}
/**
* Get a single session by name. Returns null if not found.
*/
export async function getSession(name: string): Promise<TmuxSession | null> {
const all = await listSessions();
return all.find((s) => s.id === name) ?? null;
}
/**
* Kill a session. Throws if it doesn't exist.
*/
export async function killSession(name: string): Promise<void> {
await checkTmuxVersion();
await execFileAsync("tmux", ["kill-session", "-t", name]);
}
/**
* Set the @description option on a session.
*/
export async function setDescription(
name: string,
description: string,
): Promise<void> {
await execFileAsync("tmux", [
"set-option",
"-t",
name,
"@description",
description,
]);
}