Skip to content

Repository files navigation

libtmux for Swift

ci macos

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.

ContentsIs this for you? · Products · Install · Asking · Changing · Filtering · Modes · Workspaces · MCP · Status · Docs · Tests

Is this for you?

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.

Products

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.

Install

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"]
)

Ask what is there

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)

One consistent picture

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)
}

Change what is there

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)

Filters that travel

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.

One switch changes how work reaches tmux

…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()
}

Which server is in which mode, in order of precedence

  1. The value you were handed. using(_:) and connected(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.
  2. Anything else is .direct, including a server captured from outside the closure.
  3. Two calls take their own process regardlesswait(for:), which would otherwise deadlock the scope, and buffer(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.

What it costs

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.

Being told rather than asking

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
}

Waiting without polling

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.

Workspaces, from a file or from Swift

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.

tmux as MCP tools

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-mcp

Point 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.

The tools

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.

Three things it does that a wrapper does not

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.

What it feels like

You: Which of my panes are sitting in an editor?

Agent: Three — %4 and %7 are running nvim, %12 is running vim. %4 is 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.

When it earns its keep

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.

Project status

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.
  • LibTmux is the part to build on. It is the largest, the most exercised, and the closest to settled. TmuxWorkspace and LibTmuxMCP are 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.

Requirements

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

Documentation

The DocC catalogue is the reference, and covers modes, snapshots, filtering, streaming, waiting, and platform support:

$ swift package --disable-sandbox preview-documentation --target LibTmux

CI 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 tmux

That 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.

Tests

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 YAMLWorkspaces

The 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 test

CI 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.

Repository layout

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

Platform notes

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.

Relationship to Python libtmux

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.

Related projects

  • libtmux — the Python library this is a port of
  • tmuxp — tmux session manager, and the workspace format TmuxWorkspace reads
  • libtmux-mcp — the Python MCP server for tmux
  • The Tao of tmux — the book

License

MIT. See LICENSE.

About

libtmux for Swift — drive tmux from Swift. Alpha: the API can change in any release. A port of libtmux for Python.

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages