Drive tmux from Swift. A port of libtmux for Python, in the same family of ports and holding to what that library established about tmux.
import LibTmux
let server = try Server(socketName: "libtmux-swift")
for session in try await server.sessions() {
print(session.name, session.windowCount)
}You address a server, ask it what exists, and send it commands. Everything that
comes back is a value — a Session you hold is what the server looked like when
you asked, not a live handle that changes under you. Ask again for a newer view.
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.
See Project status.
Contents — Is this for you? · Products · Install · Asking · Changing · Filtering · Modes · Workspaces · MCP · Status · Docs · Tests
Yes, if you are writing a tool that drives tmux — a session manager, a test harness, a dashboard, an agent that needs somewhere to run things — and you want tmux's own vocabulary rather than a wrapper around shelling out.
Yes, if you care that Session, Window, Pane, and Client are
Sendable and Codable values, that every call states what it throws, and that
the package builds under Swift 6 language mode with complete strict concurrency
and no unsafe flags.
Not yet, if you need a stable API. Every release so far is an alpha and names are still moving — see Project status.
No, if you want to render or emulate a terminal. This talks to tmux; it does not draw one.
Four things ship from this one package. Take only what you need — the core has one dependency, and the YAML reader is behind a trait so you do not pay for it unless you ask.
| Product | Source | What it is for | Depends on |
|---|---|---|---|
LibTmux |
Sources/LibTmux/ |
The library. Servers, sessions, windows, panes, options, hooks, filtering, snapshots, streaming. The only one most callers need. | swift-subprocess |
TmuxWorkspace |
Sources/TmuxWorkspace/ |
Builds a session from a tmuxp workspace — written in Swift, JSON, or YAML. See Workspaces. | LibTmux, and Yams with the YAMLWorkspaces trait |
LibTmuxMCP |
Sources/LibTmuxMCP/ |
tmux as MCP tools, as a library you can embed. | LibTmux |
libtmux-mcp |
Sources/libtmux-mcp/ |
The MCP server executable that serves those tools over stdio. See tmux as MCP tools. | LibTmux, LibTmuxMCP |
Each has its own README with an install snippet, a usage example, and what it does and does not cover.
TmuxWorkspace and LibTmuxMCP are both written against Server and
neither mentions a mode, which is how the mode switch below is kept honest.
Every tag until 0.1.0 is a prerelease, and a prerelease has to be named
exactly. from: "0.1.0" matches none of them — SwiftPM keeps prereleases out
of a range whose bound has none — and from: "0.1.0-alpha.2" errs the other
way, resolving forward into 0.2.0-alpha.1 and every prerelease after it.
Neither is what you want from alpha software, so name the one you tested:
.package(
url: "https://github.com/libtmux/libtmux-swift.git",
exact: "0.1.0-alpha.2"
).product(name: "LibTmux", package: "libtmux-swift")To follow unreleased work instead, depend on the branch:
.package(url: "https://github.com/libtmux/libtmux-swift.git", branch: "master")Reading a workspace from YAML needs a YAML parser, and asking for it is what
pulls one in. Without the YAMLWorkspaces trait nothing here resolves Yams;
with it, Workspace.decode(yaml:) exists:
.package(
url: "https://github.com/libtmux/libtmux-swift.git",
exact: "0.1.0-alpha.2",
traits: ["YAMLWorkspaces"]
)Three listings, each returning plain arrays of values:
let sessions = try await server.sessions()
let windows = try await server.windows()
let panes = try await server.panes()A pane knows what is running in it and where:
for pane in try await server.panes() {
print(pane.id, pane.currentCommand, pane.currentPath)
}Questions tmux answers with an exit code are answered here with a Bool:
guard try await server.hasSession("work") else { return }And anything this library does not model is one step away, in either mode. A
tmux command that runs and reports a nonzero status is a reply, not an error —
has-session answers a question that way, so run(_:) hands back both rather
than throwing:
let reply = try await server.run(
TmuxCommand("display-message", ["-p", "#{client_termname}"])
)
print(reply.isSuccess ? reply.text : reply.errorText)Three listings are three moments. snapshot() takes one, and the relationships
are resolved inside it rather than by matching ids yourself:
let snapshot = try await server.snapshot()
for window in snapshot.windows(of: session) {
print(window.name, snapshot.panes(of: window).count)
}let session = try await server.newSession(named: "work", windowName: "editor")
let logs = try await server.newWindow(in: session, named: "logs")
let pane = try await server.splitWindow(logs, direction: .right)
try await server.run("tail -f /tmp/build.log", in: pane)Read a pane back the way a person would:
let lines = try await server.capture(pane)When several changes belong together, a command list spends one tmux invocation on all of them instead of one each:
var plan = TmuxCommandList()
for name in ["edit", "test", "logs"] {
plan = plan.then("new-window", ["-d", "-n", name])
}
_ = try await server.run(plan)Filter with the standard library when the predicate is local to your code.
FilterExpr is for when the filter has to leave it — stored in a config, sent to
another process, handed to a tool. It is built from key paths, so the compiler
rejects a text operator on a number, and it holds no closures, so it encodes:
let expression = try FilterExpr<Pane>.where(\.currentCommand, .isIn(["nvim", "vim"]))
let matching = try await server.panes().filter(expression)This is what lets the MCP tools offer filtering to a
client that does not speak Swift. The full vocabulary — operators, aliases, and
which fields carry which type — is in Filtering.md.
…and never what you get back. TmuxMode is the dial, and it has two settings:
| Mode | How work travels | Where it wins |
|---|---|---|
.direct |
A tmux process per call | One call, or calls far apart. The default |
.connected(to:) |
One live connection for the whole scope | More than one call, and being told what changed |
The default needs no word at all:
let sessions = try await server.sessions()Each other mode adds one:
let names = try await server.using(.connected(to: "main")) { server in
try await server.sessions().map(\.name)
}The calls inside are the same calls and return the same types. Because the mode is a value rather than a shape of call, a program that decides at runtime writes the decision:
let mode: TmuxMode = shouldAttach ? .connected(to: "main") : .direct
let sessions = try await server.using(mode) { server in
try await server.sessions()
}- The value you were handed.
using(_:)andconnected(attachingTo:_:)give you a server in that mode, and nesting them takes the innermost —using(.direct)inside a connected scope keeps one call off the connection. - Anything else is
.direct, including a server captured from outside the closure. - Two calls take their own process regardless —
wait(for:), which would otherwise deadlock the scope, andbuffer(named:), whose bytes a connection cannot report unambiguously. Both do it so that what comes back does not depend on the mode.
Nothing is global and nothing is inherited by a task. server.mode reports
which mode a value carries, so the rule can be read rather than trusted.
swift run --package-path Benchmarks libtmux-bench runs each scenario under
each mode behind a shim standing in for the tmux binary, counting a process
every time one starts and a round trip every time a command line is handed over.
The table below is written by that benchmark rather than transcribed from it —
Scripts/update_mode_matrix.py --check fails if it has drifted, and CI runs
that check.
| Work | Direct | Connected |
|---|---|---|
| list-sessions, once | 1 process, 1 round trip | 1 process, 2 round trips |
| list-sessions, twenty times | 20 processes, 20 round trips | 1 process, 21 round trips |
| sessions, windows, panes, clients, twice-checked | 6 processes, 6 round trips | 1 process, 7 round trips |
| sessions, windows, panes, clients — one after another | 4 processes, 4 round trips | 1 process, 5 round trips |
| the same four, concurrently — a pipelined batch | 4 processes, 4 round trips | 1 process, 5 round trips |
| new-window five times, each its own command | 12 processes, 12 round trips | 1 process, 13 round trips |
| the same five as one command list | 3 processes, 3 round trips | 1 process, 4 round trips |
| new-window then split, read back | 6 processes, 6 round trips | 1 process, 7 round trips |
| Noticing a pane printed a line | Polling | Streaming |
|---|---|---|
| tmux processes spent | 2 | 1 |
| round trips spent | 2 | 2 |
Directly, a round trip is a process, so that column always agrees with itself. Connecting collapses the processes to one and charges a single extra round trip, the attach — which is why a single call is the row where the default wins, and why from the second call onward the connection is ahead. Round trips are also where a command list shows up: under a connection it and the separate commands cost the same one process, and only the round trips tell them apart.
The benchmark also prints wall-clock medians, which move with the machine — run it yourself for those.
A connection can do one thing a process cannot, which is report what changed without being asked:
let firstLine: String? = try await server.connected(attachingTo: "work") { server, events in
for await notification in events.notifications
where notification.name == "output" {
return notification.arguments
}
return nil
}tmux has no hook that fires when a pane prints something, so a wait built from commands alone has to re-read the pane on a timer and spend a process per tick. A control connection is told instead, and that makes three waits cheaper than the loop everyone writes first. Reach for them in this order.
You wrote the command. Compose a channel into it: tmux blocks server-side and returns on the signal itself, so nothing is inferred from what the screen looks like.
try await server.run("make && tmux wait-for -S built", in: pane)
try await server.wait(for: "built")The question is about state. Subscribe to a format and tmux reports each
time its value changes — no capture, no scrollback, no prompt regex.
#{pane_current_command} answers "is my command done?" exactly:
try await server.connected(attachingTo: "work") { server, control in
try await control.watch(
FormatSubscription(
name: "cmd",
scope: .pane(pane.id),
format: "#{pane_current_command}"
)
)
for await change in control.changes(named: "cmd") {
return change.value
}
return nil
}You did not write the command. For a daemon printing ready or a dev
server someone else started, wait on the pane's output. %output wakes the
wait as the pane writes, and the matching runs against the rendered grid, so a
quiet pane costs nothing while this waits:
let waited = try await server.waitForOutput(
in: pane,
matching: ["Listening on"],
stoppingAt: ["EADDRINUSE", "error"]
)Pass stops whenever a failure marker exists — a build that fails after five
seconds should end the wait then, not hold it open to report the same failure
later.
The condition is checked before it is blocked on, the way any other wait on a
predicate works: text already on screen returns at once with
matchedAtEntry: true, because "wait until it is listening" is answered by
something already listening. Pass requiringFreshOutput when only a new
occurrence counts. When a wait does end without a match, the result says which
of the three things happened: sawNewOutput: false means the pane stayed quiet
and no pattern will fix it, and otherwise tail holds what actually arrived so
the pattern can be fixed from the output rather than from memory.
The DocC catalogue's Waiting article covers why the output stream is a
doorbell rather than the text being matched.
TmuxWorkspace builds a whole session in one go, from a tmuxp workspace.
Written in Swift, it is ordinary values:
Workspace(
sessionName: "work",
windows: [
WindowPlan(
windowName: "editor",
layout: "even-horizontal",
panes: [PanePlan(), PanePlan()]
),
WindowPlan(
windowName: "logs",
panes: [PanePlan(shellCommands: ["tail -f /tmp/build.log"])]
),
]
)let session = try await WorkspaceBuilder.build(workspace, on: server)Building refuses rather than adopting a session that already has the name: two callers building the same workspace should not silently share one.
JSON needs no trait, because tmuxp's keys decode straight into these types.
Reading the YAML that tmuxp files are usually written in needs a parser, which
is what the YAMLWorkspaces trait pulls in:
try Workspace.decode(yaml: text)The fixtures the suite tests against are tmuxp's own examples, decoded both ways and compared — a stronger claim than either parsing alone.
libtmux-mcp is a Model Context Protocol server. It speaks JSON-RPC 2.0
over stdio, one message per line, so anything that launches an MCP server can
drive tmux through it.
$ swift build --product libtmux-mcpPoint a client at the built binary. It takes no flags — which tmux it talks to is environment, so a client config is where you say so:
{
"mcpServers": {
"tmux": {
"command": "/path/to/.build/debug/libtmux-mcp",
"env": {
"LIBTMUX_SOCKET": "default",
"LIBTMUX_SAFETY": "mutating"
}
}
}
}| Variable | Default | What it selects |
|---|---|---|
LIBTMUX_SOCKET |
default |
The socket name, in tmux's own socket directory |
LIBTMUX_SOCKET_PATH |
— | A socket path, when a name will not do |
LIBTMUX_TMUX_BIN |
tmux |
The tmux to run — a bare name is resolved on PATH, or give a path |
LIBTMUX_SAFETY |
mutating |
The highest tier of tool served: readonly, mutating, destructive |
LIBTMUX_MCP_WAIT_MAX_SECONDS |
120 |
The ceiling every wait is clamped to, itself capped at 300 |
All are optional. Anything the server wants to tell a human goes to stderr, because stdout is the protocol and a stray line there corrupts it.
LIBTMUX_SOCKET is a name rather than a path, and tmux resolves a name inside
TMUX_TMPDIR. Set that in the same env block when your tmux keeps its sockets
somewhere other than the default, and the name will mean the same server to this
package that it means to you.
| Tool | What it answers |
|---|---|
describe_server list_servers |
Which tmux this is and which pane is your own; what other servers are running |
describe_filters |
The filterable fields, their types, and their aliases |
list_sessions list_windows list_panes |
Listings, filtered, projected to the fields you asked for |
snapshot |
Every level at once, proven to have existed together |
capture_pane capture_since |
What a pane is showing; what it has printed since last time |
search_panes |
Which pane mentions something |
read_format |
Any tmux format, reaching fields the listings do not carry |
show_options show_environment show_hooks |
What tmux has been configured to do |
run_shell |
Runs a command, waits for it, reports its exit status |
wait_for_output watch_format wait_for_channel signal_channel |
The four waits, all bounded and cancellable |
send_keys paste_text |
Keystrokes a program should interpret; text that should not be |
new_session new_window split_pane |
Building |
rename select resize_pane select_layout respawn_pane |
Rearranging, and restarting a pane that wedged |
set_option set_environment |
Configuring |
apply_workspace |
A whole session from one declarative plan |
kill_pane kill_window kill_session kill_server |
Ending things, at the destructive tier only |
run_command run_commands |
One tmux command, or a batch that says which step failed |
Alongside them, tmux:// resources for a client that would rather browse than
call, and four prompts packaging the sequences that are easy to get wrong.
Every tool declares a JSON Schema for its arguments and, where the shape is
guaranteed, for what it answers — so a client can validate a result and a model
can know its fields without spending a call to find out. Answers travel as
structuredContent and as text, because clients read one or the other.
It will not get stuck. Every wait is clamped to a ceiling and reports what
was actually enforced. Requests are served concurrently, so a thirty-second
wait does not hold up the ping beside it, and notifications/cancelled stops
one that the client has stopped caring about. The tmux commands that block
forever without a terminal — wait-for, attach-session, command-prompt,
choose-* — are refused by name, each pointing at the tool that does the same
job safely.
It will not spend context you did not ask it to. Listings take a fields
argument, so one field can be one field rather than every record in full.
capture_pane caps its lines and says how many it dropped. run_shell returns
only what that command printed, not the whole screen. capture_since returns a
cursor, so watching something across turns sends the difference rather than the
screen — a pane that has been quiet answers nothing at all.
It will tell you it is still there. A wait that runs for a minute reports
progress the whole time, when the client asks for it with a progressToken.
It will not end the conversation. When the server runs inside tmux it knows
which pane is its own: list_panes marks that row, describe_server names it,
and the kill tools refuse it unless confirm_self is passed. The guard
compares the server's process id rather than its socket path, so a pane id that
merely repeats on another tmux is not mistaken for the caller's.
You: Which of my panes are sitting in an editor?
Agent: Three —
%4and%7are runningnvim,%12is runningvim.%4is in~/work/api, the other two are in~/work/web.
The agent asked describe_filters what a pane can be filtered on, then
list_panes with currentCommand in [nvim, vim]. It did not shell out, parse
tmux list-panes output, or guess a format string.
For a single tmux send-keys, it does not — run tmux. It earns its keep when
something has to be asked rather than done, or waited for rather than
polled: which pane is running the failing test, whether the dev server came up,
whether the session you are about to create already exists.
LibTmuxMCP is the same tools as a library, if you would rather embed them in a
server of your own than run this one.
Alpha. The library works and its suite runs against eight tmux releases on every push, but the API has had no outside use and names are still moving. Expect to update code when you update the package.
What that means concretely:
- The public API can change in any release, with no deprecation first.
Semantic versioning starts saying something at
0.1.0; until then a version number only tells you which alpha you have. - Pin an exact version, for the reasons under Install.
LibTmuxis the part to build on. It is the largest, the most exercised, and the closest to settled.TmuxWorkspaceandLibTmuxMCPare newer and thinner, and are likelier to move.- The tmux behaviour is the tested part. Compatibility with 3.2a through 3.7b is checked in CI against each release built from its own tag, so what the library claims about tmux is evidence rather than intent. The Swift surface around it is what has not settled.
Useful now for a tool you control and can update. Not yet something to put under a dependency you do not.
| Swift | 6.2 or later |
| Platforms | Linux and macOS — see Platform notes |
| tmux | 3.2a through 3.7b |
| Dependencies | swift-subprocess for the core; Yams behind a trait, for reading YAML |
The DocC catalogue is the reference, and covers modes, snapshots, filtering, streaming, waiting, and platform support:
$ swift package --disable-sandbox preview-documentation --target LibTmuxCI builds it and fails the job on any warning.
Every Swift example in this file is also code the build compiles, and most of
it is code the suite runs. Compiling catches a call that was renamed; only
running catches one that quietly began answering something else — so the
examples that can address a live server live in Examples/ and are
executed against real tmux, on sockets under this suite's own namespace.
$ python3 Scripts/check_examples.py
39 documented examples, each compiled; 36 of them run against a real tmuxThat check fails if a fence here has no example behind it, so what you read
above is what the compiler accepted and, mostly, what tmux actually did.
Examples/README.md says how a fence is matched, and what the
check cannot see.
The suite runs against real tmux — no mocks of the server — one private socket per case, with servers reaped even when a run is killed outright.
$ swift test --traits YAMLWorkspacesThe trait is off by default and six tests come with it, so the gate names it. Point the suite at a particular release to test against that one:
$ LIBTMUX_TMUX_BIN=~/tmux-3.2a/bin/tmux swift testCI runs the suite on Linux against each of tmux 3.2a, 3.3a, 3.4, 3.5, 3.6, 3.7, 3.7a, and 3.7b, each built from its own release tarball.
| Path | What is in it |
|---|---|
Sources/ |
The four products |
Tests/ |
The suite, and the fixture every suite provisions servers through |
Examples/ |
Every documented example, its own package so they compile as a consumer does — and most run against a live tmux |
Benchmarks/ |
The mode benchmark, its own package so the shipped manifest names only what ships |
Parity/ |
What Python libtmux exposes, recorded, and what this port does about each of it |
Scripts/ |
The Python tooling CI runs |
dev/Spikes/ |
Disposable experiment packages. Not part of a release |
The suite runs on both. Linux covers every supported tmux release, eight ways
in parallel; macOS runs the ends of that range, because what differs on Darwin
is this package's own handling — the TMPDIR a socket path cannot afford,
keg-only libevent and ncurses, F_SETNOSIGPIPE — and none of it varies by tmux
release.
On Darwin, build with Xcode's toolchain rather than one from swift.org.
swift-subprocess reaches Span.bytes, whose accessor back-deploys only from
Swift 6.3, so a 6.2 toolchain fails inside the dependency at any deployment
target below macOS 26 — and SwiftPM compiles a dependency at that dependency's
declared minimum, so no number set here reaches it. Xcode 26 ships Swift 6.3,
which is what the macOS lane and upstream's own CI both use.
A program that opens connections should ignore SIGPIPE, because a write to a
tmux that went away first will otherwise end the process:
signal(SIGPIPE, SIG_IGN)The library does not set this itself — the disposition is process-wide, and a library changing it would change how its host behaves at the end of every pipeline it is in. This package's own suite makes the call, which is how the need for it is known.
This is a port of libtmux for Python, and follows it where following it
earns its place. Where Swift wants something else, it gets something else:
results are plain arrays rather than a query list, a single typed TmuxError
replaces an exception hierarchy, and objects are values rather than live
handles. Scripts/parity_report.py measures the surface against Python's
recorded API and names each divergence, so a difference reads as a decision
rather than an omission.
- libtmux — the Python library this is a port of
- tmuxp — tmux session manager, and the workspace format
TmuxWorkspacereads - libtmux-mcp — the Python MCP server for tmux
- The Tao of tmux — the book
MIT. See LICENSE.