Drive tmux from C#: servers, sessions, windows, panes, clients, options, hooks and buffers, typed and asynchronous, for tmux 3.3a and newer on net8.0 and net10.0.
Run a command and read its exit status, find and filter what a server holds, type into a pane and wait for the answer, and choose how a call reaches tmux.
Add it to your project:
$ dotnet add package LibTmuxContents — Quick start · Is this for you? · Packages · Find things · Run a command · Panes · Filter · Options · Versions · Choose a path · Testing · Workspaces · Assistants · Leaves out · Support
Start a tmux server of your own, make a session, a window, and a pane, and type into the pane:
using LibTmux;
// A tmux server of its own on the socket "quickstart". Disposing the
// scope stops it, so nothing is left running.
await using OwnedServerScope owned = await Server.CreateOwnedAsync(
new ServerConnectionOptions { SocketName = "quickstart" });
Server server = owned.Value;
Session session = await server.CreateSessionAsync(new NewSessionRequest { Name = "build" });
Window window = await session.CreateWindowAsync(new NewWindowRequest { Name = "tests" });
Pane pane = (await window.GetPanesAsync())[0];
await pane.SendTextAsync("dotnet test");
Console.WriteLine($"{session.Name}:{window.Name}");Make a console project, then paste the program into Program.cs:
$ dotnet new console --output demo && \
cd demo && \
dotnet add package LibTmuxRun it with tmux 3.3a or newer on PATH:
$ dotnet runIt prints build:tests. Server.CreateOwnedAsync
starts the server on its own socket and stops it when the scope is disposed;
Server.ConnectAsync attaches to one that is already
running.
The csharp run blocks below continue from this program: server, session,
window, and pane are its variables, and ct is a CancellationToken.
Yes, if you want to drive a real terminal from code: build a dev environment, script a workspace, harness a TUI in tests, or give an assistant hands on a terminal.
Look elsewhere if you want a terminal emulator (this drives tmux and draws
nothing), full Windows support, or a process launcher: Process.Start is right
there. Native Windows has a bounded query-only
Psmux* preview.
What you get over a shell wrapper: typed entities with real IDs, one dispatch model per workload, and a version model that names the flag a tmux lacks.
| Package | Add it when | |
|---|---|---|
| LibTmux | Always. The client. One dependency: logging abstractions. | |
| LibTmux.FSharp | You write F# and want curried task helpers, seq queries, and typed portable filters over the core values. |
|
| LibTmux.Query.Json | You send queries between processes and want them as JSON. | |
| LibTmux.Testing | You test code that drives tmux and want scopes that clean up after themselves. | |
| LibTmux.Extensions.DependencyInjection | Your application composes services and wants a tmux handle injected. | |
| LibTmux.Workspace | You have tmuxp YAML to build from. | |
| LibTmux.Workspace.Cli | You want tmux-workspace: load, freeze and convert tmuxp files from a shell. Installs as a tool. |
|
| LibTmux.Mcp | You want an assistant driving tmux. Installs as a tool. |
$ dotnet add package LibTmux- One dependency. The core takes the logging abstractions. YAML, JSON and MCP ship as their own packages.
- One version. Every package carries it, so a
LibTmux.Workspacegoes with theLibTmuxof the same version. - F#.
LibTmux.FSharpkeeps the core handles and adds curried task helpers,seqqueries and portable filters. Start with its quick start.
Session.GetWindowsAsync: windows and panes
One call reads a session's windows, and
Window.GetPanesAsync reads a window's panes. They
return IReadOnlyList<T> and run no tmux command while you enumerate them:
using LibTmux;
foreach (Window each in await session.GetWindowsAsync(ct))
{
foreach (Pane every in await each.GetPanesAsync(ct))
{
Console.WriteLine($"{each.Name} pane {every.Index} {every.Width}x{every.Height}");
}
}It prints one line per pane: the window name, the pane index, and its size.
Window.RenameAsync: a changed copy
A handle holds what tmux said when it was read. An operation that changes an
object returns a replacement, and Pane.RefreshAsync
asks tmux again:
Window renamed = await window.RenameAsync("integration", ct);
Console.WriteLine($"{window.Name} -> {renamed.Name}");It prints the old name and the new one.
A replacement is still the same window: identity is the server generation plus the typed ID, not the name.
Window sameWindow = await window.RenameAsync("integration", ct);
Console.WriteLine($"{window == sameWindow} {window.Name} {sameWindow.Name}");
// Identifiers order the way tmux issued them, oldest first.
IEnumerable<Window> oldest = (await session.GetWindowsAsync(ct)).OrderBy(each => each.Id);
Console.WriteLine(string.Join(", ", oldest.Select(each => each.Id)));It prints True, both names, and the window IDs in the order tmux issued them.
Pane.RunAsync: a command's exit status
It runs a shell command in a pane and returns its real exit status and the lines it printed. A command that exits nonzero returns normally with that status:
using LibTmux;
PaneRunResult result = await pane.RunAsync("echo hello; exit 3", TimeSpan.FromSeconds(10), ct);
if (result.ExitStatus != 3 || !result.Output.Contains("hello"))
{
throw new InvalidOperationException("Expected exit status 3 and the line hello.");
}It prints nothing and throws if the status or the line is wrong.
TmuxWait.UntilAsync: wait for what a command prints
For input a shell command cannot carry, type it and wait. tmux accepts a command before the shell has finished it, so the example waits for the result:
await pane.SendTextAsync("echo hello-from-libtmux", cancellationToken: ct);
await pane.EnterAsync(ct);
// tmux accepts a command before the shell has finished it, so the result is
// waited for rather than assumed.
string output = await TmuxWait.UntilAsync(
async token => string.Join('\n', await pane.CaptureAsync(cancellationToken: token)),
text => text.Contains("hello-from-libtmux", StringComparison.Ordinal),
TimeSpan.FromSeconds(10),
TimeSpan.FromMilliseconds(20));It returns once the pane shows the line.
Pane.SplitAsync: a new pane beside this one
Window.ResizeAsync and
Pane.ResizeAsync set a size. Type into the new pane
with Pane.SendTextAsync and read it with
Pane.CaptureAsync:
using LibTmux;
Window wide = await window.ResizeAsync(new ResizeWindowRequest { Width = 120, Height = 30 }, ct);
Pane right = await pane.SplitAsync(new SplitPaneRequest { Direction = PaneDirection.Right }, ct);
Pane narrow = await right.ResizeAsync(new ResizePaneRequest { Width = "30" }, ct);
await narrow.SendTextAsync("echo $((6*7))", cancellationToken: ct);
await narrow.WaitForTextAsync("42", TimeSpan.FromSeconds(10), ct);
string screen = string.Join('\n', await narrow.CaptureAsync(cancellationToken: ct));
Console.WriteLine($"{wide.Width}x{wide.Height} {narrow.Width} {screen.Contains("42")}");
await narrow.KillAsync(cancellationToken: ct);It prints the window size, the pane's width, and whether the capture holds 42.
Pane.KillAsyncremoves a pane.Window.KillAsync,Session.KillAsync, andServer.KillAsyncremove the rest.
To filter what you already read, use LINQ:
using LibTmux;
IReadOnlyList<Window> windows = await session.GetWindowsAsync(ct);
IEnumerable<Window> building = windows.Where(
each => each.Name.StartsWith("build", StringComparison.Ordinal));QueryExtensions.Matching: declarative filters over what you hold
It turns an expression into a portable document, or throws; it never quietly falls back to filtering in memory:
using LibTmux;
using LibTmux.Query;
IReadOnlyList<Session> sessions = await server.GetSessionsAsync(ct);
IReadOnlyList<Session> building = sessions.Matching<Session>(
session => session.Name.StartsWith("build", StringComparison.Ordinal));QueryExtensions.Translate: the same filter as a document
The expression is also a document, written here and answered somewhere else. LibTmux.Query.Json puts it on the wire:
QueryDocument document = QueryExtensions.Translate<Session>(
session => session.Name.StartsWith("build", StringComparison.Ordinal)
&& session.Attached);
Console.WriteLine(document.Target); // SessionIt prints the document's target, Session.
- The document uses stable wire names:
Session.Nameissession_nameandClient.IsControlClientisclient_control_mode. The catalog carries all twelve queryable fields. - A field outside the catalog throws.
UnsafeTmuxFilteris the separate opt-in for native tmux-ffilters.
QueryPlan<T>: what runs in tmux, what runs here
QueryExtensions.Plan splits a filter against
the tmux version Server.InspectAsync reports: an
exact leading part runs inside tmux and the rest runs locally. The plan says
which, and it runs when you ask:
Server inspected = await server.InspectAsync(ct)
?? throw new InvalidOperationException("The tmux daemon is absent.");
QueryDocument predicate = QueryExtensions.Translate<Window>(
candidate => candidate.IsActive && candidate.Name.StartsWith("build", StringComparison.Ordinal));
QueryPlan<Window> plan = predicate.Plan<Window>(inspected.DaemonVersion!.Value);
QueryResult<Window> result = await plan.ExecuteAsync(inspected, ct);
Console.WriteLine($"{result.Count} matches from {result.Snapshot.Windows.Count} placements");
foreach (string reason in plan.FallbackReasons)
{
Console.WriteLine(reason);
}It prints the match count and why anything stayed local.
QueryPushdown.Autosplits the filter,Neverkeeps all of it local, andRequirerejects one that cannot run entirely in tmux.- More: query planning.
TmuxOptions and TmuxHooks: settings and callbacks
Every object reaches the option table tmux keeps for it. tmux has no types, so a value carries the text it reported alongside the readings that text supports:
using LibTmux;
await window.Options.SetAsync(new SetOptionRequest("automatic-rename", "off"), ct);
TmuxOption option = (await window.Options.GetAsync(
new GetOptionRequest("automatic-rename"), ct))[0];
Console.WriteLine($"{option.Value.Raw} flag={option.Value.Boolean} number={option.Value.Integer}");It prints the raw value, its flag reading, and its number reading.
An option the window does not hold reads as the inherited value. Hooks are arrays even with one entry:
TmuxHook hook = await server.Hooks.SetAsync(
new SetHookRequest("alert-bell", "set-option -g @rang yes"), ct);
Console.WriteLine(hook.Values[0].Command);TmuxVersionTooLowException: a command the running tmux lacks
A flag a tmux lacks is left off and a warning names it. A command it lacks sends nothing, and this exception names the version that would be needed:
using LibTmux;
// A handle says what it read: the version is what tmux reported when this
// server was reached, and null when it reported something unparsable.
TmuxVersion? version = server.Version;
Console.WriteLine($"tmux {version?.Raw} 3.4-or-newer={version?.IsAtLeast(TmuxVersion.Parse("3.4"))}");It prints the version tmux reported and whether it is 3.4 or newer.
- A tmux below 3.3a is refused the same way. Upgrade tmux and restart its server.
TmuxUnsupportedOnReleaseExceptioncovers a command a release mishandles or rejects, andTmuxVersionTooLowExceptionderives from it. Both carryActualVersion,SafeReleases, theCapabilityIdfromcapabilities.jsonand the refusedCommand.- Every difference between 3.3a and 3.8 is recorded with the test that proves it.
- The library starts the
tmuxonPATH;ServerConnectionOptions.TmuxBinaryPathpicks another binary.
Which one a call uses is visible where the call starts. One-shot calls and chains work on every supported tmux; explicit control mode needs tmux 3.7+.
| Mode | Flip it on | Dispatch | What one more command costs |
|---|---|---|---|
| One-shot | session.CreateWindowAsync(…) |
one command, awaited | another round trip on a shared client; a process for the few commands that cannot use it |
| Control | server.EnterControlModeAsync(ct) |
one client, streamed | another round trip — ~0.2 ms |
| Chained | server.Chain()…ExecuteAsync(ct) |
N batched, one invocation | more bytes on one command line — ~0.02 ms |
Each figure is the marginal cost of one more command. Timings move with the machine: the recorded run has the distribution and the host, and Choosing a mode has allocations.
Session.CreateWindowAsync: one command, a typed object back
using LibTmux;
// One-shot: a command, a typed object back.
Window built = await session.CreateWindowAsync(new NewWindowRequest { Name = "build" }, ct);
Console.WriteLine(built.Name);Server.EnterControlModeAsync: one client, held open
// Control mode: one client, held open, streaming what tmux does.
await using IControlModeSession control = await server.EnterControlModeAsync(cancellationToken: ct);
IReadOnlyList<string> reply = await control.SendAsync(
TmuxCommand.Create("new-window", "-d", "-n", "build"),
ct);IControlModeSession.Events: what tmux says unasked
Send a command, then read the notification it causes. The same stream reports pane output and the end of the session. More: Control mode.
using LibTmux;
await using IControlModeSession control = await server.EnterControlModeAsync(cancellationToken: ct);
await control.SendAsync(TmuxCommand.Create("new-window", "-d", "-n", "build"), ct);
await foreach (TmuxEvent observed in control.Events.WithCancellation(ct))
{
if (observed is TmuxNotificationEvent { Name: "window-add" } added)
{
Console.WriteLine($"window-add {added.Arguments[0]}");
break;
}
}It prints window-add and the new window's ID.
Server.Chain: many commands, one invocation
// Chained: many commands, one invocation.
await server.Chain()
.Then("new-window", "-d", "-n", "build")
.Then("new-window", "-d", "-n", "test")
.ExecuteAsync(ct);LibTmux.Testing gives a test a tmux server of its own, on its
own socket, killed deterministically:
using LibTmux.Testing;
TmuxTestFactory factory = new();
await using TemporaryHierarchyScope scope = await factory.CreateHierarchyAsync();
await scope.Pane.SendTextAsync("echo hello");Disposal attempts to stop the owned server even when the test body throws.
TmuxWait.UntilAsync, in the client package,
waits for a state that production code also needs when it reads back what a
command produced.
LibTmux.Workspace.Cli installs
tmux-workspace, which loads, captures and converts
tmuxp workspace files:
$ dotnet tool install --global LibTmux.Workspace.CliWith a workspace file saved as ~/.tmuxp/myproject.yaml, such as the
example in the package README,
build it without attaching, on the named tmux socket demo:
$ tmux-workspace load -d -L demo myprojectList the workspaces it can find:
$ tmux-workspace lsLibTmux.Mcp is a Model Context Protocol server, installed as a .NET tool:
$ dotnet tool install --global LibTmux.Mcp{ "mcpServers": { "tmux": { "command": "libtmux-mcp" } } }- Tools come in
inspect,manage,execute, andteardownsets, and teardown is off by default. run_shell_commandreturns the shell's real exit status.wait_for_textwakes from tmux's control-mode stream on tmux 3.7 and newer, and polls on older releases.capture_panereads visible text and scrollback;snapshot_pane,search_panes, andcapture_sincegive structured and incremental reads. No tool returns unbounded output.- Pane modes belong to attached humans: the server never enters one, and input refuses a pane that is in one.
- With no socket pinned, tmux picks the server: the one in
TMUX, otherwise its default socket.LIBTMUX_SOCKETorLIBTMUX_SOCKET_PATHpins one. - More: every tool, generated from the server itself, and the full instructions.
A client starts the server itself and speaks to it over standard input and output. Register it with Claude Code:
$ claude mcp add tmux -- libtmux-mcpA workspace that tmux-workspace load -L demo built lives on the socket demo.
To let a client read it and change nothing, name the socket and select the
inspect toolset:
$ claude mcp add tmux \
-e LIBTMUX_SOCKET=demo \
-e LIBTMUX_TOOLSETS=inspect \
-- libtmux-mcpA client that reads a JSON configuration takes the command and the same settings
as env:
{
"mcpServers": {
"tmux": {
"command": "libtmux-mcp",
"env": { "LIBTMUX_SOCKET": "demo", "LIBTMUX_TOOLSETS": "inspect" }
}
}
}LibTmux: the client
Servers, sessions, windows, panes, and buffers, typed and asynchronous, with one dependency: the logging abstractions.
Reference: libtmux.org and
NuGet.
Changelog: CHANGELOG.md.
LibTmux.FSharp: F# helpers over the core
Curried task helpers, seq queries, and typed portable filters over
the core handles. Start with its
quickstart.
Reference: libtmux.org,
guides, and
NuGet.
Changelog: CHANGELOG.md.
LibTmux.Query.Json: query documents as JSON
A filter written in one process can be evaluated in another; the core does not
reference System.Text.Json.
Reference: QueryJson and
NuGet.
Changelog: CHANGELOG.md.
LibTmux.Testing: a tmux server for each test
Scoped servers, sessions, and windows that clean up after themselves.
Reference: TmuxTestFactory, the
testing guide, and
NuGet.
Changelog: CHANGELOG.md.
LibTmux.Extensions.DependencyInjection: a Server for dependency injection
AddLibTmux registers a Server as a singleton and binds
ServerConnectionOptions through IOptions<T>.
Reference: Server, the
package README,
and
NuGet.
Changelog: CHANGELOG.md.
LibTmux.Workspace: sessions from tmuxp files
Parses tmuxp YAML or JSON and builds sessions, in one call or from a plan you review first.
Reference: WorkspaceBuilder, the
reference index, and
NuGet.
Changelog: CHANGELOG.md.
LibTmux.Workspace.Cli: the tmux-workspace command
Loads, captures, and converts tmuxp workspace files from a shell.
Reference: the command reference and
NuGet.
Changelog: CHANGELOG.md.
LibTmux.Mcp: a tmux server for assistants
A Model Context Protocol server that gives an assistant hands on tmux; it installs as a .NET tool.
Reference: the MCP guide, the tool reference,
and NuGet.
Changelog: CHANGELOG.md.
- Live objects. A handle holds what tmux said when you asked.
Pane.RefreshAsyncasks again. - Synchronous calls. Every call that reaches tmux is asynchronous.
- Silent fallbacks. Declarative filtering throws on an expression it cannot translate. A flag the running tmux lacks is left off, and a warning names it.
- YAML, JSON, and MCP in the core. Workspace files, query documents on the wire, and the MCP server are separate packages, so a caller who does not want them never sees their dependencies.
- A terminal emulator. This drives tmux and draws nothing.
- Native Windows.
PsmuxServerand its siblings read one psmux session and cannot create or change anything. See the preview.
- Pre-1.0. Any release may change or remove an exported identifier. Each package's changelog marks its breaks, so pin an exact version.
- tmux. 3.3a or newer, tested through 3.8. A release candidate is not
supported: see
Release candidates.
TmuxVersionordersX.Y-rcNbelowX.Yand promises nothing more. - .NET. net8.0 and net10.0.
- Systems. Linux, including WSL. macOS is best effort. Native Windows has
only the read-only
Psmux*preview, also best effort. - Trimming and NativeAOT. The
LibTmuxcore is analyzed for both.CompileandMatchingresolve properties by name, so they warn trimmed callers to preserve the filtered types' public properties.
- Guides on libtmux.org — tutorials, topics and the workspace command reference
- Choosing a mode — the three dispatch modes, measured
- Windows psmux preview — what it reads, and what it refuses
- API reference — rendered from the doc comments
- tmux MCP tools — every tool, capability row and resource, generated
- API policy — ownership and parity rules
- Optional tmux features — each feature the library gates, the release it starts at, and the error it raises; summarized in Choosing a mode
- Version deltas — every tmux difference, with the test that proves it
- Decisions — why the transport, object model and query catalog are shaped this way
- Examples — the programs these snippets come from
- Changelog — changes that span packages, with a link to the changelog of each package
- AGENTS.md — how to work in this repository
Use these packages with AI tools as you like. When contributing, check what you submit and wait for a maintainer's invitation before preparing a pull request. See the AI policy, also kept as AI_POLICY.md.
Please use the following BibTeX template to cite libtmux-dotnet in scientific discourse:
@misc{libtmux-dotnet,
author = {Tony Narlock},
year = {2026},
note = {https://libtmux.org/en/csharp/},
title = {libtmux-dotnet: C\# wrapper for tmux}
}