This document defines the architecture of the Go module.
- The module path is
github.com/libtmux/libtmux-go; its root package name istmux. - The language baseline is Go 1.26, and tracks upstream's support window rather than a version chosen once: Go supports a release until two newer ones exist, so the floor is the oldest release still receiving fixes. Raising it is routine, and the syntax it unlocks is taken rather than left on the table. No runtime dependency is accepted without a measured need and a focused bakeoff; the implementation is standard library only.
- Core and MCP both require tmux 3.2a or newer. Format fields and command flags
retain the same version gates as the Python library. On tmux 3.6 and later,
retained control clients add
no-detach-on-destroy, which strengthens survival after their startup session is destroyed without raising either support floor. - Operations that may wait for or execute tmux take
context.Contextfirst. Contexts are never stored in objects. - Ordinary APIs block. Callers decide whether to start goroutines.
- Public values are concrete and typed. Untyped maps are limited to explicit edge decoders and never form the object API. Generator specifications carry the Go types and public names used by generated APIs; documentation is not a substitute for compiler-visible type information.
- Matching a snapshot never executes tmux.
- Server handles and derived values are safe for concurrent method calls and concurrent reads. Optional warning handlers are invoked concurrently and must provide their own synchronization.
The nested module uses its own semantic versions and golang/vX.Y.Z tags. A
future v2 also adds /v2 to the module path. The Python release workflow must
accept only root vX.Y.Z tags before the first Go tag is published.
Placement follows the imports rather than taste, and every rule below was measured rather than assumed.
The library lives in tmux/ rather than at the repository root, so the last
element of its import path is the name of the package it names. Go permits a
mismatch and warns that it surprises readers; the large analogues resolve it the
same way, with go-github putting package github in github/ and
client_golang putting package prometheus in prometheus/. The import needs
no alias either way — the package clause supplies the identifier — so the reason
is the convention, not a compiler requirement.
tmuxtest sits beneath tmux because it imports it, which is where the
standard library puts httptest relative to http. tmuxq stays a sibling
because it imports nothing here and nothing here imports it. internal sits
beneath tmux because nothing outside tmux uses any of it, and putting it
there turns that from a habit into a rule the compiler enforces: an internal
package is importable only from beneath the parent of its internal directory,
which the consumer modules are not. A root internal/ would be visible to them,
and is the right home for anything genuinely shared — there is nothing today.
examples/ is a module of its own. Its examples import nothing outside this
repository now, and a module boundary is what keeps that true: an example
reaching for a dependency cannot put it in the tmux module's go.mod, which is
the module whose claim is that it has none.
The parity manifest lives in tmux/internal/parity because //go:embed cannot
reach outside the directory of the package that embeds it.
| Package | Responsibility |
|---|---|
tmux |
Server, object hierarchy, snapshots, commands, formats, options, hooks, environment, clients, filters, and model-specific errors |
tmuxq |
Model-free generic helpers for slices and iter.Seq values |
tmuxtest |
Real-tmux server lifecycle, control-client fixture, and environment helpers |
internal/tmuxcmd |
Subprocess transport, argv construction, and output splitting |
internal/generate |
Checked-in model, option, hook, format, and filter generation |
tmuxq imports no model package and owns its cardinality sentinels. Generated
filters live in tmux, so relations and predicates create no import cycle.
tmuxtest may import tmux, but tmux never imports tmuxtest. Real-tmux API
tests use package tmux_test; package-private tests stay process-free or use a
model-free internal harness.
tmux is large because tmux's own surface is, and it stays one package because
its parts cannot be separated without cycles. Session returns Window,
Window returns Pane, every model returns itself from an option read, and
generated filters name all four. Splitting along those types would require a
shared package holding most of the model anyway, or interfaces standing in for
concrete values the compatibility contract keeps concrete. Navigability is
therefore a naming and view-type problem rather than a packaging one, which is
why format values live behind Formats instead of on each receiver.
A dependency analysis over the root package -- asking each used object where it
was declared -- found control, filter and search reachable in one
direction only, and plan reachable once one shared argument builder moves.
They stay anyway. Extracting control would strand Session.OpenControl,
Server.NewSessionConnection, and the Server and Session
OpenNotifications methods from the models whose daemon identity they retain.
Extracting search would strand the eight methods that are its API; and
filter is a cycle in practice, because the search signatures name filter
types while the filter predicates name the model. The finding is recorded so
the question does not have to be reopened from scratch.
Plan follows that rule for a reason of its own. A recorded operation and the
method that runs the same command share one argv builder, which is what stops a
flag meaning one thing when it runs and another when it is planned. Those
builders are unexported, so an ops package would have to export them, or take
the request types with it into a package tmux then imports back. Recorded
operations therefore live beside the model in files named for their receiver,
the way pane_input.go and window_layout.go already are.
Server is an immutable configured handle over shared private connection
state. Session, Window, Pane, and Client are concrete values containing
typed snapshot state plus that handle for follow-up operations. Snapshot fields
never refresh implicitly; live relationships are context-first methods.
Relationship accessors on a record report whether the record carries relations
at all: Session.Windows, Session.Panes, Window.Panes, and
Window.LinkedSessions are comma-ok, matching Window.Session and
Pane.Window, which always were.
The rejected alternative was returning an empty slice, which is what a record
from a targeted lookup used to answer. It cannot be right: tmux destroys a
window when its last pane closes and a session when its last window closes, so
a materialized window with no panes does not exist, and Server.Snapshot reads
the whole server rather than a scope, so an empty relation is never a truthful
answer. A caller ranging over one got a loop that ran zero times with nothing
to say why -- and Refresh, which reads as "make this current", returned a
record whose panes had silently gone from two to none.
Which kinds a snapshot listed is recorded on it rather than inferred, because a
point lookup builds a real snapshot holding its own row: a window looked up by
ID has a snapshot with no pane index, which is indistinguishable from a window
whose panes were listed and found to be none. Server.Snapshot lists
everything; a point lookup and a search list one kind; the pane-from-environment
lookup projects one row into three.
A generated filter naming a relation does not match a record that cannot answer it, which is what the to-one branch always did with its found result.
Holding one record keeps its whole snapshot reachable, because that graph is
what the record navigates. Refresh returns a record materialized on its own,
which is the way to keep an identity without the graph, and is why its relations
report false.
Blocking list and refresh methods materialize a new Snapshot. A snapshot
contains complete indexes for sessions, winlinks, windows, panes, and clients.
It emits one window view per winlink. Linked windows can therefore share a
WindowID while retaining distinct SessionID, index, and active state. Pane
views follow those winlinks, matching the Python library's observable
resolution behavior.
Stable tmux identifiers use distinct string types: SessionID, WindowID,
PaneID, and ClientName. Materialized identity state is private and exposed
through non-stuttering read-only methods: Session.ID, Window.ID,
Window.Index, Pane.ID, Pane.Index, and Client.Name. Parent identities
retain their tmux vocabulary where needed, such as Pane.WindowID. Callers
obtain materialized values from server queries and lifecycle operations rather
than constructing partially initialized public structs.
Every format token is stored as its exact raw expansion and exposed through
Raw(name) (string, bool) on materialized models. The format specification
also assigns a closed value kind and the generator emits typed comma-ok
accessors. Examples include Pane.Active() (bool, bool),
Pane.Width() (int, bool), and Client.Created() (time.Time, bool). Boolean
formats accept only tmux's 0 and 1; integer and Unix-time formats use
strict decimal parsing. The typed accessor reports ok == false for an absent,
empty, or malformed expansion, while Raw preserves the distinction between
absence and a materialized empty value. Ambiguous composite formats remain
strings. Stable identifiers and tmux versions use their existing nominal Go
types.
An accessor drops its receiver's tmux prefix: session_name becomes
Session.Name, window_name becomes Window.Name, and
pane_current_command becomes Pane.CurrentCommand. Cross-scope projections
retain their scope prefix. The remaining words are capitalized by
internal/goname, the module's single implementation of the
lower_snake_case-to-Go convention: known initialisms take their fully
capitalized form and unseparated compounds are split, so pane_id becomes
Pane.ID, client_utf8 becomes Client.UTF8, and client_readonly becomes
Client.ReadOnly. Generated accessors and the parity omission guard both call
it, so a name the generator produces and a name the guard reserves cannot
diverge. The format specification records explicit names for
semantic collisions, including count formats that would otherwise collide
with hierarchy traversal. Generated comments begin with the Go identifier and
include the exact #{tmux_format} spelling, scope, and version floor. No
optional format value is represented by a shared pointer. Rows retain
field-availability and producing-version metadata.
Session, window, and client queries preserve Python's cross-scope active or
attached hierarchy projection. This consolidates the useful typed state from
neo.py into the canonical objects instead of creating a second object model.
Snapshots are immutable shared references assembled from one or more tmux
commands; they are observationally consistent, not atomic transactions.
Each build probes the connected server's PID, start time, socket, and version
before and after its listings and checks that identity on every nonempty row.
A server restart during collection is an invariant error, while ordinary
dangling relationships remain observable.
Each materialized record keeps that four-part daemon provenance. Its follow-up
commands wrap the requested operation in one tmux-side if-shell, comparing
the current PID, start time, and socket before execution. Under stable, trusted
tmux parser primitives and aliases, a replacement daemon rejects a stale
record before the requested command runs. The subprocess guard is not a
confinement boundary for a daemon that controls parsing. Record equality and
record-derived plan refs include the provenance; raw ID refs remain relative
to the server selected at execution. A plan containing refs from different
daemons is invalid before it sends anything.
Snapshot slice accessors return fresh slices, and iterators range over
materialized state only. Duplicate point lookup reports ambiguity. Live point
lookups (Server.Session, Server.Window, Server.Pane, and Server.Client)
and Refresh issue targeted tmux queries so tmux chooses the canonical winlink.
Live relationship resolvers materialize current state, retain explicit winlink
context, and report missing or ambiguous exact views. LinkedSessions
deduplicates session holders while preserving disappearance and error behavior.
A method that wraps a tmux command drops the receiver's noun from its Go name.
kill-pane becomes Pane.Kill, rename-session becomes Session.Rename,
link-window becomes Window.Link, and start-server becomes Server.Start.
The receiver already carries the noun at every call site, so repeating it in
the method name adds no information.
Naming a new wrapper takes three questions, in order.
First, does the tmux command's name contain the receiver's noun? If it does
not, nothing repeats: spell the command through internal/goname and stop.
Server.RefreshClient wraps refresh-client and Session.NewWindow wraps
new-window; neither repeats its own receiver.
Second, does the command create the object that noun names? A creation command
is named for its product, and a product that coincides with the receiver is not
a repetition of it. Pane.NewPane and Window.NewWindow keep the noun for the
reason Window.NewPane and Session.NewWindow carry it: the noun says what the
call returns, and Pane.New would not.
Third, is the noun a plural of the receiver's noun? Then the command ranges over
every such object rather than over the receiver. Pane.DisplayPanes wraps
display-panes, which numbers panes for tmux's current client and takes no pane
target at all, so Pane.Display would promise the receiver. A final word that
merely ends in s is not a plural of the receiver: server-access names one
feature, not a set of servers.
Otherwise drop the noun. What remains is the verb, and a word the Go signature
carries better than the tmux name does replaces that noun rather than joins it:
split-window on a window is Window.SplitPane, which names the pane it
returns. A method that wraps one flag of a general command is named for that
flag's effect rather than for the command's verb, as Pane.SetTitle is for
select-pane -T and Pane.KillOthers for kill-pane -a.
TestGoSurfaceDropsReceiverNounFromOperationNames enforces the rule over every
exported handwritten method of Server, Session, Window, Pane, and
Client, so a new command wrapper cannot adopt a repeated noun without a
recorded decision. Generated option, hook, and format members are outside the
rule: their names come from the tmux option and format specifications, where
window-status-style stays distinguishable from the session-scope
status-style.
Three shipped operations keep a noun this procedure drops. The list is closed, each entry records why the compressed spelling is worse than the repetition, and the guard rejects an entry that stops naming an operation the rule convicts, so the map cannot outlive the names it excuses.
| Method | tmux command | Why the noun stays |
|---|---|---|
Pane.BreakPane |
break-pane |
The bare verb loses the object of the break. Break reads as ending something rather than as moving the pane into a window of its own, and break-pane is the spelling in tmux's manual and in its default ! binding. |
Server.LockServer |
lock-server |
A bare Lock would sit beside Server.LockClient and lose the scope that distinguishes them: locking every attached client, not one. Lock is also Go's established name for acquiring a mutex, and Server is documented as safe for concurrent method calls. |
Server.ServerAccess |
server-access |
The receiver's noun is not the object of a verb here. server-access names tmux's access-control feature, so Server.Access would name no operation while reading like an accessor. |
Each entry is a lexical accident rather than a category: a verb that cannot stand alone, a collision with an established Go name, and a command name that is not verb-plus-object. A command that hits one of these joins the table with its reason; the procedure itself does not acquire a clause.
Part of this surface is guarded twice. The parity omission guard converts each
omitted Python name with internal/goname and reserves that spelling, which
already rejects Pane.ResizePane, Pane.SelectPane, Server.KillServer,
Session.AttachSession, Session.KillSession, Window.KillWindow,
Window.SelectWindow, and Window.SplitWindow. That coverage is a side effect
of Python's deprecations rather than a policy, so it says nothing about a
command with no Python history, such as tmux 3.7's new-pane. The receiver-noun
guard is what covers those.
An operation takes one of four parameter shapes, chosen by what the tmux command requires rather than by how many arguments it happens to have.
| Shape | Count | Used when |
|---|---|---|
(ctx) |
57 | The receiver already names everything the command needs |
(ctx, values...) |
70 | Every value is required, and there are few enough to read positionally |
(ctx, request) |
65 | Any field is optional, so a zero field means "let tmux choose" |
(ctx, values..., options) |
25 | Some values are required and the rest are optional flags |
Window.Kill needs nothing beyond its receiver. Session.Rename needs exactly
one required name. Pane.Capture takes a request because almost every capture
field is optional. Session.SetOption keeps the option name and value
positional, because omitting either is meaningless, and groups the flags that
modify the write into SetOptionOptions.
The fourth shape is why a caller writes SetOptionOptions{} at a call site
that sets no flags. The alternative, folding name and value into the request,
would let a caller omit them and turn a compile error into a runtime one. The
zero value of every options and request type is documented as the behavior a
caller gets by leaving it empty, so the empty literal is a statement that no
flag applies rather than a placeholder.
A wrapper that changes tmux state either hands back a freshly materialized
model or returns only error. Records never refresh in place, so this choice
decides whether the caller's next line can keep using the record it already
holds.
A wrapper returns a model when the tmux command changes the record's
identity, placement, extent, selection, or process.
Identity is a name, a pane title, and the fact of existing. Placement is the
session and index the view lives at. Extent is the record's own width and
height, not its children's. Selection is which sibling is current or active.
Process is what respawn-window and respawn-pane replace. Every other change
returns error.
The facets are not a list of interesting fields; they are the fields that decide which tmux object the caller is holding and what it looks like. A caller who changes one of them almost always wants to read it back, so the wrapper fetches it once instead of making every caller write the same follow-up query.
That convenience is not free, and the rule is a judgement rather than a
necessity. tmux prints nothing on a successful mutation, so a returned model is
always an extra materialization: measured against tmux 3.7b, a point lookup
costs three tmux invocations, Window.SelectLayout adds one more, while
Window.Rename adds four and Pane.Select adds seven. A caller who wanted the
fresh record would pay the same to call Refresh, and a caller who did not
pays anyway. Nothing in tmux forces the choice either way: a stale view still
resolves, because tmux matches a %pane or @window identifier ahead of the
session and index that precede it.
Everything else returns error, including the materialized fields that
describe what a record is doing: pane_in_mode and pane_mode, pane_pipe
and pane_pipe_pid, history_size, window_layout, window_linked,
session_attached. Each of those moves without the library when a client
detaches, the terminal user leaves copy mode, or the piped child exits, so a
record refreshed for one of them reports a value the caller does not own. This
is why Session.Attach, Pane.CopyMode, and Pane.Pipe return error
despite changing fields their receivers materialize, and why
Window.SelectLayout does while Window.Rotate does not: rotation reassigns
the active pane and every pane_index, and layout selection changes only the
panes' geometry.
Three consequences follow from the rule rather than extending it. Destruction
returns error because no record is left to materialize; Kill, KillOthers,
KillWith, KillWindow, and Unlink document that their receiver is no
longer live. Creation returns the created record only when tmux reports its
identity, which is why new-session, new-window, split-window, new-pane,
and break-pane carry -P -F and return their product while Window.Link,
whose link-window prints nothing, returns error. And the model that comes
back is the one the operation redefines rather than always the receiver:
creations return the new record, Window.Swap and Pane.Swap return both
endpoints in a result value, and a parent-scoped selection returns the child it
selected, so Session.SelectWindow yields a Window and Window.SelectPane a
Pane.
Options, hooks, environment variables, buffers, key bindings, and prompt
history return error for the same reason as a group: tmux stores them outside
the four records, and they are read back through Options, Hooks,
ShowEnvironment, ListBuffers, RawOption, and RawHook.
Naming a new wrapper's result takes one question. After this command returns,
is the caller's record wrong about which object it is, where it lives, how big
it is, whether it is the selected one, or what process it runs? Return a model
if it is, and error if it is not.
Every dispatched tmux command crosses one unexported executor method. Direct
control-mode processes retain their documented lifecycle. The request value and
argv builders remain private; CommandResult is public and contains argv,
decoded stdout lines, exact stdout bytes, decoded stderr lines, and exit status.
Server.Cmd(ctx, args...) preserves raw tmux behavior:
- a nonzero tmux exit is result data, not a Go transport error;
- cancellation, deadline expiry, and process-start failures are Go errors;
- no command is executed through a shell;
- decoded
Stdoutpreserves the line-oriented compatibility API, whileRawStdoutpreserves tmux's emitted bytes including delimiters and trailing newlines; - a failed high-level operation becomes a
CommandError.
A request naming a flag the running tmux does not have is refused, with a
VersionTooLowError carrying the subcommand and the capability alongside both
versions. ServerOptions.Unsupported chooses the other behavior, which omits
the flag, runs the reduced command, and reports it to
ServerOptions.WarningHandler.
Refusing is the default because dropping a flag changes what the command does
and says nothing about it. A split asked to leave a pane empty starts a shell
in it; on tmux 3.6 raw split-window -E creates no pane at all, so degrading
delivers a running process the caller asked not to have. run-shell asked for
arguments runs without them, and kill-session asked for a session group takes
one session instead of the group. Each returned success.
Python signals the same decision with warnings.warn, which prints to stderr
and is therefore seen. Go's nearest equivalent is a handler that is nil until a
caller installs one, so translating the behavior directly turned a visible
degradation into a silent one. The switch keeps the behavior available and
makes choosing it deliberate.
A Plan applies the policy of the server it runs on, so a recorded step
refuses exactly what the same request refuses when issued directly.
Plan.Preview renders without a server and so refuses, which is what a preview
that exists to catch what tmux would reject is for.
ServerOptions.WarningHandler receives concrete warning values. The default
handler is silent. A configured log/slog logger may receive structured
diagnostics, but library code never installs handlers or logs environment
values.
CommandError implements error and is discoverable with errors.As.
Library-created high-level errors retain an owned copy of the completed
CommandResult, including argv, decoded stdout, exact stdout bytes, and stderr.
Its Error text includes stderr so ordinary operation failures preserve tmux's
diagnostic without a second raw command. Operations whose primary payload may
contain secrets opt into a redacted error that retains only the exit code. This
applies to buffer contents, environment values and output, command-bearing
creation and interactive requests, server execution and key commands, respawn
commands, pipe commands, popups, and interactive attachment.
Callers must treat generic CommandError.Result diagnostics as potentially
sensitive when their own command operands contain secrets.
VersionQueryError follows the same exit-only rule, while
SnapshotDecodeError identifies the object, record, and field but redacts the
malformed field value.
Pure private request builders can seed a future deferred operation graph. An exported operation hierarchy is deferred until a real downstream consumer fixes its required identity, batching, and error semantics.
NewServer creates one plain binding. It freezes the absolute tmux executable,
environment, working directory, configuration, and socket selection, and every
record derived from it keeps that binding. Ordinary operations start the frozen
executable. There is no generic transport selector on Server.
Session.OpenControl creates an owned Connection under the subprocess
guard's parser assumptions. A connection established before replacement is
the exact-daemon boundary. Server.NewSessionConnection creates a session and
retains its creating control process as the first lane. Each connection owns
one or more internal command lanes and exposes bound Server and Session
values. Records derived from those values retain the same owner.
Plain and connection-bound records may coexist. A record materialized before a
connection remains plain; obtaining a connection does not mutate or rebind it.
Callers use Connection.Server or Connection.Session when they want the
connected path. This makes the subprocess cost and connection lifetime visible
in the value being used.
The connection binding is terminal. After close, operations return
ErrControlClosed. An exact-byte read, interactive attachment, or other
operation that needs a separate process returns
ErrConnectionRequiresProcess. A bound value cannot retarget its socket, open
another connection, or fall back to the plain runner. These refusals keep one
value on one verified daemon and prevent a transport failure from turning into
an unobserved second execution.
Dispatch still classifies argv internally. A server command carries only the
tmux command over a lane; process work includes the frozen client selectors and
may own stdio. The library classifies requests where it builds them rather than
re-parsing flat argv and duplicating tmux's client-level getopt grammar.
Completed command failures retain the same nonzero ExitCode and Stderr
through both bindings, so CommandError, missing-target classification, and
list policy do not depend on the route. A transport failure remains a Go error.
Connection.Close owns lane shutdown; copyable Server and model values do
not close shared state.
The rejected generic engine made transport capability, daemon identity, fallback policy, rebinding, and borrowed lifetime independent optional states. Most combinations were invalid. Two concrete bindings express the actual contract: a frozen subprocess server or an owned, fail-closed connection. The subprocess executor is private; tests that need process fidelity use executable fixtures, while package tests inject the private boundary.
NewServer(ServerOptions) (Server, error) validates and freezes one immutable
subprocess binding without starting tmux. It snapshots the effective
environment and working directory, resolves one absolute executable with them,
and uses that path and environment when it later starts subprocesses or control
clients. It also freezes the effective socket path; named and default selectors
receive a canonical TMUX_TMPDIR, while an inherited TMUX path becomes
explicit before lifecycle code scrubs that variable. Invalid options, an
unavailable executable, or a failed working-directory snapshot return an
error. The zero Server is invalid; operations return ErrInvalidServer.
Policy copies share private coordination and copy only immutable handle fields.
Representative signatures are:
func NewServer(options ServerOptions) (Server, error)
func (s Server) Cmd(ctx context.Context, args ...string) (CommandResult, error)
func (s Server) Sessions(ctx context.Context) ([]Session, error)
func (s Server) IsAlive(ctx context.Context) (bool, error)
func (s Session) OpenControl(
ctx context.Context,
options ConnectionOptions,
) (*Connection, error)
func (s Server) NewSessionConnection(
ctx context.Context,
request NewSessionRequest,
options ConnectionOptions,
) (Session, *Connection, error)
func (f PaneFilter) Predicate() (func(*Pane) bool, error)A list-shaped accessor returns a failure rather than an empty collection. There is no switch: an empty result means tmux answered and had nothing to report. Invalid arguments, context errors, decode failures, unsupported schema, and violated snapshot invariants remain errors of their own.
The rejected alternative was Python's, where a failed accessor yields an empty result. Its cost is not that a caller is misinformed but that it cannot tell: a socket path with a typo, a socket this process may not read, and a path that is not a socket all produce the same empty answer as a server holding nothing. The pattern that consumes it is ordinary — create the environment when none is found — and under leniency a wrong socket path makes it build a second environment beside the one it was pointed away from.
Leniency also could not be narrowed to the safe case. Every lenient branch caught any nonzero exit or transport failure without classifying it, so inverting the default would have kept the same undifferentiated catch behind a switch rather than removing it.
ErrNoServer classifies the one failure a caller routinely acts on. A tmux
server holding no sessions exits, so an absent server and an empty one are the
same state, and a program that starts what it does not find needs to recognize
it. It classifies rather than replaces: the error is returned either way, so a
socket that cannot be used is never mistaken for one with nothing on it.
It does not separate an absent server from an unreachable one, because tmux
does not. client.c treats ECONNREFUSED and ENOENT alike, prints a
constant message only for the first, and renders every other errno through
strerror, whose text follows the process locale. Matching that text would
make the classification locale-dependent, which is a worse contract than
declining to draw the line. Acting on the sentinel stays safe because creating
what was not found reports tmux's own refusal.
The policy decision a caller does want belongs to the caller. The MCP server answers "what panes are there" on an unstarted server with none, because a client orienting itself before starting anything is its ordinary opening move; it says so in one place rather than inheriting it from the library.
IsAlive draws the same line: only ErrNoServer reports false without an
error, so a probe cannot report a server as absent when the socket is merely
unusable. Server.Kill depends on that — it confirms a kill by observing the
daemon, and a probe that merely failed no longer reads as one that proved
death.
Each model has a generated JSON criteria struct. Pointer scalars express
tri-state fields; non-nil empty membership and composition slices are invalid
rather than silent match-nothing filters. Ptr returns a pointer to a copied
value for the uncommon case where a meaningful zero must remain distinct from
absence. The filter generator emits value-returning exact-match constructors,
such as PaneCommandIs, PaneIndexIs, and WindowNameIs, so common criteria
do not require pointer temporaries. Fields in one struct combine with AND.
AnyOf, Not, to-one relations, and Some, Every, and None to-many
relations follow the supplied schema. Empty relations satisfy Every.
Validate rejects invalid regular expressions, malformed relation nodes,
cycles, excessive recursive composition, invalid stable IDs and indices, and
criteria whose scalar intersections or identical positive and negative nodes
prove they cannot match. Validation does not attempt general regular-expression
intersection or boolean satisfiability. Cycle and depth checks occur before
recursive compilation, including across model relations. Predicate() (func(*T) bool, error) validates and compiles strings and sets once. Compiled
state never enters JSON.
tmuxq.Where returns a fresh slice and passes its predicate a pointer to a
shallow scratch copy, never a pointer into the input slice. Top-level predicate
mutations are discarded, but referenced state still aliases the caller; generic
predicates are for inspection and must not mutate or retain the value. The tmux
model values contain immutable private state. First uses comma-ok;
ExactlyOne returns errors compatible with ErrNoMatch and
ErrMultipleMatches. Separate helpers accept iter.Seq[T].
The struct is the wire form. The generated ParseSessionLookup,
ParseWindowLookup, ParsePaneLookup, and ParseClientLookup edge functions
accept stable JSON field and relation names separated by __. Scalar operators
take one string value; in and nin take one or more. To-many paths lower to
Some, or None for nin; other legacy string operators lower to exact,
contains, membership, negation, or regex fields. Unsupported Python-only
callable or dynamic lookups return ErrInvalidFilter. Local closures do not
serialize.
Existing raw tmux filtering remains a separate live API. Server, session, and
window search methods accept a named TmuxFilter string and pass it through to
tmux -f; buffer listing retains its raw filter. Malformed raw filters preserve
tmux behavior. Typed criteria never masquerade as raw tmux expressions. Their
inspectable shape leaves future typed pushdown open while snapshot filtering
stays purely local.
Generated filter JSON decoders reject unknown and duplicate fields. Missing or
null pointer fields remain unset; explicit empty slices remain present and are
validated as impossible where required. New schema fields are additive but are
not silently accepted by older decoders. Schema versions are published outside
the wire object, so the object itself remains the shared wire form.
Raw option, hook, and environment entries retain their exact tmux strings.
Generated typed accessors and setters cover known options with the same Go
value shape. Flags use bool, numbers use int64, and each tmux choice option
has a distinct generated string type with prefixed constants and Valid.
Choice reads preserve unknown future values as invalid named values; typed
setters reject values unavailable to the connected tmux version. Raw setters
remain the escape hatch for unknown names, future values, append behavior, and
set-option flags.
Sparse array setters replace the local array. They first establish an explicit empty local array, then apply caller-owned entries in ascending index order. The empty sparse value therefore remains present rather than unsetting to an inherited value. The returned result reports replacement and confirmed applied indices; a failure stops without rollback. Raw unset remains the operation for absence and inheritance.
Command aliases, terminal features, and terminal overrides also expose immutable parsed projections. Malformed subentries return redacted decode errors alongside the valid partial projection; the library does not log them.
Server, session, window, and pane scopes share private request builders but
expose scope-specific concrete APIs. Session, window, and pane typed setters
are methods on those receivers. Server.GlobalSessionScope and
Server.GlobalWindowScope return concrete value handles that own the global
option and hook operations; this keeps the scope in the type system and avoids
prefixing a global operation matrix onto Server. Server-scope option setters
remain direct server methods. Option and hook setters return error under the
rule in "Operation result shape": tmux keeps this state outside the four
records, so a setter has no record to refresh and Options, Hooks,
RawOption, and RawHook are the reads.
Request fields use plain values when the zero value can unambiguously mean "unset". This includes positive-only dimensions, counts, adjustments, and optional nominal targets whose zero value is not a valid tmux target. Pointers remain only where zero or an empty string is meaningful to tmux, and for recursive filter structure. Requests copy pointer and map inputs before any version query or subprocess call that could let caller mutation change a validated request. Contexts are never stored.
Session.OpenControl is the ownership boundary for ordinary object operations.
It opens one or more control-mode command lanes under the materialized
session's daemon predicate and returns a Connection. Its Server and
Session values retain a private pointer to that owner through every derived
record. On tmux 3.6 and later, lanes set no-detach-on-destroy, so destroying
the initial session moves their clients to another session when one exists
without changing the retained session record. Earlier releases use the same
control protocol and inherit the session's detach-on-destroy policy. The
binding is terminal: close makes later operations return
ErrControlClosed, and retargeting the socket, opening another connection, or
requesting an operation that needs a separate process cannot detach it.
Exact-byte reads and interactive attachment return
ErrConnectionRequiresProcess; no fallback is attempted.
The initial tmux -C process executes if-shell -F as its first command. Its
startup guard has the subprocess path's stable, trusted parser assumption.
Once the original daemon accepts the connection, later replacement closes the
lane instead of retargeting it. The startup reader consumes the outer predicate
and inner attach frames before calibrating request boundaries.
Connection.CloseContext always begins shutdown and uses its context only to
bound the join.
Server.OpenControl starts an attached tmux -C process and returns a
low-level ControlClient. It exposes commands and notifications on one client;
it is not a transport selector for model values. Reconnect crosses a private
reply boundary, opens a replacement on the last attached session observed at
that boundary, then closes the old client. Replacement failure does not
initiate old-client shutdown; old-client shutdown failure returns the live
replacement with the error. A naturally ended client can recover from its last
observed session only after the reader drains cleanly. It never hides recovery
behind command fallback or replays a command.
The client validates %begin/%end/%error framing, serializes concurrent
commands, correlates each reply by command number, and buffers ordered
notifications in a bounded in-memory queue. On overflow it keeps draining
tmux's stdout, preserves the queued prefix, and then reports a typed error.
Raw control clients retain the full stream. High-level command lanes retain no
notification queue and ask tmux not to send pane output. Notification streams
always retain the queue; zero options suppress pane output, while
IncludePaneOutput enables it. %output payloads are decoded to pane IDs and
exact bytes.
Command arguments are encoded without a shell. Single-quoted spans preserve printable bytes, adjacent quoted spans preserve literal single quotes, and tmux octal syntax carries control and non-ASCII bytes. NUL is rejected because tmux cannot represent it in an argument.
The startup context bounds process start, attach framing, and registration; it
does not own the returned client's lifetime. A command canceled after its write
returns promptly, but the request loop drains that reply before writing the next
command. Natural exit preserves queued notifications through io.EOF.
Reader failures surface after earlier notifications drain. CloseContext
rejects unaccepted requests, gives an accepted command a bounded frame-drain
window, and is idempotent and retryable.
ControlCommandResult.RawStdout preserves the control frame payload exactly.
That payload is tmux's version-specific control rendering and is not normalized
into the byte-exact pane and buffer semantics of Pane.CaptureBytes and
Server.ShowBufferBytes.
The tmuxtest.ControlMode raw-stream fixture remains available for parser and
transport tests.
ControlClient.Notifications is NextNotification as an iter.Seq2: it ends
at io.EOF without an error because the end of a stream is not a failure, and
continues past a record it could not parse because a tmux sending a
notification kind this package does not know must not end a watcher.
Server.OpenNotifications and Session.OpenNotifications wrap the same
protocol in an owned, observation-only NotificationStream whose Next
preserves wire order. Before tmux 3.6, destroying the attached session follows
that session's detach-on-destroy policy and may end the stream.
Pane.OpenObservation linearizes a visible baseline with its dedicated
notification stream. Copies of a PaneObservation share one serialized reader
and terminal state. Caller cancellation, an individual
ControlNotificationError, and explicit close do not become observation loss.
Topology loss, io.EOF, or a terminal reader failure permanently returns
ErrPaneObservationLost while preserving the underlying cause. tmux has no
pane-close notification, so a pane that ends while its window survives is
recognised by its absence from the window's next arrangement; an arrangement
this package cannot read is not evidence either way. A stream tmux ends for its
own reason, such as a control client too far behind, carries that reason into
the loss.
Plan records tmux commands as Op values and runs them together. It is
independent of the binding: the same plan means the same thing over a tmux
process and over a control connection, and the switch matrix asserts that on
every supported tmux rather than claiming it.
Identity is a Ref, addressing either an object that exists or the one a
recorded step will create. A step's ref carries its one-based index, so the
zero Ref is not silently a reference to the first step, and a plan holding
one is refused rather than guessed at. This is what lets a build be written in
one pass: a split is recorded and keys sent to its pane before tmux has been
asked for anything.
Batching is a Planner, and planners are values rather than names in a
registry, so selecting one is a compiler-checked expression and a caller can
supply their own. Sequential sends one command per invocation, Folding
groups each run of operations that neither answer nor create. Successful
results are identical through both; only the invocation count differs.
Plan.Preview renders without a server, and separates the two reasons an
operation cannot render there. A step naming an object an earlier step has yet
to create is nil, because that ID does not exist until the plan runs. Everything
else is an error naming the step: a plan is not atomic, so an argument tmux
would refuse at the last step is refused after every step before it has changed
something, and a preview reporting both the same way would hide the one that is
a defect.
Result identity is the part tmux does not provide. A command list returns one
exit status and one merged stdout with no boundary between its commands, so an
operation whose result is its output cannot be told apart from its neighbours.
Op.Chainable reports that, and rendering re-checks it rather than trusting a
Planner to have honoured it: a planner that groups a capturing or creating
operation is refused with a PlanError. A single-operation refusal is
OpFailed. Every operation in a refused command list is OpIndeterminate,
because tmux does not identify which command failed or which earlier commands
ran. Later dispatches are OpSkipped. Plan.Explain exposes that attribution
boundary before execution.
The consumer modules here stayed on the direct API, which is a finding rather
than a gap. workspace.Build wants a materialized record after each step and
already opens a control connection, so its cost is transport rather than
batching; the MCP batch tool dispatches advertised tools whose structured
results are the point, so none of its calls are foldable. Transport and
batching are independent switches, and code needing a record per step declines
this one correctly.
Tests use one explicit -S socket per test. Every real-tmux test package has a
one-line TestMain wrapper that calls os.Exit(tmuxtest.Main(m)).
tmuxtest.Main(*testing.M) int performs all cleanup and restoration before it
returns the exit code. It gives the suite a short
temporary root and sets both TMPDIR and GOTMPDIR. Each server uses a private
directory beneath that suite root so its socket and config remain available for
fallback cleanup after a test returns. Socket paths are checked against the
portable 103-byte Unix socket-path ceiling and registered for suite cleanup.
Every tmux child removes inherited TMUX, TMUX_PANE, and TMUX_TMPDIR unless
a test explicitly opts in, and starts with an exact generated minimal -f
configuration. Server and control-client startup take a caller-owned context
first and apply an internal ceiling. t.Cleanup uses a fresh bounded context,
requests kill-server, probes the daemon currently answering on the owned
socket, and verifies that daemon's death before removing the socket. A daemon
that restarted on the same socket is therefore cleaned by its current PID
rather than confused with the PID recorded at startup. Cleanup never signals a
numeric PID because it may be reused after tmux exits. Unverifiable or failed
cleanup retains the server record and artifacts. Per-test cleanup retries once
and runs while Go unwinds a test panic. The suite registry retries retained
failures when m.Run returns; an unhandled test panic terminates the process
before that fallback can run, so no in-process harness can promise cleanup in
that case or after a forced kill. After verified daemon death, retries resume
artifact removal without issuing another tmux command through files an earlier
attempt may already have removed. The final probe and removal rely on the
harness's private socket namespace: no test may start a replacement daemon
after it has returned control to cleanup. Tests never share a server. Parser
and generator units may be process-free; behavior tests use real tmux.
The reusable harness is built on supported Unix Go platforms where tmux offers
filesystem -S sockets. Other platforms compile a clear unsupported-platform
path; they do not pretend to run real-tmux tests.
The compatibility matrix covers the same supported tmux releases as Python. The race lane stresses parallel server creation, global options, hooks, environment, cancellation, cleanup, and control-client teardown.
This is alpha. Releases carry an -alpha prerelease tag, and the lowest clean
one is v0.0.1-alpha.1: semver permits v0.0.0-alpha.1, but v0.0.0- is the
namespace the go command generates pseudo-versions in, so a real tag there reads
as one. Under semver a prerelease sorts below its own release, and v0.x already
carries no compatibility promise, so the tag says what the version number alone
would leave implied.
Each module is tagged in its own directory, which is how the go command finds a module that does not sit at the repository root:
| Module | Tag |
|---|---|
| the tmux module | vX.Y.Z |
mcp |
mcp/vX.Y.Z |
workspace |
workspace/vX.Y.Z |
benchmarks, examples |
not published |
The shape rather than the numbers: a table naming the current tags is a copy of
git tag that nothing updates.
A consumer module's require on the core has to name a version the proxy can
resolve, so the core is tagged before the modules that depend on it. The local
replace directives are what make the tree build before any of that exists;
they are not a substitute for the requirement.
The MCP server addresses the socket it was started with and nothing in a call
can retarget it: -socket-name, -socket-path, LIBTMUX_SOCKET, or
LIBTMUX_SOCKET_PATH, resolved at launch, with -doctor naming which was
taken. The shared cross-port contract uses the same one-socket process boundary.
This is a decision rather than an omission, and the reasoning is not ergonomics. An MCP server runs with the operator's authority and is driven by a model reading text it did not write. A per-call socket makes "which tmux" part of that text, so a model that is talked into a socket path reaches a tmux the operator never granted — every other server on the machine, including one holding a session someone is working in. Fixing the target at launch makes that unreachable by construction rather than by a check that has to be right every time.
Two alternatives were weighed. Taking a socket per call and validating it against an operator-declared allowlist keeps the reach and adds a list to get wrong, and the failure is silent: an allowlist with one entry too many is indistinguishable from a correct one until it matters. Running one server per tmux socket is what this design already supports and costs a process. Discovery is deliberately outside MCP: an operator points a second instance at another socket, while nothing a model says can reach it from the first one.
The cost is real and is not hidden: a client that wants two tmux servers runs two of these. That is the trade, taken deliberately.
| Problem | Selected approach | Rejected approaches |
|---|---|---|
| Execution | Private one-method runner plus concrete request/result values | Subprocess calls spread through objects; an exported operation hierarchy |
| Execution binding | Frozen subprocess Server values and owned exact-daemon Connection values |
An exported Engine/CommandKind selector with fallback; a name-keyed registry; argv re-parsed by each transport |
| Queries | Generated criteria with explicit validation and predicate compilation | Regex work inside every match; forcing filters through a generic matcher interface |
| Test isolation | Per-test explicit -S socket with two-layer cleanup |
Named -L sockets; a shared suite server |
| Batching | Recorded Op values, a forward-reference Ref, and planners as values |
A name-keyed planner registry; attributing a merged stdout to grouped operations by position; a Result interface or a type parameter per operation kind |
| MCP listing criteria | Typed criteria matched in Go against the snapshot the tool already takes | A caller-supplied tmux -f expression; typed criteria compiled into one |
| MCP long-running commands | Pane-visible state observed with bounded capture or wait tools; no MCP job handle | An in-process handle table; a handle encoding paths; one-shot collection |
| MCP per-pane state | A field on the listing's own row type | A field on the shared pane summary; a separate digest tool |
| MCP command output | run_shell_command reads the pane's grid between two cursor marks |
Teeing the command's bytes to a file; copying the pane's byte stream with pipe-pane |
The execution bakeoff showed that some apparently missing tmux targets still
return success for display-message; failure tests use commands with stable
failure semantics. The query bakeoff showed that compilation belongs before
iteration and that fresh-slice ownership plus isolated top-level value copies
can be preserved with one allocation.
The harness bakeoff exposed inherited pane targeting, stale named sockets,
shared global-state leakage, and the need to control both Go temporary-root
variables.
The listing bakeoff was settled by a security result rather than by ergonomics.
tmux's -f filter is a format, and a format containing #(...) reaches
format_job_get and runs it as a shell job. It does not reproduce from a
one-shot tmux client, because the job is filed under that client's job tree and
server_client_lost frees it microseconds later; it reproduces first try over a
control-mode client, which is what the MCP server holds open whenever the tmux
server has a session. Passing a caller's filter through would therefore have
made every listing tool an execution vector while it still reported
readOnlyHint: true, and only while the persistent client was present.
Compiling typed criteria into -f was rejected for the same reason one level
removed: the pushdown it buys is a local pipe carrying a few kilobytes, and it
puts a format assembler on the boundary for good.
The detached-command bakeoff settled two things. A handle carrying paths would make the server read caller-supplied paths later, and one-shot collection would stop answering when repeated. The earlier server therefore kept bounded handle state in process. The shared capability contract later removed background job handles entirely: the process already lives in a tmux pane, so its pane id and capture cursor remain observable across MCP restarts without a second lifecycle authority.
The per-pane state bakeoff was settled by measuring the tool list. Hanging the state off the shared pane summary added its schema to the four other tools that report a pane and never fill it in -- 304 bytes each, advertised to every client on every session. A row type belonging to the listing costs it once.
The command-output bakeoff was run because the grid arithmetic had accumulated five compensations -- an echo to remove, a grid that moved, a scrollback that was erased, a screen that was cleared, rows that wrapped -- and each one looked like a reason to replace the approach rather than patch it again. Twenty-five shapes were put to all three: the marks answered twenty-four, and both byte-capture approaches answered twenty.
The four they lost are the same four, and they are the point. A byte capture
returns what the program wrote; the grid returns what a terminal shows. So
clear arrives as ESC[H ESC[J ESC[3J, a progress bar written with carriage
returns arrives as every frame it drew rather than as the one it left, and a
coloured word arrives wrapped in escapes. tmux has already done that rendering,
and redoing it outside tmux is a terminal emulator.
Teeing lost a second time, decisively: a command whose stdout is a pipe is not
a command running in a terminal. [ -t 1 ] reports a pipe, so colour is off,
paging is off, and any program that branches on isatty takes the other
branch -- which is the opposite of what run_shell_command on a tmux pane is
for. pipe-pane keeps the tty in the core experiment, but it is not a public
MCP route; the portable surface answers with what the terminal shows.
The compensations are therefore the price of the answer, not evidence against the approach. What the bakeoff did find was a case none of them covered: a command whose whole output is blank lines was reported as having printed nothing, because a capture that is nothing but empty lines arrives as no lines at all. Both losers got it right for free, and the marks already count the rows, so the fix is theirs grafted onto the winner.
The batching bakeoff settled the result shape. Distinguishing what an operation
produced by its Go type reaches for either an interface with one implementation
or a type parameter that every plan method would have to thread, and both buy
less than they cost: an operation produces at most an ID, at most a stdout, and
a status. One OpResult carrying all three, with the fields an operation does
not produce left zero, is what the caller reads.