Typed control of tmux for Bun and TypeScript — immutable snapshots, declarative queries, zero runtime dependencies.
Quickstart • Querying • Packages • API reference • Examples • Changelog
Warning
Alpha. Releases carry an -alpha prerelease tag. The API is not
settled, and any release may change or remove exported identifiers without a
deprecation period. Pin an exact version. Not recommended for production.
Read the changelog before you upgrade.
Yes, if you drive tmux from a program — an agent that runs commands and reads what they printed, a workspace launcher, a test harness, a dashboard — and you want the terminal's state as typed data rather than as parsed strings.
Probably not, if you want a .tmux.conf generator or a TUI. This is a
library for controlling a running server, not for configuring one.
The idea in one line: read the whole server once into an immutable snapshot, then query it like data.
$ bun add libtmuxnpm, pnpm, yarn
$ npm i libtmux$ pnpm add libtmux$ yarn add libtmuxRequires Bun 1.3.14+ or Node 22+, and tmux 3.2a or newer.
import { Server } from "libtmux";
const server = new Server();
const snapshot = await server.snapshot();
// No further tmux calls: everything below resolves against the snapshot.
const editors = snapshot.panes.where({ currentCommand: "vim" });
console.log(editors.count(), editors.at(0)?.sessionName);Building something rather than reading it looks like this — and this block is a
literal excerpt of examples/quickstart/quickstart.ts, which the
integration suite runs against a real tmux server:
const session = await server.newSession({ name: "quickstart" });
const editor = await session.newWindow({ name: "editor" });
await editor.split();
const snapshot = await server.snapshot();
const found = snapshot.windows.where({ name: "editor" }).one();
const paneCount = found.panes.length;This is the part worth judging the library on. .where() takes declarative,
serializable criteria; .filter() takes an ordinary predicate. They are
never overloaded into each other.
// Equality, string operators, AND/OR/NOT, and regular expressions as data.
snapshot.sessions.where({
AND: [
{ name: { startsWith: "prod" } },
{ windows: { some: { name: { regex: { pattern: "^log", flags: "" } } } } },
],
});
// Quantifiers over relations: some / every / none, and is / isNot.
snapshot.windows.where({ session: { is: { name: "work" } } });
// Case-insensitive when you ask for it.
snapshot.sessions.where({ name: { contains: "API", mode: "insensitive" } });A Selection is immutable, ordered, replayable, and Iterable — but it is
deliberately not an Array:
selection.length;
selection.at(-1);
selection.toArray();
[...selection];
selection.one({ name: "work" }); // throws NoMatchError / MultipleMatchesError
selection.oneOrUndefined({ name: "work" });
selection.exists({ name: "work" });Criteria are data, so they serialize — the same object can come from a config file, an MCP call, or a CLI flag.
Three packages, released together, each usable on its own.
| Package | What it is | npm |
|---|---|---|
| libtmux | The library. Server, session, window, pane and client handles over a snapshot. | |
| @libtmux/mcp | An MCP server exposing tmux to an AI agent. | |
| @libtmux/workspace | Declarative workspace builder, tmuxp-shaped config. | |
| examples | Runnable examples, executed as tests. | — |
libtmux — the library
$ bun add libtmuximport { Server } from "libtmux";
const server = new Server();
const session = await server.newSession({ name: "work" });
const editor = await session.newWindow({ name: "editor" });
await editor.split();
await editor.panes.at(0)?.sendKeys("echo hello");
const lines = await editor.panes.at(0)?.capture();Read next: Snapshots · Querying · Operations · Watching · Recipes · Errors · API reference
@libtmux/mcp — tmux for an AI agent
A stdio MCP server. Point it at a socket and an agent can list sessions, read a pane, send keys, and wait for output rather than polling for it.
$ npx -y @libtmux/mcpAdd it to any MCP client — this is the whole configuration:
{
"mcpServers": {
"tmux": {
"command": "npx",
"args": ["-y", "@libtmux/mcp"],
"env": { "LIBTMUX_SOCKET_NAME": "agent" }
}
}
}Claude Code, in one command
$ claude mcp add tmux --env LIBTMUX_SOCKET_NAME=agent -- npx -y @libtmux/mcpThe tools an agent reaches for first:
| Tool | What it does |
|---|---|
run_command |
Runs a shell command, waits for it, reports its real exit status |
wait_for_text |
Blocks until a pane prints something, streaming tmux notifications |
wait_for_text_task |
The same wait as an MCP task: a handle now, the result later |
observe |
Only what a pane printed since your cursor |
whoami |
Which pane the server runs in, and which panes a person is watching |
build_workspace |
A session and all its windows in one tmux invocation |
Panes, windows, sessions, layouts, options, buffers and environment are covered
too, and the server is browsable: tmux:// resources, subscribable pane
contents, prompts, and completions.
Read next: Why it exists · Configuration · Choosing the right tool · Long waits
@libtmux/workspace — declarative sessions
Describe a session; apply it. Applying twice converges rather than duplicating.
$ bun add @libtmux/workspaceimport { Server } from "libtmux";
import { applyWorkspace } from "@libtmux/workspace";
const server = new Server();
await applyWorkspace(server, {
session_name: "api",
windows: [
{ window_name: "editor", panes: ["vim", "git status"] },
{ window_name: "server", panes: [{ shell_command: "bun dev", focus: true }] },
],
});Read next: The config shape · Converging
examples — runnable, and run
Four programs covering acquisition, control-mode watching, the act-then-wait loop an agent needs, and building a workspace. Each is executed by the integration suite, so the code there is the code that runs.
$ bun test examplesTransport, chaining and concurrency are independent, each one token at the call site, and none of them changes what you get back — the full table is here.
| Mode | Turn it on | When to use it |
|---|---|---|
| spawning | the default | A script that runs a few commands and exits |
| connected | await server.connect() |
Anything long-lived, or a loop reacting to events |
| watching | server.watch() |
Reacting to a change rather than polling to find it |
| planned | .plan + server.batch([…]) |
Creating or changing several things at once |
Twelve windows, measured: one-at-a-time costs 64 tmux invocations and about a
second; batched costs 5 and about 40 ms. Same answer, different cost —
reproduce it with
bun packages/libtmux/scripts/bench-modes.ts.
- Zero runtime dependencies. A property under test, not an aspiration.
- Real tmux, every commit. CI runs the suite against every tmux release the badge above names — no mocks stand in for a server.
- Documentation is a gate. Every public symbol carries a compiled example, the API reference is generated from the source that implements it, and every link, install command and recipe on this page is checked on each run.
- Softly tracks Python libtmux. Names and shapes follow 0.62.0 where TypeScript agrees with them; each departure is a decision a gate holds to the code.
packages/libtmux the library
packages/mcp the MCP server
packages/workspace the workspace builder
examples runnable examples, used as tests
attic reference notes
Working here: AGENTS.md routes to the policy that governs a change. The layout and the change discipline live there; how we write is in .github/WRITING.md, and the gates, real tmux, and releases are in .github/CONTRIBUTING.md.
MIT — a port of tmux-python/libtmux.