ZMX Session Backend
ZMX is an optional persistent-session backend for botmux. It is intended for macOS and Linux hosts that want a lightweight session daemon to keep the CLI alive, with a native local attach available whenever the complete terminal experience is needed.
ZMX is an explicit opt-in backend. botmux does not install ZMX, and merely finding it on PATH never makes botmux select it automatically.
Install and probe
botmux requires zmx >= 0.7.0. That floor is the fix for upstream issue #201 — commit 8ba312d7 fix(send): preserve client leadership, shipped in v0.7.0 (2026-07-23): zmx send now uses a dedicated .Send IPC tag that only queues input into the PTY buffer, without claiming the leader or rewriting terminal size. ZMX officially supports macOS and Linux.
On other hosts, download the prebuilt binary for your architecture from the official ZMX installation instructions, then put zmx on the PATH of the same system user that runs the botmux daemon.
Before creating a new ZMX-backed session, botmux checks the executable, version, and zmx list control plane. Any failure fails closed with an actionable session error; botmux never silently falls back to PTY.
⚠️ Upgrading from 0.6: replacing the
zmxbinary on disk does not replace per-session daemons that are already running; after upgrading to 0.7.0+, manually stop and recreate every session launched by 0.6, then restart botmux. botmux performs no automatic cold migration, andbotmux restartalone is insufficient. ZMX's IPCTagenum is non-exhaustive (unknown tags fall through the_arm and are ignored), so a 0.6 daemon discards the new.Sendtag outright whilezmx sendstill exits 0 — the command reports success and the input never arrives.
For contributors, the default pnpm test command runs mocked/pure unit tests and does not require ZMX to be installed. Coverage that launches a real zmx binary lives in *.e2e.ts, runs only when E2E is requested explicitly, and skips automatically when ZMX is unavailable—the same pattern already used by the tmux and Herdr E2E suites.
Enable ZMX
Prefer enabling it only for the bots that need it in ~/.botmux/bots.json:
To make ZMX the deployment-wide default backend, set this in ~/.botmux/.env instead:
Run botmux restart after editing. A per-bot backendType overrides the deployment default.
Runtime model
botmux assigns each managed session the deterministic name bmx-<first 8 chars of sessionId>. The ZMX daemon owns the CLI's PTY. Instead of keeping a fake attach leader alive, botmux uses three leaderless interfaces:
zmx tailis used only as a low-latency change/liveness signal. botmux drains stdout but never gives its payload bytes to the worker: the current upstreamzmx tailANSI filter deletes multi-byte UTF-8, so Chinese and emoji cannot rely on this stream.zmx sendqueues raw input bytes into the PTY without attaching, changing the leader, or resizing it.zmx historyis the sole authoritative plain-text screen source. Tail/send wake asynchronous capture immediately; a staggered 250ms hot poll and at-most roughly 1.5s cold safety poll also catches pure Unicode that produces no tail event. Before idle completion, botmux forces one post-call capture (bounded retries, then the last successful snapshot).
A new session briefly uses one non-interactive client for creation only; output and input then use the interfaces above. A local zmx attach can therefore become the real leader and let the local terminal control size and the full TUI. Input sent from Lark through botmux does not steal that leadership.
Display, input, and terminal-size boundary
This integration deliberately uses eventually consistent plain-text screen semantics. Its persistent-session lifecycle is close to tmux, but it is not a complete terminal mirror:
- ZMX exposes an eventually consistent plain-text screen from
history; it does not preserve color, cursor state, OSC, or the alternate screen. Capture is single-flight per session and a dirty latch forces one follow-up when activity arrives during a capture. - The ZMX backend does not provide botmux's interactive Web TUI or resize the backing PTY. Use local
zmx attachwhen you need raw ANSI, a fullscreen TUI, or terminal-size negotiation. - A local attach leader controls terminal size. Without one, the ZMX session keeps its existing size; botmux's
senddoes not change it. ZMX exposes no leaderless resize primitive, so botmux'sresize()is a deliberate no-op. botmux creates sessions from a non-TTY client, which lands on ZMX'sgetTerminalSizefallback, so managed sessions run at a fixed 120×24 and the CLI wraps its TUI at 120 columns — the width you see in Lark. Use a localzmx attachto take leadership if you need a different size. - Upstream
sendcurrently provides no delivery ACK or backpressure. botmux sends 1 KiB chunks and rejects any single backend input over 64 KiB before writing a prefix. It never retries an ambiguous result automatically because the input may already be queued and a retry could duplicate it; the backend reports failure to its caller instead of hiding a retry internally. zmx historycan restore only the bounded scrollback still retained by ZMX / ghostty; older output is evicted once the upstream scrollback budget is exceeded. It reconstructs the eventually consistent observable state rather than a lossless transcript or terminal recording; transient output after the daemon has exited cannot be recovered. Workflow raw PTY replay logs therefore do not have tmux's lossless semantics.
Enter the same session locally
botmux list shows the effective backend for every session. Select a ZMX row and press Enter to attach safely to its existing bmx-* session. If the backing session has disappeared, the command refuses to create an empty shell that could masquerade as the original CLI.
When the daemon runs on macOS, you can also explicitly enable Native CLI opening under Dashboard Settings and keep Attach current session mode selected. The Lark card's Open CLI button will then attach iTerm2 / Terminal to the same ZMX session instead of starting a second CLI. This feature is off by default and requires operate permission.
Unsupported combinations
- Adopt: ZMX is not scanned or accepted as a
/adoptsource. Use the supported tmux / Herdr / Zellij path when adopting an existing external session. - Runners that depend on hidden OSC completion events:
codex-app,mira, andmirlose their final/thread events in plain history, so these combinations fail closed at startup. Use tmux / PTY for those CLIs. - File sandbox and read isolation: the child PTY belongs to the ZMX session daemon, so botmux cannot currently apply its bwrap / Seatbelt filesystem boundary. Combining
backendType: "zmx"withsandbox: true, globalBOTMUX_SANDBOX=1, or the legacyreadIsolation: trueflag therefore fails closed on every platform; config migration foldsreadIsolationinto the unified sandbox request, and the worker gate enforces that same decision before startup. The worker posts an actionable session notification before refusing to start. When isolation is required, enable the sandbox and switch to tmux / PTY; otherwise explicitly disable the corresponding isolation setting.
Troubleshooting
- Run
zmx versionandzmx listas the same user that runs the daemon to verify version 0.7.0 or newer,PATH, and the socket directory. - After an upgrade from 0.6, manually stop and recreate old session daemons; botmux does not cold-migrate them automatically, and restarting botmux alone does not replace them. Check this first if
zmx sendsucceeds but the CLI receives nothing. - If you set
ZMX_DIR, make sure the daemon and the shell used for local attach share the same value. botmux preservesZMX_DIR, but strips inheritedZMX_SESSION/ZMX_SESSION_PREFIXso nested sessions and prefixes cannot rewrite the deterministicbmx-*target. - Inspect
botmux logs. When a probe is inconclusive, botmux conservatively refuses to start/recreate a session so it cannot launch a duplicate CLI or delete a still-live one.
Dashboard session queries can report the ZMX backend and deterministic session name, but those fields are not liveness checks. See Dashboard external read-only queries and security boundary.
