/** * 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; // 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 { 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 { 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 { 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, ]); }