Add zmx multiplexer support - #326
Conversation
|
Two issues to flag before merging.
Thanks for pulling this together. |
|
Sure! Just added fixes for those two issues. Additional fix for tab expose coming next. |
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>
|
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 |
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
-CCprojection engine.Three choices that look inconsistent on purpose
No
bindRawMultiplexerfor zmx, though all three neighbours have one. That binding is a suppression switch — it disables project identity, theworking|blocked → idlecompletion 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 noAgentProjectIdentity.Source.zmxand norawMultiplexerType(launching:)entry.zmxAutoEnablesyncs throughHistoryExtensionPayload, not its ownCKRecordfield likeherdrAutoEnableright 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:currentVersiongoes to 3 with azmxAutoEnableVersionconstant. 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 underSIGSTOP: plain returns all 25 rows in 4.0 s;timeout 2returns 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-historytimeouts stay, since truncation there costs one preview. The capture loop reuses that listing instead of a second--shortrun.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 iserrin field two with nopid.cwd=(HEAD) is percent-decoded;start_dir=(v0.7.0) must not be —zmx versionsays0.7.0for both, so the field name is the only discriminator.Drive-by:
MultiplexerTypegains aniconName, replacing two hardcoded icon ladders that disagreed (settings and the picker drew tmux and zellij swapped). Guide page retitled, orphaned"tmux Tips"key removed. TheLocalizable.xcstringsdiff 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
#Previewrunning both wire formats through the real parser into the realSessionPickerOverlay. A podman harness behind the branch (zmx HEAD, v0.7.0, no-zmx, plus tmux and zellij) adds a 34-assertion parser test that compilesZmxDiscoveryParser.swiftwithswiftc; 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;⌘Dover a zmx tab gives a native split (isTmuxPane == false); an agent inside zmx gets a card with a project; a finishedzmx runreplays with no spurious card or "Done"; auto-start lands inzmx attach mainwithout popping the picker; with discovery off the emitted command containszmxzero times.Zero warnings in touched files on the iOS Simulator and Mac Catalyst (
rootshell-StandalonedefinesSTANDALONE, so those paths type-check too). Not covered: CloudKit sync across two devices, and a launched Catalyst build.