f0dc1445fc
- psmux prints `name: 1 windows (created …)` whatever `list-sessions -F`
asks for. Read as names, every live pane looked gone and the heal beat
closed its run (and its tab) within seconds while the session kept
running. tmux.js (and the Update all helper) now take the text before the
first `:` — a session name never holds one.
- `npm run setup` used `(cd client && npm install)`; cmd.exe's ( ) is no
subshell, so `npm run link-cli` ran inside client/ ("Missing script") and
Update now failed on Windows. Now `npm --prefix client install`.
- Update now: an npm step names itself in its error and shows the END of
npm's output (the error, not the warnings), says when it hit the time
limit, and gets 20 min instead of 5.
697 lines
25 KiB
JavaScript
697 lines
25 KiB
JavaScript
/**
|
|
* @file tmux.js
|
|
* @description Thin wrapper around the `tmux` CLI for the terminal-run
|
|
* feature. Every dashboard-managed session is named `agenthub-lane-<id>` (see
|
|
* `pty-run.js`) so a real terminal can attach to the exact same session with
|
|
* `tmux attach -t agenthub-lane-<id>` (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 -- <argv>` 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 `<home>/.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.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { execFile, 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.
|
|
// 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).
|
|
const EXEC_OPTS = { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"], cwd: "/" };
|
|
let execImpl = (args, opts = EXEC_OPTS) => execFileSync("tmux", 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;
|
|
}
|
|
}
|
|
/** 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.
|
|
if (err.code === "EMEMBERGONE") 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) : execImpl(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++;
|
|
}
|
|
function __setScopeImpl(fn) {
|
|
scopeImpl = fn;
|
|
}
|
|
function __reset() {
|
|
execImpl = (args, opts = EXEC_OPTS) => execFileSync("tmux", args, opts);
|
|
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(execImpl(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 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
|
|
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 >= 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(execImpl(LIST_ARGS))
|
|
.split("\n")
|
|
.map((x) => x.trim())
|
|
.find(Boolean);
|
|
} catch {
|
|
return true;
|
|
}
|
|
if (!first) return true;
|
|
const answers = (t) => {
|
|
try {
|
|
execImpl(["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) {
|
|
try {
|
|
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(execImpl(["display-message", "-p", "#{pid}"])).trim();
|
|
return /^\d+$/.test(pid) ? pid : 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)) execImpl(args);
|
|
}
|
|
} else {
|
|
execImpl(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 -- <text>`), 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);
|
|
}
|
|
|
|
/** 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 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 execImpl(["display-message", "-p", "#{version}"]).trim() || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
__setScopeImpl,
|
|
session,
|
|
pane,
|
|
hasSession,
|
|
newSession,
|
|
enableMouse,
|
|
paneCommand,
|
|
sendCommand,
|
|
sendText,
|
|
pasteText,
|
|
capturePane,
|
|
sendKey,
|
|
killSession,
|
|
listSessions,
|
|
liveSessions,
|
|
isTmuxAvailable,
|
|
clientVersion,
|
|
serverVersion,
|
|
serverPid,
|
|
memberAttach,
|
|
__setExecImpl,
|
|
__setAccountImpl,
|
|
__settle,
|
|
__reset,
|
|
};
|