Skip to content

About

Alpha: a typed, async-first .NET client for tmux 3.2a-3.7b. Practical parity with Python libtmux, native to C# and F#. Ships an MCP server for AI agents.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1,301 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

libtmux for C#

libtmux for C#

LibTmux downloads build tmux 3.3a – 3.8 license

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 package add LibTmux --prerelease

Alpha. Releases carry an -alpha prerelease tag, and any release may change or remove exported identifiers without a deprecation period. Pin an exact version. It is not recommended for production.

Contents — Quick start · Is this for you? · Packages · Find things · Run a command · Filter · Options · Versions · Choose a path · Testing · Workspaces · Assistants · Leaves out · Support

Quick start

Connect to the tmux server that is running, make a session, a window, and a pane, and type a command into the pane. This is the whole program:

using LibTmux;

// Requires a tmux server already listening on this socket:
// ConnectAsync() discovers one, it never starts one. With nothing
// running yet, call Server.CreateOwnedAsync() instead.
Server server = await Server.ConnectAsync();
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");

To run it, make a console project and paste the program into Program.cs, with tmux 3.3a or newer on PATH and a server running:

$ dotnet new console --output demo && \
    cd demo && \
    dotnet package add LibTmux --prerelease

It prints nothing. The new session build has a window tests, and its pane has received dotnet test. With no server running, call Server.CreateOwnedAsync; it starts one and stops it when disposed.

Every csharp run block below runs against a tmux server of its own, with server, session, window, pane, and ct already in hand.

Is this for you?

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, a version model that names the flag a tmux lacks, and examples that run against live tmux in CI.

Packages

Package Add it when
LibTmux v Always. The client. One dependency: logging abstractions.
LibTmux.FSharp v You write F# and want curried task helpers, ordinary seq queries, and typed portable filters over the core values.
LibTmux.Query.Json v You send queries between processes and want them as JSON.
LibTmux.Testing v You test code that drives tmux and want scopes that clean up after themselves.
LibTmux.Extensions.DependencyInjection v Your application composes services and wants a tmux handle injected.
LibTmux.Workspace v You have tmuxp YAML to build from.
LibTmux.Workspace.Cli v You want tmux-workspace: load, freeze and convert tmuxp files from a shell. Installs as a tool.
LibTmux.Mcp v You want an assistant driving tmux. Installs as a tool.
$ dotnet package add LibTmux --prerelease

The core takes exactly one dependency. Anything that would add another ships as its own package, so a caller who does not want YAML never sees YamlDotNet.

They all carry one version, so any LibTmux.Workspace goes with the LibTmux of the same version, without a table to consult.

LibTmux.FSharp is the F# companion built on LibTmux. Both packages are maintained in the libtmux organization by the same primary author.

It keeps the core handles and task I/O, then adds F#-native composition for snapshots and portable filters. Start with its runnable F# quickstart.

Find sessions, windows, and panes

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:

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, so two handles that read different names name one window, and a handle read from a restarted server does not, even where tmux reused the number:

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.

Run a command and read what it printed

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:

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.

Filter what you read

Plain LINQ

Ordinary filtering is LINQ over what you already read:

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:

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);   // Session

It prints the document's target, Session.

  • The document uses stable wire names: Session.Name is session_name and Client.IsControlClient is client_control_mode. The catalog carries all twelve queryable fields.
  • A field outside the catalog throws. UnsafeTmuxFilter is the separate opt-in for native tmux -f filters.

Change options and hooks

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:

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

Check the tmux version

TmuxVersionTooLowException: a command the running tmux lacks

Where a flag is missing, the request goes out without it and a warning names what was left off. Where a whole command is missing, nothing is sent and this exception names the version that would be needed:

// 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 the tmux server, or use LibTmux 0.0.0-alpha.21, the last release that supports tmux 3.2a. Every measured difference between 3.3a and 3.8 is recorded with the test that proves it.

Choose how to reach tmux

Which one a call uses is visible where the call starts, and all three work on every supported tmux.

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, as medians of 100 samples against tmux 3.7b.

Absolute timings move with the machine, so the recorded run gives the distribution, the tmux, the host, and the date. Choosing a mode has allocations and how to rerun the table.

Session.CreateWindowAsync: one command, a typed object back

// 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);

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

Control mode is an order of magnitude cheaper per command, because its client is already running. A chain wins for a batch by paying one round trip for the whole sequence.

Testing your own code

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

Disposing kills the server, so a test that fails part way through leaves nothing behind. TmuxWait.UntilAsync, in the client package, waits for a state that production code also needs when it reads back what a command produced.

Workspaces from the command line

LibTmux.Workspace.Cli installs tmux-workspace, which loads, captures and converts tmuxp workspace files:

$ dotnet tool install --global LibTmux.Workspace.Cli --prerelease

With a workspace file saved as ~/.tmuxp/myproject.yaml, such as the example in the package README, build it without attaching, on a tmux socket of its own:

$ tmux-workspace load -d -L demo myproject

List the workspaces it can find:

$ tmux-workspace ls

An assistant on your terminal

LibTmux.Mcp is a Model Context Protocol server. It is a separate package that installs as a .NET tool:

$ dotnet tool install --global LibTmux.Mcp --prerelease
{ "mcpServers": { "tmux": { "command": "libtmux-mcp" } } }
  • It exposes 45 tools from the inspect (18), manage (14), execute (9), and teardown (4) sets, plus the static tmux://capabilities resource.
  • Existing and user-configured sockets omit teardown by default and expose 41.
  • The full reference is generated from the server itself.
  • Pin one socket for the process with LIBTMUX_SOCKET or LIBTMUX_SOCKET_PATH.

It is built so an assistant never gets stuck and never wastes context:

  • run_shell_command returns the shell's real exit status.
  • wait_for_text wakes from tmux's control-mode stream on tmux 3.7 and newer, and polls on older releases. If that stream cannot start, the wait ends unless you set LIBTMUX_MCP_ALLOW_POLLING_FALLBACK=true.
  • Nothing returns unbounded output. capture_pane reads visible text and scrollback, while snapshot_pane, search_panes, and capture_since give structured and incremental reads.
  • Pane modes belong to attached humans. The server never enters, drives, or exits one, and input refuses a pane that is in one. Wait for the person to leave the mode; do not cancel it.
  • Toolset and tool selection is frozen at startup and reported through tmux://capabilities.

Full instructions.

An MCP client starts the server itself and speaks to it over standard input and output. Register it with Claude Code:

$ claude mcp add tmux -- libtmux-mcp

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

A 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" }
    }
  }
}

Package directories

LibTmux: the client

Servers, sessions, windows, panes, and buffers, typed and asynchronous, with one dependency: the logging abstractions.

Reference: libtmux.org and NuGet.

LibTmux.FSharp: F# helpers over the core

Curried task helpers, ordinary seq queries, and typed portable filters over the core handles. Start with its quickstart.

Reference: libtmux.org, guides, and NuGet.

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.

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.

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.

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.

LibTmux.Workspace.Cli: the tmux-workspace command

Loads, captures, and converts tmuxp workspace files from a shell.

Reference: the command reference and NuGet.

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.

What it leaves out

  • Live objects. A handle holds what tmux said when you asked. Pane.RefreshAsync asks 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. PsmuxServer and its siblings read one psmux session and cannot create or change anything. See the preview.

Support and status

  • Alpha. Any release may change or remove exported identifiers.
  • tmux. Stable 3.3a and newer. CI builds 3.3a, 3.4, 3.5, 3.6, 3.7a, 3.7b, 3.7c, and 3.8. Development, release-candidate, and next-* versions have unknown capability state.
  • .NET. net8.0 and net10.0.
  • Systems. Linux, including WSL. macOS is best effort, with highly limited CI coverage. CI builds on Windows x64, where the types that need a real tmux are annotated unsupported.
  • Windows preview. The Psmux* preview reads a Windows psmux session natively or from WSL. It is best effort, with highly limited CI coverage; its smoke harness runs by hand.
  • Trimming and NativeAOT. The LibTmux core is analyzer-gated, and its smoke app is published and run for linux-x64 on net8.0 and net10.0.
  • Trimmed callers. Compile and Matching resolve properties by name and warn callers to preserve the filtered types' public properties.

Documentation

Attribution

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

License

MIT. Practical parity with Python libtmux, rewritten in C#.

About

Alpha: a typed, async-first .NET client for tmux 3.2a-3.7b. Practical parity with Python libtmux, native to C# and F#. Ships an MCP server for AI agents.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Used by

Contributors

Languages