npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

libtmux

v0.1.0-alpha.12

Published

Typed, Bun-first TypeScript control of tmux: immutable snapshots, declarative queries, and a fully typed session, window, and pane API.

Readme

libtmux

Typed, Bun-first TypeScript control of tmux.

npm downloads typescript tmux dependencies license

Acquire an immutable snapshot of a tmux server, query it with declarative criteria, and drive sessions, windows, and panes with a fully typed API.

Requires tmux 3.2a or newer, Node 22+ or Bun 1.3.14+, and TypeScript 5.7+ — 5.6 and below ship no ES2024 lib, which these declarations are built against.

Linux is the only supported host for real tmux control. The macOS CI lane checks package artifacts without exercising tmux; macOS runtime behavior is unproven. WSL is untested. This is a proof boundary, not an OS rejection.

$ bun add --exact [email protected]

[!WARNING] Alpha. Prerelease software: the API can change between alpha releases without a deprecation cycle, and it is not covered by semantic versioning until the 0.1.0 release itself — 0.1.0-alpha.N comes before it. Pin an exact version, and read the changelog before you upgrade.

Quickstart

import { Server } from "libtmux";

const server = new Server();

const session = await server.newSession({ name: "work" });
const editor = await session.newWindow({ name: "editor" });
await editor.split();

const snapshot = await server.snapshot();
const window = snapshot.windows.where({ name: "editor" }).one();

console.log(window.panes.length); // 2
await window.panes.at(0)?.sendKeys("echo hello");

Start here

The API is small and the pieces stack in one order. Read them in this order and each one uses the last:

  1. Snapshots — server.snapshot() reads the whole server once. Everything else resolves off it without talking to tmux again.
  2. Querying — .where() for declarative criteria, .filter() for a predicate. Both run against the snapshot you already hold.
  3. Operations — newWindow, split, sendKeys: the calls that change something, each resolving to a handle.
  4. Running several at once — plan and batch, once you are making more than a couple of changes.
  5. Watching — server.watch() for what changed rather than what is true, and server.connect() to pair a server with that observer.

A snapshot answers what is true now; a watch answers what changed. Most programs need the first, and reach for the second when they have to react.

Those five are the page, in the order it is written. What follows them — deadlines, engines, observation, options, environments, errors — is depth for when a program needs it, and nothing above depends on any of it.

What this page claims a call costs is measured rather than asserted: examples/observe/ counts the invocations a snapshot and a hundred queries actually make, and runs as a test against a real server.

Also: API reference · Benchmarks

Snapshots

snapshot() is the only call that talks to tmux. It acquires the whole server in one round of commands, and everything reachable from the result resolves locally.

const snapshot = await server.snapshot();

snapshot.sessions; // Selection<Session>
snapshot.windows; // Selection<Window>
snapshot.panes; // Selection<Pane>
snapshot.clients; // Selection<Client>

A snapshot never changes. Acquire again to see later state, and the earlier snapshot keeps reporting its own instant, so a value you captured cannot shift underneath you.

const before = await server.snapshot();
await server.newSession({ name: "later" });

before.sessions.length; // unchanged
(await server.snapshot()).sessions.length; // includes "later"

server.sessions(), windows(), panes(), and clients() each take a snapshot of their own — one invocation apiece, carrying an identity read and four listings — so reaching for several in a row costs several round trips and describes several different instants. In a loop that is an N+1. Take one snapshot() and read the collections off it: they are cheaper together and they agree with each other.

To read one handle at a later instant rather than re-acquiring everything, call refreshed(). It answers with a new handle and leaves the receiver alone, so the snapshot you already hold keeps agreeing with itself — a handle that advanced in place would leave where({ name: "old" }) matching an object whose name had become "new". A window keeps its placement, so one linked into two sessions stays on the one you resolved.

const window = snapshot.windows.one({ name: "editor" });
await window.rename("build");

window.name; // "editor" — the instant this was read at
(await window.refreshed()).name; // "build"

Querying

.where() takes declarative criteria. encodeWhereDocument serializes them in a model-tagged document. .filter() takes an ordinary predicate. They are never overloaded into each other.

snapshot.panes.where({ currentCommand: "vim" });
snapshot.panes.filter((pane) => pane.currentCommand?.startsWith("v") === true);
snapshot.panes.map((pane) => pane.id); // an array, not a Selection

Most programs need equality and a relation: one({ id }) to re-find something they already hold, { session: { is: { id } } } to scope to it. Both consumers in this repository use those and nothing else, and .filter() covers the rest of what a program that built the server itself wants.

The operators below are for the other case — a caller exploring a server it did not build, or one holding a query it did not write: a saved filter, a --where flag, an argument from an agent. That is also what encodeWhereDocument and decodeWhereDocument are for, and why criteria are data rather than a predicate. If you know the ids, you do not need any of this.

Criteria support equality, string operators, AND/OR/NOT, regular expressions as data, and relation quantifiers:

snapshot.sessions.where({
  AND: [
    { name: { startsWith: "prod" } },
    { windows: { some: { name: { regex: { pattern: "^log", flags: "" } } } } },
  ],
});

snapshot.windows.where({ session: { is: { name: "work" } } });

Clients are queryable on the same terms — ClientWhere alongside SessionWhere, WindowWhere, and PaneWhere. tmux gives a client no id of its own, so it is identified by the terminal it occupies, and a control client occupies none:

snapshot.clients.where({ controlMode: "1" });
snapshot.clients.where({ session: { is: { name: "work" } } });
snapshot.clients.one({ tty: "/dev/pts/3" });

Matching is case-sensitive unless you say otherwise:

snapshot.sessions.where({ name: { contains: "API", mode: "insensitive" } });

A Selection is immutable, ordered, and replayable. It is Iterable, but it is not an Array:

selection.length;
selection.at(0);
selection.toArray();
[...selection];

Cardinality helpers each accept optional criteria:

selection.first({ name: "work" });
selection.one({ name: "work" }); // NoMatchError / MultipleMatchesError
selection.oneOrUndefined({ name: "work" });
selection.exists({ name: "work" });
selection.count({ name: "work" });

Every field each model accepts, and the operators that go with them, are in the criteria reference — generated from the same table the compiler matches against, so it lists what .where() actually takes.

Relations

Relations are plain properties, because the data was already acquired. Reading one issues no tmux command.

session.windows; // Selection<Window>
session.panes; // Selection<Pane>
window.session; // Session | undefined
window.linkedSessions; // every session a window is linked into
pane.window;
client.pane;

Server accessors are the async ones, because they acquire:

await server.sessions();
await server.windows();

Field names

Handles read in idiomatic TypeScript. tmux's own token names stay available under format, which is also where you reach fields the shortened names do not cover.

pane.id; // pane_id
pane.currentCommand; // pane_current_command
window.index; // window_index
session.name; // session_name

pane.format.pane_current_command;

Object IDs are authenticated before a snapshot handle or event exposes them. Their brands stop an ID already read as one kind from being mixed with another, while the input types still accept configuration and other raw strings:

import type { PaneId, PaneIdInput } from "libtmux";

const capturedPane: PaneId = pane.id;
const configuredPane: PaneIdInput = "%0";

Field values

tmux has one wire type, and everything on it is text. A field whose shape this port knows is decoded on the way out, so a pid is an authenticated safe integer, a flag is a boolean, and a timestamp is a Date:

pane.panePid; // SafeInteger | null
pane.active; // boolean | null
session.created; // Date | null
window.index; // SafeInteger — an identity tmux always populates

Which fields those are comes from tmux's own format.c, and an integration test holds every one of them to a live server on each version CI runs. Anything else is left exactly as tmux sent it, empty string included.

The text is never lost. format is the row as it arrived:

pane.format.pane_pid; // "2334787"
pane.format.pane_active; // "1"

Criteria take decoded values as well as wire text. A non-null decoded value lowers to its exact wire spelling. A null criterion instead matches every value that field's decoder treats as absent or invalid, including an empty typed field and a time reported as 0. Serialized bytes use stable tmux field names and text values; a decoded WhereDocumentV1 restores camelCase criteria names. The same type therefore covers what you write and what comes back.

import { isSafeInteger, safeInteger } from "libtmux";

snapshot.panes.where({ active: true });
snapshot.panes.where({ active: "1" }); // what a flag encodes to
snapshot.windows.where({ index: 3 }); // a number, written as one
snapshot.panes.where({ pid: "2334787" }); // or its wire text
snapshot.panes.where({ pid: process.pid }); // or one you computed
const candidate = Number(process.env["BUILD_PID"]);
if (isSafeInteger(candidate)) snapshot.panes.where({ pid: candidate });

A numeric criterion takes any number, and a value that is not an exact integer — NaN, an infinity, a fraction, an unsafe integer — raises QueryValidationError naming the field it was written under. That check is at the boundary rather than in the type because the type cannot see a number that arrived at runtime, and making the literal case pay for the runtime one cost every correct query a safeInteger call.

SafeInteger is what decoded fields carry, so a value read off a handle is already proven exact. safeInteger authenticates one from elsewhere and throws when it is not, and isSafeInteger narrows without throwing — useful where you want the refusal at the point the value arrives rather than at the query.

Invalid wire spellings remain type errors:

// @ts-expect-error a flag encodes to "0" or "1"; nothing else is a flag
snapshot.panes.where({ active: "yes" });
// @ts-expect-error and no number encodes to "banana"
snapshot.panes.where({ pid: "banana" });

A string you have at runtime — from a config file, an argument, a raw .format value — says nothing yet about which it is. Say what it means:

const fromConfig = process.argv[2] ?? "";
// A number: refused here, by `safeInteger`, rather than at the query.
snapshot.windows.where({ index: safeInteger(Number(fromConfig)) });
// Or ask about the characters rather than the value, which is what the
// substring operators are for and why they stay plain strings.
snapshot.windows.where({ index: { contains: fromConfig } });

Together, on a server with two windows. This is a literal excerpt of examples/fields/fields.ts, which the integration suite runs against a real tmux server:

const snapshot = await server.snapshot();
const panes = snapshot.panes.where({ session: { is: { name: "fields" } } });

// Numbers arrive as numbers, so geometry is arithmetic rather than parsing.
const area = panes
  .toArray()
  .reduce((total, pane) => total + (pane.width ?? 0) * (pane.height ?? 0), 0);

// Flags arrive as booleans. `"0"` is truthy; `false` is not.
const activeCount = panes.count({ active: true });

// A criterion takes the decoded shape as readily as the text tmux sends.
const pids = panes
  .toArray()
  .map((pane) => pane.panePid)
  .filter((pid) => pid !== null);

// Times arrive as Date.
const created = snapshot.sessions.one({ name: "fields" }).created;
if (created === null) throw new Error("expected tmux to report a creation time");
const sessionAgeMs = Date.now() - created.getTime();

// The text tmux actually sent is still on the row. Each window has an active
// pane, so this narrows to one window before asking for one pane.
const raw = panes.one({ active: true, window: { is: { name: "second" } } }).format.pane_active;
if (raw !== "1") throw new Error(`expected the raw row to hold "1", saw ${JSON.stringify(raw)}`);

Criteria are camelCase too. WHERE documents use tmux's stable spellings so a stored query stays readable by other tools:

import { decodeWhereDocument, encodeWhereDocument } from "libtmux";

const encoded = encodeWhereDocument({
  model: "pane",
  version: 1,
  where: { title: { contains: "log" } },
});
const document = decodeWhereDocument(JSON.parse(encoded) as unknown);
if (document.model === "pane") snapshot.panes.where(document.where);

Use compileBoundedRegex when arbitrary input needs the same bounded regular- expression grammar as selection criteria:

import { compileBoundedRegex } from "libtmux";

const matcher = compileBoundedRegex("^ready-[0-9]+$");
if (!matcher.test("ready-12")) throw new Error("expected a match");

A criterion is spelled like the handle accessor it filters, so a pane reads pane.currentCommand and filters on currentCommand. Only the serialized name is fixed by the schema. A field keeps its prefix in the rare case where dropping it would shadow a relation: sessionWindows is a window count, windows is the windows themselves.

Fields that carry one value for the whole server — version, pid, socketPath — are readable on a handle but are not criteria, since filtering rows by one would match all of them or none.

Operations

Every handle exposes the tmux commands that apply to it. Each returns a promise; those that create something return a handle to it.

Everything else resolves to nothing, and leaves the handle reading the instant it was acquired at. That is deliberate — a handle that advanced in place would leave a snapshot disagreeing with itself — so a mutation you need the result of is two calls:

await window.rename("build");
window.name; // still "editor": the instant this handle was read at
(await window.refreshed()).name; // "build"

Sessions, windows, and panes:

import { isSplitSize, isTmuxName, splitSize } from "libtmux";

await server.validateLayouts([{ layout: "even-h", panes: 2 }]);
const session = await server.newSession({ name: "work" });
const requestedName: unknown = "editor";
// A name holding `:` or `.` is refused: tmux rewrites it before 3.7, fails on
// 3.7, keeps it after, and it cannot be addressed as a target on any of them.
if (!isTmuxName(requestedName)) throw new TypeError("invalid window name");
const window = await session.newWindow({ name: requestedName });
const requestedSize: unknown = "30%";
if (!isSplitSize(requestedSize)) throw new TypeError("invalid split size");
const pane = await window.split({ size: requestedSize, startDirectory: "/srv" });
await pane.split({ size: splitSize(20) });

const active = session.activePane; // the active window's active pane
const focused = window.activePane;
await window.rename("build");
await window.selectLayout("even-horizontal");
await window.nextLayout();
await window.previousLayout();
await window.rotate(); // panes move through the layout; the layout stays
await pane.pasteBuffer("scratch");
await window.select();
await session.selectWindow("build");
await pane.resize({ width: 100 });
await pane.zoom();
await pane.unzoom();
await window.unzoom();
await pane.setTitle("build output");
await pane.kill();

zoom and unzoom set a state rather than flipping one. tmux offers only resize-pane -Z, which toggles, so each call asks tmux to evaluate #{window_zoomed_flag} and toggle in the same command — idempotent, one invocation, and no window in which someone else's resize changes the answer between the read and the write. Any ordinary resize unzooms first, so a size and a zoom cannot both hold.

direction says which side a split lands on, which window a new one sits beside, and lets a resize adjust rather than set. tmux reaches "above" and "left" only by pairing an axis with -b, so a boolean cannot express them:

import { PaneDirection, ResizeAdjustmentDirection, WindowDirection } from "libtmux";

await pane.split({ direction: PaneDirection.Above });
await pane.split({ direction: PaneDirection.Left });
await session.newWindow({ direction: WindowDirection.Before, name: "logs" });
await pane.resize({ amount: 5, direction: ResizeAdjustmentDirection.Down });

A window's placement is measured from the session's selected window, since the command addresses the session. shellCommand runs something other than the default shell. tmux hands it to a shell, so a whole command line works, and it replaces the shell rather than running inside it — when it exits the window or pane closes, unless remain-on-exit says otherwise:

await session.newWindow({ name: "server", shellCommand: "npm run dev" });
await window.split({ shellCommand: "tail -f log/development.log" });
await server.newSession({ name: "ci", shellCommand: "just watch" });

Moving and linking windows between sessions:

await window.swapWith(other);
await other.move({ index: 2, session: "other" });
await window.link({ session: "other" }); // one window, in two sessions
await pane.breakOut();
await pane.joinTo("other:1");
await pane.swapWith(otherPane);

Each of those takes a handle as readily as a string, and a handle is checked against this object's server before the command runs:

const owner = await server.newSession({ name: "handle-targets" });
const shared = await owner.newWindow({ name: "shared" });
const guest = await server.newSession({ name: "handle-guest" });

await shared.link({ session: guest }); // the session, not its name
await owner.selectWindow(shared); // the window, not its index

const left = await owner.newWindow({ name: "left" });
const right = await owner.newWindow({ name: "right" });
await right.panes.one().joinTo(left.panes.one());

A tmux id is unique only within one running daemon, so @1 exists on every server that has a window. Passing a handle from a different server raises TypeError rather than addressing whatever holds that id here — which is what a bare id cannot be checked for, since a string carries no server.

A handle names a placement rather than a window, because one window can sit in two sessions at once. Moving a window leaves the handle pointing at a placement that no longer exists, so read the moved window back from a fresh snapshot instead of reusing the handle that moved it.

Unlink the placement resolved in the session you intend to remove:

const destination = await server.newSession({ name: "unlink-example" });
await window.link({ session: destination.id });
const placement = (await server.snapshot()).windows.one({
  id: window.id,
  session: { is: { id: destination.id } },
});
await placement.unlink();

removePlacement unlinks a linked placement or destroys the final ungrouped window. It refuses a grouped session, whose members share one window list:

await window.removePlacement();

killIfWindowUnshared kills a pane only while its window has one placement:

await pane.killIfWindowUnshared();

Pane input and contents:

await pane.sendKeys("ls -la");
await pane.sendKeys("q", { enter: false, literal: true });
await pane.sendPrefix();
const lines = await pane.capture({ start: -100 });
await pane.clearHistory();
await pane.respawn("htop", { kill: true });
await pane.displayMessage("#{pane_current_command}");

Copy mode needs no client, so a detached pane enters and leaves it like any other:

await pane.enterCopyMode();
await pane.exitCopyMode();

The choosers and popups all need a client, and they divide on what awaiting one means. A popup and a menu stay open until someone dismisses them, and tmux answers only then, so the promise reports that it is over:

await pane.displayPopup("less README.md");
await pane.displayMenu("Pane", [{ command: "kill-pane", key: "k", name: "Kill" }]);

The rest resolve as soon as the chooser is on screen. Awaiting one tells you it opened, never what was chosen — measured on tmux 3.7c, where choose-tree returns in milliseconds against the seconds a menu spends waiting to be dismissed. Bind a tmux command to the selection if you need to act on it:

await pane.chooseTree();
await pane.chooseBuffer();
await pane.findWindow("editor");
await pane.customizeMode();

Server-wide commands and paste buffers:

await server.hasSession("work");
await server.sourceFile(`${process.env["HOME"] ?? "."}/.tmux.conf`); // tmux does not expand `~`
await server.listCommands();
await server.runShell("echo hi");
await server.ifShell("[ -d /srv ]", "display-message ok");

await server.setBuffer("scratch", "text");
await server.loadBuffer("scratch", await Bun.file("payload.bin").bytes());
await server.showBuffer("scratch");
await server.showBufferBytes("scratch");
await server.listBuffers();
await server.deleteBuffer("scratch");
await server.kill();

Clients, and re-reading a handle:

await client.switchTo(session);
await client.detach();
await session.detach();

const later = await pane.refreshed(); // a new handle, at a new instant
pane.equals(later); // true — the same pane, read twice
pane.sameTmuxIdAs(later); // same `%n`, wherever it came from

Every handle describes itself when logged or interpolated, rather than rendering as [object Object]. toString and the runtime inspect hook give the same text, so console.log is useful without reaching for a field:

pane.toString(); // Pane(%1 Window(@1 0:editor, Session($0 work)))
window.toString(); // Window(@1 0:editor, Session($0 work))
server.toString(); // Server(/tmp/tmux-1000/default)
console.log(`${pane} stopped responding`);

refreshed answers with a new handle rather than advancing this one, so use what it returns. equals compares the connection and daemon generation as well as the tmux id, because %1 is unique only within one running daemon — two servers both have one, and so does the pane that replaced it after a restart. sameTmuxIdAs is the raw-id comparison when that is genuinely what you want. Every handle also has showOptions / setOption / unsetOption, and format for tmux's own field names. Pane.pipeTo streams a pane to a command for as long as the pipe is open, and Server.saveBuffer writes a buffer to a file without bringing it back through this process. Those read one object's own view: a session that has set nothing reports nothing, while the values actually governing it are tmux's global defaults. Server.showGlobalOptions, setGlobalOption and unsetGlobalOption reach those, taking "session" or "window" for which table — history-limit and default-shell live in the first, remain-on-exit in the second, and none of the three is readable any other way. showResolvedOptions answers the other question: what governs this object, with its own values and everything it inherits resolved into one map.

Commands this package does not model

tmux has far more commands than any wrapper types. cmd runs one through the same path as everything else — same socket, environment, deadline, and errors — so reaching an unmodelled command never means building your own subprocess:

await server.cmd("list-keys", ["-T", "copy-mode"]);
await pane.cmd("clock-mode");
await window.cmd("rotate-window");
await server.cmd("display-message", ["-p", "#{version}"], { target: null });

A handle sends its own id as the target, so pane.cmd("clock-mode") addresses that pane. Pass target to address something else, or null for a command that takes none. Failure raises TmuxCommandError carrying tmux's own stderr.

Running several commands at once

Creating things one at a time costs two tmux invocations per mutation: one runs its command and one captures the snapshot needed to find what it made. That second one is what makes the result a handle rather than an id — tmux prints the new id, but a handle is only meaningful inside a consistent graph, and acquiring one is the whole server. A batch shares a single final snapshot across all of its mutations, so twelve windows cost 14 invocations rather than 25 — twenty-four against thirteen for the creations themselves, plus in each case the one query that reads the result back. The benchmarks measure both. The invocation counts hold anywhere; the snapshot's wall-clock grows with the server, at about 1.6 ms per pane on the machine that page reports.

Reach for cmd where you want the id and not the handle — server.cmd( "new-window", ["-d", "-P", "-F", "#{window_id}"]) is the one invocation, and its printed line is a target string every other method accepts.

plan describes a mutation instead of running it. It takes what the direct call takes and resolves to what the direct call resolves to; batch runs the planned commands in order and resolves all of them from one final snapshot:

const [first, second] = await server.batch([
  session.plan.newWindow({ name: "editor" }),
  session.plan.newWindow({ name: "logs" }),
]);

first and second are Window handles, typed one by one — a batch mixing kinds keeps each element's own type, so a planned split comes back as a Pane with Pane methods on it:

const [created] = await server.batch([editor.plan.split()]);
const lines = await created.capture();

For a command this package does not model, or one whose output you want raw, pipeline takes arguments and returns a result per command, in order — so a creating command's -P -F lands where you asked for it and a silent command stays empty rather than shifting the one behind it:

const [[madeWindow], , [madeOther]] = await server.pipeline([
  ["new-window", "-d", "-P", "-F", "#{window_id}", "-n", "editor"],
  ["set-option", "-g", "status", "off"],
  ["new-window", "-d", "-P", "-F", "#{window_id}", "-n", "logs"],
]);

It is not atomic. tmux runs the commands in order and stops at the first failure, leaving everything before it applied; the TmuxCommandError names the command that failed rather than the whole sequence. Take a snapshot() afterwards when you need to know what survived.

Each command is a separate tmux invocation. Arbitrary command output has no delimiter that a command alias cannot replace or print, so one combined stream cannot promise positional results.

Watching

A snapshot answers what is true now. server.watch() answers what changed, over one persistent tmux -C connection rather than a command per read.

Each watch() or connect() owns a real attached tmux client until it closes. That client appears in snapshots, increments session_attached, and is visible to client hooks and attachment-sensitive policy such as destroy-unattached. Multiple watches or connections create multiple clients.

await using events = server.watch({ target: session.id });
await events.ready(); // attached; changes from here on are announced

for await (const event of events) {
  if (event.kind === "window-add") console.log("opened", event.windowId);
  if (event.kind === "output") process.stdout.write(event.data);
}

Events are a discriminated union, so event.kind narrows the rest of the shape with no cast. Names are tmux's own, without the leading %, and a notification this version does not model arrives as { kind: "unknown", name, args } rather than being dropped.

The stream is an AsyncDisposable, so await using ends the tmux process when the scope exits, including on a thrown error. A consumer that falls behind gets its oldest events dropped rather than an unbounded buffer; events.dropped counts them and bufferSize sets the bound.

That bound is this side's. tmux keeps its own, and its remedy for a client that lets a pane's output back up is to kill it — five minutes behind and the whole connection goes with too far behind. pauseAfterSeconds asks tmux to pause the one pane instead:

await using paced = server.watch({ pauseAfterSeconds: 5 });

tmux then reports pause for the pane it stopped, the connection asks it back at once, and continue follows. The pair is a record of what was missed, not something to act on.

watch() is the notification observer and owns this pacing policy. connect() adds the same event channel and daemon-lifetime tracking to a server; its commands still use ordinary tmux processes.

A connection attaches, so it needs a session to attach to. Connecting to a server with none fails at connect() with tmux's own words rather than through whichever command runs first:

await server.connect(); // LibTmuxError: ... could not attach: no sessions

tmux sends a control client no pane output until it attaches, so watching attaches to a session. A server with no sessions has nothing to watch, and a client hears about the session it attached to — name the one you care about with target rather than relying on whichever tmux considers most recent.

await using needs Symbol.asyncDispose, so a consumer's lib includes ESNext.Disposable alongside its ECMAScript target. events.close() is the same operation for a project that cannot add it.

Subscribing to a format

A snapshot reads a format when you ask for it. A subscription reports it when it changes:

await using events = server.watch({
  subscriptions: [{ format: "#{pane_current_command}", name: "cmd", scope: "all-panes" }],
});

const report = await events.find((event) => event.kind === "subscription-changed");
if (report?.kind === "subscription-changed") console.log(report.name, report.value);

tmux expands each subscription at most once per second and reports only when the value differs from the last report for that object, so a report is a change rather than a sample. The first evaluation always reports.

scope takes a pane or window id, "all-panes", or "all-windows"; omitted, the format expands once per session. A report names the object it expanded against, so a pane-scope one carries paneId, windowId and windowIndex.

A connected server adds and drops them while it runs with subscribeFormat and unsubscribeFormat. A subscription belongs to the control client, so the connection re-issues its own after a reconnect.

Waiting for something to happen

Most work is "do this, then wait until that". waitFor is the join between a snapshot and the stream, and it subscribes before it reads — so a change that lands in between is still seen, rather than leaving you waiting on a condition that already came true:

await using live = await server.connect();

await session.newWindow({ name: "build" });
const settled = await live.waitFor((server) => server.windows.exists({ name: "build" }));

For a single event rather than a state, find takes the first one that matches and gives up on a deadline:

const opened = await live.subscribe().find((event) => event.kind === "window-add");

Each subscribe() is an independent view with its own buffer, so a loop and a waitFor can run side by side without taking each other's events.

Commands beside a connection

watch() opens a connection for notifications only. connect() returns a server paired with that observer. Commands remain process-boundary invocations because control mode cannot delimit arbitrary alias-expanded or waiting command output truthfully:

await using live = await server.connect();

for await (const event of live.subscribe()) {
  if (event.kind !== "window-add") continue;
  const snapshot = await live.snapshot(); // one atomic identity-plus-four-listings invocation
  console.log(snapshot.windows.count());
}

A connection can reopen itself after a drop with watch({ reconnect: { attempts: 3 } }), which reports a reconnected event so the gap is visible. The count bounds one outage rather than the connection's whole life, so a watcher that recovers today still recovers next week. It is off by default. A connected server refuses to start commands while its observer is reconnecting; after the attach, it binds each new command to the daemon carrying that observer. It never replays a mutation.

The API is the same Server. Connecting adds the observer and daemon binding; commands, including loadBuffer, still use the server's engine.

Recipes

Wait for a pane to print something

Polling a pane either misses output between reads or spends a command on each one. Subscribe first, then act, so a line printed while you were still connecting is not lost:

await using events = server.watch({ target: session.id });
await events.ready();

await pane.sendKeys("make build");

let seen = "";
for await (const event of events) {
  if (event.kind !== "output" || event.paneId !== pane.id) continue;
  seen += event.data;
  if (seen.includes("BUILD OK")) break;
}

Name the session to watch when reading pane output. Structural notifications — a window opening, a session renaming — reach a control client wherever it is attached, but tmux sends pane output only for the session that client attached to, and an untargeted watch() lands on whichever session tmux considers current. On a server with one session that is the one you meant; on a server with two it is silence rather than an error.

A pane echoes what is typed into it, so waiting for text that also appears in the keys you just sent matches the echo rather than the output. Wait for something the command prints.

Act, then wait, with an observer

The loop an agent runs. connect() pairs commands through the server engine with notifications from a persistent observer. Subscribe before sending so a marker printed in between is still seen.

This recipe is a literal excerpt of examples/agent/agent.ts, which the integration suite runs against a real tmux server:

const session = await server.newSession({ name: "agent" });

await using live = await server.connect({ target: session.id });

const pane = (await live.snapshot()).sessions.one({ id: session.id }).panes.one();

const printed = live
  .subscribe()
  .find(
    (event) => event.kind === "output" && event.paneId === pane.id && event.data.includes(marker),
    { timeoutMs: 30_000 },
  );

await pane.sendKeys(command);

const event = await printed;

Build a workspace

One session, a window per concern, each pane already running its process. The first window is named as the session is created, so the workspace does not open with a stray shell nobody asked for:

const built = await server.newSession({
  name: "work",
  shellCommand: "nvim",
  windowName: "editor",
});

const [logs, shell] = await server.batch([
  built.plan.newWindow({ name: "logs", shellCommand: "journalctl -f" }),
  built.plan.newWindow({ name: "shell" }),
]);

await logs.selectLayout("even-horizontal");
shell.name; // "shell"

Drive a pane and read what it said

await pane.sendKeys("git status --short");

const lines = await pane.capture();
const changed = lines.filter((line) => line.trim() !== "").length;

capture() reads the visible pane; pass start to reach into the scrollback. sendKeys presses Enter unless you say otherwise, and takes keys literally with { literal: true } when the text could be read as a tmux key name. The keys and the Enter travel as two commands in one tmux invocation, so nothing interleaves between them and tmux resolves each against the pane as it finds it — a key that leaves copy mode is followed by an Enter the pane itself receives.

Watch for a change and react to it

waitFor subscribes before it reads, so a change landing between the two is still seen rather than leaving you waiting on something that already happened:

await using connected = await server.connect();

await session.newWindow({ name: "build" });
const settled = await connected.waitFor((current) => current.windows.exists({ name: "build" }));

settled.windows.count({ name: "build" }); // 1

For a single event rather than a state, find takes the first match and answers undefined on its deadline. It raises instead when the connection ends, so the undefined a caller reports on is only ever the thing not happening:

const opened = await live.subscribe().find((event) => event.kind === "window-add", {
  timeoutMs: 5_000,
});

Seeing what it runs

onInvocation is called once per tmux invocation, after it answers or fails — the seam for logs, traces and metrics, so watching the library does not mean supplying a whole engine:

import type { TmuxInvocationReport } from "libtmux";

const reports: TmuxInvocationReport[] = [];
const traced = new Server({
  socketPath: server.socketPath,
  onInvocation: (report) => reports.push(report),
});
await traced.snapshot();

reports[0]?.commands[0]?.[0]; // "display-message", the identity read
reports[0]?.durationMs;

TmuxInvocationReport is exported for a typed collector. Every command reports, including the identity read and four listings behind snapshot(), the version probe, and commands a custom engine executed. The report carries the commands tmux received, durationMs for the invocation itself, queuedMs for any wait under maxInFlight, exitCode when tmux answered, and error with delivery when it did not.

An observer cannot change what a command does. Anything it throws is swallowed: a command must not fail on account of the code watching it. An async observer is allowed and is never awaited — the invocation is already decided, so waiting would only delay it — and a rejection is swallowed on the same grounds rather than left to the host's unhandled rejection policy.

examples/observe/ runs this against a real server, and counts its own reports to check the two costs above rather than restate them.

Deadlines and cancellation

Every command gets 30 seconds unless the server or the call says otherwise. Change it for the whole server, lift it with null, and override or cancel a single call:

const server = new Server({ timeoutMs: 10_000 });
const patient = new Server({ timeoutMs: null }); // waits as long as tmux takes

const controller = new AbortController();
const lines = pane.capture({ signal: controller.signal, timeoutMs: 30_000 });
controller.abort(new Error("the caller moved on"));
// Rejects with a TmuxTransportError carrying kind: "cancelled", and your own
// reason as its `cause`.
await lines.catch(() => undefined);

A cancelled or timed-out command always rejects with a TmuxTransportError rather than with the reason itself, because the error also says how far the command got — delivery distinguishes one tmux never started from one it may have run. What you passed to abort() is preserved as cause.

Every operation that takes options accepts both, so the rule you learn on one method holds on the next. The default applies to every command the server runs, including the version probe it makes first and the identity read plus four listings behind snapshot() — except a popup, a menu, a prompt, a confirmation, display-panes and wait-for. Those wait on a person or on another command, and a default would cut them off; a deadline passed on the call still binds them. signal is typed structurally, so a real AbortSignal satisfies it without the published types requiring a DOM or Node library.

Choosing how work is arranged

Commands travel through the server's engine. Three independent choices compose around that execution path:

| Mode | Turn it on | What changes | When to use it | | -------------- | ---------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------- | | connected | await server.connect() | Adds notifications and connection-lifetime tracking. | A server-shaped API in a loop that also reacts to events. | | watching | server.watch() | Yields tmux's notifications as they happen. | Reacting to a change without issuing commands through that value. | | planned | .plan instead of the direct call | Shares one final snapshot across server.batch([…]). | Creating or changing several things in order. | | concurrent | Promise.all | Independent commands overlap, up to maxInFlight. | Slow work on independent targets — not ordering-sensitive setup. |

connect() hands back the same handles as the base server and adds an event observer. Its commands still use the server engine and process boundaries.

How many commands run at once

Every invocation is a tmux client process with its own pipes, so a Promise.all over a whole server starts that many processes. maxInFlight bounds them, and defaults to 16:

const bounded = new Server({ maxInFlight: 4 });
bounded.tmuxBin;

The ceiling costs no throughput, because there was none to lose: tmux runs commands on one thread. The benchmarks show Promise.all over twelve creations costing the same twenty-five processes as doing them in order, and arriving out of order in two or three of every three runs. Fanning out buys no work — pipeline does the same twelve in thirteen. Both totals count the query that reads the result back, which is why neither is a multiple of twelve.

Waiting for a slot spends the request's own deadline rather than extending it. A request that never gets one raises TmuxTransportError with delivery of "not_started" — the one status a mutation may retry blindly — and signal cancels the wait as it cancels the command.

Supplying an engine

An engine can put tmux behind SSH, a container, or a daemon. It receives the complete invocation, including environment, stdin, cancellation, and deadline. This runnable example keeps execution local on the current server's socket; a remote runner has the same obligations:

import { Server, TmuxServerRestartedError } from "libtmux";
import { flattenInvocation, guardRequest } from "libtmux/engine";
import type { TmuxCommandResult, TmuxEngine, TmuxInvocationRequest } from "libtmux/engine";

/** `run` is yours: execute one complete request and return what tmux wrote. */
function engineOver(
  endpoint: string,
  run: (request: TmuxInvocationRequest) => Promise<TmuxCommandResult>,
): TmuxEngine {
  return {
    // Where this reaches tmux. A socket path on another machine is not an
    // address, so without this two servers on the same path compare equal.
    endpoint,
    async execute(request) {
      // A command addressed by id must not run on a daemon that reissued that
      // id. This plan keeps the wrapper and its exact refusal detector together
      // so an engine inherits restart safety instead of rebuilding it.
      const guarded = guardRequest(request);
      const result = await run(guarded.request);
      if (guarded.refusedBy(result.exitCode, result.stderr)) {
        throw new TmuxServerRestartedError("the daemon this handle was read from is gone");
      }
      return result;
    },
  };
}

const socketPath = server.socketPath;
if (socketPath === undefined) throw new Error("this example needs a socket path");
const tmuxBin = server.tmuxBin;

const throughEngine = new Server({
  engine: engineOver(`local://${socketPath}`, async (request) => {
    const argv = [request.executable, ...flattenInvocation(request)];
    const controller = new AbortController();
    const abort = (): void => controller.abort();
    request.signal?.addEventListener("abort", abort, { once: true });
    if (request.signal?.aborted === true) abort();

    try {
      const child = Bun.spawn([tmuxBin, "-S", socketPath, ...argv.slice(1)], {
        ...(request.environment === undefined ? {} : { env: request.environment }),
        signal: controller.signal,
        stderr: "pipe",
        stdout: "pipe",
        stdin: request.stdin === undefined ? "ignore" : "pipe",
        ...(request.timeoutMs === undefined ? {} : { timeout: request.timeoutMs }),
      });
      if (request.stdin !== undefined && child.stdin !== undefined) {
        await child.stdin.write(request.stdin);
        await child.stdin.end();
      }
      const [exitCode, stdout, stderr] = await Promise.all([
        child.exited,
        new Response(child.stdout).arrayBuffer(),
        new Response(child.stderr).arrayBuffer(),
      ]);
      return {
        cmd: argv,
        exitCode,
        signal: child.signalCode,
        stderr: new Uint8Array(stderr),
        stdout: new Uint8Array(stdout),
      };
    } finally {
      request.signal?.removeEventListener("abort", abort);
    }
  }),
});

(await throughEngine.snapshot()).sessions.count();

The seam is one structured invocation in and bytes plus status out. One at the graph would make every implementer responsible for framing, capability gating and normalization; this one leaves them responsible only for execution. The request carries global flags and a nonempty command list separately, so an engine cannot accidentally split a snapshot into several clients. Honour its daemonGuard with guardRequest, or bind the engine to one daemon.

watch() and connect() are the two calls an engine does not carry. Both hold a local tmux -C attach process open, which is the thing an engine exists to avoid needing, so they refuse rather than attaching to whichever tmux this machine happens to be running.

For a connection scoped to one block, withConnection closes it on the way out whether the body returns or throws — useful where await using is not available, since it needs Symbol.asyncDispose in the consumer's lib:

const built = await server.withConnection(async (connected) => {
  await session.newWindow({ name: "build" });
  return connected.waitFor((current) => current.windows.exists({ name: "build" }));
});

pipeline and batch stay per-call-site choices because both stop at the first failure. batch adds one final snapshot that turns printed ids into handles; pipeline returns the printed lines directly.

Use pipeline or batch when command order matters; a connection does not make concurrent mutations safe to reorder.

Options and hooks

// A pane keeps history-limit lines and a stream reader keeps a bounded buffer,
// so output larger than either is gone before anything asks. tmux can send it
// somewhere durable instead, and write a buffer out without reading it back.
await pane.pipeTo("cat >> /tmp/build.log");
await pane.pipeTo();
await server.saveBuffer("captured", "/tmp/captured.txt");

await session.setOption("status-left", "[work] ");
(await session.showOptions()).get("status-left");
await session.unsetOption("status-left");

// A handle reports its own view. A session that has set nothing reports
// nothing, while the defaults governing it are tmux's global tables.
(await server.showGlobalOptions("session")).get("history-limit");
await server.setGlobalOption("window", "remain-on-exit", "on");
await server.unsetGlobalOption("window", "remain-on-exit");

// Or ask what governs one object, own and inherited resolved together.
(await session.showResolvedOptions()).get("history-limit");
(await window.showResolvedOptions()).get("main-pane-width");
(await pane.showResolvedOptions()).get("allow-rename");
(await server.showResolvedOptions()).get("message-limit");

// A hook holds a list of commands; a write replaces it unless it appends.
await server.setHook("after-new-window", "display-message created");
await server.setHook("after-new-window", "display-message again", { append: true });
await server.showHooks();
await server.unsetHook("after-new-window");

Typed option and hook methods treat names as literal data, including #. Use cmd when deliberately asking tmux to expand a computed name.

Window and pane scopes report only what was set on them, never inherited values. OptionScope supplies the same lowercase scope values when a named constant is clearer than a string literal.

Environments

A session's environment is what tmux hands to the processes it starts, so it is how you seed a variable for every pane a workspace creates. Server carries the global environment and Session its own:

await session.setEnvironment("DATABASE_URL", "postgres://localhost/dev");
await session.getEnvironment("DATABASE_URL");
await session.showEnvironment();
await session.unsetEnvironment("DATABASE_URL");
await session.removeEnvironment("SSH_AUTH_SOCK");
await server.setEnvironment("EDITOR", "vim");

A variable is in one of three states, which is why the value type is EnvironmentValue — string | null — and lookups can also answer undefined:

| State | getEnvironment | In showEnvironment | | --------------------------------------- | ---------------- | -------------------- | | Set to a value | the value | present | | Marked for removal from child processes | null | present | | Not carried at all | undefined | absent |

removeEnvironment is the middle row and unsetEnvironment is the last: the first leaves an entry saying "unset this before spawning", which is how tmux keeps a stale SSH_AUTH_SOCK out of a new pane. Removal marks are routine rather than exotic — tmux's own update-environment puts several in every session. SetEnvironmentOptions covers tmux's -F and -h as expandFormat and hidden, and EnvironmentScope names the two scopes.

Errors

A failing tmux command raises TmuxCommandError, which carries its parts so you can branch without parsing a message:

import { TmuxCommandError } from "libtmux";

try {
  await pane.capture();
} catch (error) {
  if (error instanceof TmuxCommandError) {
    error.args; // the argument vector
    error.exitCode;
    error.stderr; // tmux's own lines
    error.target; // the -t target, when there was one
  }
}

A command that never got an answer raises TmuxTransportError instead. It says what failed between this process and tmux, and — the part that matters for a mutation — how far the command got:

import { TmuxTransportError } from "libtmux";

try {
  await session.newWindow({ name: "build" });
} catch (error) {
  if (error instanceof TmuxTransportError) {
    error.kind; // "cancelled" | "contract" | "pipe" | "protocol" | "spawn" | "timeout"
    error.delivery; // "not_started" | "written" | "replied" | "indeterminate"
    error.stdout; // whatever arrived before the failure
  }
}

delivery is the question a retry depends on. not_started is the only value that is safe to retry blindly; after a timeout the answer is indeterminate, because tmux may well have created that window before the pipe went quiet. Every path reports the same type, so a timeout during snapshot() is not a different shape from a timeout during kill().

A handle that outlived its daemon raises TmuxServerRestartedError. tmux numbers a restarted daemon's objects from the start, so a %1 read before the restart names a pane that exists and belongs to somebody else — and a socket path is a place, not a process:

import { TmuxServerRestartedError } from "libtmux";

try {
  await pane.kill();
} catch (error) {
  if (error instanceof TmuxServerRestartedError) {
    error.delivery; // always "not_started" — a refused command never ran
  }
}

The refusal is tmux's, not a check this library made first and hoped would still hold: a command carrying a raw id goes as if-shell -F conditioned on the daemon's pid and start time, and tmux evaluates that inside the same command queue entry that would run it. The connected observer invalidates its runtime when that daemon disappears. Reading a stale handle's captured fields still works; only commands are refused.

An unreachable server raises rather than reading as empty, so an empty result means exactly one thing. Ask without raising when you need to:

await server.isAlive(); // false for a missing daemon, socket, or binary
await server.checkAlive(); // the assertion form

Library failures extend LibTmuxError; invalid arguments may throw native TypeError. Every one carries code, a string naming its class that survives a bundler renaming identifiers — the check to write where instanceof cannot reach:

try {
  await server.snapshot();
} catch (error) {
  if (error instanceof Error && "code" in error && error.code === "TmuxTransportError") {
    // the command never got an answer
  }
}

A query that matches nothing raises NoMatchError, one that matches several where you asked for one raises MultipleMatchesError, and criteria the schema rejects raise QueryValidationError. A waitFor that reaches its deadline with the condition still unmet raises WaitTimeoutError, which is worth catching by name: it says the state never arrived, where a LibTmuxError from the same call says only that the connection ended and nothing about the condition. A criterion naming a field newer than the tmux that answered raises VersionTooLowError rather than matching nothing — the error names the field, the release that has it, and the release running, because "no pane has this" and "your tmux has never heard of this" are different answers.

Two bases sit above those. Catch ObjectNotFoundError for "not there, however you asked": a query that matched nothing (NoMatchError) and tmux refusing a target that does not exist (TmuxObjectNotFoundError) both extend it. MultipleObjectsError is its counterpart and has one subclass today, MultipleMatchesError; it stays so the pair reads the same, and so code ported from Python, which catches ObjectDoesNotExist and MultipleObjectsReturned, has a name to catch. Import library errors from libtmux or libtmux/errors.

libtmux/selection carries parseLegacyWhere, which converts Python-style name__contains= filter strings into criteria. It is for code being ported rather than written fresh, so it sits on the subpath rather than in the root export, as the engine and type inventories do.

Migrating from alpha.9

Use these error names. The old names remain deprecated aliases to the same constructors, so existing instanceof checks still work. error.name reports the canonical name. The libtmux/exc entrypoint remains a compatibility export; use libtmux/errors for new imports.

QueryValidationError.code is now reason, because every error now carries a code naming its own class.

| Previous name | Canonical name | | ------------------------- | -------------------------- | | LibTmuxException | LibTmuxError | | ObjectDoesNotExist | ObjectNotFoundError | | MultipleObjectsReturned | MultipleObjectsError | | TmuxObjectDoesNotExist | TmuxObjectNotFoundError | | VersionTooLow | VersionTooLowError | | WaitTimeout | WaitTimeoutError | | TmuxServerRestarted | TmuxServerRestartedError |

Completed command results, including custom TmuxEngine results, use exitCode instead of returncode. Update result construction and property reads together. TmuxCommandError.exitCode is unchanged. Transport failures retain their kind, delivery, partial output and optional signal; they have no completed exit code. A TmuxEngine that resolves without a numeric exitCode raises TmuxTransportError with kind: "contract" rather than being read as a failed command.

Use server.checkAlive() in place of the deprecated server.raiseIfDead(). Both reject when the server cannot be reached.

The compatibility call:

await server.raiseIfDead();

The canonical call:

await server.checkAlive();

For pane.split(), window.split() and planned splits, replace vertical: true with direction: PaneDirection.Below, and vertical: false with direction: PaneDirection.Right. Omitting both still splits below. The legacy vertical option is deprecated; passing it with direction throws TypeError before executing a command.

Running inside tmux

import { Session } from "libtmux";

const session = await Session.fromEnv();

The pane is authoritative: $TMUX carries a session id that goes stale when a pane moves, so the session is resolved through $TMUX_PANE.

Dependencies

None. The package installs a tmux client and nothing else — no third-party code to audit, pin, or deduplicate, and no transitive tree at all.

Validation of tmux's own output is real work, so it did not go away: it lives in a small internal validator with a Zod-shaped surface. Its failures carry the value that failed, which Zod reports only by type.

Consumers

Two working consumers live in this repository:

  • packages/mcp — an MCP server exposing tmux through this library. Its waits stream tmux's notifications rather than polling, and its run_shell_command frames what it sends so a pane's echo cannot be read as output.
  • packages/workspace — a tmuxp-shaped workspace builder. Applying a workspace twice converges the running session rather than duplicating it.

Examples

See examples/. Every example is executed by the integration suite.

Every TypeScript block in this README is typechecked against the working tree by bun run typecheck:readme, each one on its own, so a snippet here cannot drift from the signature it documents.

bun run typecheck:ambient-free compiles a consumer against the emitted declarations with no ambient types in scope — no @types/node, no DOM — so the published types cannot start requiring either without the gate saying so.

Entrypoints

The root export carries the everyday handle, query, operation, error, and constant surface. Each model is also its own subpath: libtmux/server, libtmux/session, libtmux/window, libtmux/pane, libtmux/client, and libtmux/selection. Supporting subpaths are libtmux/formats, libtmux/constants, libtmux/common, and libtmux/errors.

libtmux/testing records a real server's answers and replays them, so a consumer's own unit tests need no tmux:

import { recordInvocations, replayInvocations } from "libtmux/testing";

// Once, against real tmux: wrap the engine and keep what it answered.
const recorder = recordInvocations();
const recording = new Server({ engine: recorder.engine, socketPath: server.socketPath });
await recording.snapshot();

// Ever after, with no server, no binary and no daemon.
const replayed = new Server({ engine: replayInvocations(recorder.recording()) });
(await replayed.snapshot()).sessions.count();

A recording is JSON and replays a run rather than answering a query: the same calls in the same order get the same answers, and one the recording never saw raises rather than reporting an empty server. Record guards are rewritten to the ones each replayed call asks for, so a fixture is not tied to the run that made it.

Four specialist subpaths keep their complete contracts out of the root: libtmux/engine for custom execution engines, libtmux/types for the full operation and event type inventory, and libtmux/field-types for generated format-field, decoded-value, and handle-alias types.

tmux versions

The package targets the same range as the Python library, and the format registry withholds newer fields from an older server, so each version is asked only for the fields it has.

bun run test:compat runs the real-tmux gates against every build in LIBTMUX_TMUX_BUILDS (a directory of prefixes) plus whatever is on PATH, and says so when it only found one. CI runs them against both ends of the range and a release in between, since a difference between two releases is only visible when both are present.

To gate your own code on a version, ask the server rather than parsing #{version} yourself. The version is probed once per connection and cached, so asking repeatedly costs nothing:

const version = await server.version(); // { major, minor, suffix, raw }

if (await server.versionAtLeast("3.4")) {
  await pane.customizeMode();
}

raw is tmux's own string, so a development form such as next-3.8 survives the parsed fields. Development builds compare above every tagged release.

One behaviour differs across the range rather than being gated: tmux 3.3a suppressed run-shell output for an invocation with no attached client, which later versions restored. runShell returns an empty result there rather than failing.

Parity with the Python library

parity/python-0.62.0.json is a ledger against libtmux 0.62.0. Every public symbol in that release carries a decision: ported directly, adapted to a shape that suits TypeScript better, or not ported for a recorded reason. A ported or adapted symbol also names the TypeScript that covers it and the test that proves it.

What is not ported mostly falls into three groups: neo.py, whose responsibilities are split across the generated metadata, the codec, the graph, and the handles; the pytest helpers, which