Skip to content

MuxAdapter

MuxAdapter is the contract the whole library exists to provide: one set of verbs that means the same thing on tmux, rmux, herdr, WezTerm, Zellij, cmux, and otty. You rarely touch it directly — you resolve a MuxSession for the multiplexer you are inside, with its Exec already bound, and call its methods.

Import from the main entry:

import {
resolveMux,
type MuxSession,
type OpenedPane,
} from 'cyber-mux'

Run the probe, pick the matching adapter (tmux / rmux / herdr / wezterm / zellij / cmux / otty), and return it as a MuxSession with Exec bound. Throws if the process is in no supported multiplexer.

const mux = resolveMux(process.env)
mux.name // 'tmux' | 'rmux' | 'herdr' | 'wezterm' | 'zellij' | 'cmux' | 'otty'

deps.exec (default nodeExec) is both the runner the detection probe uses AND the default every session method binds. Gate on probeMultiplexer(env).mux !== 'none' first if a caller runs with-or-without a multiplexer — see Detection.

A test binds a fake once instead of a real backend:

const mux = resolveMux(process.env, { exec: fakeExec })

This process’s own pane, as a MuxTarget the session can address — the value you pass as open’s from so a pane:* split lands on the caller rather than on whichever pane the user happens to be looking at.

Returns undefined when this session is in no pane, or in a pane belonging to a different multiplexer than the session drives — in which case a pane:* open falls back to the backend’s own default rather than splitting a foreign pane id.

Every MuxSession method takes a trailing, optional MuxDeps:

export interface MuxDeps {
exec?: Exec
}

Omit it in the common case — the method runs with the Exec bound at resolveMux. Pass { exec } for a one-off override (a recording fake in a test, a decorated runner) without re-resolving the session:

mux.open(opts, { exec: fakeExec })

Create a pane, tab, or workspace and return its handle plus the workspace it landed in. The at placement decides which:

at Opens
'tab' (default) A new tab in the current (or within) workspace.
'pane:right' / 'pane:down' A split of the from pane.
'pane:float' A floating pane above the layout, resizing nothing. tmux 3.7+ and zellij only — wezterm, rmux, herdr, cmux, and otty throw FloatingPanesUnsupportedError; ask adapter.canFloatPanes first.
'workspace' A genuinely separate workspace/session, leaving the caller’s untouched.

Key MuxOpenOptions fields:

  • cwd (required) — working directory the new pane starts in.
  • launch — command line to run inside it; omit for a blank shell.
  • from — the pane a pane:right/pane:down placement splits (and, for pane:float, the pane whose region the float opens over). Pass it — omitting it does not mean “the caller”, it means “whatever this backend defaults to”, and the two backends default to opposite panes. Use mux.callerPane().
  • within — the workspace a tab placement opens inside (a workspace value from a prior open).
  • ratio — fraction of the split kept by the original pane (0 < ratio < 1); the adapter handles each backend’s opposite sign convention for you. Dropped on 'pane:float', which takes no share of the region and so has no original pane to size against.
  • env — variables set at the new space’s birth, split or not.
  • label — a name for the space at birth, at whatever tier at opens.
  • workspaceGroup — an opaque group id for a backend with no workspace tier to group opened spaces under; routed through group.

OpenedPane carries id (the pane), tab (always present — every multiplexer has a tab tier), and workspace (absent on a backend, like tmux, with no workspace tier).

const pane = mux.open({
cwd: process.cwd(),
at: 'pane:right',
from: mux.callerPane(),
launch: 'claude',
})
  • mux.rename(target, tier, name, deps?) — name an already-open space at 'pane' or 'tab'. This is the one naming route birth cannot serve (herdr labels a new workspace’s root tab 1 with no birth flag to change it).
  • mux.group(target, group, name?, deps?) — group an already-open tab into group, storing the tab’s own name beside it. MuxOpenOptions.workspaceGroup routes through this.
  • mux.sendText(target, text, deps?) — type text literally, pressing no Enter. Text that names a key (Enter, Up) is typed as those characters, never interpreted.
  • mux.sendKeys(target, keys, deps?) — press named keys in order (Up Down Enter Escape Tab C-c F1F12, …). Never adds an Enter you did not write.
  • mux.submit(target, text?, deps?) — take the pane’s turn: type text if given, then always press Enter. With no text, sends a bare Enter only — flushing an already-staged buffer without re-typing it. See nudge for the send-and-verify wrapper.
  • mux.read(target, opts?, deps?){ text, truncated? } — capture the pane’s current output. opts.lines is the read WINDOW: a row count, 'all' for the whole scrollback, or omitted for the backend’s own default (the viewport). Pass opts.truncation to also learn whether rows above that window were dropped; truncated is absent unless you ask, because absent means undetermined and a false that means “I did not check” is indistinguishable from “you have everything”. Every backend answers it: each asks for one row more than the window and compares row counts, which costs one extra query — none on Zellij (whose lines read already holds the whole scrollback), and none at lines: 'all', where an unbounded window omitted nothing by construction. Opt-in because read is the hottest verb on this seam; the CLI, one invocation per process, always asks (read).
  • mux.waitForOutput(target, opts, deps?)Promise<MuxWaitResult> — block until the pane’s output matches, or the deadline passes. opts carries the pattern (match literal or regex), timeoutMs, and the same lines window read takes; the result says whether it matched and carries the output either way, so a caller that guessed the wrong pattern still sees what the pane said. The one asynchronous method on the seam.
  • mux.focus(target, deps?) — beam the attached client to the pane, across workspace and tab.
  • mux.nudge(target, message, opts?, deps?)submit with a receipt; see nudge.
  • mux.paneExists(target, deps?)boolean — whether the pane is still live.
  • mux.isPaneFocused(target, deps?)boolean | undefined — read-only focus probe, three-valued on every backend: true positively focused, false positively not, undefined the backend could not answer (callers fail open). undefined is never a stand-in for false — an unresolvable pane, a listing that carries no focus field, and a session with no client attached all report it, because a confident “not focused” read out of a silence is a plain wrong answer rather than a cautious one.
  • mux.listPanes(deps?)LivePane[] — enumerate every live pane the backend can see.
  • mux.teardown(target, deps?) — close the pane.

Three members are present only on backends that support the underlying concept — check for them before use. All are reached bound, the same way as the rest of the session (methods take deps?, no Exec):

  • mux.worktree? — a BoundWorktreeWorkspaceCapability, present on herdr. On tmux, rmux, WezTerm, Zellij, cmux, and otty it is undefined; fall back to plain git plus mux.open.
  • mux.regions? — geometry introspection (describeRegion / describeWorkspace), present on tmux, rmux, and herdr, absent on WezTerm, Zellij, cmux, and otty. Backs template save. The raw RegionInspector carries a third member, resizePane(exec, target, ratio), which the bound form does not expose.
  • mux.agentLifecycle? — the native per-pane agent-state wait (waitForState), present on herdr and otty and absent everywhere else. See Agent, which is where the emulate-or-refuse decision lives.

Then the capability flags — static declarations, not methods:

  • mux.canSizeSplits? — whether the backend honors ratio; false/absent means a requested ratio degrades to the backend’s own even split. The one flag the bound session carries; the four below are read from the raw adapter.
  • adapter.canFloatPanes? — whether pane:float opens a real floating pane. tmux 3.7+ and Zellij declare it; everywhere else a float is refused by name rather than substituted with a split.
  • adapter.canZoomPanes? — whether a pane can be zoomed to fill its tab (setPaneZoom / isPaneZoomed).
  • adapter.canMovePanes? — whether movePane can move a pane beside another.
  • adapter.canBreakPanes? — whether breakPane can break a pane out to its own tab or workspace.

Ask the flag before opening rather than catching the refusal after: that is the whole reason these are declarations.

  • mux.focusOnOpen'preserved' | 'restored' | 'stolen' — what open() does to the caller’s focus. Unlike the two flags above this one is required, so every adapter answers it. 'preserved' (tmux, rmux, herdr) means nothing moves at any instant, on any route. 'restored' (WezTerm, Zellij 0.45+, cmux, otty) means a focus move happens and is deterministically undone before open() returns — the caller ends where they started, though a human watching may see a flicker. 'stolen' means the move stands; no backend declares it today. It replaced the boolean opensWithoutStealingFocus, which had to call a restore either “no theft” or “theft” and chose the first, so Zellij’s visible round trip declared the same value as tmux’s -d.

    The declaration covers every route open() can take, including the focus move an adapter makes to choose a split target: a backend whose new-pane has no target flag honors from by focusing that pane first, and that counts. An adapter declares the weakest value any of its routes earns.

Everything above is the ergonomic, Exec-bound MuxSession surface. Underneath it is the pure, exec-injected MuxAdapter — every method takes its Exec as the first argument instead of one being bound. Reach for it when threading your own runner through per call rather than binding one, or when composing at a layer below resolveMux.

import { resolveMuxAdapter, callerPane, nodeExec, withReason, type MuxAdapter, type Exec } from 'cyber-mux'

resolveMuxAdapter(env, exec?)MuxAdapter

Section titled “resolveMuxAdapter(env, exec?) → MuxAdapter”

Run the probe and return the matching raw adapter (tmux / rmux / herdr / wezterm / zellij / cmux / otty). Throws if the process is in no supported multiplexer. exec defaults to nodeExec; resolveMux calls this internally and binds the result into a MuxSession.

const adapter = resolveMuxAdapter(process.env)
adapter.name // 'tmux' | 'rmux' | 'herdr' | 'wezterm' | 'zellij' | 'cmux' | 'otty'

The free-function form of mux.callerPane(), for the raw adapter — this process’s own pane as a MuxTarget the adapter can address.

Every MuxSession method above has a raw counterpart that takes exec first and drops deps: open(exec, opts), rename(exec, target, tier, name), group(exec, target, group, name?), sendText(exec, target, text), sendKeys(exec, target, keys), submit(exec, target, text?), read(exec, target, opts?), waitForOutput(exec, target, opts), focus(exec, target), teardown(exec, target), paneExists(exec, target), isPaneFocused(exec, target), listPanes(exec). The optional capabilities are reached the same way, exec-first: adapter.worktree (see Worktree), adapter.regions, and adapter.agentLifecycle (see Agent).

Four methods live only here, with no bound counterpart on MuxSession — pane geometry the session surface deliberately does not carry:

  • setPaneZoom(exec, target, zoomed) — zoom a pane to fill its tab, or restore it. Absolute, not a toggle: a caller that had to read the current state first could not act on a backend that cannot report one.
  • isPaneZoomed(exec, target)boolean | undefined — the read side, three-valued like isPaneFocused.
  • movePane(exec, target, destination, side)OpenedPane — move a pane beside another ('right' | 'down').
  • breakPane(exec, target, at)OpenedPane — break a pane out into its own 'tab' or 'workspace'.

Each is gated by the matching can* flag above; a backend without it refuses by name. The semantics of every method are identical to its bound counterpart described above — only the calling convention differs.

callerPane and nudge stay exported as free functions for this raw seam, in addition to being folded into MuxSession as methods.

Every raw adapter method takes an Exec — a synchronous command runner returning trimmed stdout or null on failure. resolveMux binds nodeExec (or a supplied fake) into every MuxSession method for you; the raw seam is where you’d bind it yourself.

import { nodeExec, withReason, type Exec } from 'cyber-mux'
  • nodeExec — the real runner, over execFileSync.
  • exec.lastError — the backend’s own words for why the most recent call returned null, when the runner supplies them. A diagnostic, never a control-flow signal — null stays the one failure sentinel.
  • withReason(exec, message) — append exec.lastError to a failure message when there is one, so a refused split reports the backend’s actual reason.

A test passes its own Exec that returns canned stdout, driving the whole adapter with no real multiplexer — or binds it once into a MuxSession with resolveMux(env, { exec: fake }).