Repository Public
1/**2 * Thin argv-array wrapper around `tmux -L <socketName> -f <generated conf>`.3 * Never goes through a shell. The tmux server daemonizes itself, so it outlives the runtime4 * process: sessions survive a runtime restart (not a host reboot).5 */6import { execFile } from 'node:child_process';7import fs from 'node:fs/promises';8import path from 'node:path';9import type { RuntimeConfig } from './config.js';10import { atomicWrite, resolveBin, sanitizedBaseEnv, SECRET_ENV_NAMES } from './core-util.js';1112/** Applies to the outer terminal of attach clients only; programs inside the panes are unaffected. */13export const ATTACH_TERMINAL_OVERRIDES = 'xterm-256color:smcup@:rmcup@:csr@:indn@';1415export const TMUX_CONF = [16 '# Generated by claude-remote runtime — do not edit (rewritten at every start).',17 '# Keep the server alive with zero sessions: otherwise a new-session can race a server that',18 '# is exiting because its last session was just killed.',19 'set -s exit-empty off',20 'set -g remain-on-exit on',21 'set -g history-limit 20000',22 'set -g status off',23 'set -g window-size latest',24 'setw -g aggressive-resize off',25 'set -s escape-time 0',26 'set -g default-terminal "xterm-256color"',27 'set -as terminal-features ",xterm-256color:RGB"',28 '# The attach client (TERM=xterm-256color) renders in the NORMAL buffer of the browser terminal',29 '# (no alternate screen) and never scrolls it (no scroll region: tmux redraws instead), so the',30 '# browser scrollback is written only by the runtime (core-scrollback.ts).',31 `set -s terminal-overrides "${ATTACH_TERMINAL_OVERRIDES}"`,32 'set -g mouse off',33 'set -g allow-passthrough off',34 'set -s set-clipboard off',35 'set -g set-titles off',36 'set -g visual-activity off',37 'set -g monitor-activity off',38 'set -g destroy-unattached off',39 '# The browser terminal must be transparent: no prefix key, no key bindings.',40 'set -g prefix None',41 'set -g prefix2 None',42 'unbind-key -a',43 'unbind-key -a -T root',44 '# Never let runtime secrets live in the server-global environment.',45 ...SECRET_ENV_NAMES.map((n) => `set-environment -gu ${n}`),46 '',47].join('\n');4849const FIELD_SEP = '|';50const LIST_FORMAT = [51 '#{session_name}',52 '#{pane_pid}',53 '#{pane_dead}',54 '#{pane_dead_status}',55 '#{pane_dead_signal}',56 '#{session_attached}',57 '#{window_activity}',58 '#{window_width}',59 '#{window_height}',60].join(FIELD_SEP);6162export interface TmuxSessionRow {63 name: string;64 panePid: number | null;65 paneDead: boolean;66 /** Exit status of the dead pane process; null while alive or when killed by a signal. */67 paneDeadStatus: number | null;68 paneDeadSignal: number | null;69 attached: number;70 /** `window_activity`, epoch seconds. */71 activitySec: number | null;72 width: number | null;73 height: number | null;74}7576/** Exit code as a shell would report it: status, or 128+signal, or null when unknown / alive. */77export function paneExitCode(row: TmuxSessionRow): number | null {78 if (!row.paneDead) return null;79 if (row.paneDeadStatus !== null) return row.paneDeadStatus;80 if (row.paneDeadSignal !== null) return 128 + row.paneDeadSignal;81 return null;82}8384export class TmuxError extends Error {85 constructor(86 message: string,87 public readonly exitCode: number | null,88 public readonly stderr: string,89 ) {90 super(message);91 }92}9394export interface NewSessionOptions {95 name: string;96 cwd: string;97 cols: number;98 rows: number;99 env: Record<string, string>;100 /** Executed directly by tmux (execvp), no shell — must have at least 2 elements. */101 argv: string[];102}103104function intOrNull(s: string | undefined): number | null {105 if (s === undefined || s === '') return null;106 const n = Number(s);107 return Number.isFinite(n) ? n : null;108}109110const NO_SERVER_RE = /no server running|no sessions|error connecting to|No such file or directory/i;111112export class Tmux {113 private constructor(114 readonly bin: string,115 readonly socketName: string,116 readonly confPath: string,117 /** Sanitized environment for every tmux child (also what a freshly started server inherits). */118 readonly env: Record<string, string>,119 ) {}120121 static async create(config: Pick<RuntimeConfig, 'dataDir' | 'tmuxSocketName'>): Promise<Tmux> {122 if (!/^[A-Za-z0-9._-]+$/.test(config.tmuxSocketName))123 throw new Error('invalid tmux socket name');124 const bin = await resolveBin('tmux');125 if (!bin) throw new Error('tmux not found on PATH');126 const runDir = path.join(config.dataDir, 'run');127 await fs.mkdir(runDir, { recursive: true, mode: 0o700 });128 const confPath = path.join(runDir, 'tmux.conf');129 await atomicWrite(confPath, TMUX_CONF, 0o600);130 return new Tmux(bin, config.tmuxSocketName, confPath, sanitizedBaseEnv());131 }132133 /** Global arguments placed before every tmux command (also used for the node-pty attach). */134 baseArgs(): string[] {135 return ['-L', this.socketName, '-f', this.confPath];136 }137138 attachArgv(name: string): string[] {139 return [...this.baseArgs(), 'attach-session', '-t', `=${name}`];140 }141142 run(args: string[], timeoutMs = 10_000, env: Record<string, string> = this.env): Promise<string> {143 return new Promise((resolve, reject) => {144 execFile(145 this.bin,146 [...this.baseArgs(), ...args],147 { env, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, encoding: 'utf8' },148 (err, stdout, stderr) => {149 if (err) {150 const code = typeof err.code === 'number' ? err.code : null;151 reject(152 new TmuxError(153 `tmux ${args[0] ?? ''} failed: ${stderr.trim() || err.message}`,154 code,155 stderr,156 ),157 );158 } else resolve(stdout);159 },160 );161 });162 }163164 async version(): Promise<string | null> {165 try {166 return (await this.run(['-V'])).trim() || null;167 } catch {168 return null;169 }170 }171172 async hasSession(name: string): Promise<boolean> {173 try {174 await this.run(['has-session', '-t', `=${name}`]);175 return true;176 } catch (err) {177 if (err instanceof TmuxError && err.exitCode === 1) return false;178 throw err;179 }180 }181182 async newSession(o: NewSessionOptions): Promise<void> {183 if (o.argv.length < 2)184 throw new Error('newSession argv must have >= 2 elements (direct exec, no shell)');185 const envArgs: string[] = [];186 for (const [k, v] of Object.entries(o.env)) envArgs.push('-e', `${k}=${v}`);187 // tmux gives a new pane the PATH of the *client* that created it (not the session's -e188 // PATH), so a caller-supplied PATH has to travel in this client's own environment.189 const clientEnv = o.env.PATH === undefined ? undefined : { ...this.env, PATH: o.env.PATH };190 const args = ['new-session', '-d', '-s', o.name, '-x', String(o.cols), '-y', String(o.rows), '-c', o.cwd, ...envArgs, '--', ...o.argv];191 try {192 await this.run(args, 10_000, clientEnv);193 } catch (err) {194 // A server that was shutting down (older server started with exit-empty on, or a195 // kill-server) can drop the client; nothing was created, so one retry is safe.196 if (!(err instanceof TmuxError) || !/server exited|no server running|lost server|error connecting/i.test(err.stderr)) throw err;197 if (await this.hasSession(o.name).catch(() => false)) return;198 await this.run(args, 10_000, clientEnv);199 }200 }201202 /** Returns false when the session did not exist. */203 async killSession(name: string): Promise<boolean> {204 try {205 await this.run(['kill-session', '-t', `=${name}`]);206 return true;207 } catch (err) {208 if (err instanceof TmuxError && err.exitCode === 1) return false;209 throw err;210 }211 }212213 /** One `list-sessions` call; an absent server is an empty list, not an error. */214 async listSessions(): Promise<TmuxSessionRow[]> {215 let out: string;216 try {217 out = await this.run(['list-sessions', '-F', LIST_FORMAT]);218 } catch (err) {219 if (err instanceof TmuxError && err.exitCode === 1 && NO_SERVER_RE.test(err.stderr))220 return [];221 throw err;222 }223 const rows: TmuxSessionRow[] = [];224 for (const line of out.split('\n')) {225 if (!line) continue;226 const f = line.split(FIELD_SEP);227 const name = f[0];228 if (!name || f.length < 9) continue;229 rows.push({230 name,231 panePid: intOrNull(f[1]),232 paneDead: f[2] === '1',233 paneDeadStatus: f[2] === '1' ? intOrNull(f[3]) : null,234 paneDeadSignal: f[2] === '1' ? intOrNull(f[4]) : null,235 attached: intOrNull(f[5]) ?? 0,236 activitySec: intOrNull(f[6]),237 width: intOrNull(f[7]),238 height: intOrNull(f[8]),239 });240 }241 return rows;242 }243244 async getSession(name: string): Promise<TmuxSessionRow | null> {245 return (await this.listSessions()).find((r) => r.name === name) ?? null;246 }247248 /** Last `lines` lines of the pane (history + visible screen), trailing blank lines removed. */249 async capturePane(name: string, lines: number): Promise<string> {250 const out = await this.run(['capture-pane', '-p', '-J', '-t', `=${name}:`, '-S', `-${lines}`]);251 const all = out.replace(/\s+$/u, '').split('\n');252 return all.slice(-lines).join('\n');253 }254255 async setOption(target: string | null, name: string, value: string): Promise<void> {256 await this.run(257 target === null258 ? ['set-option', '-g', name, value]259 : ['set-option', '-t', `=${target}`, name, value],260 );261 }262263 /**264 * The configuration file is only read when the tmux server starts. A server that survived a265 * runtime restart gets the options attach clients depend on re-applied (idempotent).266 */267 async applyServerOptions(): Promise<void> {268 try {269 await this.run(['set-option', '-s', 'terminal-overrides', ATTACH_TERMINAL_OVERRIDES]);270 } catch (err) {271 if (!(err instanceof TmuxError) || !NO_SERVER_RE.test(err.stderr)) throw err;272 }273 }274275 /** Test/ops helper — production code never calls this (sessions must outlive the runtime). */276 async killServer(): Promise<void> {277 try {278 await this.run(['kill-server']);279 } catch {280 /* no server */281 }282 }283}284