SPB Git

Repository Public

tmux.ts 9.9 KB 284 lines typescript

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
Files main
Menu

Theme