/** * @file tmux.js * @description Thin wrapper around the `tmux` CLI for the terminal-run * feature. Every dashboard-managed session is named `agenthub-lane-` (see * `pty-run.js`) so a real terminal can attach to the exact same session with * `tmux attach -t agenthub-lane-` (or `agenthub lanes shell`). Never builds a * shell string — every call is `execFileSync("tmux", [...argv])` (a member's * listing: `execFile`) with an explicit argument array (matches this repo's * rule for git in worktree.js). * No call composes a shell command line at all, `sendCommand` included: it * takes over an idle pane with `respawn-pane -- ` rather than typing a * quoted line into that pane's shell, which keeps an API key passed in `env` * out of the pane's rendered text, its scrollback and the user's shell history. * `sendText` is the one primitive that sends typed text — a pane running an * interactive chat program that reads stdin as a message, not a shell — and * `sendKey` presses one named key (Shift+Tab for `pane-mode.js`), never text. * * Server mode, one Linux user per account (S3): a member's panes live on THAT * member's tmux server, socket `/.agenthub/tmux/sock` (`run-as.js`), so * they run as the member. Every call naming a session goes there, resolved * from the session's workspace, and runs AS the member (`sudo -u`, tmux by * the absolute path sudoers lists) — never as a host client: the member owns * that socket's folder, and a host client on it could be handed a fake server * or replaced by a member's command (`detach-client -E`). sudo logs its * command line, so a session's environment and argv travel in a launch file * (`run-as.launchArgv`) and a paste on stdin. Listing covers every server; * a member's runs in the background and a poll reads its last answer. * With no member (local mode, S3 not installed, the host's own workspaces) * nothing changes. * Calls on our OWN server are bounded like a member's (5 s, then a minute's * back-off) and run that server's own binary (`/proc//exe`, found from * its socket), so a tmux upgraded under it never mixes client and server * versions — a 3.8 client spun a 3.6 server at 100% CPU. * @author Nguyễn Ngọc Trí Vĩ */ const { execFile, execFileSync } = require("node:child_process"); const fs = require("node:fs"); const path = require("node:path"); // Test seam: swap the exec implementation so unit tests never invoke a real // tmux binary. Mirrors run-spawner.js's __injectChildForTest/__reset style. // stderr is ignored, not inherited: every caller already wraps these in // try/catch and treats failure as "no session" — piping tmux's own // "error connecting to ..." noise to the process's stderr just spams the // journal on every poll while no tmux server is running yet. // cwd "/": a tmux server is forked from the call that starts it and keeps // that cwd; once it is deleted, tmux ignores every pane's `-c` (issue #69). // Bounded like a member's call (below): our server can spin too — a 3.6 // server asked by a 3.8 client sat at 100% CPU, and one unbounded // `has-session` froze the whole dashboard for five minutes. const HOST_TIMEOUT_MS = 5000; const EXEC_OPTS = { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"], cwd: "/", timeout: HOST_TIMEOUT_MS, killSignal: "SIGKILL", maxBuffer: 1 << 20, }; /** * The binary that started the tmux server listening on `socket` * (`/proc//exe`, which still runs after an upgrade replaced the file), * or null. A client must match its server: tmux upgraded under a running * server left PATH's newer client talking to the old server, which spun at * 100% CPU on it. Found from /proc alone — asking tmux would BE that call. * `procRoot`/`uid` are test seams. */ function serverExeFor(socket, { procRoot = "/proc", uid = process.getuid?.() } = {}) { let inode = null; try { for (const line of fs.readFileSync(path.join(procRoot, "net", "unix"), "utf8").split("\n")) { // Num RefCount Protocol Flags Type St Inode Path — Flags 00010000 = listening. const f = line.trim().split(/\s+/); if (f.length >= 8 && f[3] === "00010000" && f.slice(7).join(" ") === socket) { inode = f[6]; break; } } } catch { return null; } if (!inode) return null; const want = `socket:[${inode}]`; for (const pid of fs.readdirSync(procRoot)) { if (!/^\d+$/.test(pid)) continue; const dir = path.join(procRoot, pid); try { if (!fs.readFileSync(path.join(dir, "comm"), "utf8").startsWith("tmux")) continue; if (uid != null && fs.statSync(dir).uid !== uid) continue; for (const fd of fs.readdirSync(path.join(dir, "fd"))) { if (fs.readlinkSync(path.join(dir, "fd", fd)) !== want) continue; const exe = path.join(dir, "exe"); fs.accessSync(exe, fs.constants.X_OK); return exe; } } catch { /* gone, or not ours to read */ } } return null; } /** Our default socket, as tmux itself picks it (no -L/-S). */ function hostSocket() { if (process.env.TMUX) return process.env.TMUX.split(",")[0]; const dir = process.env.TMUX_TMPDIR || "/tmp"; return path.join(dir, `tmux-${process.getuid()}`, "default"); } // The binary every call on OUR server runs, re-read every HOST_BIN_TTL_MS. // PATH's tmux when no server runs (that call starts one), off Linux, and for // `-V` (the installed client's version, for the upgrade advice). const HOST_BIN_TTL_MS = 10_000; let hostBin = { at: 0, bin: "tmux" }; function hostTmuxBin() { if (process.platform !== "linux") return "tmux"; if (Date.now() - hostBin.at < HOST_BIN_TTL_MS) return hostBin.bin; let bin = "tmux"; try { bin = serverExeFor(hostSocket()) || "tmux"; } catch { /* no /proc: PATH's */ } hostBin = { at: Date.now(), bin }; return bin; } let execImpl = (args, opts = EXEC_OPTS) => execFileSync(args[0] === "-V" ? "tmux" : hostTmuxBin(), args, opts); // Linux: the tmux server lives in the cgroup of whatever started it — the // dashboard's systemd unit, or the terminal/IDE scope a dev server runs in — // and dies when that cgroup is stopped (an update's restart under a unit // without KillMode=process, systemd-oomd, closing that app). So the FIRST // session, which starts the server, is created inside a transient scope of // its own: panes end only on Quit (`quit.js`) or with the machine. `--scope` // runs tmux as a child, so it keeps this process's environment. const realScopeImpl = (args) => execFileSync( "systemd-run", [ "--user", "--scope", "--quiet", "--collect", "--description=AgentHub tmux server", "--", "tmux", ...args, ], EXEC_OPTS ); let scopeImpl = realScopeImpl; // S3: the member account a session belongs to (null = the host's own server), // and how a tmux command runs AS that member on their server (`input` = its // stdin). Seams, so tests never sudo. let accountImpl = (name) => require("./run-as").accountForSession(name); // A member's server is theirs: they can stop it (SIGSTOP) or fill it with // sessions. So each call on it is bounded (time, output), and a server that // timed out or flooded us is not asked again for MEMBER_BACKOFF_MS — its // panes read as UNKNOWN meanwhile. LISTING (every poll: GET /api/run and // /api/lanes, the heal beat, the liveness reaper) never waits for a member: // it runs async (`memberListImpl`) into `listed`, which the poll reads. A // call a user's click makes (new-session, send-keys, kill, has-session) // stays synchronous. // ponytail: a click on a frozen member's pane (and a pane-token / gateway // has-session check) still blocks up to MEMBER_TIMEOUT_MS once per backoff; // make those async too if that ever shows. const MEMBER_TIMEOUT_MS = 3000; const MEMBER_BACKOFF_MS = 60_000; const MEMBER_OPTS = { ...EXEC_OPTS, timeout: MEMBER_TIMEOUT_MS, killSignal: "SIGKILL", maxBuffer: 1 << 20, }; const { stdio: _piped, ...MEMBER_LIST_OPTS } = MEMBER_OPTS; // execFile always pipes const sudoTmux = (acct, args) => { const runAs = require("./run-as"); return runAs.sudoArgv(acct, "tmux", ["-S", runAs.tmuxSocket(acct), ...args]); }; const realMemberImpl = (acct, args, input) => { const [cmd, argv] = sudoTmux(acct, args); return execFileSync(cmd, argv, input == null ? MEMBER_OPTS : { ...MEMBER_OPTS, input }); }; // The same, async, with execFileSync's error shape: `status` = exit code, // ETIMEDOUT / ENOBUFS for a kill at the timeout / a flood. const realMemberListImpl = (acct, args) => new Promise((resolve, reject) => { const [cmd, argv] = sudoTmux(acct, args); execFile(cmd, argv, MEMBER_LIST_OPTS, (err, out) => { if (!err) return resolve(out); if (typeof err.code === "number") err.status = err.code; if (err.killed) err.code = "ETIMEDOUT"; if (err.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") err.code = "ENOBUFS"; reject(err); }); }); let memberImpl = realMemberImpl; let memberListImpl = realMemberListImpl; let unreachable = new Map(); // member login → backoff end function reachable(acct) { if ((unreachable.get(acct.login) || 0) > Date.now()) { throw Object.assign(new Error(`${acct.login}: tmux server not answering`), { code: "ETMUXHUNG", }); } } function noteHang(acct, err) { if (err.code === "ETIMEDOUT" || err.code === "ENOBUFS") { unreachable.set(acct.login, Date.now() + MEMBER_BACKOFF_MS); } } function memberCall(acct, args, input) { reachable(acct); try { return memberImpl(acct, args, input); } catch (err) { noteHang(acct, err); throw err; } } // Our own server under the same back-off: a call that timed out stops every // call on it for MEMBER_BACKOFF_MS (ETMUXHUNG), so a spinning server costs // the event loop one HOST_TIMEOUT_MS per minute, not one per poll. const HOST_ACCT = { login: "host" }; function hostCall(args) { reachable(HOST_ACCT); try { return execImpl(args); } catch (err) { noteHang(HOST_ACCT, err); if (err.code === "ETIMEDOUT") hostBin.at = 0; // look for the server again throw err; } } /** Test seam. A sync `member` fake also answers the async listing. */ function __setAccountImpl(fn, member) { accountImpl = fn; if (member) { memberImpl = member; memberListImpl = (acct, args) => new Promise((resolve) => resolve(member(acct, args))); } } function accountOf(name) { try { return accountImpl(name); } catch (err) { // A member's workspace with no usable account never falls back to the // host's server: that would run the pane as the host in their folder. // Under S3 neither does any other failure to tell (the DB unreadable // mid-call): the call fails instead of guessing "the host". if (err.code === "EMEMBERGONE" || require("./linux-accounts").s3Enabled()) throw err; return null; // no DB yet, a malformed name: the host's own server } } /** Run a tmux command on the server that holds session `name`. */ function onServerOf(name, args, input) { const acct = accountOf(name); return acct ? memberCall(acct, args, input) : hostCall(args); } // Whether this tmux takes `=name` targets: true for tmux itself, false for // psmux (the WinGet `tmux` on Windows), null until a live session has shown // which. A fake exec in a test is real tmux unless it says otherwise. let exactTargets = null; function __setExecImpl(fn, { exact = true } = {}) { execImpl = fn; scopeImpl = null; // a fake tmux never starts a real scope exactTargets = exact; listed = new Map(); listGen++; unreachable = new Map(); } function __setScopeImpl(fn) { scopeImpl = fn; } function __reset() { execImpl = (args, opts = EXEC_OPTS) => execFileSync(args[0] === "-V" ? "tmux" : hostTmuxBin(), args, opts); hostBin = { at: 0, bin: "tmux" }; scopeImpl = realScopeImpl; accountImpl = (name) => require("./run-as").accountForSession(name); memberImpl = realMemberImpl; memberListImpl = realMemberListImpl; exactTargets = null; listed = new Map(); listGen++; unreachable = new Map(); } // The `list-sessions` answer, shared for LIST_TTL_MS. Every open dashboard // polls GET /api/run, and each synchronous spawn forks this whole process — // measured 2,247 calls, 60 s of a 90 s run blocked, at 10 browsers. A session // this process creates or kills clears it, so only a pane that exits on its // own can read as live for up to a second. "No server" (exit 1) is an answer // too and is shared; any other failure is not. const LIST_TTL_MS = 1000; const LIST_ARGS = ["list-sessions", "-F", "#{session_name}"]; // psmux (Windows) ignores `-F` and prints `name: 1 windows (created …)`. A // session name never holds a `:` (tmux rewrites it), so the text before the // first one is the name in either form. const sessionNames = (out) => String(out) .split("\n") .map((l) => l.split(":")[0].trim()) .filter(Boolean) .join("\n"); let listed = new Map(); // member login ("" = ours) → { at, out } | { at, err } let listGen = 0; // bumped by a change this process makes: drops lists in flight function listOutput(acct = null) { if (acct) return memberListOutput(acct); const hit = listed.get(""); if (hit && Date.now() - hit.at < LIST_TTL_MS) { if (hit.err) throw hit.err; return hit.out; } try { const out = sessionNames(hostCall(LIST_ARGS)); listed.set("", { at: Date.now(), out }); return out; } catch (err) { if (err.status === 1 || err.code === "ENOENT") listed.set("", { at: Date.now(), err }); else listed.delete(""); throw err; } } // A member's list is never waited for: a read answers from `listed` and, // once that is MEMBER_LIST_TTL_MS old, starts one async re-list. Until a first // answer — or once the last is MEMBER_LIST_MAX_AGE_MS old (nobody polled) — // or while their server fails, it throws: their panes are UNKNOWN. A session // this process creates or kills is written into the answer at once (`noteSession`). const MEMBER_LIST_MAX_AGE_MS = 15_000; // > the 10 s heal beat // A member's list is a `sudo` (fork + PAM session + 3 auth.log lines) — at a // 1 s TTL every open dashboard re-asked each member ~30 times a minute. A pane // this process starts or ends is corrected at once (`noteSession`); only one // that exits on its own reads live for up to these 5 s. const MEMBER_LIST_TTL_MS = 5_000; const refreshing = new Set(); // member logins with a list in flight function memberListOutput(acct) { const hit = listed.get(acct.login); const age = hit ? Date.now() - hit.at : Infinity; if (age >= MEMBER_LIST_TTL_MS) refreshList(acct); if (age >= MEMBER_LIST_MAX_AGE_MS) { throw Object.assign(new Error(`${acct.login}: tmux list pending`), { code: "ETMUXPENDING" }); } if (hit.err) throw hit.err; return hit.out; } function refreshList(acct) { const key = acct.login; if (refreshing.has(key)) return; refreshing.add(key); const gen = listGen; const hangs = unreachable; // a test's __reset starts a new one const done = (entry) => { refreshing.delete(key); if (gen === listGen) listed.set(key, { at: Date.now(), ...entry }); }; new Promise((resolve) => { reachable(acct); resolve(memberListImpl(acct, LIST_ARGS)); }).then( // A member's server answers for their own workspaces only: a name it // lists that belongs to anyone else (a server the member runs to lie // about another pane) is not believed. (out) => done({ out: sessionNames(out) .split("\n") .filter((n) => ownedBy(n.trim(), acct)) .join("\n"), }), (err) => { if (hangs === unreachable) noteHang(acct, err); // "No server" is an empty list; any other failure is shared too, so a // broken server of theirs is not asked again on every poll. done(err.status === 1 || err.code === "ENOENT" ? { out: "" } : { err }); } ); } /** A session this process made or ended: ours re-lists, a member's answer is * corrected in place (an unknown one stays unknown). */ function noteSession(name, acct, alive) { listGen++; listed.delete(""); const hit = acct && listed.get(acct.login); if (!hit || hit.err) return; const names = hit.out.split("\n").filter((n) => n.trim() && n.trim() !== name); if (alive) names.push(name); listed.set(acct.login, { at: hit.at, out: names.join("\n") }); } /** Test seam: resolves once no member list is in flight. */ async function __settle() { while (refreshing.size) await new Promise((r) => setImmediate(r)); } function ownedBy(name, acct) { try { return accountImpl(name)?.login === acct.login; } catch { return false; } } /** Our own server first, then every member's (S3). */ function allServers() { try { return [null, ...require("./run-as").membersWithTmux()]; } catch { return [null]; } } /** * Probe once, on a session that exists: `=name` failing where the bare name * answers means this tmux has no exact-match syntax (psmux answers * "no server running on session '=name'"), so every target there is the bare * name. Until a session exists to ask, tmux's own syntax. */ function exact() { if (exactTargets !== null) return exactTargets; let first; try { first = sessionNames(hostCall(LIST_ARGS)) .split("\n") .map((x) => x.trim()) .find(Boolean); } catch { return true; } if (!first) return true; const answers = (t) => { try { hostCall(["has-session", "-t", t]); return true; } catch { return false; } }; if (answers(`=${first}`)) exactTargets = true; else if (answers(first)) exactTargets = false; return exactTargets ?? true; } // tmux resolves a bare `-t agenthub-lane-1` by PREFIX when no exact match // exists, so it would hit `agenthub-lane-12` once lane 1's session is gone — // and killSession would kill the wrong lane. `=` forces an exact match; pane // and option targets need the trailing `:` (tmux 3.4 rejects `=name` there). // psmux has neither form and names a session by its bare name. const session = (name) => (exact() ? `=${name}` : name); const pane = (name) => (exact() ? `=${name}:` : name); function hasSession(name) { // A fresh shared list that names it answers "yes" without a spawn: each // synchronous one blocks every socket (terminal echo included) — 50-150 ms // per psmux call on Windows — and a pane-token request, an AI-proxy call or // a lane broadcast asks this every time. "No" is always asked for real, so a // pane that just died is never trusted past the list's 1 s TTL. const hit = listed.get(""); try { if ( hit && !hit.err && Date.now() - hit.at < LIST_TTL_MS && hit.out.split("\n").includes(name) && !accountOf(name) ) return true; onServerOf(name, ["has-session", "-t", session(name)]); return true; } catch { return false; } } /** Our running tmux server's pid, or null when none answers. */ function serverPid() { try { const pid = String(hostCall(["display-message", "-p", "#{pid}"])).trim(); return /^\d+$/.test(pid) ? pid : null; } catch { return null; } } /** * When the tmux server that holds (or held) session `name` started, in epoch * ms — the host's, or the member's under S3 — or null when there is no such * server or it cannot be asked. A pane gone while its server ran on since * before the pane began exited by itself; one whose server is gone or newer * died with it (a reboot, a killed tmux). */ function serverStartedAt(name) { try { const out = String(onServerOf(name, ["display-message", "-p", "#{start_time}"])).trim(); return /^\d+$/.test(out) ? Number(out) * 1000 : null; } catch { return null; } } /** `-e NAME=value` pairs for tmux, in a stable order. */ function envFlags(env) { const flags = []; for (const [key, value] of Object.entries(env || {})) flags.push("-e", `${key}=${value}`); return flags; } /** * 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). * * `env` becomes tmux `-e NAME=value` pairs, which tmux puts in the pane's own * environment (tmux >= 3.2). The dashboard uses it for AGENTHUB_LANE_ID so a `agenthub` * call from inside the pane knows exactly which lane it belongs to instead of * guessing from cwd — see resolveLaneArg in bin/agenthub.js. */ function newSession({ name, cwd, argv, env }) { // No argv = tmux's own default shell (a Windows Terminal pane). const cmd = argv.length ? ["--", ...argv] : []; noteSession(name, null, false); const args = ["new-session", "-d", "-s", name, "-c", cwd, ...envFlags(env), ...cmd]; const acct = accountOf(name); if (acct) { // As the member (the first call starts their server); env and argv in // the launch file, never on sudo's logged command line. const launch = require("./run-as").launchArgv(acct, env, argv); memberCall(acct, ["new-session", "-d", "-s", name, "-c", cwd, "--", ...launch]); noteSession(name, acct, true); } else if (scopeImpl && process.platform === "linux" && !serverPid()) { try { scopeImpl(args); } catch { // No user manager (SSH without linger, no systemd): the plain start. if (!hasSession(name)) hostCall(args); } } else { hostCall(args); } try { enableMouse(name); } catch (err) { // The pane's command exited at once (a CLI that refuses the terminal tmux // gives it, say) and tmux closed the session with it: say that, not the // "Command failed: tmux set-option …" that hid the real problem. if (hasSession(name)) throw err; throw Object.assign( new Error( `the session's command exited as soon as it started: ${argv[0] || "the default shell"}` ), { code: "EPANEEXITED" } ); } } // Without this the pane has no scrollbar/drag scroll at all — wheel events // just pass through to the running program instead of entering tmux's own // copy-mode scrollback. xterm.js forwards tmux's mouse-tracking escapes // automatically once mouse mode is on, so no client-side change is needed. // Called on every attach (not just at creation) so a session started before // this option existed — or before a server restart picked up the change — // still gets it; `set-option` is idempotent, so re-running it is harmless. // The status bar goes off here too: the dashboard's tab bar already names the // pane, and tmux's green line only eats a row of the terminal. function enableMouse(name) { // tmux runs `;`-separated commands from ONE argv (each tmux call is a // synchronous spawn on the event loop), stopping at the first that fails — // so the options every tmux knows go first, in their own call. psmux (no // `=name` targets) is not known to chain, so it gets one call per option. const run = (...cmds) => { if (exact()) return onServerOf( name, cmds.flatMap((c, i) => (i ? [";", ...c] : c)) ); for (const c of cmds) onServerOf(name, c); }; run( ["set-option", "-t", pane(name), "mouse", "on"], ["set-option", "-t", pane(name), "status", "off"] ); // A draggable scrollbar beside the pane (mouse mode makes it drag). Only a // tmux SERVER of 3.7+ knows the option; an older one refuses it, which is // fine — the pane just has none (`/agenthub-doctor` says how to upgrade). try { run( ["set-option", "-t", pane(name), "pane-scrollbars", "on"], // tmux's default (`fg=white,bg=black,width=1`) is a one-column track in // ANSI black — the terminal theme's border colour, almost the background — // so it reads as no scrollbar at all. Bright slider. ONE column: tmux 3.7c // reserves `width` columns but paints only the first, so a second one is // never cleared and shows stale cells — a ghost second scrollbar. [ "set-option", "-t", pane(name), "pane-scrollbars-style", "fg=brightwhite,bg=black,width=1,pad=0", ] ); } catch { /* tmux < 3.7 */ } } /** * 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 ( onServerOf(name, [ "display-message", "-p", "-t", pane(name), "#{pane_current_command}", ]).trim() || null ); } catch { return null; } } /** * Run `argv` in an existing session's pane. Only ever called when the pane sits * at a shell prompt (see `paneCommand`), so what `-k` kills is an idle shell. * * `respawn-pane` and not `send-keys`, because `env` may carry a secret — an AI * Router's API key, which Codex reads from the variable its `env_key` names. * Typing it would put a `KEY='sk-live-…' codex …` line into the pane's rendered * text, which the dashboard streams to every attached browser, into the tmux * scrollback, and into the user's shell history. `-e` hands the value to the * tmux server through argv instead, exactly as `newSession` already does, and * the pane never renders it. Running argv directly also means no shell to quote * for, so the whole class of quoting bugs goes with it. */ function sendCommand(name, argv, env, cwd) { const acct = accountOf(name); if (acct) { const launch = require("./run-as").launchArgv(acct, env, argv); memberCall(acct, [ "respawn-pane", "-k", "-t", pane(name), ...(cwd ? ["-c", cwd] : []), "--", ...launch, ]); return; } onServerOf(name, [ "respawn-pane", "-k", "-t", pane(name), ...(cwd ? ["-c", cwd] : []), ...envFlags(env), "--", ...argv, ]); } /** * Type a literal line of plain text into an existing pane and press Enter — * for a program that reads stdin as a message rather than a shell command * (an interactive `claude`/`codex` chat pane), where `sendCommand`'s POSIX * quoting and `KEY=value ` env prefix would show up as literal punctuation in * the message instead of being interpreted. `text` must be single-line: `-l` * sends it as one literal keystroke burst, so an embedded newline would submit * only the first line and leave the rest sitting unsent in the input box. */ function sendText(name, text) { onServerOf(name, ["send-keys", "-t", pane(name), "-l", text]); onServerOf(name, ["send-keys", "-t", pane(name), "Enter"]); } /** * Paste `text` into the pane as one bracketed paste, then press Enter. Unlike * `sendText` it may span lines: the text goes into a named buffer through an * argv element (`set-buffer -- `), never a shell, and `-d` deletes the * buffer after the paste so a prompt does not linger in tmux's buffer list. */ function pasteText(name, text) { const buf = `agenthub-paste-${name}`; // A member's prompt goes in on stdin: sudo logs argv. if (accountOf(name)) onServerOf(name, ["load-buffer", "-b", buf, "-"], text); else onServerOf(name, ["set-buffer", "-b", buf, "--", text]); onServerOf(name, ["paste-buffer", "-p", "-d", "-b", buf, "-t", pane(name)]); onServerOf(name, ["send-keys", "-t", pane(name), "Enter"]); } /** The pane's visible screen as plain text (no escape sequences). */ function capturePane(name) { return String(onServerOf(name, ["capture-pane", "-p", "-t", pane(name)])); } /** Press ONE named key (`BTab`, `Escape`) in the pane — a key name, never text. */ function sendKey(name, key) { onServerOf(name, ["send-keys", "-t", pane(name), key]); } /** Idempotent — a session that's already gone is not an error. */ function killSession(name) { try { onServerOf(name, ["kill-session", "-t", session(name)]); } catch { /* already gone */ } noteSession(name, accountOfQuiet(name), false); } /** * A member's tmux server as the Users page shows it: `{running: false}`, or * `{running: true, version, pid, startedAt, sessions}`, or `{stuck: true}` * when it did not answer (hung, or backed off). Asked with `tmux-member`, i.e. * with that server's own binary. */ function memberServerInfo(acct) { try { const out = String( memberCall(acct, [ "display-message", "-p", "#{version}\t#{pid}\t#{start_time}\t#{server_sessions}", ]) ).trim(); const [version, pid, start, sessions] = out.split("\t"); return { running: true, version: version || null, pid: /^\d+$/.test(pid) ? Number(pid) : null, startedAt: /^\d+$/.test(start) ? Number(start) * 1000 : null, sessions: /^\d+$/.test(sessions) ? Number(sessions) : null, }; } catch (err) { if (err.code === "ETMUXHUNG" || err.code === "ETIMEDOUT" || err.code === "ENOBUFS") { return { running: true, stuck: true }; } // "no server running" / "error connecting": nothing to report. return { running: false }; } } /** * Stop a member's tmux server — every pane of theirs ends (an admin's Users * page action, so the next start runs the installed tmux). Throws when the * server did not go: a stuck one may need root (`sudo kill `). */ function killMemberServer(acct) { unreachable.delete(acct.login); // asked deliberately, past any back-off try { memberCall(acct, ["kill-server"]); } finally { listGen++; listed.delete(acct.login); } } /** Session names starting with `prefix`. Empty array if tmux has no server running at all. */ function listSessions(prefix) { const names = []; for (const acct of allServers()) { try { names.push(...listOutput(acct).split("\n")); } catch { /* no server there */ } } return names.map((s) => s.trim()).filter((s) => s && s.startsWith(prefix)); } /** * Every live session name from ONE `list-sessions` — what a poll asks instead * of a synchronous has-session per name, each of which blocks the event loop * (and so every terminal keystroke) for a whole tmux spawn. Empty when no * server runs (tmux exits 1) or tmux is not installed; null when OUR call * itself failed (EAGAIN/ENOMEM under memory pressure), which proves nothing * about the panes — a caller must not read that as "every pane is gone". A * MEMBER's server that fails (stopped, flooding, gone wrong) proves nothing * either, but only about that member's panes: `has` answers true for them, * so nobody's runs end and everyone else's are still checked. */ function liveSessions() { const live = new Set(); const unknown = new Set(); // member logins whose server did not answer for (const acct of allServers()) { try { for (const s of listOutput(acct).split("\n")) if (s.trim()) live.add(s.trim()); } catch (err) { if (err.status === 1 || err.code === "ENOENT") continue; if (!acct) return null; unknown.add(acct.login); } } if (unknown.size) { const has = live.has.bind(live); live.has = (name) => has(name) || unknown.has(accountOfQuiet(name)?.login); } return live; } function accountOfQuiet(name) { try { return accountImpl(name); } catch { return null; } } /** * `[file, args]` attaching a terminal to a MEMBER's session (as the member, * sudo), or null for one on our own server (pty-attach runs our binary). */ function memberAttach(name) { const acct = accountOf(name); if (!acct) return null; const runAs = require("./run-as"); return runAs.sudoArgv(acct, "tmux", [ ...["-S", runAs.tmuxSocket(acct), "attach-session", "-t", session(name)], ]); } function isTmuxAvailable() { return clientVersion() !== null; } /** The tmux binary's version (`3.7c`, `next-3.8`), null when it is absent. */ function clientVersion() { try { return execImpl(["-V"]) .trim() .replace(/^tmux\s+/, ""); } catch { return null; } } // The oldest tmux AgentHub asks for, and how each platform upgrades it. // Windows' is psmux: before 3.4.0 a dashboard terminal stalled until a // refresh, and fixes for frame push, writes and synchronized output landed in // 3.3.6–3.4.0. Elsewhere 3.7, the first with pane scrollbars (enableMouse). const UPGRADES = { win32: { min: [3, 4, 0], name: "psmux", how: "close every AgentHub terminal pane, then run: winget upgrade marlocarlo.psmux", }, darwin: { min: [3, 7, 0], name: "tmux", how: "brew upgrade tmux" }, linux: { min: [3, 7, 0], name: "tmux", how: "distros ship older ones (Ubuntu 24.04 has 3.4), so build the release from " + "https://github.com/tmux/tmux/releases (needs libevent-dev libncurses-dev bison: " + "./configure && make && sudo make install)", }, }; /** What to tell the user when `version` is older than their platform asks * for; null when it is new enough or cannot be compared. Advice only — * AgentHub never installs or restarts tmux. */ function upgradeAdvice(version = clientVersion(), platform = process.platform) { const want = UPGRADES[platform]; const m = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(version || ""); if (!want || !m) return null; const have = [m[1], m[2], m[3] || 0].map(Number); const i = have.findIndex((n, k) => n !== want.min[k]); if (i === -1 || have[i] > want.min[i]) return null; return `${want.name} ${version} is older than ${want.min.join(".")} — ${want.how}`; } /** The RUNNING server's version, null when none runs. A server started before * an upgrade keeps its old version until every tmux session has closed. */ function serverVersion() { try { return hostCall(["display-message", "-p", "#{version}"]).trim() || null; } catch { return null; } } module.exports = { __setScopeImpl, serverExeFor, session, pane, hasSession, newSession, enableMouse, paneCommand, sendCommand, sendText, pasteText, capturePane, sendKey, killSession, serverStartedAt, listSessions, liveSessions, isTmuxAvailable, clientVersion, serverVersion, upgradeAdvice, serverPid, memberAttach, memberServerInfo, killMemberServer, __setExecImpl, __setAccountImpl, __settle, __reset, };