/** * src/session/tmux.ts (H1) — thin synchronous wrappers around the tmux CLI. * * Used only when cfg.useTmux is on. Running the shell inside a tmux session * lets it survive a server/host restart: the node-pty process is just a tmux * *client*; killing it detaches, and the tmux server keeps the shell alive. * * Every call is best-effort and never throws (tmux missing / session gone are * treated as "false"/no-op). */ import { execFile, execFileSync } from 'node:child_process' import { promisify } from 'node:util' import { SESSION_ID_RE } from '../protocol.js' const execFileAsync = promisify(execFile) /** Upper bound on one `capture-pane` payload. A pane is one screen, so this is * generous; it exists so a wedged tmux can never hand us unbounded output. */ const CAPTURE_MAX_BUFFER = 512 * 1024 /** Every tmux call is bounded. maxBuffer caps a flood but nothing caps the WAIT, * and a tmux server stopped mid-syscall (SIGSTOP, a hung filesystem) would * otherwise block forever — fatally so for the synchronous calls, which the event * loop cannot interrupt. */ const TMUX_TIMEOUT_MS = 3000 /** * Tab-separated, machine-readable. tmux reports both clocks in unix SECONDS. * * BOTH activity clocks are requested, because neither alone answers "has anything * happened here" and they are not ordered: * * - `session_activity` tracks CLIENT activity — it advances on attach and on * keypresses, and never moves for a detached session no matter how much its shell * is printing. Measured on tmux 3.6a with a detached session in a `while true; do * echo tick; sleep 1; done` loop: after 6 s it still equalled session_created * while window_activity had advanced. Using it alone as the liveness signal makes * idle-cleanup kill precisely the long-running, unattended, actively-working * sessions this feature exists to protect — on one real host, 3 of them. * - `window_activity` tracks pane OUTPUT, but an attach with no output bumps only * session_activity. Observed on the same host: one session whose session_activity * was 4 s newer than its window_activity, another whose window_activity was 25 * days newer. * * So the parser takes max(window_activity, session_activity). * * CAVEAT: in `list-sessions`, `#{window_activity}` resolves against the session's * CURRENT window only. Every session this app creates is single-window (plain * `new-session`), so it is exact here; a user who opens a second window inside one * (Ctrl-b c) and leaves output only in a background window could still read as * quieter than it is. Taking the max limits that to "as stale as session_activity", * never staler. */ const LIST_FORMAT = [ '#{session_name}', '#{session_created}', '#{window_activity}', '#{session_activity}', '#{session_attached}', '#{window_width}', '#{window_height}', ].join('\t') /** * One `web_*` tmux session as tmux itself describes it (A). * * `attached` is tmux's own client count, NOT "this server is streaming it": a * session can be attached from a plain terminal, or by a second web-terminal * process. So "we do not track it" and "nobody is using it" are different * questions, and the UI must not conflate them. */ export interface TmuxSessionSummary { readonly id: string readonly createdAtMs: number readonly lastActivityAtMs: number readonly attached: boolean readonly cols: number readonly rows: number } /** Is the tmux binary available on PATH? */ export function tmuxAvailable(): boolean { try { execFileSync('tmux', ['-V'], { stdio: 'ignore', timeout: TMUX_TIMEOUT_MS }) return true } catch { return false } } /** tmux session name for a web-terminal session id (UUIDs are tmux-safe). */ export function tmuxName(sessionId: string): string { return `web_${sessionId}` } /** * A tmux `-t` target that matches ONE session by exact name. * * Without the `=`, tmux resolves a target as exact name, then NAME PREFIX, then * fnmatch. That is a hole in this module's central promise: a session named * `web__mine` is refused by parseSessionList (its suffix is not a UUID) and so * is never listed — but `-t web_` prefix-matches it, so a kill aimed at a * non-existent id would end the user's own session instead. Verified on tmux 3.6a: * `has-session -t web_` succeeds against `web__MINE`, and fails with the * `=` prefix. Every -t in this file goes through here. */ function exact(name: string): string { return `=${name}` } /** * The same, for a target-PANE. `capture-pane` takes a pane, and tmux rejects a bare * `=name` there outright ("can't find pane: =web_x") — the `=` only qualifies the * session part of a target, so the session has to be named as such with a trailing * `:`. Verified on tmux 3.6a against a decoy named `web__decoy`: with the real * session gone, `-t web_` printed the DECOY's screen while `-t =web_:` * failed with "can't find session". Without this the preview leaks the contents of a * session the user created for their own work. */ function exactPane(name: string): string { return `=${name}:` } /** Does a tmux session with this name already exist (e.g. after a restart)? */ export function hasSession(name: string): boolean { try { execFileSync('tmux', ['has-session', '-t', exact(name)], { stdio: 'ignore', timeout: TMUX_TIMEOUT_MS, }) return true } catch { return false } } /** Kill a tmux session (ends the shell). No-op if it's already gone. */ export function killSession(name: string): void { try { execFileSync('tmux', ['kill-session', '-t', exact(name)], { stdio: 'ignore', timeout: TMUX_TIMEOUT_MS, }) } catch { // already gone — fine } } /** * Parse `tmux list-sessions -F LIST_FORMAT` output. Pure, so the parsing rules are * testable without a tmux binary. * * Two filters, both load-bearing: * - the name must start with `web_`, so we never enumerate (and therefore never * offer to kill) a tmux session the user created for their own work; * - the remainder must be a UUID v4 per SESSION_ID_RE — the same gate the attach * protocol applies (M7). Anything else cannot have been created by this app, and * refusing it here is also what keeps a hostile name from reaching `tmux -t` * (e.g. a literal `-t`, or a name full of path traversal). */ export function parseSessionList(stdout: string): TmuxSessionSummary[] { const rows: TmuxSessionSummary[] = [] for (const line of stdout.split('\n')) { if (line === '') continue const parts = line.split('\t') if (parts.length < 7) continue const [name, created, winActivity, sessActivity, attached, width, height] = parts as [ string, string, string, string, string, string, string, ] if (!name.startsWith('web_')) continue const id = name.slice('web_'.length) if (!SESSION_ID_RE.test(id)) continue // Number('') is 0, which IS finite — so an empty field would sail through as // "created at the epoch, idle ever since", i.e. instantly eligible for the idle // cleanup. Require actual digits. const num = (v: string): number => (/^\d+$/.test(v) ? Number(v) : NaN) const createdSec = num(created) const winSec = num(winActivity) const sessSec = num(sessActivity) const cols = num(width) const rows_ = num(height) if ( !Number.isFinite(createdSec) || !Number.isFinite(winSec) || !Number.isFinite(sessSec) || !Number.isFinite(cols) || !Number.isFinite(rows_) ) { continue } // The two clocks are NOT ordered: window_activity tracks pane output, while an // attach bumps session_activity without producing any. Observed on a real host: // one session with session_activity 4s NEWER than window_activity, another with // window_activity 25 DAYS newer. The question being asked is "has anything // happened here", so take whichever is later. const activitySec = Math.max(winSec, sessSec) rows.push({ id, createdAtMs: createdSec * 1000, lastActivityAtMs: activitySec * 1000, attached: attached !== '0', cols, rows: rows_, }) } return rows.sort((a, b) => b.lastActivityAtMs - a.lastActivityAtMs) } /** * Every `web_*` tmux session on the host, most recently active first. Never * throws; [] when tmux is missing or no tmux server is running. * * ASYNC on purpose. This is polled — every device's home screen refreshes on a * timer — and the spawn costs ~73 ms on a host with 69 sessions. Done * synchronously that is 73 ms with the event loop stopped, i.e. 73 ms during which * no PTY byte reaches any terminal on any device. The server is a byte-shuttle * first; nothing on a polling path may block it. */ export async function listSessions(): Promise { try { const { stdout } = await execFileAsync('tmux', ['list-sessions', '-F', LIST_FORMAT], { encoding: 'utf8', maxBuffer: CAPTURE_MAX_BUFFER, timeout: TMUX_TIMEOUT_MS, }) return parseSessionList(stdout) } catch { return [] } } /** * The session's CURRENT screen, without attaching. Returns null if it is gone. * * `-p` prints to stdout and `-e` keeps the escape sequences, so the bytes can go * straight into a read-only xterm and keep their colour. Deliberately NOT * `attach-session`: attaching would make tmux resize the window to the new * client's dimensions and SIGWINCH whatever is running inside it. */ export async function capturePane(name: string): Promise { try { const { stdout } = await execFileAsync('tmux', ['capture-pane', '-p', '-e', '-t', exactPane(name)], { encoding: 'utf8', maxBuffer: CAPTURE_MAX_BUFFER, timeout: TMUX_TIMEOUT_MS, }) return stdout } catch { return null } }