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.
With tmux already running on its default socket:
import LibTmux
let server = try Server(socketName: "default")
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 core tmux I/O reports TmuxError
explicitly, 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.
Five 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, TmuxWorkspace |
libtmux-mcp |
Sources/libtmux-mcp/ |
The MCP server executable that serves those tools over stdio. See tmux as MCP tools. | LibTmux, LibTmuxMCP |
TmuxFixture |
Tests/TmuxFixture/ |
Real-server provisioning and reaping for tests and benchmarks. | LibTmux |
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.3" 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 an exact release:
.package(
url: "https://github.com/libtmux/libtmux-swift.git",
exact: "0.1.0-alpha.5"
).product(name: "LibTmux", package: "libtmux-swift")Note
This page documents unreleased master. The exact dependency above installs
the released alpha.3 API; read that tag's README for matching
examples. To compile the examples on this page, depend on master:
.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.5",
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)Holding a value and wanting a newer view of it is the common case, and it does not need a whole listing:
let work = try await server.session(named: "work")
let fresh = try await server.refresh(pane)Absence is nil, not an error — an object going away is ordinary. A re-read
also refuses a value from a different daemon, because tmux restarts its ids at
zero and a same-numbered pane on a replacement server is not the one you had.
snapshot() collects sessions, windows, panes, and clients into one value. The
relationships resolve inside that value 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)
}The listings are separate tmux commands. snapshot() checks the daemon
incarnation before and after them and reports a replacement, but another client
can still mutate the same daemon between listings. The result is not a tmux
transaction.
let session = try await server.newSession(named: "work", windowName: "editor")
_ = try await server.setOption("@purpose", to: "development", scope: .session(session))
let logs = try await server.newWindow(in: session, named: "logs").window
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)The same expression can also travel all the way to tmux, so the rows that would have been discarded never cross the process boundary:
let matching = try await server.panes(where: expression)Both give the same answer. The difference is what crosses the boundary: the first formats, pipes and decodes every row before discarding most of them, and the second does not, so the cost tracks the size of the result rather than the size of the server. What tmux cannot evaluate — a regular expression, which runs on this package's bounded engine rather than tmux's — simply widens the predicate and is decided here instead.
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:
(One caveat, in typed errors: a scope takes a
closure, so it widens the thrown type.)
| 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 | 7 processes, 7 round trips | 1 process, 8 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 | 5 processes, 5 round trips | 1 process, 6 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 try await notification in events.notifications
where notification.name == "output" {
return notification.arguments
}
return nil
}Every call throws TmuxError and says so, so a program can be
throws(TmuxError) from top to bottom. The scoped forms are the exception:
they take a closure, and Swift 6.2 cannot carry a closure's thrown type out of
one. Wrap the scope to narrow it back:
func names(_ server: Server) async throws(TmuxError) -> [String] {
try await withTmuxError {
try await server.using(.connected(to: "main")) { server in
try await server.sessions().map(\.name)
}
}
}This is a language limitation rather than a choice: a throws(TmuxError)
overload of the scopes is unreachable, because a closure literal's thrown type
is never inferred from its body, and a scope that fails on its own behalf has no
way to rethrow that as the body's error type.
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; \(server.shellInvocation) 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 try 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. A
small liveness check detects a pane removed while it is quiet:
let ready = try RegexPattern("Listening on")
let failed = try RegexPattern("EADDRINUSE|error", options: [.caseInsensitive])
let waited = try await server.waitForOutput(
in: pane,
matching: [ready],
stoppingAt: [failed]
)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
thing happened: timedOut with sawNewOutput: false means the pane stayed
quiet and no pattern will fix it, timedOut with output means tail holds what
actually arrived so the pattern can be fixed from that rather than from memory,
expiredWhileReading means the timeout ended before the pane could be read
at all, so nothing was established either way, and alternateScreen means a
pager, editor, or other full-screen program held the pane, which tmux fills
from a grid it keeps out of history — matching is suppressed there rather than
run against what the program painted.
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:
let workspace = 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. A later failure removes the exact session this build created; a rollback failure reports both errors.
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 and which tools it exposes are fixed from its startup environment:
{
"mcpServers": {
"tmux": {
"command": "/path/to/.build/debug/libtmux-mcp",
"env": {
"LIBTMUX_SOCKET": "agent",
"LIBTMUX_TOOLSETS": "inspect,manage,execute"
}
}
}
}| Variable | Default | What it selects |
|---|---|---|
LIBTMUX_SOCKET |
libtmux-mcp |
Plain tmux socket name |
LIBTMUX_SOCKET_PATH |
— | Absolute socket path, mutually exclusive with LIBTMUX_SOCKET |
LIBTMUX_TMUX_CONFIG |
bundled minimal config | Absolute tmux configuration path |
LIBTMUX_TOOLSETS |
depends on server provenance | Comma-separated subset of the four toolsets; empty selects none |
LIBTMUX_TOOLS |
empty | Exact names added after toolset expansion |
LIBTMUX_EXCLUDE_TOOLS |
empty | Exact names removed last, including nested authority |
LIBTMUX_TMUX_BIN |
tmux |
The tmux to run — a bare name is resolved on PATH, or give a path |
LIBTMUX_MCP_WAIT_MAX_SECONDS |
120 |
The ceiling every wait is clamped to, itself capped at 300 |
The default launch uses the dedicated libtmux-mcp socket and minimal config.
It adds teardown only when the daemon retains this process's launch nonce from
that config. Explicit sockets, explicit configs, and existing daemons never
gain implicit teardown authority. Unknown names and empty list tokens fail
before the socket is opened. When stdio ends, the process removes only the
default daemon whose launch nonce and incarnation still match; existing and
replacement daemons remain.
Toolsets classify direct operations; they do not sandbox the host. execute
exposes run_shell_command and send_keys, which act with the tmux user's
authority. Grant it only to clients trusted to act as that user.
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.
The registry contains 45 tools: 18 inspect, 14 manage, nine execute, and
four teardown. A configured executable exposes the first three toolsets by
default (41 tools); an authenticated daemon started from the bundled minimal
configuration also receives teardown (45 tools).
| Tool | What it answers or does |
|---|---|
list_sessions list_windows list_panes |
Lists the objects on the selected socket |
get_server_info get_session_info get_window_info get_pane_info |
Returns one level's typed metadata |
capture_pane capture_since snapshot_pane search_panes |
Reads bounded terminal content |
find_pane_by_position wait_for_text |
Finds a pane or waits for bounded output |
get_tmux_variables show_option show_environment show_hooks |
Reads configuration and environment values |
call_read_tools_batch |
Runs up to 16 eligible inspect operations under one outer approval |
rename_session rename_window select_window select_pane select_layout |
Renames or selects tmux state |
resize_window resize_pane move_window swap_pane set_pane_title |
Rearranges windows and panes |
wait_for_channel signal_channel |
Coordinates through bounded tmux channels |
set_mouse_enabled set_history_limit |
Applies typed server settings |
create_session create_window split_window respawn_pane |
Starts configured pane processes without command payloads |
run_shell_command |
Runs a command in a pane, waits, and reports its exit status |
send_keys send_keys_batch paste_text |
Sends bounded input to pane programs |
set_synchronize_panes |
Makes later pane input fan out across a window |
clear_pane_scrollback kill_pane kill_window kill_session |
Deletes retained or live tmux state |
Pane modes remain human-client state, not MCP automation state. Read bounded
scrollback with capture_pane and its start/end range, use search_panes to
discover matching output, or use snapshot_pane when one MCP response should
contain both pane metadata and bounded content; the metadata and capture are
separate reads, not an atomic snapshot. Carry the opaque capture_since cursor
across turns for new output. If a pane is already in a human-owned mode, read
pane_in_mode or pane_mode through get_tmux_variables, report it, and leave
entry or cancellation to the client that owns the interaction.
Pane input reads fresh typed death, mode-count, effective synchronization,
caller, and terminal-attendance state. Missing, malformed, or inconsistent
caller/client context fails closed. A zoomed terminal client protects its
active pane; an unzoomed terminal client protects every pane in its active
window. Control clients do not count. send_keys checks every configured
member of the effective synchronized cohort; send_keys_batch repeats that
check for every executed row. Returned pane IDs describe configured
membership, not proof of delivery, and the checks remain observational because
tmux state can change after a snapshot. A process-wide reservation protects
every configured member through dispatch, so pane input refuses overlap with
an active run or other input.
paste_text checks its target before staging and immediately before its one
paste dispatch. It keeps non-empty text and its optional Enter target-only in
one private buffer and removes the buffer on every path. Empty text without
Enter still performs its initial safety check, then returns without creating a
buffer or dispatching input. run_shell_command checks before setup and again
before dispatch, refuses a second run instead of waiting, requires a single
configured pane running a recognized POSIX shell, and contains the command in
a subshell so it cannot leave shell state behind. Those guarantees assume the
selected shell, tmux daemon, and configuration are trusted; they do not claim
containment against a hostile shell environment. force=true bypasses only an
exact caller-pane match; it never bypasses attendance, active-operation, mode,
death, shell, or target-count checks.
Every tool declares native input and output schemas and carries its complete
capability row under _meta["com.git-pull.libtmux-mcp/capability"]. The same
immutable registry drives registration, dispatch, descriptions, filtering, and
the tmux://capabilities report. That static report is the only MCP resource;
the server exposes no prompts or dynamic templates.
Every answer travels as both structuredContent and JSON text, so clients that
read either representation receive the same typed result.
The rows disclose process reach, tmux effects, output classes, one
inputLiteralization map, and conservative MCP annotations. Detailed input
sink tables remain internal validation data. The rows are consent metadata,
not a security boundary. Terminal content, environment values, configured
commands, and tmux metadata may contain secrets or untrusted instructions.
Earlier alpha releases used ordered safety tiers and different tool names. These names are not aliases on the frozen 45-tool surface:
| Earlier name or URI | Current path |
|---|---|
enter_copy_mode, exit_copy_mode |
No MCP replacement. Read history through capture_pane, locate output with search_panes, continue with capture_since, and leave human-owned pane modes unchanged. Applications that own pane state can use the core library's enterCopyMode and cancelModes APIs. |
LIBTMUX_SAFETY |
Use LIBTMUX_TOOLSETS; any present legacy value stops startup. |
LIBTMUX_MCP_TOOLS |
Use LIBTMUX_TOOLSETS= with LIBTMUX_TOOLS to preserve its exact allowlist; any present legacy value stops startup. |
tmux://snapshot |
Compose list_sessions, list_windows, list_panes, and snapshot_pane. |
tmux://sessions |
Use list_sessions. |
tmux://filters |
Read native input schemas from tmux://capabilities and filter bounded listings client-side. |
tmux://sessions/{session}/windows |
Use list_windows with the session reference. |
tmux://panes/{pane} |
Use get_pane_info with the pane reference. |
tmux://panes/{pane}/content |
Use capture_pane for a bounded slice, snapshot_pane for metadata and content, or capture_since to continue across turns. |
describe_server |
Use get_server_info; list_panes also marks the caller pane. |
list_servers |
No discovery route; pin one socket per process and run another process for another socket. |
describe_filters |
No separate route; read the published input schemas and filter bounded listings client-side. |
snapshot |
Compose list_sessions, list_windows, list_panes, and snapshot_pane; the capability report carries frozen connection provenance. |
read_format |
Use get_tmux_variables for validated names; arbitrary tmux formats have no replacement. |
show_options |
Use show_option for one named option. |
run_shell |
Use run_shell_command; it remains synchronous and bounded. |
wait_for_output |
Use wait_for_text. |
watch_format |
No format subscription; use capture_since, wait_for_text, or a typed metadata read. |
new_session, new_window, split_pane |
Use create_session, create_window, and split_window. |
rename, select |
Use the session/window and pane/window variants, such as rename_session and select_pane. |
set_option |
Use set_mouse_enabled, set_history_limit, set_synchronize_panes, or set_pane_title; there is no generic setter. |
set_environment |
No public process-environment mutation route. |
apply_workspace |
Compose create, split, layout, title, selection, and execution tools explicitly. |
kill_server |
No server-wide teardown route; kill selected sessions or administer tmux outside MCP. |
run_command, run_commands |
No raw tmux-command interpreter; call the typed tools instead. |
The removed prompts remain useful as typed-tool workflows:
| Earlier prompt | Current typed workflow |
|---|---|
run_and_wait |
Call run_shell_command and read its framed exit status and bounded output instead of polling the pane. |
watch_until_ready |
Call wait_for_text with ready and stop patterns; carry a capture_since cursor when following output across turns. |
build_workspace |
Compose create_session, create_window, split_window, layout, title, selection, and execution calls. |
find_my_pane |
Call get_server_info, then use the isCaller marker from list_panes before changing topology. |
Waits have deadlines. 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. wait_for_text and
wait_for_channel expose the two blocking operations through bounded calls.
It will not spend context you did not ask it to. Pane reads collect at most
262,144 bytes per stream and return at most 128,000 UTF-8 bytes in whole rows;
they say when older output was omitted. run_shell_command returns only what
that command printed, not its echoed wrapper or the shell prompt.
capture_since returns a cursor, so watching something across turns sends the
difference rather than the screen. call_read_tools_batch retains complete
nested envelopes where they fit, marks each elided result, reports aggregate
success, failure, stop, and truncation totals, and keeps the complete
newline-delimited response at or below 1,000,000 bytes.
A request ID that cannot fit inside that ceiling receives a bounded id: null
invalid-request response before any tool runs.
search_panes scans at most 200 panes, 20,000 lines, 1,000,000 bytes, or five
seconds, and reports which ceiling truncated the search.
Long waits report life signs. When a client sends a progressToken, a long
wait sends advisory progress while output has capacity. Final answers and
protocol errors take priority when the client stops draining output.
It will not end the conversation. When the server runs inside the selected
tmux it knows which pane is its own. Direct teardown tools refuse that pane,
window, or session unless the call explicitly passes force. The guard does
not claim to constrain equivalent commands sent through an execute tool.
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 called list_panes, selected the rows whose current command was an
editor, and answered from the typed metadata. 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
47 documented examples mapped to consumer sources
41 have live-test call sitesThat check fails if a fence here has no example behind it. The Examples test
run is what compiles those sources and exercises the 41 live call sites; CI
runs both gates.
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 runtime products |
Tests/ |
The suite, and the TmuxFixture product 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 |
Tools/McpSwap/ |
The private native MCP config swap utility; its nested package is not shipped |
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.