176 lines
4.4 KiB
TypeScript
176 lines
4.4 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 = 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<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);
|
|
|
|
// 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,
|
|
]);
|
|
}
|