Core LibraryalphaGuides

Choose documentation 3

v0.1.0.alpha.3

Current version

v0.1.0.alpha.3
English

Prerelease This site documents an alpha of libtmux. Its structure, URLs and APIs are subject to change.

Server bindings

Drive tmux from Ruby: sessions, windows and panes as handles, snapshots you filter in plain Ruby, and control connections that report what tmux does.

API reference · Guides · RubyGems

Part of libtmux for Ruby. This gem is the core; the packages below build on it.

Create and split windows, read and filter what a server holds, and type into a pane and capture what it shows.

It suits scripts, tests that need a real tmux, and tools that drive a terminal program. Cancel a request that is waiting, and follow events as tmux emits them.

Contents — Quick start · Find things · Create · Type and read · Your own server · Cancel · Link windows · Options · Watch tmux · Groups · Packages · Leaves out · Support · Documentation

Quick start

Install the alpha. It needs Ruby 3.3 or newer and tmux 3.3a or newer on PATH:

Terminal window
$ gem install libtmux --pre

This program creates a session, adds a window, splits it, and prints each window with its panes. Server.start closes its private tmux server when the block exits, and the snapshot stays readable afterward:

require "libtmux"
snapshot = LibTmux::Server.start do |server|
session = server.new_session(name: "work", window_name: "main", command: ["/bin/cat"])
window = session.new_window(name: "logs", command: ["/bin/cat"])
window.split(direction: :horizontal, size: "40%", command: ["/bin/cat"])
server.snapshot
end
snapshot.windows.each do |window|
puts "#{window.name}: #{window.panes.map(&:id).join(', ')}"
end

It prints:

main: %0
logs: %1, %2

Requiring the gem starts no tmux process, scheduler or MCP server. Server.start gives its tmux server a socket and an empty configuration of its own, so it never touches a tmux you already use.

Find sessions, windows, and panes

Server#list_sessions: what tmux has now

One call asks tmux for every session and returns frozen handles in numeric ID order. list_windows and list_panes are its siblings, and Session#list_windows and Window#list_panes narrow them:

require "libtmux"
LibTmux::Server.start do |server|
server.new_session(name: "work", command: ["/bin/cat"])
server.new_session(name: "logs", command: ["/bin/cat"])
sessions = server.list_sessions
puts "sessions: #{sessions.map(&:id).sort.join(', ')}"
end

It runs on a private server and prints:

sessions: $0, $1

An empty array means tmux answered with nothing to list. A listing that fails raises CommandError.

Server#snapshot: one read, many queries

Read the server once, then filter that read with where, select, one and one_or_nil. These queries make no tmux calls. Continue with the snapshot from the quick start:

panes = snapshot.panes
active_ids = panes.where(active: true).map(&:id)
wide_panes = panes.select { |pane| pane.width >= 40 }
panes_by_window = panes.group_by { |pane| pane.window.name }
logs = snapshot.windows.one(name: "logs")
missing = snapshot.windows.one_or_nil(name: "missing")

one raises NoMatchError or MultipleMatchesError unless exactly one record matches. one_or_nil returns nil for no match and still rejects duplicates.

A selection keeps the membership it captured, so call server.snapshot again to read later changes. The field catalog lists every query field and wire name.

Server#search_panes: the matching panes in one call

search_panes captures the server and answers a selection of the panes whose fields match where:. explain_panes shows the plan without running tmux. This program also uses the snapshot queries above:

require "libtmux"
LibTmux::Server.start do |server|
work = server.new_session(name: "work", command: ["/bin/cat"], receipt: true)
server.new_session(name: "logs", command: ["/bin/cat"])
snapshot = server.snapshot
session = snapshot.sessions.where(name: "work").one
puts "matching session: #{session.name}"
missing = snapshot.sessions.one_or_nil(name: "missing")
puts "missing session: #{missing.nil?}"
panes = server.search_panes(where: {id: work.pane.id})
puts "matching pane: #{panes.one.id}"
end

It prints:

matching session: work
missing session: true
matching pane: %0

Create sessions, windows, and panes

Server#new_session: a session and its first window

new_session starts a detached session that runs command:, an argv array. Ask for a receipt to read the IDs tmux assigned to the session, window and pane:

require "libtmux"
LibTmux::Server.start do |server|
created = server.new_session(
name: "work", window_name: "main", command: ["/bin/cat"], receipt: true
)
puts "session: #{created.entity.id}"
puts "window: #{created.window.id}"
puts "pane: #{created.pane.id}"
end

It prints:

session: $0
window: @0
pane: %0

Creation returns once tmux has assigned the IDs. It says nothing about whether the program started or exited cleanly.

Session#new_window: add a window

Pane#split and Window#split add panes the same way. A new window takes a name: and the same command: as a session:

require "libtmux"
LibTmux::Server.start do |server|
session = server.new_session(name: "work", command: ["/bin/cat"])
window = session.new_window(name: "logs", command: ["/bin/cat"])
puts "window: #{window.snapshot.name}"
puts "session windows: #{session.list_windows.length}"
end

It prints:

window: logs
session windows: 2
  • cwd: names a directory on the machine running Ruby, and is checked before the command is sent.
  • environment: is a String-to-String map of literal values.
  • new_window(index:) refuses a slot that is taken.
  • Pane#split targets that exact pane, and size: is a cell count or a percentage string such as "40%".
  • New windows and splits keep focus unless you pass focus: true.

The full contract is in Creation.

Type into a pane and read it back

Pane#send_text: type, then read what the pane showed

send_text types literal text, and send_keys sends tmux key names such as Enter. Pane#capture reads the screen.

To wait for output, poll the pane until the line appears. A control connection reports it as an event instead, but needs tmux 3.7 or newer:

require "libtmux"
LibTmux::Server.start do |server|
created = server.new_session(
name: "capture", command: ["/bin/sh"],
environment: {"ENV" => "/dev/null"}, receipt: true
)
pane = created.pane
pane.send_text("printf '\\nlibtmux capture ready\\n'")
pane.send_keys("Enter")
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
captured = pane.capture.stdout.lines(chomp: true)
until captured.include?("libtmux capture ready")
raise "Output did not arrive within five seconds" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
sleep 0.05
captured = pane.capture.stdout.lines(chomp: true)
end
raise "Expected output is absent from the pane" unless captured.include?("libtmux capture ready")
puts "libtmux capture ready"
end

It prints:

libtmux capture ready
  • Sending input does not show that the shell finished the command.
  • capture returns the visible screen. start: and finish: choose a range, and join: joins wrapped lines.
  • escapes:, escape_bytes:, preserve_trailing: and trim_trailing: change the bytes it returns.
  • mode_screen:, alternate: and pending: read a copy-mode screen, the saved screen and an unfinished escape sequence. Pass at most one.
  • Binary buffers keep their trailing newlines. See write_buffer and read_buffer in the layout and bytes recipe.

The full contract is in Pane input and capture.

Use the tmux server you already run

Server.new: borrow a daemon

Server.open and Server.new bind an endpoint that already exists. Closing the binding retires its clients and leaves the daemon running; Server#kill stops it.

The outer Server.start block here only supplies a daemon to borrow:

require "libtmux"
LibTmux::Server.start do |owner|
owner.new_session(name: "work", command: ["/bin/cat"])
server = LibTmux::Server.new(endpoint: owner.endpoint)
begin
puts "connected sessions: #{server.list_sessions.length}"
ensure
server.close
end
puts "sessions after client close: #{owner.list_sessions.length}"
end

It prints:

connected sessions: 1
sessions after client close: 1
  • Endpoint.new takes an explicit socket path or socket name. Endpoint.from_env is the opt-in for $TMUX.
  • A binding keeps a private link to the socket it bound, so commands never reach a different daemon that later takes the same path.
  • A ref is valid on the server that issued it. Another server raises TargetNotFoundError.

See ownership and errors.

Wait, time out, and cancel

Cancellation.new: wake a request that is waiting

Typed operations take timeout: and cancel:. Pass a Cancellation token as cancel:, call cancel from another thread to wake the request, join its callers, then close the token:

require "libtmux"
LibTmux::Server.start do |server|
server.new_session(name: "work", command: ["/bin/cat"])
cancellation = LibTmux::Cancellation.new
waiting = Thread.new do
server.run(["wait-for", "-S", "ready", ";", "wait-for", "held"], timeout: 5, cancel: cancellation)
rescue LibTmux::Cancelled => error
error
end
server.wait_for("ready", timeout: 5)
cancellation.cancel
failure = waiting.value
puts "request: #{failure.class}"
puts "delivery: #{failure.delivery}"
cancellation.close
end

It prints:

request: LibTmux::Cancelled
delivery: possibly_sent

Cancelled reports delivery as :not_sent or :possibly_sent. Cancelling does not undo what tmux already queued.

The plain-Ruby recipe also checks that the client was reaped. The contracts are in Cancellation tokens and ownership and errors.

A WindowLink keeps a session, an index and a window ID together, so a window linked twice stays one window. link_window needs an explicit index::

require "libtmux"
LibTmux::Server.start do |server|
session = server.new_session(name: "links", command: ["/bin/cat"])
window = session.list_windows.fetch(0)
session.link_window(window.ref, index: 4)
session.link_window(window.ref, index: 9)
links = session.list_window_links
puts "indexes: #{links.map(&:index).join(', ')}"
puts "windows: #{links.map { |link| link.window.id }.uniq.join(', ')}"
end

It prints:

indexes: 0, 4, 9
windows: @0
  • Select, unlink, move, swap and display check the whole link identity in the same tmux queue turn as the operation.
  • A hook that waits before dispatch cannot turn a stale link into its replacement.
  • Rewriting command aliases during an operation is outside that guarantee, so coordinate configuration changes.

The contract is in Window links, and the linked-windows recipe runs this at three indexes.

Read and set options and hooks

Session#options: a value with its inheritance

Options#set writes an option at the scope of its accessor. Options#get reads it back as an OptionValue, and as converts it strictly. Hooks#set stores a tmux command:

require "libtmux"
LibTmux::Server.start do |server|
session = server.new_session(name: "work", command: ["/bin/cat"])
session.options.set("history-limit", 5000)
limit = session.options.get("history-limit")
puts "history-limit: #{limit.as(:integer)}"
puts "inherited: #{limit.inherited?}"
session.hooks.set("after-new-window", command: "display-message created")
puts "hook: #{session.hooks.get('after-new-window').as(:string)}"
end

It prints:

history-limit: 5000
inherited: false
hook: display-message created
  • An OptionValue keeps the raw bytes, whether the value is inherited, and the array index. as takes :bytes, :string, :integer or :boolean.
  • A missing array index raises NoMatchError, and a present empty value stays present.
  • Reading an array without an index raises MultipleMatchesError when it has several entries.
  • Hook values stay tmux command strings, and indexed hooks run in index order.

The contracts are in Options and hooks and Option values.

Watch tmux change

Server#open_control: events as they happen

A ControlConnection runs a command with exchange and streams what tmux reports through subscribe. The subscription’s next waits for the next ControlEvent:

require "libtmux"
LibTmux::Server.start do |server|
session = server.new_session(name: "work", command: ["/bin/cat"])
window = session.list_windows.fetch(0)
server.open_control(session: session.ref) do |control|
reply = control.exchange("display-message -p ready", timeout: 5)
puts "reply: #{reply.blocks.last.body.strip}"
events = control.subscribe(max_events: 64, max_bytes: 65_536)
window.rename("renamed")
event = events.next(timeout: 5) until event&.raw&.start_with?("%window-renamed")
puts "event: #{event.raw.strip}"
end
end

It prints:

reply: ready
event: %window-renamed @0 renamed
  • exchange answers a GuardedReply. It holds the blocks seen between two boundaries, and a hook can print into the same interval.
  • A subscription is bounded. A reliable one raises SubscriptionOverflow when it falls behind, and a tail one reports the lost range as a :gap event.
  • pause_output and resume_output return a GuardedReply. A reply does not show that the action took effect.
  • To reconnect, close the old connection and pass it as open_control(session: ref, reconnect: old_connection). The replacement has a new generation, and every new subscription starts with a :reconnect gap. Nothing is replayed.

The contracts are in Control connections, and the control overflow recipe runs both subscription modes.

Run several commands in order

Server#run_group: several commands through one client

A group submits several tmux commands in order through one client. The GroupResult holds one aggregate status, and each step reports unknown:

require "libtmux"
LibTmux::Server.start do |server|
server.new_session(name: "work", command: ["/bin/cat"])
group = server.run_group([
["set-option", "-g", "@before", "kept"],
["select-pane", "-t", "%4294967294"],
["set-option", "-g", "@after", "never run"]
])
puts "success: #{group.success?}"
puts "steps: #{group.steps.map { |step| step.fetch(:outcome) }.join(', ')}"
puts "@before: #{server.options(scope: :session).get('@before').as(:string)}"
end

It prints:

success: false
steps: unknown, unknown, unknown
@before: kept

The first command keeps its effect when the second fails, and the third never runs. The group recipe checks the same.

Packages

Start with libtmux. Add the companion for your caller:

GemRequireUse it for
libtmuxlibtmuxBlocking scripts, snapshots, and control connections
libtmux-asynclibtmux/asyncConcurrent commands and bounded streams in Async tasks
libtmux-mcplibtmux/mcpAn MCP server with explicit endpoints and tool policy
libtmux-workspacelibtmux/workspaceYAML/JSON workspace plans and a CLI to apply them

libtmux: the core

This gem: blocking server, session, window and pane handles, immutable snapshots, and control connections. The sections above run against it.

API reference · Guides · RubyGems

libtmux-async: concurrent commands

Concurrent commands and bounded control streams inside an Async task that your application owns. Require libtmux/async, then open a scope with LibTmux::Async.open.

API reference · Guide · RubyGems

Terminal window
$ gem install libtmux-async --pre

Capture every pane concurrently with Scope#map. server is an open LibTmux::Server:

Async do |parent|
LibTmux::Async.open(parent: parent, server: server) do |scope|
refs = scope.server.list_panes.map(&:ref)
screens = scope.map(refs, concurrency: 2) { |ref| scope.server.pane(ref).capture }
Example.check(screens.length == refs.length && screens.all?(&:success?), "concurrent captures failed")
end
end.wait

libtmux-mcp: a tmux server for MCP clients

An MCP server over stdio for one tmux server, with an explicit tool policy. Installs libtmux-mcp; require libtmux/mcp to embed it through LibTmux::MCP::Application.

API reference · Tools · Guides · RubyGems

Terminal window
$ gem install libtmux-mcp --pre

The server borrows a tmux server that is already running. Start one on a socket of its own:

Terminal window
$ tmux -L agent new-session -d -s work

Add the server to Claude Code. Until you enable more tools, it serves tmux_capabilities and tmux_snapshot:

Terminal window
$ claude mcp add tmux -- libtmux-mcp --socket-name agent --endpoint dev

The MCP README covers other clients, every tool and authored shell commands.

libtmux-workspace: YAML workspaces

YAML or JSON workspaces, validated, planned offline and applied to an open server. Installs libtmux-workspace; require libtmux/workspace to use it from Ruby through LibTmux::Workspace.

API reference · Command reference · Guides · RubyGems

Terminal window
$ gem install libtmux-workspace --pre

Save this workspace as workspace.yaml:

session_name: work
environment:
PROJECT_MODE: development
windows:
- window_name: editor
window_index: 1
layout: tiled
panes:
- shell_command: printf 'editor ready\n'
- {}

Check it without contacting tmux:

Terminal window
$ libtmux-workspace validate workspace.yaml
Workspace configuration is valid.

The workspace README loads it into a running server and lists every option.

What it leaves out

  • Live objects. A snapshot record is what tmux said when you read it, and a handle reads tmux again each time you list. See Captured records and Live handles.
  • A terminal emulator. Pane#capture returns the bytes tmux holds for the screen, with the flags above to shape them.
  • An implied server. Endpoint.new needs an explicit socket, and no API in the recipes uses the default server.
  • Replay. A reconnected control connection reports a gap and never replays requests or missed output. See Control flow.
  • Transactions. A group runs in order and keeps the effects of the steps before a failure.
  • Whole-program typing. RBS declarations ship with the gem and selected installed calls are checked. See Signature proof.

Support and status

Alpha. APIs may change between releases. See the initial alpha notes.

  • Ruby 3.3 or newer. The gem depends on json, tmpdir, securerandom, fiddle and io-console.
  • tmux 3.3a or newer. Unpatched 3.3 is below the minimum. A server handle checks the connected daemon’s release before it sends a command, and refuses an older daemon even when its client is newer.
  • Control connections need tmux 3.7 or newer. An older daemon refuses open_control before a control client attaches. Subprocess operations and snapshots do not need it. The optional feature table lists the other per-release requirements.
  • Linux, including WSL, and macOS. The compatibility workflow runs Ruby 3.3, 3.4 and 4.0 with tmux 3.3a through 3.8 on Ubuntu 24.04. macOS is supported on a best-effort basis, and CI coverage for it is highly limited.
  • tmux master and old daemons. On Ubuntu the workflow also runs the suite against a pinned tmux master, and checks that tmux 3.0a, 3.1c, 3.2a and 3.3 daemons are refused.
  • Per-revision results. Read the workflow’s results for your revision before relying on one combination.

Documentation

ReferenceEvery public class and method, one page each
GuidesGetting started, server bindings and the Async scope
RecipesComplete programs with checked excerpts
Execution modesBlocking calls, Async tasks and control subscriptions
Ownership and errorsWhat close and failure mean
Public behaviorDefaults, ownership, blocking and failure of each call
Field catalogQuery fields and wire names
Public methodsEach exported method, its source and its contract
ContributingLocal builds, checks and the test loops

MIT. See LICENSE.

Esc

Type to search.

Reference preview Open reference