/** * 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 { 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 { await checkTmuxVersion(); const { name, width = 120, height = 40, command = "" } = opts; const args = [ "new-session", "-d", "-s", name, "-x", String(width), "-y", String(height), ]; if (command) args.push(command); await execFileAsync("tmux", args); return name; } /** * List all tmux sessions with metadata. */ export async function listSessions(): Promise { 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); // 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 { 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 { 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 { await execFileAsync("tmux", [ "set-option", "-t", name, "@description", description, ]); }