Skip to content

Add zmx multiplexer support - #326

Open
dave-atx wants to merge 8 commits into
kitknox:mainfrom
dave-atx:zmx-upstream
Open

Add zmx multiplexer support#326
dave-atx wants to merge 8 commits into
kitknox:mainfrom
dave-atx:zmx-upstream

Conversation

@dave-atx

Copy link
Copy Markdown

Closes #324.

Adds zmx alongside tmux, zellij and herdr: session discovery with live previews, attach on tap, per-profile and global auto-start, settings, guide and full localization. Five commits, one per phase.

zmx is attach/detach and nothing more — one session is exactly one PTY, no windows, splits or control protocol — so it rides the existing discovery and raw-attach path and touches none of the tmux -CC projection engine.

Three choices that look inconsistent on purpose

No bindRawMultiplexer for zmx, though all three neighbours have one. That binding is a suppression switch — it disables project identity, the working|blocked → idle completion edge, and the alt-screen presence grant — and none of it applies to a transparent passthrough. Worse, it releases only after alt-screen ownership is observed and lost, which for zmx never happens, so agent detection would stay dead for the life of the surface. Not binding is what gives agents inside zmx a project, via OSC 7. Hence also no AgentProjectIdentity.Source.zmx and no rawMultiplexerType(launching:) entry.

zmxAutoEnable syncs through HistoryExtensionPayload, not its own CKRecord field like herdrAutoEnable right above it — a new top-level field needs a production CloudKit schema deploy. Envelope absence is ambiguous ("cleared" vs "old writer"), so the merge is version-gated: currentVersion goes to 3 with a zmxAutoEnableVersion constant. Without the gate, one reconnect from an older device mid-rollout would erase the setting.

One un-timed zmx list, against the defensive idiom used elsewhere. Measured with 4 daemons under SIGSTOP: plain returns all 25 rows in 4.0 s; timeout 2 returns zero rows in 2.0 s, because zmx probes sockets serially and emits nothing until the last finishes — same wall clock, no data, indistinguishable from zmx not being installed. A 5 s backstop still catches a real hang; per-history timeouts stay, since truncation there costs one preview. The capture loop reuses that listing instead of a second --short run.

Parsing is positional for a related reason: labels are user-defined and print after the built-ins, so key lookup would misreport client counts and an err=-substring rule would silently hide healthy sessions. A real error row is err in field two with no pid. cwd= (HEAD) is percent-decoded; start_dir= (v0.7.0) must not be — zmx version says 0.7.0 for both, so the field name is the only discriminator.

Drive-by: MultiplexerType gains an iconName, replacing two hardcoded icon ladders that disagreed (settings and the picker drew tmux and zellij swapped). Guide page retitled, orphaned "tmux Tips" key removed. The Localizable.xcstrings diff is large but mechanical: 14 keys added, 1 removed, 0 modified, translated into every language, no reordering.

Testing

No unit-test target for app code, so in-repo coverage is a #Preview running both wire formats through the real parser into the real SessionPickerOverlay. A podman harness behind the branch (zmx HEAD, v0.7.0, no-zmx, plus tmux and zellij) adds a 34-assertion parser test that compiles ZmxDiscoveryParser.swift with swiftc; kept out to keep the diff product-only, happy to send separately.

Verified in the app on iPhone and iPad simulators against those hosts: 25+ mixed sessions listed with live previews; attach/detach/reattach replay scrollback correctly, including a path with a space; a SIGSTOPed daemon is dropped without taking tmux and zellij down with it; the 256 KiB overflow ladder fires once per attempt and still returns everything; ⌘D over a zmx tab gives a native split (isTmuxPane == false); an agent inside zmx gets a card with a project; a finished zmx run replays with no spurious card or "Done"; auto-start lands in zmx attach main without popping the picker; with discovery off the emitted command contains zmx zero times.

Zero warnings in touched files on the iOS Simulator and Mac Catalyst (rootshell-Standalone defines STANDALONE, so those paths type-check too). Not covered: CloudKit sync across two devices, and a launched Catalyst build.

@kitknox

kitknox commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Two issues to flag before merging.

  1. Profile editor uses tmux settings for zmx in ProfileEditorSheet.swift, ProfileMultiplexerSessionEditor only distinguishes herdr from the other multiplexers, so zmx currently falls through to the tmux path. Could you add explicit zmx handling? The editor should also reject zmx session names beginning with -

  2. Discovered zmx start_dir - Would it be better to drive this from OSC 7/PWD since that replays when attaching in the latest version? Otherwise we are stuck with the startup dir only?

Thanks for pulling this together.

@dave-atx

dave-atx commented Aug 25, 2026

Copy link
Copy Markdown
Author

Sure! Just added fixes for those two issues. Additional fix for tab expose coming next.

dave-atx and others added 8 commits August 25, 2026 00:09
Discovers zmx sessions on connect and attaches to them from the session
picker, alongside tmux, zellij and herdr.

zmx is session attach/detach and nothing more: one session is exactly one
PTY, with no windows, tabs, splits or control protocol. It therefore rides
the existing discovery and raw-attach path and touches none of the tmux
control-mode projection engine.

Discovery and attach land together because they cannot be separated: adding
`case zmx` to `MultiplexerType` makes `attachToSession`'s switch
non-exhaustive, so a discovery-only commit would not build.

The attach case deliberately does not call `bindRawMultiplexer`, unlike its
three neighbours. That binding suppresses agent project identity and the
completion edge for multiplexers that own the alternate screen for a whole
attach; zmx is a transparent PTY passthrough, so binding it would disable
agent detection on the surface permanently.
Adds zmx to the per-connection "Auto-start multiplexer" option beside Off,
tmux, tmux -CC and herdr, with the full fan-out across ssh, mosh, tssh and
trzsz, profile storage, history and CloudKit sync. `--zmx` joins `--tmux`
and `--herdr` as a typed flag on all four commands.

`zmx attach` is attach-or-create, so auto-start never fails on a fresh
host. Unlike herdr there is no unnamed default session, so a name is always
passed -- "main" unless configured otherwise.

zmxAutoEnable syncs through `HistoryExtensionPayload` rather than a
top-level CKRecord field, unlike the herdrAutoEnable line beside it: a new
record field needs a production CloudKit schema deploy, which is the cost
the envelope exists to avoid. Because an envelope member's absence is
ambiguous between "cleared" and "writer predates the field", the merge is
version-gated -- currentVersion goes to 3. Without the gate, one reconnect
from an older device during a mixed-version rollout would silently erase
the setting.

Auto-start also suppresses the session picker for zmx, as it already does
for tmux and herdr: the connection is committed to a multiplexer, so
sessions of another type must not pop the picker over its own freshly
started session.
Settings gains a zmx discovery toggle and a zmx Auto-Start page for the
session name and an optional custom command, with a preview of the exact
command that will run -- matching the tmux and herdr pages beside it. The
multiplexer guide gains a zmx section and is retitled accordingly.

Also unifies multiplexer icons behind `MultiplexerType.iconName`. The
settings screen and the session picker each carried their own hardcoded
icon ladder and the two disagreed: settings drew tmux as
rectangle.split.2x1 and zellij as rectangle.split.3x1, the picker drew them
the other way round. One source of truth fixes that pre-existing mismatch
and makes the next multiplexer one case rather than two ladders.
Adds the new zmx user-facing strings to the catalog with translations for
every supported language, and drops the now-unreferenced "tmux Tips" key
left behind by the guide retitle.
README's multiplexer bullets now name all four; CHANGELOG gains the zmx
entries under Unreleased -- fold them into whichever version you cut next.
ProfileMultiplexerSessionEditor only distinguished herdr from everything
else, so a profile set to zmx fell through to the tmux path:

  - the "Use Global Default" row showed the *tmux* session name as the zmx
    default (masked today only because both default to "main"),
  - a custom command was read from "tmuxCustomCommand", so a zmx custom
    command failed to disable the name field and a tmux one wrongly did,
  - the footer described tmux.

Give each of those an explicit zmx branch.

The leading-dash rule was open-coded in zmxExecCommandLine and in the
settings captions but missing from the editor, which is why the editor
accepted `-d` and the connection then silently substituted the default.
Move the rules to MultiplexerSessionName so the editor, the exec line and
the captions share one definition and cannot drift apart again.

A name starting with `-` gets its own warning rather than the generic
charset one: every character in `-d` is legal, so the shared message sends
the user hunting for an illegal character that isn't there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0165MYDsTTDAWWXTnqb4qRMK
The comment justifying the discovery-seeded pwd rested on two claims that
are no longer true:

  - "Ghostty's OSC 7 action does not reach handlePwdChange here" -- it
    does. GHOSTTY_ACTION_PWD is dispatched to handlePwdChange for any
    surface, with no transport or host gating, because Ghostty parses the
    sequence out of the byte stream.
  - "zmx's attach replay never re-emits OSC 7" -- it does now.
    serializeTerminalState calls writePwd (util.zig), covered by its own
    "replays the pwd without a NUL sentinel" test, added for zmx kitknox#222.

So OSC 7 is the authority and the replayed value supersedes the seed
moments after attach. Keep the seed, but document it as the fallback it
actually is: zmx v0.7.0 reports start_dir= and has no replay at all, and
until the replay lands the pane has no directory for the agent card to
name a project from.

Nothing stripped the URI form, though, so a replayed OSC 7 would have set
pwd to `file://box/home/dave/web%20app`. Decode it in handlePwdChange, the
one point every pwd source funnels through; bare paths (v0.7.0's
start_dir=, LocalShellSession) pass through untouched, since a real
directory may contain a space or a literal `%`. ZmxDiscoveryParser now
delegates its private copy of that decoding to the same helper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0165MYDsTTDAWWXTnqb4qRMK
The tab exposé already renders a multiplexer's own tabs for tmux, zellij and
herdr. zmx panes were excluded: a zmx pane is bound through a raw passthrough
rather than a control channel, so the exposé had no session identity to key on.

Add a ZmxExposeAdapter alongside the existing three:

- Topology and capture run over the same one-shot exec channel the other
  adapters use -- `zmx list` for the session list, `zmx history <name> --vt`
  for each cell's screen. `ZMX_SESSION_PREFIX=` is neutralised on every call
  because `zmx list` reports real socket names while every other subcommand
  prepends that variable.
- Selecting a cell switches the pane with `ZMX_SESSION=<current> zmx attach
  <target>`, which zmx treats as a session switch rather than a new client.
- TerminalView gains a passthrough multiplexer slot, so a zmx pane carries a
  session identity without owning the alternate screen. The slot is cleared
  on connection teardown and updated when a switch lands.

Each capture ends with a trailing `echo`, matching the zellij and herdr
adapters. `zmx history --vt` ends mid-line on a cursor-position escape rather
than a newline, so without the terminator the next pane marker is appended to
the capture's final line and `MuxScript.sections`, which matches markers per
line, stops recognising it -- every session after the first would lose its
frame and render a placeholder forever. tmux needs no terminator because
`capture-pane` ends its output with a newline.

Verified against a zmx host on iPhone and iPad: the exposé lists the session's
peers and every cell renders its live screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kitknox

kitknox commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Just tried this out and the session discovery is working. tab exposé only works if you attached via session discovery. When I manually attach from macOS local shell it doesn't work ie running zmx a foo1. This is tricky to do correctly, was this missed or just more challenging to make possible with zmx architecture? The zmx tab exposé thumbnails are also not scaling correctly so I get line wrapped for example if I have as a test htop running in a session. Also please back out the .gitgnore for your private repo docs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: zmx terminal multiplexer support

2 participants