Typed control of tmux for Bun and TypeScript — immutable snapshots, declarative queries, and a library that ships zero runtime dependencies.
Quickstart • Querying • Packages • API reference • Hosted API reference • Examples • Changelog
Warning
Releases are numbered 0.1.N. The API is not settled: any release may change
or remove exported identifiers. Pin an exact version, and read the changelog
of each package you use before you upgrade. The root changelog
summarizes each release and links the package changelogs.
- Yes, if you drive tmux from a program (an agent, a workspace launcher, a test harness, a dashboard) and want the terminal's state as typed data, not parsed strings.
- Probably not, if you want a
.tmux.confgenerator or a TUI. It controls a running server; it does not write your configuration. - The idea: 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 libtmux- tmux 3.3a or newer. An older daemon is refused with
VersionTooLowErrorbefore any command runs. - Bun 1.3.14 or newer, or Node 22 or newer.
- Runs on Linux, including WSL. macOS is supported on a best-effort basis, and CI coverage for it is highly limited.
Create a session, split a window, and read it back. The program starts a tmux server of its own and stops it on the way out:
import { Server } from "libtmux";
const server = new Server({ socketName: "libtmux-demo" });
try {
const session = await server.newSession({ name: "work" });
const editor = await session.newWindow({ name: "editor" });
await editor.split();
// One read of the server. Everything after it resolves locally.
const snapshot = await server.snapshot();
const found = snapshot.windows.where({ name: "editor" }).one();
console.log(found.panes.length);
// 2
} finally {
await server.kill();
}More: examples/quickstart runs the same calls and adds a
pane action and a failed command.
Server.snapshot: read the server once
new Server() reaches the server your own tmux command does. A snapshot
reads it once, then answers every query without another tmux call:
import { Server } from "libtmux";
const server = new Server();
const snapshot = await server.snapshot();
const editors = snapshot.panes.where({ currentCommand: "vim" });
console.log(editors.count(), editors.at(0)?.session?.name);Selection.where: criteria as data
.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" } });More: every field and operator.
Selection: immutable, ordered, iterable
It is 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" });encodeWhereDocument: store a query
Criteria are data. encodeWhereDocument writes a model-tagged query;
decodeWhereDocument validates one read from a
config file, MCP call, or CLI.
Window.refreshed: read a handle again
A Session, Window or Pane you hold is what tmux said when it was read.
refreshed() reads it again and returns a new handle, and the handle you called
it on stays as it was. Session and Pane have it too.
import { Server } from "libtmux";
const server = new Server({ socketName: "libtmux-refresh" });
try {
await server.newSession({ name: "work", windowName: "editor" });
const window = (await server.snapshot()).windows.one({ name: "editor" });
await window.rename("build");
console.log(window.name, (await window.refreshed()).name);
// editor build
} finally {
await server.kill();
}Pane.sendKeys and Pane.capture: type, then read
Window.split makes the pane and
Pane.resize sets its height. sendKeys returns when tmux
has the keys, so read until cat has echoed them:
import { Server } from "libtmux";
const server = new Server({ socketName: "libtmux-pane" });
try {
const session = await server.newSession({ name: "work" });
const pane = await session.windows.one().split({ shellCommand: "cat" });
await pane.resize({ height: 5 });
await pane.sendKeys("hello");
let lines = await pane.capture();
while (lines.length === 0) lines = await pane.capture();
console.log(lines[0], (await pane.refreshed()).height);
// hello 5
} finally {
await server.kill();
}More: options and hooks, from
Session.setOption to
Server.setHook.
Server.batch: plan, then run
plan describes a mutation without running it. batch runs
the planned commands in order and resolves each to a typed handle, from one
final snapshot:
import { Server } from "libtmux";
const server = new Server({ socketName: "libtmux-batch" });
try {
const session = await server.newSession({ name: "work" });
const [editor, logs] = await server.batch([
session.plan.newWindow({ name: "editor" }),
session.plan.newWindow({ name: "logs" }),
]);
const [pane] = await server.batch([editor.plan.split()]);
await pane.sendKeys("echo hello");
console.log(editor.name, logs.name);
// editor logs
} finally {
await server.kill();
}pipeline runs raw tmux arguments the same way, one
result each. More:
batching.
Server.watch: control mode, no polling
watch() holds one persistent tmux -C connection and yields each change as
tmux announces it. Open it, await ready(), then act:
import { Server } from "libtmux";
const server = new Server({ socketName: "libtmux-watch" });
try {
const session = await server.newSession({ name: "work" });
await using events = server.watch({ target: session.id });
await events.ready();
await session.newWindow({ name: "build" });
const opened = await events.find((event) => event.kind === "window-add", {
timeoutMs: 10_000,
});
console.log(opened?.kind);
// window-add
} finally {
await server.kill();
}- Needs tmux 3.7 or newer.
Server.connectpairs the same observer with a server's commands, andwaitForwaits for a state to hold.
More: watching.
The four packages share one release version.
| 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. | |
| @libtmux/workspace-cli | The native tmux-workspace command for Node and Bun. |
|
| examples | Runnable examples, executed as tests. | — |
libtmux: the library
Server, Session, Window,
Pane and Client handles over an immutable snapshot
of tmux.
API reference · npm · Changelog
$ bun add libtmuximport { Server } from "libtmux";
const server = new Server({ socketName: "libtmux-package" });
try {
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();
} finally {
await server.kill();
}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 instead of polling for it.
API reference · Tools · npm · Changelog
With no socket chosen it passes tmux none, so tmux picks the server: the one it is launched inside, otherwise its default socket:
$ npx -y @libtmux/mcp@0.1.2Add it to any MCP client — this is the whole configuration:
{
"mcpServers": {
"tmux": {
"command": "npx",
"args": ["-y", "@libtmux/mcp@0.1.2"],
"env": { "LIBTMUX_SOCKET": "agent" }
}
}
}Claude Code, in one command
$ claude mcp add tmux --env LIBTMUX_SOCKET=agent -- \
npx -y @libtmux/mcp@0.1.2The tools an agent reaches for first:
| Tool | What it does |
|---|---|
list_sessions |
Lists stable session identities and metadata |
capture_since |
Returns only pane output after a cursor |
wait_for_text |
Waits for bounded literal or regular-expression matches |
run_shell_command |
Runs a command through a pane and reports output and exit status |
create_session |
Creates a session without accepting a command or environment value |
Tools are grouped in the toolsets inspect, manage, execute and teardown.
tmux://capabilities is the one resource.
Read next: Why it exists · Configuration · Toolsets and trust · Choosing the right tool
@libtmux/workspace: declarative sessions
Describe a session; apply it. Applying twice converges rather than duplicating.
API reference · npm · Changelog
$ bun add @libtmux/workspace libtmuximport { Server } from "libtmux";
import { applyWorkspace } from "@libtmux/workspace";
const server = new Server({ socketName: "libtmux-workspace" });
try {
await applyWorkspace(server, {
session_name: "api",
windows: [
{ window_name: "editor", panes: ["vim", "git status"] },
{ window_name: "server", panes: [{ shell_command: "bun dev", focus: true }] },
],
});
} finally {
await server.kill();
}Read next: The config shape · Converging
@libtmux/workspace-cli: tmuxp workspaces from the shell
tmux-workspace loads, captures and converts tmuxp workspace
files on Node or Bun. Ordinary loads start no Python.
Command reference · npm · Changelog
Save this document as workspace.yaml:
session_name: dev
windows:
- window_name: editor
layout: main-vertical
panes:
- vim
- topLoad it detached, on a socket of its own:
$ npx -y @libtmux/workspace-cli@0.1.2 load ./workspace.yaml \
-L workspace-cli-demo \
-dRead next: Install · Load and capture · Command reference
examples: runnable programs
Reading a server, watching it, driving a pane and waiting, and building a workspace.
$ bun test examplesObservation, planning and concurrency compose around one command engine. The full table has more.
| Mode | Turn it on | When to use it |
|---|---|---|
| connected | await server.connect() |
Pairing commands with a persistent event observer |
| watching | server.watch() |
Reacting to a change rather than polling to find it |
| pipeline | server.pipeline([…]) |
Ordered commands when printed output is enough |
| planned | .plan + server.batch([…]) |
Ordered mutations that must return typed handles |
| concurrent | Promise.all |
Independent work that may safely overlap |
- Twelve windows cost 25 tmux invocations one at a time, 13 through
pipelineand 14 throughbatch. Each count includes the query that reads the result back. - A snapshot is one invocation whatever the server's size, and querying it is none.
- The benchmarks add wall-clock time and
say why
Promise.allbuys nothing over doing the same work in order.
$ bun packages/libtmux/scripts/bench-modes.tsmaxInFlight: bound the tmux processes
Every invocation is a tmux client process, so a Promise.all over a whole
server starts that many. maxInFlight caps them. A custom
TmuxEngine runs tmux over SSH or in a container instead.
import { Server } from "libtmux";
const server = new Server({ socketName: "libtmux-bounded", maxInFlight: 4 });
try {
const session = await server.newSession({ name: "work" });
const names = ["a", "b", "c", "d", "e", "f"];
await Promise.all(names.map(async (name) => session.newWindow({ name })));
console.log((await server.snapshot()).windows.count());
// 7
} finally {
await server.kill();
}- No runtime dependencies in the library. A
test holds it.
@libtmux/mcpand@libtmux/workspacedeclare their own. - Real tmux. CI runs the suite against real servers, with no mocks standing in for tmux.
- Tracks Python libtmux. Names and shapes follow it where TypeScript agrees. Each departure is a recorded decision.
packages/libtmux the library
packages/mcp the MCP server
packages/workspace the workspace builder
packages/workspace-cli the tmux-workspace command
examples runnable examples
attic reference notes
Working here: AGENTS.md routes to the policy for a change. Writing rules are in .github/WRITING.md; gates, real tmux and releases are in .github/CONTRIBUTING.md.
Use these packages with AI tools as you like. When contributing, check what you submit and wait for a maintainer's invitation before preparing a pull request. See the AI policy, also kept as AI_POLICY.md.
Please use the following BibTeX template to cite libtmux-ts in scientific discourse:
@misc{libtmux-ts,
author = {Tony Narlock},
year = {2026},
note = {https://libtmux.org/en/ts/},
title = {libtmux-ts: TypeScript wrapper for tmux}
}MIT — a port of tmux-python/libtmux.