Skip to content

Multiplexers

cyber-mux drives seven terminal multiplexers through one contract, so callers never write host-specific code. But the multiplexers are not the same shape: they disagree on how many nesting tiers they have, whether they track git worktrees, and whether they can name a pane or tell you which one is focused. This page is the map of those differences — what each multiplexer supports and what cyber-mux does when it falls short.

Capability tmux rmux herdr WezTerm (alpha) Zellij (alpha) cmux (alpha) otty (alpha)
Workspace tier ✗ (collapses to window) ✗ (collapses to window) ✓ (a real Window/Workspace split) ✗ (placement collapses to tab, but occupancy is reported) ✓ (Window/Workspace) ✓ (Window)
Worktree binding
Name a pane ✗ (throws / warns) ✓ (names its surface) ✗ (throws / warns)
Report focused pane ✓ (best-effort) ✓ (best-effort) ✓ (pane get) ✓ (list-clients) ✓ (list-clients) ✓ (source-read) ✓ (docs-read)
Knows the running harness
Size splits ✓ (--size, 10–90%)
Floating pane ✓ (tmux 3.7+, new-pane) ✗ (refused by name) ✗ (refused by name) ✗ (refused by name) ✓ (new-pane --floating) ✗ (refused by name) ✗ (refused by name)
Focus on open preserved (-d) preserved (-d) preserved (--no-focus) restored restored (0.45+) restored restored
Resize an open pane ✗ (refused by name) ✗ (refused by name) ✗ (refused by name) ✗ (refused by name)
Zoom a pane ✓ (resize-pane -Z) ✓ (resize-pane -Z) ✓ (pane zoom --on/--off) ✓ (zoom-pane) ✓ (toggle-fullscreen) ✗ (refused by name) ✗ (refused by name)
Move a pane beside another ✓ (move-pane) ✓ (move-pane) ✓ (pane move --tab) ✓ (split-pane --move-pane-id) ✗ (refused by name) ✗ (refused by name) ✗ (refused by name)
Break a pane out ✓ (break-pane) ✓ (break-pane) ✓ (pane move --new-tab) ✓ (move-pane-to-new-tab) ✗ (refused by name) ✓ (break-pane, source-read) ✗ (refused by name)

Report focused pane is isPaneFocused, and it answers three ways, never two: focused, not focused, or unknown. Every backend can now reach all three — a query that cannot be answered says so rather than returning a confident “no”, so a caller can tell “the user is elsewhere” from “nobody can tell me”. Callers fail open on unknown.

✓ (best-effort) on tmux and rmux means the answer is derived rather than native: the pane must be its window’s active pane, in the session’s current window, with a client attached. WezTerm and Zellij read the attached client directly (list-clients), which is the only reading that survives more than one tab — a per-tab “active pane” flag is true on several panes at once and cannot answer this. cmux and otty are marked source-read and docs-read because neither backend can be driven on Linux CI (#128); their row fields have not been observed on a running binary.

Focus on open is focusOnOpen, and it has three values:

  • preserved — nothing moves, at any instant, on any route. The backend has a suppress-focus flag, every route passes it, and no route has to visit a pane to choose a split target.
  • restored — a focus move happens and is deterministically undone before open() returns. The caller ends on the pane they started on; a human watching may see a flicker. This is the honest answer for the four backends whose split has no target flag, so the anchor pane must be focused to choose it.
  • stolen — focus moves and stays moved. No backend declares this today.

The distinction matters because restored and preserved used to share one value. Zellij restores and tmux never moves, and a caller drawing a UI around “will the user’s eye jump” needs to tell those apart.

Drives tmux via its CLI (split-window, new-window, send-keys, capture-pane, list-panes, …).

  • No workspace tier. tmux calls the tab concept a window and has nothing above it, so both workspace and tab placements collapse to a new window — the finest “own visible space” tmux offers.
  • Never steals focus. Every creating command passes -d: new-window always did, and split-window and new-pane now do too. Without it tmux activates whatever it just created, so a pane:right open used to move the attached client onto the new pane. It no longer does, on any placement.
  • No worktree binding. tmux has no workspace tier to bind a git worktree to, so it omits the optional worktree capability; callers fall back to plain git plus a placement-appropriate open().
  • No harness awareness. tmux cannot say which agent runs in a pane, so listPanes leaves harness unset.
  • Can resize an open pane. resize-pane -x/-y takes a cell count, so cyber-mux converts the seam’s ratio against the pane’s own split region rather than sending -x <percent>, which tmux reads against the window — the same number only when the window holds a single split.
  • focus resolves the pane’s session and window from list-panes -a first, then beams in order: switch-clientselect-windowselect-pane. An unresolvable pane throws rather than issuing a false-success beam.
  • Floating panes need tmux 3.7 or newer. --at pane:float drives new-pane, the command tmux added in 3.7 for a pane that sits above the tiled layout and so resizes none of the region’s other panes. cyber-mux does not probe the version: on an older tmux the command does not exist and tmux answers with its own unknown command, which surfaces as a failed open naming the command. A float takes tmux’s default size — half the window’s width by a quarter its height — and --ratio is dropped, because a float takes no share of a split whose fraction it could be.

Drives rmux via its CLI (split-window, new-window, send-keys, capture-pane, list-panes, …) — an async Rust reimplementation of the tmux command language: rmux list-commands reports roughly 90 commands under tmux’s own names, flags, target syntax, and #{...} format vocabulary.

rmux runs natively on Linux, macOS, and Windows — no other backend on this page runs natively on Windows, and that is why cyber-mux added it. Read that as a fact about rmux’s own portability: the adapter was built and verified against a live rmux 0.10.0 on Linux, and has not been exercised on Windows or macOS.

  • No workspace tier, the same as tmux: both workspace and tab placements collapse to a new window.
  • Never steals focus. -d on both creating commands, new-window and split-window. Verified live on 0.10.0 rather than inherited from the tmux resemblance: a bare split-window made the new pane active, and -d left the original active while -P -F still reported the new id.
  • No worktree binding. Like tmux, it has no workspace tier to bind a git worktree to, so it omits the optional worktree capability; callers fall back to plain git plus a placement-appropriate open().
  • Pane identity is tmux-shaped and stable (%0, %1, …), addressable from outside the session: split-window -P -F '#{pane_id}' and new-window -P -F report the new pane’s id from the birth command itself.
  • Native cwd and env at birth. -c and -e on both split-window and new-window set the new pane’s working directory and environment directly — unlike WezTerm and Zellij, no command-prefix env compensation is needed.
  • Can size a split. -l N% sizes the new pane, the same direction as tmux’s -l, so cyber-mux inverts the caller’s ratio (1 - ratio).
  • Naming mirrors tmux exactly. A tab is a window, renamed with rename-window; a pane has no rename verb, so its name is its title, set via select-pane -T — which moves no focus.
  • Workspace grouping uses tmux’s own mechanism. cyber-mux tags panes with @-prefixed window user options (@cm_ws, @cm_tab) and lists a group with the server-side filter list-windows -f '#{==:#{@cm_ws},<id>}'.
  • Reports region geometry. list-panes -t <id> -F '#{pane_left}…' reports window-relative rects with the divider column excluded, so rmux implements the optional regions capability and template save works on it.
  • Can resize an open pane. resize-pane -x/-y takes a cell count, so cyber-mux converts the seam’s ratio against the pane’s own split region rather than sending a percentage — rmux reads -x 60% against the window, exactly as tmux does, which is a different number at any nesting depth. Verified on a live rmux 0.10.0.
  • No harness awareness and no agent-state feed — herdr is the only backend with either.
  • focus resolves the pane’s session and window from list-panes -a, then beams switch-clientselect-windowselect-pane, the same sequence as tmux.
  • Floating panes: not supported. new-pane — the tmux 3.7 command --at pane:float drives — does not exist on rmux (unknown command: new-pane), so --at pane:float is refused by name, exactly as WezTerm, herdr, cmux, and otty refuse it — never substituted with a tiled split.
  • Detection runs before tmux’s. An rmux pane exports $RMUX (tmux’s own <socket>,<pid>,<session> triple) and $RMUX_PANE (a %N pane id) — but it also sets $TMUX/$TMUX_PANE for tmux compatibility, so cyber-mux checks the rmux variables first; otherwise an rmux session would resolve to the tmux adapter. The process-ancestry walk matches the rmux-daemon process; CYBER_MUX=rmux is the explicit override.

Driven via its CLI (pane split, tab create, workspace create, pane run, pane read, …). herdr is agent-aware and returns rich JSON envelopes, which cyber-mux parses defensively.

  • Has a workspace tier, and binds git worktrees to it. herdr binds a git worktree to a workspace as a first-class record, and that binding is what its UI groups a repo’s primary checkout and its worktrees by — so herdr implements the optional worktree capability where tmux and WezTerm cannot.
  • Only the worktree route binds. git worktree add followed by workspace create --cwd <checkout> yields a workspace with no worktree record — herdr does not know it is a worktree at all, and leaves it out of the group. Only worktree create / worktree open produce the binding. (herdr’s worktree list still shows such a checkout with an open_workspace_id, matching it by path after the fact — the list view is misleading here; the workspace record is the truth.)
  • Creating a worktree opens a workspace for the source checkout too when the repo has none — a group needs its parent.
  • Knows the running harness. listPanes reports each pane’s running harness, because herdr knows which agent is in each pane.
  • Never steals focus. workspace create, tab create, and pane split all pass --no-focus. On herdr 0.8.2 the split already left focus alone without the flag; it is passed anyway so the guarantee does not rest on a backend default that a later release could change.

Driven via wezterm cli (spawn, split-pane, list --format json, send-text, activate-pane, …) against WezTerm’s built-in multiplexer.

Verified against WezTerm 20240203-110809-5046fc22 — the newest stable release, and the build CI’s live-backends job pins. WezTerm has shipped no stable release since 2024-02-03, so that pin is the ceiling rather than a lag. On every PR that job runs mux.wezterm.integration.test.ts against a real headless wezterm-mux-server: opening at pane:* and at workspace, reading the pane and its cwd back out of list --format json, running a command in it and capturing the output, tearing it down, and the two refusals this backend answers by name (--at pane:float, and rename at the pane tier). Two things it does not cover, which therefore still rest on wezterm cli --help and the CLI reference: the focus answer, and --percent split sizing. And note what it drives — a headless mux server, not the GUI a real caller sits in. The gaps below are real, spec’d limitations rather than forced parity:

  • A genuine fourth placement level. WezTerm’s native tiers are Workspace › Window › Tab › Pane. --at workspace maps to a real WezTerm Window spawned into a fresh (or caller-named) Workspace, never a bare new tab; --at tab maps to a real WezTerm Tab in the current window. tmux and herdr both collapse Workspace and Tab onto one level; WezTerm keeps them genuinely distinct.
  • Never binds a git worktree, despite having a real Workspace tier. Its CLI has no worktree subcommand or concept of one, so — like tmux, for the opposite reason — it falls back to plain git plus a placement-appropriate open().
  • No --env flag on any space-creating command. spawn and split-pane take no such option, so every WezTerm open takes the command-prefix-or-warn fallback that herdr needs only for its one worktree route.
  • No way to title a pane, at birth or after. rename(..., 'pane', …) throws rather than silently no-op’ing; open’s pane-tier --label degrades to a stderr warning instead. A new tab’s label has no birth flag either (unlike tmux -n or herdr --label) — every tab is named by a post-birth set-tab-title. A new workspace’s name, by contrast, is native at birth (it doubles as the --workspace value spawn already takes). listPanes/describeRegion never report a label at all — title is always the ambient running-program name, never something an author chose.
  • A real focus-query primitive, in a place that is easy to miss. list --format json carries an is_active field, and it is the wrong one: it is per-tab, so with two tabs open it reads true on two panes at once, and a probe built on it would confidently name a pane the user is not looking at. The right one is list-clients --format json’s focused_pane_id, which names the pane the attached client is actually on. isPaneFocused reads that, and answers unknown when no client is attached — a headless mux server has nobody viewing anything, so “not focused” would be a fact read out of an absence.
  • No suppress-focus flag on any creating verb, so focus is restored rather than preserved. Neither spawn nor split-pane has a --no-focus, and both activate what they create (measured). So every open reads list-clients first and activate-panes the caller back afterwards — a visible detour that ends where it started, which is what focusOnOpen: 'restored' declares.
  • --percent sizes the new pane — the same direction as tmux’s -l, not herdr’s pass-through — when sizing a pane:* split.
  • spawn/split-pane report only the bare pane id — unlike tmux/herdr, its tab (and, on a tab or pane:* placement, its workspace) cost one follow-up list --format json call.

Driven via zellij action (new-pane, new-tab, write-chars, send-keys, dump-screen, focus-pane-id, list-panes --json, rename-pane, rename-tab-by-id, close-pane, …) against Zellij’s built-in multiplexer. Requires Zellij ≥ 0.45.0. Two releases set that floor: 0.44 added per-pane CLI addressing, without which no faithful adapter is possible, and 0.45.0 added --no-focus, which every open that has no split target to choose now passes. On an older binary that flag is an unknown argument, so the open fails loudly rather than silently stealing focus.

Verified against Zellij 0.45.0 — the version CI’s live-backends job pins, which is the same 0.45.0 the adapter declares as its floor, so the suite runs at the floor rather than above it. On every PR that job runs mux.zellij.integration.test.ts against a real headless Zellij: opens at tab, workspace, pane:right and pane:float; both focus paths (the --no-focus open, and the focus-and-restore round trip a named from forces); list-panes --json telling a float from a tiled pane and reporting a pane’s id and cwd; rename; focus/isPaneFocused; run-and-capture and its lines trim; and teardown. Not covered, because the adapter does not implement them: pane geometry and resizePane, both refused by name below. The gaps below are real, spec’d limitations rather than forced parity:

  • Has a real session tier, but a placement can’t reach it. Zellij’s workspace-equivalent is a session, and pane ids are session-scoped — cyber-mux’s pane target carries no session, so it can only ever operate inside the ambient session. A workspace placement therefore collapses to a new tab, the same way tmux collapses workspace to a window, even though the underlying tier is real. It still reports the occupied session, though: OpenedPane.workspace carries the ambient $ZELLIJ_SESSION_NAME — occupancy is answered even where placement can’t reach the tier.
  • Never binds a git worktree. Its CLI has no worktree subcommand or concept of one, so — like WezTerm — it falls back to plain git plus a placement-appropriate open().
  • No --env flag on new-pane/new-tab. Every Zellij open takes the same command-prefix-or-warn fallback as WezTerm: env rides in as an env K=V prefix on the launch command when there is one, or a stderr warning when there isn’t.
  • Can name a pane, at birth or after. new-pane --name sets it at birth; rename-pane and rename-tab-by-id rename an already-open space.
  • Reports focused pane for real — off list-clients, not is_focused. list-panes --json carries an is_focused field per pane, and it is true on more than one record at a time: a live session marks both the floating plugin pane and the tiled pane beneath it. It reports “focused within its layer”, so a probe reading it can name a pane the client was never on. isPaneFocused reads list-clients instead — the same authority the focus restore below already used — and answers unknown when no client is attached.
  • Restores focus on open, from Zellij 0.45.0. That release added --no-focus, which is why the adapter’s floor moved from 0.44.1 to 0.45.0. A tab/workspace open, and a pane:* open that names no from, pass the flag and move nothing. A pane:* open that does name a from cannot: under --no-focus Zellij anchors the split on the pane the command was issued from rather than on the focused one, so passing it would split the wrong pane. That path instead focuses the target, splits it, and then focuses back to the pane that had focus before — a visible round trip, ending where it started. That round trip is why this backend declares focusOnOpen: 'restored' rather than 'preserved': one of its two routes genuinely moves the user before putting them back, and the old boolean had no way to say so. On Zellij < 0.45.0 the open fails loudly with Zellij’s own unknown-argument error rather than silently stealing focus. Both mechanisms are asserted against a real Zellij 0.45.0 in CI’s live-backends job.
  • Cannot size a split. Zellij’s tiled splits are always even: new-pane’s size flags (-x/-y/--width/--height) all require --floating, so a tiled split has no flag to honor a ratio with. cyber-mux does drive floats — --at pane:float is new-pane --floating and canFloatPanes is true — but a float takes no share of a split whose fraction it could be. So ratio is dropped on both paths, and a tiled caller gets Zellij’s own even split, the same degrade path as a backend with no canSizeSplits.
  • No region introspection yet. Pane geometry is deliberately not implemented (a follow-up), so template save refuses on Zellij by naming the backend, the same as WezTerm. Zellij does have zellij action resize, but it takes a direction and no amount — a nudge of a size it never states — and with no rects to measure against, nothing can converge that on a named fraction. So resizePane is refused on Zellij too.

Each multiplexer answers its own liveness and focus probes, so a herdr pane id is never queried with a tmux command or vice versa. Anything a multiplexer cannot determine — a missing pane, an unreadable focus state — is reported as unknown, never a false negative.

Driven via cmux CLI (new-pane, new-surface, workspace create, send, send-key, read-screen, focus-panel, close-surface, list-panels, rename-tab, …) against cmux, a Ghostty-based macOS terminal built for AI coding agents.

Not verified against a live binary as of 2026-09-08 — read off cmux’s own Swift source (manaflow-ai/cmux at commit 71eb616d): the CLI’s argument parser, its own verb inventory, the server-side payload builders, and its docs/cli-contract.md. cmux is a macOS GUI app, so CI’s Linux live-backends runner has none to drive: there is no cmux row in that job and no mux.cmux.integration.test.ts. Unlike the WezTerm and Zellij sections above, nothing here names a version it was driven against. A source read is stronger than the docs read this section used to rest on, and it is still not a verification:

  • Has a real workspace tier. cmux’s hierarchy is Window → Workspace → Pane → Surface, where a Surface is the terminal unit (a tab within a pane). --at workspace maps to a new workspace; --at tab maps to a new surface in the current pane; --at pane:* maps to a new pane (a split).
  • Groups workspaces, unlike every other backend with a workspace tier. cmux has a tier above its workspace — workspace-group, a named collapsible sidebar section holding several top-level workspaces — so a --at workspace open carrying a workspace group id is really grouped rather than ignored the way herdr’s and WezTerm’s is. The opaque id rides cmux’s --idempotency-key, so re-using it finds the group instead of minting a second one. A --at tab or --at pane:* open is not grouped: it lands in the workspace the caller is already in. One caveat: workspace-group create always mints a brand-new anchor workspace, so the first grouping call for a given id adds a workspace to the sidebar. It does not steal focus, and repeat calls for the same id open nothing.
  • Never binds a git worktree. Its CLI has no worktree subcommand or concept of one, so — like WezTerm and Zellij — it falls back to plain git plus a placement-appropriate open().
  • Env is native at the workspace tier, and not adopted yet. workspace create takes a repeatable --env KEY=VALUE (and --env-file) that later panes and surfaces in that workspace inherit; new-surface and new-pane take none. Every open still takes the same command-prefix-or-warn fallback as WezTerm and Zellij — swapping a working compensation for a native flag would change behavior on source-only evidence, so it waits for a real boundary (#132).
  • Names a surface, never a pane. rename-tab --surface <id> --title <text> is the only rename in reach: cmux has no pane-rename verb at any layer, because a pane is a geometric container with no name. A --label on a split therefore names the split’s surface. A workspace is the exception and is named at birth, by workspace create --name.
  • Reports focused surface. list-panels --json carries a focused field per surface, so isPaneFocused answers true/false — and unknown when the row carries no such field, rather than reading that silence as “not focused”. The row shape is a source read, never observed on a running binary, so an absent field is exactly the shape of being wrong here.
  • Restores focus on open. new-pane has no split-target flag, so a --at pane:* open with a from has to focus that surface before splitting it. Every open therefore reads which surface is focused first and focuses it back afterwards. Note that cmux’s creating verbs do take --focus <true|false> and document a false default (#167) — cyber-mux does not pass it, because the restore is correct whether or not that documented default is what ships, and the flag itself is unverifiable without a Mac.
  • Cannot size a split. new-pane has no size flag, and resize-pane is cell-based rather than fractional, so a ratio degrades to cmux’s own even split.
  • Cannot set a split’s directory natively. new-surface and workspace create both take --cwd; new-pane does not. A pane:* open carries the directory as a cd on the command line instead — a shell-level cd, so it lands in that surface’s shell history and means nothing in a non-shell surface.
  • Reports no working directory in the listing. A surface row carries the directory it was created with, not where its shell is, so LivePane.cwd is absent on cmux rather than stale.
  • No region introspection. Pane geometry is not reported by the CLI, so template save refuses on cmux by naming the backend.
  • macOS only (cmux is a native Swift/AppKit app).

Driven via otty CLI (pane split, pane send-keys, pane capture, pane focus, pane close, tab new, open, panes, …) against otty, a native terminal-centric workspace app built for AI coding agents.

Not verified against a live binary as of 2026-09-06 — built from the otty docs. Like cmux, otty is a desktop GUI app (macOS/Windows), so CI’s Linux live-backends runner has none to drive: there is no otty row in that job and no mux.otty.integration.test.ts, and nothing here names a version it was driven against:

  • Has a real workspace tier. otty’s hierarchy is Windows > Tabs > Splits > Panes. --at workspace maps to a new window (otty open, which always opens one — it takes no --new-window, and names the window at birth with --title); --at tab maps to a new tab (otty tab new --title, which likewise names it at birth); --at pane:* maps to a split, otty pane split --direction right|down.
  • Never binds a git worktree. Its CLI has no worktree subcommand, so — like the other GUI-based backends — it falls back to plain git plus open().
  • No --env flag on any space-creating command. Every otty open takes the command-prefix-or-warn fallback.
  • Every tier but tab sets a directory natively. otty open [path] takes the directory as a positional argument and pane split takes --cwd. Nothing otty publishes puts a working directory on tab new, so a --at tab open carries it as a cd on the command line instead — a shell-level cd, so it lands in that tab’s shell history and means nothing in a non-shell pane. otty ships no source, so the flag can be neither confirmed nor ruled out from the outside; the cd is the route that is correct either way, where a --cwd otty does not accept would fail every --at tab open outright. Note that pane split --cwd appears only in otty’s orchestration guide, not in its CLI reference, whose window/tab/pane section lists no flags at all.
  • Cannot name a pane. otty scopes rename to windows and tabs, so tab rename names an already-open tab and a pane-tier rename is refused by name — a --label on a --at pane:* open degrades to a warning instead, exactly as on WezTerm.
  • Reports focused pane. panes --json carries an is_focused field, so isPaneFocused answers true/false — and unknown when a row carries no such field. otty documents the command but never its row fields, so an absent is_focused is the likeliest way this is wrong, and it must not read as a confident “not focused”.
  • Restores focus on open. pane split has no target flag, so a from is honored by focusing that pane first; every open reads which pane is focused and focuses it back afterwards. otty’s orchestration guide does show pane split --no-focus (#172), which cyber-mux does not pass: it would not suppress the anchor move, and the restore is correct either way.
  • Can size a split. pane split --size sizes the new pane, so ratio — the fraction kept by the original — is inverted (same as tmux, WezTerm, and cmux). otty’s unit is a whole percent over a documented 10–90 range, so the fraction is scaled and rounded, and a ratio outside that range (say 0.95, a 5% new pane) is clamped into it with a warning on stderr rather than sent as a size otty would reject.
  • Atomic send-keys. pane send-keys can mix literal text and key: tokens in one call — cyber-mux composes sendText and sendKeys from this.
  • No region introspection. Pane geometry is not reported, so template save refuses on otty. otty pane resize --right N exists but counts cells, and with no pane positions there is no split extent to take a fraction of, so resizePane — which takes a ratio — is refused as well. That is the contrast with pane split --size above: a share needs no extent to be expressed in, a cell count does.
  • macOS/Windows desktop app.

cyber-mux detects GNU Screen (a CYBER_MUX=screen override, or a screen ancestor) so it can say so honestly, but it does not drive it: pinning CYBER_MUX=screen yields a named error, not a backend. The blocker is identity, which is load-bearing across the whole contract (MuxTarget.id, currentPane, LivePane.id). Screen addresses its split regions positionally — there is no per-region id to send to or read from — and $WINDOW is left unset in windows opened via screen -X, exactly the panes a driver creates, so a pane cannot even identify itself. Every backend above ships a stable per-pane id (tmux $TMUX_PANE, rmux $RMUX_PANE, herdr $HERDR_PANE_ID, WezTerm $WEZTERM_PANE, Zellij $ZELLIJ_PANE_ID, cmux $CMUX_SURFACE_ID, otty $OTTY_PANE_ID); screen has no equivalent for driven panes. Rather than ship a half-faithful adapter whose pane identity is unstable, cyber-mux rejects the value with the reason — an honest “no” beats a backend that silently drives the wrong pane.