libtmux Reference MCP Search

Control mode vs one-shot

Edit this page on GitHub

pane.send_keys("...") looks like one call in every port. What happens underneath it — and what it costs — is not the same thing twice. Three distinct lanes show up, in some combination, across the eight ports:

  1. One-shot subprocess. Each command spawns a fresh tmux process, which parses argv, does the thing, prints its output, and exits. This is the default in every port, and the only lane in Python: every .cmd() call underneath the object API is a subprocess.Popen around a tmux invocation.
  2. A persistent control-mode client. tmux -C attach-session starts one long-lived tmux process that stays attached and speaks a line-oriented protocol over its stdout — commands go in, replies and asynchronous notifications (%window-add, %output, …) come out, without starting a process per call.
  3. One invocation, several commands. tmux accepts more than one command per invocation (;-joined, or one -F-tagged list-* per line). A port can fold several logical operations into a single process start without opening a control-mode connection at all.

Where each port draws the lineLink to section

PortOne-shotFolded invocationPersistent control client
Pythonevery calltest-only (ControlMode, libtmux._internal)
TypeScriptdefaultpipeline(), batch()connect() / watch() — notifications only, commands stay per-process
Goprocess pathplan / Runconnection (Session.OpenControl), streaming (OpenNotifications)
Rustplan feature, sequentialplan, foldedcontrol-mode feature
C#“One-shot” mode“Chained” mode (server.Chain())“Control” mode (EnterControlModeAsync)
C++bounded subprocess (default)ChainServer::control()Connection
Javaevery callnot documented here — see the port’s own reference
Swiftdefaultserver.connect() / .watch() (notifications; see below)

Two things are worth noticing in that table before you pick a lane.

Notifications and commands are separableLink to section

The most easily-missed distinction, and TypeScript states it most directly: a control-mode connection for reading tmux’s event stream is not the same decision as running your commands through it. TypeScript’s connect() returns the same handles as an ordinary server and adds an event observer; your commands — session.newWindow(...), pane.sendKeys(...) — still run as separate tmux processes even while connected. The reason given is blunt: control mode cannot delimit arbitrary alias-expanded or waiting command output truthfully, so commands that need trustworthy output keep using their own process.

Go and C# instead let a control-mode connection carry commands directly (Session.OpenControl, EnterControlModeAsync) as a genuine alternative to one-shot for repeated work — Go’s own comparison table calls it “one tmux client per lane” against “one tmux process per operation.” Rust’s control-mode feature does the same for its async engine. So “does control mode run my commands, or only tell me what changed?” is a real per-port question, not a detail — check the port’s own docs before assuming either answer.

A control client is a real clientLink to section

Every port that offers a persistent connection says a version of the same thing: opening one attaches a real tmux client. It shows up in list-clients, it increments session_attached, and it is visible to anything that keys off attachment — a destroy-unattached option, a client hook, tmux’s own idle-client accounting. Python’s ControlMode helper exists specifically to satisfy commands that require a real attached client in tests (display-popup, detach-client); it is libtmux._internal, not part of the public API, precisely because the rest of the library never needs one. Opening several connections at once (TypeScript’s watch() called twice, say) creates several such clients, each counted separately.

Why fold several commands into one invocationLink to section

Every mutation you make against a fresh session usually needs a second command right after it — read back the ID tmux assigned, list what now exists — so “create three windows” is naturally six processes: three to create, three to discover what was created. Folding removes half of that. TypeScript’s batch() runs several planned mutations and resolves every typed handle from one final snapshot; Go’s Plan and C#‘s Chain do the version of the same idea specific to their APIs; C++‘s Chain builds one argv carrying several commands. None of this needs an attached client — it’s still one tmux process, just given more to do per start.

What this costs in practiceLink to section

Two ports publish numbers, and they agree on the shape even though the absolute values are machine- and tmux-version-specific and not something to port to your own hardware:

  • Rust’s matrix example runs the same create-and-query workload five ways. Blocking sequential and async sequential both cost 6 processes for 6 dispatches; folding the same 6 into async batches costs 3 processes; routing them over a control-mode connection costs exactly 1.
  • C#‘s README reports the marginal cost of one more command in each mode, as medians against tmux 3.7b: roughly 2.3 ms for another one-shot process, roughly 0.2 ms for another command over an already-open control client, and roughly 0.02 ms for another command folded into one chained invocation.

Read the crossover, not the digits: a control connection is cheaper per command because its client is already running, while a chain wins for a one-off batch because it pays exactly one round trip for the whole sequence and needs no attached client at all. For a handful of commands run once, one-shot is simplest and the difference doesn’t matter. Once you’re issuing tens of commands in a loop, or you need tmux’s own notifications rather than polling capture_pane on a timer, that’s the point to reach for whichever of the other two lanes your port offers.

Choosing a laneLink to section

Every port defaults to one-shot, so the code you write first is the code below. Where a port offers control mode, it is opt-in at the point the server handle is constructed — the object API above it does not change.

#include <libtmux/libtmux.hpp>
// One-shot: every call answers with a value; no tmux failure is thrown.
const auto server = libtmux::Server::at_default();
if (!server.has_value()) {
return 1;
}
const auto session = server->new_session("work");
if (!session.has_value()) {
return 1;
}
const auto pane = session->active_pane();
if (pane.has_value()) {
(void)pane->send_text("echo hello");
(void)pane->send_key("Enter");
}

Watch the cost directly — one process per command is visible from outside:

Terminal window
$ tmux -C attach-session -t work
Esc

Type to search.