@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
667 lines (666 loc) • 36.8 kB
JavaScript
// tmux as the Hook's terminal provider, for computers without Herdr.
//
// Two tmux servers are Hook servers: the owner's own (the default socket,
// server name "tmux"), where the Hook finds agents the owner started, and a
// hidden one on its own socket (`tmux -L phren`, server name "tmux-phren"),
// where agents started from the phone run. Nobody has to know tmux for the
// second: it starts on the first launch, and `tmux -L phren attach` shows it.
//
// tmux knows processes, not agents. The agent in a pane comes from its
// foreground processes' command lines, and its status from the harness's own
// lifecycle hooks (pane-status.ts). Everything runs through execFile with an
// argument array: text from the phone reaches tmux as an argument or on
// stdin, never through a shell.
import { execFile } from "node:child_process";
import { accessSync, constants, existsSync, statSync } from "node:fs";
import { lstat, readdir } from "node:fs/promises";
import { hostname, tmpdir, userInfo } from "node:os";
import path from "node:path";
import { BridgeError, objects, serverName } from "./protocol.js";
import { harnessStatus } from "./harness-status.js";
import { dialogStatus, notePaneStatus, paneStatus, startupStatus } from "./pane-status.js";
import { plainTitle } from "./session-activity.js";
import { herdrPanes } from "./terminal-herdr.js";
/** The owner's own tmux server (the default socket). */
export const TMUX_DEFAULT = "tmux";
/** The hidden server phone-started agents run in: `tmux -L phren`. */
export const TMUX_HIDDEN = "tmux-phren";
/** The tmux socket name (`-L`) a Hook server name stands for: "tmux" is the
* default socket and "tmux-<name>" the socket `<name>`. */
export function tmuxSocketName(server) {
const match = /^tmux(?:-([A-Za-z0-9_][A-Za-z0-9_.-]{0,63}))?$/.exec(server);
return match ? match[1] ?? "default" : undefined;
}
/** The Hook server name for a tmux socket name. */
export function tmuxServerName(socket) {
const name = socket === "default" ? TMUX_DEFAULT : `tmux-${socket}`;
return tmuxSocketName(name) === socket && serverName.safeParse(name).success ? name : undefined;
}
let cachedBinary;
/** The tmux on PATH or in the usual install folders; the Hook's service
* PATH can be shorter than a login shell's. Rechecked every minute. */
export function tmuxBinary() {
// PHREN_TMUX=off keeps the Hook on Herdr alone.
if (process.env.PHREN_TMUX === "off")
return undefined;
if (cachedBinary && Date.now() - cachedBinary.at < 60_000)
return cachedBinary.value;
const folders = [...(process.env.PATH ?? "").split(path.delimiter), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/home/linuxbrew/.linuxbrew/bin"];
let value;
for (const folder of folders) {
if (!folder || !path.isAbsolute(folder))
continue;
const candidate = path.join(folder, "tmux");
try {
if (statSync(candidate).isFile()) {
accessSync(candidate, constants.X_OK);
value = candidate;
break;
}
}
catch { /* next folder */ }
}
cachedBinary = { at: Date.now(), value };
return value;
}
/** The environment tmux and the panes it starts get: the Hook's, without
* another multiplexer's variables. */
function tmuxEnvironment() {
return Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith("HERDR_") && name !== "TMUX" && name !== "TMUX_PANE"));
}
function tmuxError(stderr, error) {
const said = stderr.replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, 200);
if (error.name === "AbortError")
return new BridgeError(499, "Request cancelled.");
if (error.killed || error.signal === "SIGTERM")
return new BridgeError(504, "tmux did not answer. Refresh before trying again.");
if (error.code === "ENOENT")
return new BridgeError(503, "tmux is not installed on this computer.");
if (/no server running|error connecting to|No such file or directory/i.test(said))
return new BridgeError(503, "tmux is not running on this computer.", { code: "not_running" });
return new BridgeError(409, said ? `tmux: ${said}` : "tmux could not perform this action. Refresh the session before trying again.");
}
/** The folders this user's tmux sockets live in: `$TMUX_TMPDIR/tmux-<uid>`,
* where `-L` looks, then `/tmp/tmux-<uid>` when TMUX_TMPDIR points elsewhere
* (a server started from a shell with another TMUX_TMPDIR than the Hook's). */
export function tmuxSocketFolders(env = process.env) {
const uid = process.getuid?.();
if (uid === undefined)
return [];
const base = env.TMUX_TMPDIR && path.isAbsolute(env.TMUX_TMPDIR) ? env.TMUX_TMPDIR : "/tmp";
return [...new Set([path.join(base, `tmux-${uid}`), path.join("/tmp", `tmux-${uid}`)])];
}
/** Sockets outside `-L`'s folder, by name: tmux reaches them with `-S`. */
const socketPaths = new Map();
function socketFlags(socket) {
const file = socketPaths.get(socket);
return file ? ["-S", file] : ["-L", socket];
}
/** The tmux sockets in `folder` this user owns, by name, most recently active
* first, so a live server sorts ahead of stale socket files left by killed
* runs. A socket outside the first folder of `tmuxSocketFolders` is remembered
* by path. */
export async function tmuxSocketsIn(folder) {
const uid = process.getuid?.(), primary = tmuxSocketFolders()[0];
const names = (await readdir(folder).catch(() => [])).slice(0, 256);
const found = [];
for (const name of names) {
if (!tmuxServerName(name))
continue;
const info = await lstat(path.join(folder, name)).catch(() => undefined);
if (!info?.isSocket() || (uid !== undefined && info.uid !== uid))
continue;
found.push({ name, at: info.mtimeMs });
if (folder !== primary && !socketPaths.has(name) && !existsSync(path.join(primary ?? folder, name)))
socketPaths.set(name, path.join(folder, name));
}
found.sort((a, b) => b.at - a.at);
// Most recent first, then capped: each candidate costs a tmux process
// per snapshot, and stale files from killed runs sort to the end.
return found.slice(0, 32).map(entry => entry.name);
}
const defaultDeps = {
binary: tmuxBinary,
run: (socket, args, options = {}) => new Promise((resolve, reject) => {
const binary = tmuxBinary();
if (!binary) {
reject(new BridgeError(503, "tmux is not installed on this computer."));
return;
}
// -u: without a UTF-8 locale (launchd and systemd services often have
// none) tmux 3.7 prints a format's tabs as "_" and no pane parses.
const child = execFile(binary, ["-u", ...socketFlags(socket), ...args], { timeout: options.timeoutMs ?? 5_000, maxBuffer: 4_194_304, env: tmuxEnvironment(), signal: options.signal }, (error, stdout, stderr) => { if (error)
reject(tmuxError(String(stderr), error));
else
resolve(String(stdout)); });
if (options.input !== undefined)
child.stdin?.end(options.input);
else
child.stdin?.end();
}),
processes: () => new Promise(resolve => {
execFile("ps", ["-A", "-o", "pid=,pgid=,tpgid=,tty=,args="], { timeout: 3_000, maxBuffer: 8_388_608, env: { ...process.env, LC_ALL: "C" } }, (error, stdout) => resolve(error ? "" : String(stdout)));
}),
version: () => new Promise(resolve => {
const binary = tmuxBinary();
if (!binary) {
resolve("");
return;
}
execFile(binary, ["-V"], { timeout: 3_000 }, (error, stdout) => resolve(error ? "" : String(stdout)));
}),
sockets: async () => (await Promise.all(tmuxSocketFolders().map(tmuxSocketsIn))).flat(),
sleep: (ms, signal) => new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new BridgeError(499, "Request cancelled."));
return;
}
const timer = setTimeout(() => { signal?.removeEventListener("abort", abort); resolve(); }, ms);
const abort = () => { clearTimeout(timer); reject(new BridgeError(499, "Request cancelled.")); };
signal?.addEventListener("abort", abort, { once: true });
}),
};
let deps = defaultDeps;
/** For tests: replace how tmux and ps run; the returned function restores them. */
export function setTmuxDeps(replacement) {
const previous = deps;
deps = { ...deps, ...replacement };
return () => { deps = previous; };
}
function socketOf(server) {
const socket = tmuxSocketName(serverName.parse(server));
if (!socket)
throw new BridgeError(400, "This is not a tmux server.");
return socket;
}
const tmux = (server, args, options) => deps.run(socketOf(server), args, options);
// tmux ids carry sigils the Hook's ids do not allow: session $3, window @5,
// pane %12 become s3, w5 and p12, and back.
const SIGILS = { s: "$", w: "@", p: "%" };
export function fromTmuxId(value) {
const match = /^([$@%])(\d{1,9})$/.exec(value);
return match ? `${match[1] === "$" ? "s" : match[1] === "@" ? "w" : "p"}${match[2]}` : undefined;
}
export function toTmuxId(value, kind) {
const match = /^([swp])(\d{1,9})$/.exec(value);
if (!match || match[1] !== kind)
throw new BridgeError(409, "This tmux pane changed. Refresh the computer.");
return SIGILS[kind] + match[2];
}
/** The agent a command line runs, by the harness's executable or package. */
export function agentFromCommand(command) {
const words = command.trim().split(/\s+/).filter(Boolean).slice(0, 4);
const [first, second] = words, base = (word) => word ? path.basename(word) : "";
// A Node, Bun or Deno launcher runs the harness as its script.
const program = /^(node|nodejs|bun|deno)$/.test(base(first)) && second ? second : first;
if (!program)
return undefined;
const name = base(program);
if (name === "claude" || program.includes("/claude/versions/") || program.includes("@anthropic-ai/claude-code"))
return "claude";
if (name === "codex" || program.includes("@openai/codex"))
return "codex";
if (name === "opencode" || name === ".opencode" || program.includes("opencode-ai"))
return "opencode";
if (name === "copilot" || program.includes("@github/copilot"))
return "copilot";
if (name === "phren-agent" || program.includes("@phren/agent/") || /\/packages\/agent\/dist\/bin\.js$/.test(program))
return "phren";
// `phren agent` runs the same agent through the CLI.
const next = words[words.indexOf(program) + 1];
if ((name === "phren" || program.includes("@phren/cli/") || /\/packages\/cli\/dist\/index\.js$/.test(program)) && next === "agent")
return "phren";
return undefined;
}
export function parseProcesses(text) {
return text.split("\n").flatMap(line => {
const match = /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(\S+)\s+(.*)$/.exec(line);
return match ? [{ pid: Number(match[1]), pgid: Number(match[2]), tpgid: Number(match[3]), tty: match[4], args: match[5] }] : [];
});
}
/** The pane terminal's foreground process group, oldest process first. */
function foreground(processes, paneTty) {
const tty = paneTty.replace(/^\/dev\//, "");
return processes.filter(p => (p.tty === tty || `tty${p.tty}` === tty) && p.tpgid > 0 && p.pgid === p.tpgid).sort((a, b) => a.pid - b.pid);
}
const FIELDS = ["session_id", "session_name", "session_attached", "session_activity", "window_id", "window_name", "window_active",
"pane_id", "pane_pid", "pane_tty", "pane_active", "pane_current_path", "pane_current_command", "@phren_agent", "pane_title", "@phren_label"];
const FORMAT = FIELDS.map(field => `#{${field}}`).join("\t");
export function parsePanes(text) {
return text.split("\n").flatMap(line => {
const values = line.split("\t");
if (values.length !== FIELDS.length)
return [];
const row = Object.fromEntries(FIELDS.map((field, index) => [field, values[index]]));
return fromTmuxId(row.session_id) && fromTmuxId(row.window_id) && fromTmuxId(row.pane_id) && /^\d+$/.test(row.pane_pid) ? [row] : [];
});
}
async function listRows(server) {
try {
return parsePanes(await tmux(server, ["list-panes", "-a", "-F", FORMAT]));
}
catch (error) {
// The hidden server starts with the first launch; until then it has no panes.
if (server === TMUX_HIDDEN && error instanceof BridgeError && error.details?.code === "not_running")
return [];
throw error;
}
}
/** The agent's status in a pane: its lifecycle events (Claude, Codex,
* phren-agent) or its own records (OpenCode, Copilot), then blocked while a
* working agent draws a dialog on the screen. */
async function agentStatus(server, pane, terminal, agent, pids) {
let status;
if (agent === "opencode" || agent === "copilot") {
const recorded = await harnessStatus(agent, pids).catch(() => undefined);
status = recorded ? notePaneStatus(server, pane, terminal, recorded) : undefined;
}
else
status = await paneStatus(server, pane, terminal);
const read = () => tmuxTerminal.readScreen(server, pane, { scope: "pane", source: "visible", lines: 40, format: "ansi", timeoutMs: 2_000 });
// Codex runs SessionStart only with its first turn; until then its screen says.
return await dialogStatus(server, pane, terminal, agent, status, read) ?? startupStatus(server, pane, terminal, agent, read);
}
const STATUS_RANK = ["blocked", "waiting", "working", "done", "idle"];
/** One tmux server in the shape of the Hook's pane snapshot (the shape
* Herdr's `session.snapshot` answers): workspaces are tmux sessions, tabs
* windows, panes panes. */
export async function tmuxSnapshot(server) {
const rows = await listRows(server);
const processes = rows.length ? parseProcesses(await deps.processes()) : [];
const host = hostname(), short = host.split(".")[0];
const workspaces = new Map(), tabs = new Map();
const panes = [], agents = [];
let focused;
for (const row of rows) {
const workspace = fromTmuxId(row.session_id), tab = fromTmuxId(row.window_id), pane = fromTmuxId(row.pane_id);
if (!workspaces.has(workspace))
workspaces.set(workspace, { workspace_id: workspace, label: row.session_name });
const tabKey = `${workspace}\0${tab}`;
if (!tabs.has(tabKey))
tabs.set(tabKey, { tab_id: tab, workspace_id: workspace, label: row.window_name });
const running = foreground(processes, row.pane_tty);
const agent = [row.pane_current_command, ...running.map(p => p.args)].map(agentFromCommand).find(Boolean);
const terminal = `${pane}:${row.pane_pid}`;
const status = agent ? await agentStatus(server, pane, terminal, agent, running.map(p => p.pid)) : undefined;
// Claude and Codex report every turn through their lifecycle hooks; until
// the first event the status is unknown (a folder-trust screen, say). The
// others count as idle until their own records say otherwise.
const hooked = agent === "claude" || agent === "codex";
const name = row["@phren_agent"] || undefined;
// A harness spins a glyph in front of its title while it works.
const title = row.pane_title && row.pane_title !== host && row.pane_title !== short ? plainTitle(row.pane_title) : undefined;
panes.push({ pane_id: pane, tab_id: tab, workspace_id: workspace, terminal_id: terminal, cwd: row.pane_current_path || undefined,
foreground_cwd: row.pane_current_path || undefined, title, ...(row["@phren_label"] ? { label: row["@phren_label"] } : {}),
...(agent ? { agent, agent_status: status?.status ?? (hooked ? "unknown" : "idle"), ...(status?.seq ? { state_change_seq: status.seq } : {}) } : {}),
...(agent && name ? { agent_name: name } : {}) });
if (agent && name)
agents.push({ name, pane_id: pane });
if (Number(row.session_attached) > 0 && row.window_active === "1" && row.pane_active === "1"
&& (!focused || Number(row.session_activity) > Number(focused.session_activity)))
focused = row;
}
// A window's status is its most pressing pane's.
for (const tab of tabs.values()) {
const statuses = panes.filter(p => p.tab_id === tab.tab_id && p.workspace_id === tab.workspace_id && typeof p.agent_status === "string").map(p => String(p.agent_status));
const status = STATUS_RANK.find(value => statuses.includes(value)) ?? statuses[0];
if (status)
tab.agent_status = status;
}
return { workspaces: [...workspaces.values()], tabs: [...tabs.values()], panes, agents,
...(focused ? { focused_workspace_id: fromTmuxId(focused.session_id), focused_tab_id: fromTmuxId(focused.window_id), focused_pane_id: fromTmuxId(focused.pane_id) } : {}) };
}
/** The Hook's key names as tmux key names; undefined for a literal character. */
export function tmuxKey(key) {
const named = { enter: "Enter", esc: "Escape", escape: "Escape", up: "Up", down: "Down", left: "Left", right: "Right",
tab: "Tab", "shift+tab": "BTab", space: "Space", backspace: "BSpace", home: "Home", end: "End", pageup: "PPage", pagedown: "NPage" };
if ([...key].length === 1)
return undefined;
const lower = key.toLowerCase();
if (named[lower])
return named[lower];
const modified = /^(alt|ctrl|shift)\+(.+)$/i.exec(key);
if (modified) {
const rest = modified[2], inner = [...rest].length === 1 ? rest : tmuxKey(rest);
if (inner)
return `${{ alt: "M-", ctrl: "C-", shift: "S-" }[modified[1].toLowerCase()]}${inner}`;
}
throw new BridgeError(400, `The key ${key.slice(0, 20)} is not supported in tmux.`);
}
/** send-keys calls for `keys`, in order: named keys together, literal characters with -l. */
export function sendKeysCalls(pane, keys) {
const calls = [];
let named = [], literal = "";
const flush = () => {
if (named.length)
calls.push(["send-keys", "-t", pane, ...named]);
if (literal)
calls.push(["send-keys", "-t", pane, "-l", "--", literal]);
named = [];
literal = "";
};
for (const key of keys) {
const name = tmuxKey(key);
if (name === undefined) {
if (named.length)
flush();
literal += key;
}
else {
if (literal)
flush();
named.push(name);
}
}
flush();
return calls;
}
/** tmux 3.0 and later run a multi-argument command directly, without a shell. */
async function requireDirectExec() {
if (!deps.binary())
throw new BridgeError(503, "tmux is not installed on this computer.");
const match = /(\d+)\.(\d+)/.exec(await deps.version());
if (!match || Number(match[1]) < 3)
throw new BridgeError(409, "Phren needs tmux 3.0 or later to start agents. Update tmux on the computer.");
}
/** The login shell a started agent runs under, and falls back to when it exits. */
function loginShell() {
const shell = process.env.SHELL && path.isAbsolute(process.env.SHELL) ? process.env.SHELL : (() => { try {
return userInfo().shell ?? "";
}
catch {
return "";
} })();
// Only a POSIX shell takes the launcher script below.
return shell && /^(sh|bash|zsh|ksh|dash)$/.test(path.basename(shell)) && existsSync(shell) ? shell : "/bin/sh";
}
/** A session name tmux accepts: no "." or ":" and nothing unprintable. */
function sessionName(label, taken) {
const base = (label ?? "phren").replace(/[.:\x00-\x1f\x7f]/g, "_").trim().slice(0, 60) || "phren";
let name = base;
for (let n = 2; taken.has(name) && n < 1000; n++)
name = `${base}-${n}`;
return name;
}
async function paneRow(server, pane) {
return parsePanes(await tmux(server, ["list-panes", "-a", "-F", FORMAT])).find(row => row.pane_id === pane);
}
export const tmuxTerminal = {
kind: "tmux",
async ping(server) {
if (!deps.binary())
throw new BridgeError(503, "tmux is not installed on this computer.");
// The hidden server answers even before it has started: a launch starts it.
if (server !== TMUX_HIDDEN)
await tmux(server, ["list-sessions", "-F", "#{session_id}"], { timeoutMs: 3_000 });
},
snapshot: tmuxSnapshot,
async listPanes(server) { return herdrPanes(server, await tmuxSnapshot(server)); },
async processes(server, pane) {
const row = await paneRow(server, toTmuxId(pane, "p"));
if (!row)
throw new BridgeError(409, "This tmux pane changed. Refresh the computer.");
return { shellPid: Number(row.pane_pid), foregroundPids: foreground(parseProcesses(await deps.processes()), row.pane_tty).map(p => p.pid) };
},
async readScreen(server, pane, read) {
const lines = Math.max(1, Math.min(10_000, Math.floor(read.lines)));
const args = ["capture-pane", "-p", "-t", toTmuxId(pane, "p"), ...(read.stripAnsi === false || read.format === "ansi" ? ["-e"] : []),
...(read.source === "recent" ? ["-S", `-${lines}`] : [])];
const text = (await tmux(server, args, { timeoutMs: read.timeoutMs })).replace(/\n$/, "").split("\n");
// A screen's unused rows come back blank; the readers want its last drawn lines.
while (text.length && !text.at(-1).replace(/\x1b\[[0-9;]*m/g, "").trim())
text.pop();
return text.slice(-lines).join("\n");
},
async sendKeys(server, pane, keys) {
const target = toTmuxId(pane, "p");
for (const call of sendKeysCalls(target, keys))
await tmux(server, call);
},
async shellCommand(server, pane, command) { await tmuxTerminal.prompt(server, pane, command); },
async prompt(server, pane, text, signal) {
const target = toTmuxId(pane, "p"), buffer = `phren-${process.pid}-${Date.now().toString(36)}`;
// The text reaches tmux on stdin and the pane as one bracketed paste
// (-p), newlines kept (-r), then Enter submits it.
await tmux(server, ["load-buffer", "-b", buffer, "-"], { input: text, signal });
await tmux(server, ["paste-buffer", "-d", "-p", "-r", "-b", buffer, "-t", target], { signal });
await deps.sleep(150, signal);
await tmux(server, ["send-keys", "-t", target, "Enter"], { signal });
},
async create(server, { workspace, label, cwd }) {
const directory = cwd && path.isAbsolute(cwd) ? ["-c", cwd] : [];
const name = label ? ["-n", label.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 100)] : [];
if (workspace) {
await tmux(server, ["new-window", "-d", "-t", `${toTmuxId(workspace, "s")}:`, ...directory, ...name]);
return;
}
const taken = new Set((await tmux(server, ["list-sessions", "-F", "#{session_name}"]).catch(() => "")).split("\n").filter(Boolean));
await tmux(server, ["new-session", "-d", "-s", sessionName(label, taken), "-x", "200", "-y", "50", ...directory, ...name]);
},
async startAgent(server, pane, { name, kind, args, timeoutMs, env }) {
if (!["claude", "codex", "copilot", "opencode", "phren"].includes(kind))
throw new BridgeError(400, "This agent cannot be started in tmux.");
await requireDirectExec();
const target = toTmuxId(pane, "p"), row = await paneRow(server, target);
if (!row)
throw new BridgeError(409, "This tmux pane changed. Refresh the computer.");
// The name marks the pane for the phone (a conductor, a scheduled run).
await tmux(server, ["set-option", "-p", "-t", target, "@phren_agent", name]).catch(() => undefined);
// The harness runs under a login shell, so it gets the owner's PATH, and
// the pane falls back to that shell when it exits. The harness and its
// arguments are the script's positional parameters, never script text.
const shell = loginShell();
// Variables for the agent alone (a dispatch id) ride on the respawn.
const variables = Object.entries(env ?? {}).flatMap(([key, value]) => ["-e", `${key}=${value}`]);
await tmux(server, ["respawn-pane", "-k", "-t", target, ...(row.pane_current_path ? ["-c", row.pane_current_path] : []), ...variables,
"--", shell, "-l", "-c", 'shell="$1"; shift; "$@"; exec "$shell" -l', "phren", shell, kind, ...args]);
// Started once the harness is the pane's foreground program.
const deadline = Date.now() + Math.min(timeoutMs, 30_000);
for (;;) {
const snapshot = await tmuxSnapshot(server);
const current = objects(snapshot.panes).find(p => p.pane_id === pane);
if (!current)
throw new BridgeError(409, `tmux closed the pane before ${kind} started.`);
if (current.agent === kind)
return;
if (Date.now() >= deadline)
throw new BridgeError(504, `${kind} did not start in the tmux pane. Check that it is installed and on the login shell's PATH.`);
await deps.sleep(250);
}
},
// tmuxPaneFromEnv reads the socket from TMUX's first field and the pane
// from TMUX_PANE; the server pid and session index are not used.
paneEnv(server, { pane }) {
const socket = socketOf(server), folder = tmuxSocketFolders()[0];
const file = socketPaths.get(socket) ?? (folder ? path.join(folder, socket) : undefined);
return file ? { TMUX: `${file},0,0`, TMUX_PANE: toTmuxId(pane, "p") } : undefined;
},
// A pane user option, not `select-pane -T`: agents overwrite the pane title.
async renamePane(server, pane, label) {
await tmux(server, ["set-option", "-p", "-t", toTmuxId(pane, "p"), "@phren_label", label.replace(/[\x00-\x1f\x7f]/g, " ")]);
},
async focusPane(server, pane) {
const target = toTmuxId(pane, "p");
await tmux(server, ["select-window", "-t", target]);
await tmux(server, ["select-pane", "-t", target]);
},
async closePane(server, pane) { await tmux(server, ["kill-pane", "-t", toTmuxId(pane, "p")]); },
async groupAction(server, operation, { workspace, tab }, label) {
if (tab) {
const target = toTmuxId(tab, "w");
if (operation === "focus")
await tmux(server, ["select-window", "-t", target]);
else if (operation === "rename")
await tmux(server, ["rename-window", "-t", target, (label ?? "").replace(/[\x00-\x1f\x7f]/g, " ")]);
else
await tmux(server, ["kill-window", "-t", target]);
return;
}
if (!workspace)
throw new BridgeError(400, "Choose a tmux session.");
const target = toTmuxId(workspace, "s");
// A session has no focus of its own without an attached client.
if (operation === "focus")
return;
if (operation === "rename")
await tmux(server, ["rename-session", "-t", target, sessionName(label, new Set())]);
else
await tmux(server, ["kill-session", "-t", target]);
},
};
/** The owner's tmux servers that answer, by Hook server name: the default
* socket and every other socket in the user's tmux folders (`tmux -L work`
* is "tmux-work"), the 16 most recently active. Sockets are ordered by
* activity, so stale socket files from killed runs can't crowd out a live
* server. The hidden server is not among them, nor a test's private server
* (by its socket name, or its sessions all in a test's temp folder). */
async function ownerServers() {
const sockets = [...new Set(["default", ...await deps.sockets().catch(() => [])])]
.filter(socket => socket !== "phren" && !isTestName(socket));
// Sockets arrive most recently active first, so the cap keeps live servers.
const names = sockets.flatMap(socket => tmuxServerName(socket) ?? []).slice(0, 16);
const running = await Promise.all(names.map(name => tmux(name, ["list-sessions", "-F", "#{session_path}"], { timeoutMs: 3_000 })
.then(text => !isTestServer(text.split("\n").filter(Boolean)), () => false)));
return names.filter((_, index) => running[index]);
}
/** A name a test gives its private server or temp folder: a "test", "tests"
* or "it" word (phren-test-123, phren-scroll-test-123, phren-tmux-it-Qc4LM8). */
function isTestName(name) {
return /(^|[-_.])(tests?|it)([-_.]|$)/i.test(name) || /^phren.*test/i.test(name);
}
/** A server every session of which starts in a test's temp folder (a temp
* folder named like a test, or phren's own `phren-*` scratch folders): what a
* killed test run leaves behind on a socket with an ordinary name. */
function isTestServer(sessionPaths) {
const roots = [...new Set([tmpdir(), "/tmp", "/var/tmp"].map(root => path.resolve(root)))];
return sessionPaths.length > 0 && sessionPaths.every(folder => roots.some(root => {
const rest = path.relative(root, path.resolve(folder));
return rest !== "" && !rest.startsWith("..") && !path.isAbsolute(rest) && rest.split(path.sep).some(part => isTestName(part) || part.startsWith("phren-"));
}));
}
/** The tmux servers the Hook drives, as `/v1/muxes` lists servers: the owner's
* servers that answer, and (unless `hidden: false`) the hidden server whenever
* tmux is installed (a launch starts it). An already running hidden server
* remains discoverable beside Herdr too. `kind` names the actual multiplexer. */
export async function tmuxServers(options = {}) {
if (!deps.binary())
return [];
const includeHidden = options.hidden ?? true;
const hiddenRunning = includeHidden || await tmux(TMUX_HIDDEN, ["list-sessions", "-F", "#{session_id}"], { timeoutMs: 3_000 }).then(() => true, () => false);
return [...await ownerServers(), ...(hiddenRunning ? [TMUX_HIDDEN] : [])]
.map(name => ({ id: `tmux:${name}`, kind: "tmux", terminal: "tmux", session: name, running: true }));
}
export async function tmuxHealth() {
if (process.env.PHREN_TMUX === "off")
return { state: "off" };
if (!deps.binary())
return { state: "missing" };
const [version, servers, hidden] = await Promise.all([
deps.version().then(text => /(\d+\.\d+[a-z]?)/.exec(text)?.[1]),
ownerServers(),
tmux(TMUX_HIDDEN, ["list-sessions", "-F", "#{session_id}"], { timeoutMs: 3_000 })
.then(text => ({ running: true, sessions: text.split("\n").filter(Boolean).length }), () => ({ running: false })),
]);
const major = Number(/^(\d+)/.exec(version ?? "")?.[1]);
return { state: "ok", ...(version ? { version, launches: major >= 3 } : {}), servers, hidden };
}
/** The tmux pane this process runs in, from the variables tmux sets in every
* pane, with its session and window asked of that server. */
export async function tmuxPaneFromEnv(env = process.env) {
const socket = env.TMUX?.split(",")[0], pane = env.TMUX_PANE;
if (!socket || !path.isAbsolute(socket) || !pane || !fromTmuxId(pane))
return undefined;
const server = tmuxServerName(path.basename(socket));
if (!server)
return undefined;
// A socket outside `-L`'s folder (another TMUX_TMPDIR) is reached by path.
const primary = tmuxSocketFolders(env)[0];
if (primary && path.dirname(socket) !== primary && !socketPaths.has(path.basename(socket)))
socketPaths.set(path.basename(socket), socket);
try {
const [session, window] = (await deps.run(path.basename(socket), ["display-message", "-p", "-t", pane, "#{session_id}\t#{window_id}"], { timeoutMs: 1_500 })).trim().split("\t");
const workspace = fromTmuxId(session ?? ""), tab = fromTmuxId(window ?? "");
return workspace && tab ? { server, workspace, tab, pane: fromTmuxId(pane) } : undefined;
}
catch {
return undefined;
}
}
/** The pane of the client that last did anything on `server`: the phone's
* attach, when it names no pane. */
async function activeClientPane(server) {
const rows = (await tmux(server, ["list-clients", "-F", "#{client_activity}\t#{pane_id}"])).split("\n").filter(Boolean).map(row => row.split("\t"));
const newest = rows.sort((a, b) => Number(b[0]) - Number(a[0]))[0]?.[1];
if (!newest || !fromTmuxId(newest))
throw new BridgeError(409, "No terminal is attached to this tmux server.");
return newest;
}
/**
* Scrolls one pane for a phone whose terminal gets no wheel events of its
* own: a server with `mouse off` (tmux's default) never turns on the phone's
* mouse reporting, and tmux draws on the alternate screen, so the phone has
* no local history either. Does what tmux's default WheelUpPane binding does:
* an app tracking the mouse (Claude, Codex) gets wheel events, and anything
* else scrolls in `copy-mode -e`, which ends by itself at the bottom.
* `lines` > 0 is older output; 0 leaves copy mode so typing reaches the pane.
*/
export async function tmuxScroll(server, pane, lines) {
const target = pane ? toTmuxId(pane, "p") : await activeClientPane(server);
// One scroll per pane at a time: two overlapping swipes could otherwise
// enter and cancel copy mode out of order and leave the pane in it, where
// it would swallow the next typed prompt.
const key = `${server}\0${target}`;
const run = (scrollQueues.get(key) ?? Promise.resolve()).then(() => scrollPane(server, target, lines), () => scrollPane(server, target, lines));
const settled = run.catch(() => undefined);
scrollQueues.set(key, settled);
void settled.then(() => { if (scrollQueues.get(key) === settled)
scrollQueues.delete(key); });
return run;
}
const scrollQueues = new Map();
async function scrollPane(server, target, lines) {
const state = async () => {
const [mode, mouse, sgr, width, height] = (await tmux(server, ["display-message", "-p", "-t", target,
"#{pane_mode}\t#{mouse_any_flag}\t#{mouse_sgr_flag}\t#{pane_width}\t#{pane_height}"])).replace(/\n$/, "").split("\t");
return { mode, mouse: mouse === "1", sgr: sgr === "1", width: Number(width) || 80, height: Number(height) || 24 };
};
const before = await state();
const copy = before.mode === "copy-mode";
if (lines === 0) {
if (copy)
await tmux(server, ["send-keys", "-t", target, "-X", "cancel"]);
return { history: false };
}
// Another mode (the tree chooser, the clock) is the owner's; leave it.
if (before.mode && !copy)
return { history: false };
if (!copy && before.mouse) {
// tmux's `send -M`, as raw bytes: a paste without brackets reaches the
// app as typed input. The pane's middle keeps X10 coordinates ASCII.
const button = lines > 0 ? 64 : 65;
const x = Math.min(95, Math.ceil(before.width / 2)), y = Math.min(95, Math.ceil(before.height / 2));
const event = before.sgr ? `\x1b[<${button};${x};${y}M` : `\x1b[M${String.fromCharCode(32 + button, 32 + x, 32 + y)}`;
const buffer = `phren-scroll-${process.pid}-${Date.now().toString(36)}`;
await tmux(server, ["load-buffer", "-b", buffer, "-"], { input: event.repeat(Math.abs(lines)) });
// Newer tmux shows a paste's control characters as text (ESC as "^[")
// unless -S, a flag older tmux, which pastes them as they are, refuses.
const paste = (flags) => tmux(server, ["paste-buffer", ...flags, "-d", "-r", "-b", buffer, "-t", target]);
await paste(["-S"]).catch(error => /unknown flag|invalid option/i.test(String(error?.message)) ? paste([]) : Promise.reject(error));
return { history: false };
}
if (lines < 0 && !copy)
return { history: false };
if (!copy)
await tmux(server, ["copy-mode", "-e", "-t", target]);
await tmux(server, ["send-keys", "-t", target, "-X", "-N", String(Math.abs(lines)), lines > 0 ? "scroll-up" : "scroll-down"]);
return { history: (await state()).mode === "copy-mode" };
}
/** The command that attaches the phone's SSH terminal to a tmux server. */
export function tmuxAttach(server) {
const binary = deps.binary();
if (!binary)
throw new BridgeError(503, "tmux is not installed on this computer.");
return { file: binary, args: [...socketFlags(socketOf(server)), "attach-session"] };
}
/** For tests: forget the tmux executable lookup and the sockets found by path. */
export function resetTmuxBinary() { cachedBinary = undefined; socketPaths.clear(); }