/** * @file tmux.js * @description Thin wrapper around the `tmux` CLI for the terminal-run * feature. Every dashboard-managed session is named `ccam-lane-` (see * `pty-run.js`) so a real terminal can attach to the exact same session with * `tmux attach -t ccam-lane-` (or `ccam lanes shell`). Never builds a * shell string — every call is `execFileSync("tmux", [...argv])` with an * explicit argument array (matches this repo's rule for git in worktree.js). * The one place a command line is composed is `sendCommand`, which types into * an existing pane's shell: there the shell IS the consumer, so every argument * is POSIX single-quoted first and sent with `send-keys -l` (literal). * @author Nguyễn Ngọc Trí Vĩ */ const { execFileSync } = require("node:child_process"); // Test seam: swap the exec implementation so unit tests never invoke a real // tmux binary. Mirrors run-spawner.js's __injectChildForTest/__reset style. let execImpl = (args) => execFileSync("tmux", args, { encoding: "utf8" }); function __setExecImpl(fn) { execImpl = fn; } function __reset() { execImpl = (args) => execFileSync("tmux", args, { encoding: "utf8" }); } function hasSession(name) { try { execImpl(["has-session", "-t", name]); return true; } catch { return false; } } /** * Create a detached tmux session running `argv` as the pane's command. Throws * if tmux itself fails to start (caller decides how to surface that). */ function newSession({ name, cwd, argv }) { execImpl(["new-session", "-d", "-s", name, "-c", cwd, "--", ...argv]); } /** * The command currently running in the session's active pane (`bash`, `zsh`, * `claude`, …). Null when tmux can't answer — callers treat that as "unknown, * don't touch the pane". */ function paneCommand(name) { try { return ( execImpl(["display-message", "-p", "-t", name, "#{pane_current_command}"]).trim() || null ); } catch { return null; } } /** POSIX single-quote escaping — the pane is a shell, so argv must be quoted. */ function shellQuote(arg) { return `'${String(arg).replace(/'/g, `'\\''`)}'`; } /** * Type `argv` into an existing session's pane and press Enter. Only ever * called when the pane sits at a shell prompt (see `paneCommand`); `-l` sends * the string literally so no character is read as a tmux key name. */ function sendCommand(name, argv) { execImpl(["send-keys", "-t", name, "-l", argv.map(shellQuote).join(" ")]); execImpl(["send-keys", "-t", name, "Enter"]); } /** Idempotent — a session that's already gone is not an error. */ function killSession(name) { try { execImpl(["kill-session", "-t", name]); } catch { /* already gone */ } } /** Session names starting with `prefix`. Empty array if tmux has no server running at all. */ function listSessions(prefix) { let out; try { out = execImpl(["list-sessions", "-F", "#{session_name}"]); } catch { return []; } return out .split("\n") .map((s) => s.trim()) .filter((s) => s && s.startsWith(prefix)); } function isTmuxAvailable() { try { execImpl(["-V"]); return true; } catch { return false; } } module.exports = { hasSession, newSession, paneCommand, sendCommand, killSession, listSessions, isTmuxAvailable, __setExecImpl, __reset, };