Skip to content

Remote projects over SSH backed by zmx (#104)#138

Merged
thdxg merged 24 commits into
mainfrom
remote-projects-ssh
Jul 6, 2026
Merged

Remote projects over SSH backed by zmx (#104)#138
thdxg merged 24 commits into
mainfrom
remote-projects-ssh

Conversation

@thdxg

@thdxg thdxg commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Closes #104. Stage 2 of the remote-projects work — builds directly on #137 (central project files + the scp-style path: grammar), which deliberately landed the foundation and nothing more.

Handoff note: this PR is being implemented incrementally by an agent session. The full agreed plan is below; commits land in reviewable chunks. If picking this up mid-flight, diff the plan against the checklist at the bottom to see what's left.

Context

A project can now only be a local directory. Issue #104 asks for first-class remote projects: a project that is a remote machine + directory, where each pane is a persistent zmx session on that host, surviving Macterm quits and even local reboots (the remote daemon outlives the laptop). The mapping mirrors local persistence exactly — Macterm stays the source of truth for tabs/splits/layout; zmx (on the remote host) keeps each terminal alive:

Macterm project  → [user@]host:dir   (parsed by ProjectPath, landed in #137)
Macterm pane     → one zmx session on that host
New pane/tab     → ssh -t host 'cd dir && exec zmx attach macterm-<id>'
Quit Macterm     → ssh clients die → sessions detach, keep running remotely
Relaunch         → snapshot restores sessionName → same command reattaches

Settled design

One hard constraint shapes the spawn path: nested zmx does not work (a local zmx attach wrapping an ssh to a remote zmx attach breaks — known upstream, cmux hit it too). So a remote pane's surface runs ssh directly as the surface command, with no local zmx wrapper. Persistence for remote panes comes entirely from the remote daemon; the local ssh process is disposable.

  • Model: Project.path stays a single string and is now allowed to be a remote spec ([user@]host:dir), parsed on demand by ProjectPath (landed in Centralize project layout files in ~/.config/macterm/projects (#114) #137 — files written then are already forward-compatible). No new stored fields; Project.isRemote / remoteSpec are computed. Panes already persist projectPath verbatim in the snapshot, so restored remote panes know their host without new snapshot schema.
  • Spawn: for a remote pane, createSurface sets config.command to the ssh invocation and skips the zmx command_wrapper and the ZMX_DIR env pin (both are local-persistence machinery). The remote command is cd <dir> && exec zmx attach <sessionName> with the directory single-quote-escaped; built by a new pure, unit-tested RemoteSpawn helper. initial_input (declared run:) is typed into the remote shell after connect, same semantics as local. ssh -t only — no BatchMode for the pane itself, so password/2FA prompts appear in the pane and interactive auth just works.
  • ssh options are the user's ssh config's job: port, identity, ControlMaster, IPv6 literals — all via a ~/.ssh/config alias (same position Centralize project layout files in ~/.config/macterm/projects (#114) #137 took for the grammar). Macterm injects no connection tuning. ControlMaster in the user's config is the recommended (documented) way to make splits/reconnects fast.
  • zmx must already be on the remote host (in non-interactive-shell PATH). No upload/install flow in this stage — a missing binary surfaces as zmx: command not found in the pane (see failure handling). Settings gets a one-line docs pointer, mirroring the local zmx-unavailable messaging.
  • Failure handling rides ghostty's abnormal-exit screen: an unreachable host / auth failure / missing zmx makes the surface command exit non-zero, and ghostty already shows the error output + "press any key to close" screen (the same one that surfaced the ZMX_SESSION bug in Centralize project layout files in ~/.config/macterm/projects (#114) #137 testing). Stage 2 adds no bespoke banner or project-row status; the pane shows exactly what ssh said. Reconnect = new tab/pane or re-apply layout.
  • Session ops route by project: ZmxClient gains a transport — the existing local subprocess, or ssh -o BatchMode=yes -o ConnectTimeout=5 <host> zmx <args> for remote (BatchMode is correct here: background ops must never hang on an auth prompt). Close pane/tab kills the remote session through it, same as local.
  • The orphan reaper stays local-only (explicit guard + doc). Reaping zero-client macterm-* sessions on a shared remote host would destroy sessions belonging to another machine's Macterm (same name prefix, legitimately detached). Remote orphans (crash leftovers) are the user's to zmx kill — documented limitation until sessions carry a per-installation marker.
  • Smart tab naming works for remote panes, two-tiered. ProcessInspector is local-kernel sysctl and the pane's local foreground pid is just ssh — so remote panes leave the local poll and get their own pipeline:
    • Tier 1 (in-band, zero round trips): execution-gated OSC titles. The local provenance gate ("adopt a reported title only while the foreground process is a real program, pinned to that pid") has an in-band analogue: TerminalExecutionTracker's OSC 133 state. A remote pane adopts OSC 0/2 titles only while executing, expires them when the command ends, and discards prompt-time titles — same policy as local, gated by wire framing instead of a pid.
    • Tier 2 (the real process name): a batched per-host remote resolver mirroring ZmxForegroundResolver over ssh. One BatchMode=yes ssh exec per host per tick runs a small POSIX script resolving all of that host's macterm-* sessions at once: remote zmx ls → leader pids → ps -o tpgid= (tty foreground pgid) → ps -o comm=. Same session→leader→tpgid→comm pipeline as local, different transport. Its own throttled cadence (~3–5s, only while the remote project is frontmost, stopped when idle/unfocused, overlapping probes dropped à la ZmxRefreshGate) — never the local 250ms burst. Covers title-silent TUIs (helix). Any failure degrades silently to tier 1.
    • displayTitle precedence for remote panes mirrors local: execution-gated OSC title → tier-2 process name → host name when idle. OSC 133 status indicators work when the remote shell has shell integration; without it, panes show no execution state and tier 1 is inert (tier 2 still works).
  • Features that assume a local cwd are disabled for remote projects: "Replace Project Path with Current Dir" (OSC 7 reports a remote path; writing it into a local-looking Project.path would corrupt the project), directory-source palette entries, and the layout save of live run:/shell: (both resolve through local pids — a remote pane saves as a plain leaf with its declared cwd). Apply works fully: declared tabs/splits/cwd/run spawn over ssh via the same LayoutReconciler, where remote panes match as idle leaves (the injected live-command lookups return nil).
  • UX: sidebar "New Project" splits into Local Folder… (today's open panel) and Remote Machine… — a small sheet with display name, [user@]host (or ssh alias), and remote directory, validated by ProjectPath.parse and composed into the path: string. Both also become AppCommand palette entries. Remote project rows get a small SF-symbol badge; colors from MactermTheme.
  • Persistence semantics: snapshot machinery is untouched — remote panes persist sessionID/sessionName/projectPath exactly like local ones and reattach on relaunch by re-running the same ssh command. Central project files with a remote path: now match openable projects, so Apply/Save Layout and add-project auto-apply work for remote projects out of the box (Centralize project layout files in ~/.config/macterm/projects (#114) #137's decoupled model needs zero changes).
  • Quit: killing the app kills the local ssh clients → remote zmx sessions detach and keep running. The quit dialog treats a remote pane's ssh as a running process (it is — and closing it detaches, so the confirm is honest).

Resolved open questions from #104

Question Decision
Require zmx on the remote, or upload a bundled binary? Require preinstalled (this stage). Upload/install is a possible follow-up; it needs per-arch Linux binaries and PATH/version management.
ControlMaster for faster splits? Not injected by Macterm — recommended via the user's ssh config, documented.
How are connection failures surfaced? In the pane, via ghostty's abnormal-exit screen (ssh's own stderr). No project-row status in this stage.
Port / identity / IPv6 fields? Deliberately not in the grammar (#137) — use an ssh-config alias.

Implementation checklist

  • Macterm/System/RemoteSpawn.swift — pure builder: pane spawn command (ssh -t <host> "cd <dir'> && exec zmx attach <name>" with quoting), remote zmx op argv (ssh -o BatchMode=yes -o ConnectTimeout=5 <host> zmx <args>) + RemoteSpawnTests (quoting: spaces, single quotes, ~ dirs)
  • Macterm/Model/Project.swift — computed isRemote/remoteSpec via ProjectPath.parse; no stored-schema change
  • Macterm/Model/SplitNode.swift (Pane) — remote awareness derived from projectPath; skip local-zmx wrapping flags for remote panes
  • GhosttyTerminalNSView.createSurface — remote branch: config.command = ssh invocation, no command_wrapper, no ZMX_DIR pin; initial_input unchanged
  • Macterm/System/ZmxClient.swift — transport injection (local subprocess | ssh); kill/list for remote sessions; reaper hard-guarded local-only with rationale comment
  • Macterm/App/AppState.swift — route session kills by project; exclude remote panes from the local foreground poll; host-name idle-title fallback; disable replaceProjectPathWithCurrentDir for remote
  • Remote tab naming tier 1 — execution-gated OSC titles: Pane.receiveReportedTitle remote branch keyed to TerminalExecutionTracker state (adopt while executing, expire on command end, discard at prompt) + tests
  • Remote tab naming tier 2 — Macterm/System/RemoteForegroundResolver.swift: batched per-host ssh probe (zmx lsps -o tpgid=ps -o comm= in one POSIX script), throttled remote cadence (frontmost-only, ~3–5s, drop overlapping probes), silent degradation on failure + parser/cadence tests
  • UX: AppCommand.newRemoteProject (palette + menu; the existing openProject stays the local path — no new hotkey, palette-only), sidebar New Project menu (Local Folder… / Remote Machine…), remote-machine sheet, network badge on remote rows
  • Layout apply/save for remote projects: LayoutBuilder cwd resolution against a remote root string; LayoutSerializer.save emits plain leaves for remote panes; reconciler tests with nil live-lookups
  • assets/project.schema.json — drop "Remote projects are not yet supported"; document remote semantics
  • AGENTS.md — remote projects section (spawn path, no-nested-zmx constraint, reaper scope, disabled features); Known Limitations updated (remote reaper, zmx-preinstalled requirement)
  • Login-shell portability: every remote command ships as sh -c '<single-quote-free script>' — sshd runs commands through the login shell, and fish/nushell don't parse &&/'\'' (found immediately by loopback testing against a nushell login shell; wire formats verified over real ssh)
  • Remote script preambles: PATH extended with ~/.local/bin:/usr/local/bin:/opt/homebrew/bin (sshd's default PATH is bare; non-POSIX login shells don't source profiles), and the pane script falls back to TERM=xterm-256color when the remote can't resolve xterm-ghostty (both verified over loopback)
  • Palette path mode recognizes remote specs: typing devbox:~/dev/api offers Add/Switch remote project, gated strictly (no whitespace, hostname-shaped host, ~//-anchored dir) so colon-containing command queries are never swallowed
  • mise run format && lint && test green
  • main merged in (website redesign Redesign website landing + docs pages #143, control socket + macterm CLI Add a control socket and bundled macterm CLI (read-only verbs) #139/Sample the benchmark under a real workload via the macterm CLI #141/Add mutation verbs to the macterm CLI (create, split, run, grid) #142); docs site gains a comprehensive Remote projects page and a macterm CLI page (the CLI had a repo doc but no website page), plus remote cross-references in the intro, palette, layouts, and persistence pages
  • Manual verification, three environments: loopback (ssh-to-self; caught the nushell wire-format bug), Docker Linux harness (aarch64 + amd64, hostile by construction: nushell login shell, zmx in ~/bin, broken/exec-ing ~/.profile — Dockerfile in the collapsed appendix below), and a real CMU host (AFS home, dash /bin/sh, unchangeable bash login shell with .profile exec'ing zsh). The CMU host found four real bugs the green environments couldn't: dash rejecting sh -lc, ~/bin missing from the PATH list, silent pane-close on spawn failure, and the profile exec-hijack family. All fixed; final acceptance run passes end to end (spawn in project dir, visible output, tab naming, kill-on-close, persistence across relaunch)

Real-host findings (post-review testing)

Tested against a real CMU host (nushell login shell, dash /bin/sh, zmx in ~/bin, AFS home) and a Docker aarch64 harness reproducing those conditions. Fixes landed:

  • sh -lcsh -c: older dash (common as /bin/sh) rejects the -l login flag and silently drops to an interactive shell that ignores the command — panes closed with no error. Now sh -c, sourcing /etc/profile/~/.profile ourselves (best-effort) plus a fallback PATH list to reproduce what -l was for.
  • Optional Project.zmxPath: PATH resolution can't cover every host (network-mounted home, exotic /bin/sh, PATH set only in a non-POSIX shell config — AFS hosts hit all three). An absolute zmx path, set in the sheet and persisted to projects.json + the layout file, is used verbatim and bypasses PATH entirely. Verified over the container with PATH=/nonexistent.
  • PATH preamble widened to include ~/bin/~/.cargo/bin; failed spawns print a macterm: diagnostic and drop to a shell instead of vanishing the pane.
  • The blind-pane mystery, solved (and two earlier diagnoses corrected). The CMU host's panes showed a prompt and echoed keystrokes but no command output, never attached zmx, and didn't persist. Root cause: the user's ~/.profile ends in exec zsh. Sourced inline under the preamble's >/dev/null 2>&1 silencing, the exec replaced our script wholesale — the exec'd zsh inherited stdout/stderr→/dev/null (prompt still visible because ZLE writes to /dev/tty; keystrokes visible via kernel pty echo). Fix, after two more failed containment attempts (a subshell harvest still let the exec'd shell block forever reading the pane's tty stdin; </dev/null still spun on a profile exec-looping into a login shell that re-reads it): profiles are now never executed, in any form. The fallback dir list covers real zmx locations (including ~/bin); the explicit zmxPath covers the rest. A regression test asserts no wire format references a profile. Verified in the Docker harness against an exec-looping profile with stdin held open: attach in 1s. Bisected on the real host: /etc/profile renders, ~/.profile is the poison, cwd and TERM innocent. Correction, closed: the earlier "zmx renders blind under TERM=xterm-ghostty" finding came from an A/B test confounded by this hijack (the blind arm sourced profiles, the working arm didn't). Retested un-confounded on the same host: renders fine. The conditional infocmp TERM fallback is restored — hosts lacking ghostty terminfo degrade to xterm-256color, hosts shipping it keep full capabilities.

Verification plan

  1. Unit: RemoteSpawnTests quoting matrix; reconciler with remote lookups; command routing.
  2. Loopback e2e without a real server: add localhost (ssh to self) as a remote project → panes spawn, zmx ls on "the remote" shows macterm-* sessions; quit Macterm → sessions stay alive with 0 clients; relaunch → panes reattach (scrollback intact); close tab → session killed.
  3. Failure paths: unreachable host → ghostty error screen with ssh's message; host without zmx in PATH → command not found screen; wrong dir → cd error visible.
  4. Layout: central file with remote path: + tabs: → add project → tabs spawn remotely per declaration; Apply Layout reconciles; Save Layout writes plain leaves.
  5. Tab naming (loopback remote): run btop in a remote pane → tab shows btop (tier 2); run a command under a title-emitting shell → title adopted while executing, dropped at the prompt (tier 1); idle pane shows the host name; yank the network mid-poll → names freeze, no errors.
  6. Local regression: full local smoke (tabs, splits, persistence, reaper) unaffected.

Non-goals (this stage)

  • Uploading/installing zmx on the remote host — planned as a follow-up: install-on-demand, not a project-creation checkbox. When a pane spawn hits command not found, offer a one-click "Install zmx on this host" that uname -m's the host, scp's the arch-matched binary to ~/.local/bin/zmx (already on the pane PATH via the preamble), and retries. Chosen over a sheet checkbox because it only runs when needed, the arch is knowable by then, it doesn't gate project creation on a network round-trip, and it leaves room to handle version-skew re-push (zmx IPC changes across versions kill sessions). Requires sourcing per-arch/per-OS Linux binaries — the reason it's out of scope here.
  • Remote orphan reaping
  • Per-project ssh options UI (port/identity/ControlMaster) — ssh config owns these
  • Remote quick terminal
  • Project-row connection-status indicators

🤖 Generated with Claude Code

Appendix: Docker test-host harness (reproduces the hostile-host conditions; session scratchpads are ephemeral so preserved here)
# A stand-in remote host for Macterm remote-project testing (#104).
# Hostile by construction:
#   - NON-POSIX login shell (nushell)
#   - zmx in ~/bin (not on the bare non-interactive ssh PATH)
#   - ~/.profile that errors on line 1 and/or execs another shell
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends \
      openssh-server sudo ca-certificates procps curl \
    && rm -rf /var/lib/apt/lists/* && mkdir /run/sshd
RUN useradd -m -s /usr/bin/nologin dev && echo 'dev:devpass' | chpasswd
RUN ARCH=$(uname -m) && NU_VER=0.101.0 \
    && curl -fsSL "https://github.com/nushell/nushell/releases/download/${NU_VER}/nu-${NU_VER}-${ARCH}-unknown-linux-gnu.tar.gz" -o /tmp/nu.tar.gz \
    && tar xzf /tmp/nu.tar.gz -C /tmp && cp /tmp/nu-*/nu /usr/local/bin/nu \
    && rm -rf /tmp/nu* && chsh -s /usr/local/bin/nu dev
# zmx binary from https://zmx.sh/a/zmx-0.6.0-linux-<arch>.tar.gz, extracted next to this Dockerfile
COPY zmx /home/dev/bin/zmx
# the hostile profile; for the exec-loop variant use: exec bash -l
RUN printf 'source ~/.deno/env\nexport PATH="$HOME/bin:$PATH"\nexec zsh\n' > /home/dev/.profile
RUN chmod +x /home/dev/bin/zmx && chown -R dev:dev /home/dev
RUN sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config
EXPOSE 22
CMD ["/usr/sbin/sshd", "-D", "-e"]

Run: docker build -t macterm-remote-test . && docker run -d --name macterm-remote -p 2222:22 macterm-remote-test, then an ssh-config alias Host macterm-docker / HostName localhost / Port 2222 / User dev. Bake stable host keys + your pubkey in for repeat runs (see PR discussion).

Bootstrap commit for the draft PR; implementation lands in follow-up
commits on this branch. Full plan in the PR description.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
macterm 51bcc83 Commit Preview URL

Branch Preview URL
Jul 06 2026, 05:52 AM

thdxg added 6 commits July 5, 2026 00:45
Two deliberately different ssh profiles: the pane command is interactive
(-t, no BatchMode) so password/2FA prompts render inside the pane, and
cd's into the project dir before exec'ing 'zmx attach <session>' — a
remote pane is persisted entirely by the REMOTE daemon, never wrapped in
local zmx (nested zmx is broken upstream). Background ops (kill/ls) get
BatchMode + ConnectTimeout so a dead host can't hang close/quit paths on
an auth prompt. Tilde segments in the remote directory stay unquoted so
the remote shell expands them; everything else is single-quote-escaped.

Port/identity/ControlMaster stay inexpressible by design — use an
ssh-config alias (the #137 stance).
A remote pane's surface command is RemoteSpawn's ssh invocation —
config.command replaces the local shell, the zmx command-wrapper and
ZMX_DIR pin stay off (nested zmx is broken upstream; the remote daemon
owns persistence), and no local working_directory is set (the cd runs
remotely). Declared run: still types into the remote shell via
initial_input; declared shell: doesn't apply (the remote session spawns
the remote login shell). Pane caches isRemote for the poll paths.
A remote pane's session lives on the remote daemon, so killing it by
name against the local zmx silently no-ops and strands the session.
ZmxClient gains killRemoteSession (BatchMode + ConnectTimeout ssh, a
longer subprocess cap since connect alone can eat the local 5s budget,
defaulted to a no-op so local-only test doubles stay unchanged);
Pane.killPersistentSession routes by the pane's projectPath, and the
terminate-on-quit sweep partitions local vs remote kills. The orphan
reaper stays local-only on purpose: zero-client macterm-* sessions on a
shared remote host legitimately belong to other machines' Macterms.
The local pipeline can't see across ssh — the pane's local foreground
is always the ssh client — so remote panes get two tiers:

Tier 1, in-band: OSC titles gated by OSC 133 execution state instead of
a foreground pid. A title arriving while a command runs is the program
naming itself and is adopted; prompt-time churn is discarded, and the
running→ended edge expires the title (the pid-change analogue). Zero
round trips; inert without remote shell integration.

Tier 2, the real process name: RemoteForegroundResolver batches one
BatchMode ssh per host per ~3s (frontmost project only, overlapping
probes dropped) running a portable POSIX probe — the same
session→leader→tpgid→comm pipeline ZmxForegroundResolver runs locally.
A failed probe freezes names at last-known instead of flapping.

Remote panes leave the local poll entirely: reading the local process
table would stomp probe names, expire remote titles instantly, and feed
the tracker a perpetual 'ssh is running'. Idle fallback title is the
host name (the local login shell never runs in a remote pane).
LockedBox moves to test Support for reuse.
The sidebar's New Project button becomes a menu — Local Folder (the
open panel, as before) or Remote Machine, a sheet collecting name,
[user@]host (or ssh alias), and remote directory, validated through
ProjectPath and composed into the scp-style Project.path. Also a
palette/menu command, since every user-invokable action lives in
AppCommand. Remote project rows get a small network badge with the
spec as its help text.

Replace Project Path with Current Directory is disabled for remote
projects at both the command guard and the AppState entry: OSC 7
reports a directory on the REMOTE host, and adopting it would corrupt
the project's identity into a local-looking path.
LayoutBuilder.resolveCwd gets a remote branch: under a [user@]host:dir
root, no local filesystem semantics apply — a declared ~ must stay
remote-home (expandingTildeInPath would substitute the LOCAL user's),
fileURLWithPath would resolve the spec against the local cwd, and the
result must keep its host prefix so the pane stays remote. Relatives
join the root's directory as plain strings.

ProcessInspector's pane-level foregroundPID gates on isRemote at the
single choke point, so every consumer agrees: layout save emits plain
leaves for remote panes, reconcile matches them as idle, and the quit
dialog shows the display title instead of 'ssh'.

Schema and AGENTS.md document the remote model: spawn path, no nested
zmx, local-only reaper, preinstalled-zmx requirement, ssh-config-owned
tuning, and the two-tier naming pipeline.
@github-actions github-actions Bot added area:ui Views, Settings UI area:terminal Terminal surface, ghostty integration area:state AppState, models, persistence area:tests Test changes area:docs Documentation labels Jul 5, 2026
@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Window-state benchmark

State Metric main@8f027f32a this branch Δ
focused CPU % 1.27 0.77 -39%
Memory (RSS MB) 107.2 108.5 +1%
CPU ms/s (powermetrics) 12.9 7.5 -42% 🔻
Wakeups/s (powermetrics) 189.8 168.5 -11%
unfocused CPU % 1.17 1.00 -14%
Memory (RSS MB) 111.5 112.8 +1%
CPU ms/s (powermetrics) 11.3 9.7 -14%
Wakeups/s (powermetrics) 176.1 177.5 +1%
minimized CPU % 0.20 0.13 -34%
Memory (RSS MB) 111.7 113.0 +1%
CPU ms/s (powermetrics) 2.0 1.3 -39%
Wakeups/s (powermetrics) 80.3 78.4 -2%
workload-focused CPU % 2.30 1.93 -16%
Memory (RSS MB) 168.5 170.2 +1%
CPU ms/s (powermetrics) 22.3 19.4 -13%
Wakeups/s (powermetrics) 221.5 227.7 +3%
workload-unfocused CPU % 2.43 1.70 -30% 🔻
Memory (RSS MB) 168.8 170.5 +1%
CPU ms/s (powermetrics) 23.8 16.7 -30% 🔻
Wakeups/s (powermetrics) 229.8 216.9 -6%
workload-minimized CPU % 0.30 0.23 -22%
Memory (RSS MB) 168.8 170.5 +1%
CPU ms/s (powermetrics) 2.9 2.2 -21%
Wakeups/s (powermetrics) 114.5 122.0 +7%

🎉 Labeled benchmark:improvement

This PR is labeled benchmark:improvement because these metrics improved by ≥25% vs main@8f027f32a (beyond each metric's absolute noise floor):

  • focused — CPU ms/s (powermetrics): 12.9 → 7.5 (-42%)
  • workload-unfocused — CPU %: 2.43 → 1.70 (-30%)
  • workload-unfocused — CPU ms/s (powermetrics): 23.8 → 16.7 (-30%)

30s sampling window per state; CPU % is the process CPU-time delta over the window. Runs land on different shared runners, so treat small deltas as noise — 🔺/🔻 marks changes ≥25% that also clear the metric's absolute noise floor (CPU % ≥0.5, Memory (RSS MB) ≥25, CPU ms/s ≥5, Wakeups/s ≥50), and those add the benchmark:regression / benchmark:improvement label.

thdxg added 3 commits July 5, 2026 10:59
sshd executes the remote command string through the user's LOGIN shell,
and the previous wire format was POSIX-only: fish/nushell login shells
don't parse '&&', tilde-plus-single-quote splicing, or the '\''
escape. Loopback testing against a nushell login shell surfaced this
immediately.

Every remote command now ships as sh -c '<script>' where the script
deliberately contains no single quotes (double-quote escaping inside,
tilde segments bare): that outer form is the one thing bash, zsh, fish,
and nu all tokenize identically before POSIX sh takes over. Verified by
feeding the exact wire strings through nu -c — the attach-style command
cd's into a space-containing dir, and the foreground probe resolved all
live local sessions (hx, lazygit, claude, btop) end to end.
Typing devbox:~/dev/api into the palette now mirrors typing a local
path: path mode engages and offers 'Add remote project' (or 'Switch to
remote project' when one already matches the spec structurally).
Recognition is deliberately stricter than ProjectPath.parse — path mode
short-circuits the whole palette, so a command query containing a colon
must never be swallowed: no whitespace, a hostname-shaped host, and a
~- or /-anchored directory are required. No host round-trip: the typed
spec itself is the offer.
Loopback testing surfaced both. sshd's default PATH is bare and
non-POSIX login shells don't source the profiles that would extend it —
a zmx perfectly usable interactively was invisible to ssh command
execution. Every remote sh -c script now extends PATH with the common
install dirs (~/.local/bin, /usr/local/bin, /opt/homebrew/bin),
existing entries winning. Ops also move under sh -c for consistency.

ssh forwards TERM=xterm-ghostty, which most remotes have no terminfo
for — TUIs would refuse to start. The pane script falls back to
xterm-256color unless the remote actually resolves the current TERM
(verified over loopback: infocmp fails, fallback engages).
@github-actions github-actions Bot added the area:palette Command palette label Jul 5, 2026
thdxg added 12 commits July 5, 2026 12:04
A remote pane sitting at a plain prompt showed '-nu' instead of 'nu':
the probe reports the login shell's argv[0], which by convention carries
a leading '-' (-/opt/homebrew/bin/nu), and taking the basename kept the
dash. Local kernel comm never has this, so normalizeRemoteComm strips a
leading '-' before the basename, remote-only.
Two bugs from a real Linux x86_64 remote test. First: zmx installed in
~/bin was invisible — the PATH preamble missed ~/bin (and ~/.cargo/bin),
so the non-interactive ssh couldn't find it even though the interactive
shell could. Both added.

Second, the worse one: when the remote command failed, the pane just
closed with no message — the surface's command (ssh) exiting fires
closeSurface, and unlike the default-shell case there's no abnormal-exit
screen for an explicit command. The pane script now guards zmx presence
and the cd: on failure it prints a diagnostic to the pane and drops into
${SHELL:-/bin/sh} -l instead of exiting, so the pane stays open and the
error is visible. Only the happy path execs the attach.
…104)

Answering 'do we have to hardcode PATHs': mostly no. Switch the remote
wrapper from 'sh -c' to 'sh -lc' — a login sh sources the user's
profile and picks up their real PATH (~/bin, ~/.cargo/bin, anything),
so we stop guessing where zmx lives. Naming sh explicitly keeps the
script POSIX regardless of which login shell sshd hands the outer
string to.

The hardcoded dir list survives, demoted to a fallback appended AFTER
the login shell sets its own PATH (so the user's wins). It's not
redundant: verified over loopback that a profile which short-circuits
before its PATH export (a bad 'source' line — exactly this machine's
~/.profile, which dies on a missing .deno/env) leaves ~/bin unresolved
under 'sh -lc' alone; the fallback catches it. Same for a user who only
sets PATH in fish/nu config, which sh never reads.
Verified the remote pipeline against a Docker Ubuntu aarch64 host with
a nushell login shell, zmx in ~/bin, and a ~/.profile that dies before
its PATH export — reproducing the field conditions. All of it works:
zmx resolves via the fallback, TERM degrades to xterm-256color, attach
creates the session, the foreground probe resolves it (sleep), kill
tears it down. Learned the broken-profile abort is shell-dependent
(fatal under zsh, non-fatal under dash), which is exactly why the
fallback isn't optional — doc corrected to say so instead of implying
sh -lc alone suffices.
Two fixes from testing against a real CMU host (milkshark), plus a
Docker aarch64 harness that reproduces its conditions.

Portability: sh -lc is unusable — older dash (its /bin/sh) rejects -l
outright, silently dropping to an interactive shell that ignores our
command. Switch to sh -c (runs everywhere) and reproduce what -l did by
sourcing /etc/profile and ~/.profile ourselves (best-effort, so a
broken profile can't abort the script), then the fallback PATH list.

Explicit zmx path: even with profiles + fallback, PATH resolution can't
cover every host (network-mounted home, exotic /bin/sh, PATH set only
in a non-POSIX shell config). Project gains an optional zmxPath used
verbatim, bypassing PATH entirely — the deterministic escape hatch.
Surfaced in the New Remote Project sheet, persisted in projects.json
and the layout file (ProjectFile.zmxPath), threaded through spawn, kill,
and the foreground probe. When set, the presence guard is skipped.

Verified over the container with PATH=/nonexistent: the explicit path
still runs zmx version, attaches a session, lists, and kills.
The infocmp-conditional fallback was exactly wrong. The forwarded
TERM=xterm-ghostty fails both ways: hosts without its terminfo can't
start TUIs (the case the conditional handled), and hosts WITH it — a
real CMU host, and any modern ncurses that ships the entry — hit worse:
zmx 0.6.0's interactive rendering breaks under TERM=xterm-ghostty. The
prompt draws but no command output does; the pane looks dead while the
session works fine (same host, same binary, same session rendered
perfectly under a script(1) pty and under a forced xterm-256color).
The conditional passed on such hosts and left the broken TERM in place;
Docker only worked because Ubuntu lacks the entry and the fallback
fired. xterm-256color is the one value that works everywhere.

Worth an upstream report to thdxg/zmx: interactive attach with
TERM=xterm-ghostty toward a ghostty client renders nothing.
…#104)

The real cause of the blind panes on the CMU host — after three wrong
theories (PATH, sh -lc, TERM) — was the user's own ~/.profile ending in
an exec into another shell. Sourced inline under our >/dev/null
silencing, the exec replaced the script wholesale: the exec'd shell
inherited stdout/stderr pointed at /dev/null, giving a pane with a
visible prompt (ZLE writes to /dev/tty), visible keystrokes (kernel pty
echo), and invisible command output — and zmx never attached, so
nothing persisted. Every symptom, one mechanism.

Profiles are now sourced inside a command-substitution subshell purely
to harvest PATH: an exec/exit/abort in a dotfile can only kill the
subshell, the substitution comes back empty, and the script proceeds
with the fallback dirs. Reproduced and verified against the Docker
harness with an exec-ing ~/.profile: inline sourcing → hijack, no
session; subshell sourcing → attaches with clients=1.

Bisected on the real host: /etc/profile alone renders, ~/.profile is
the poison, cwd and TERM are innocent. This also confounds the earlier
TERM=xterm-ghostty diagnosis (that A/B varied profile-sourcing too);
the forced xterm-256color stays for terminfo-portability reasons, but
its 'zmx breaks under xterm-ghostty' rationale is now suspect.
Un-confounded retest on the CMU host: with no profile sourcing, zmx
attach under the forwarded TERM=xterm-ghostty renders perfectly. The
'blind under xterm-ghostty' finding that justified forcing
xterm-256color unconditionally was an artifact of the ~/.profile
exec-hijack (the blind test arm sourced profiles, the working arm
didn't). With the hijack fixed by subshell containment, the conditional
infocmp fallback is strictly better: hosts lacking the ghostty terminfo
still degrade to xterm-256color, hosts shipping it keep truecolor and
styled underlines. Comment documents the history so it isn't
re-litigated.
The subshell containment shipped one message ago was still wrong: with
the pane's tty as inherited stdin, the exec'd shell from the user's
~/.profile sat reading keystrokes forever and the harvest substitution
never completed — pane blocked before attach, blind again. My container
verification had masked exactly this by piping scripted input, which
the exec'd shell consumed and exited on. Adding </dev/null fixed the
shell-exec case but the harness's nastier profile (exec bash -l, which
re-reads ~/.profile: an exec loop) still spun forever.

Three containment attempts, three distinct pane-killers. The altitude
fix: profiles are arbitrary code in the pane's critical path and are
now never executed, in any form. The fallback dir list covers where
zmx actually lives (including ~/bin — the motivating host never needed
the harvest at all), and the explicit zmxPath covers everything else
deterministically. Verified in the container against the exec-looping
profile with stdin held open silently: attach in 1s. A regression test
asserts no wire format ever references a profile again.
thdxg added 2 commits July 6, 2026 14:40
# Conflicts:
#	Macterm/Model/SplitNode.swift
Post-merge with main's website redesign (#143): a comprehensive Remote
Projects page (requirements, ssh-config aliasing with a ControlMaster
recipe, the sheet + palette flows, behavior table, remote layouts with
zmxPath, troubleshooting keyed to the pane diagnostics, limitations),
and a macterm CLI page adapted from docs/cli.md — the CLI (#139/#142)
had a repo doc but no website page. Existing pages get the remote
cross-references: persistence (remote sessions survive local reboots),
layouts (remote path: + zmxPath), palette (remote specs in path mode),
intro. Also fixes main's new ControlHandlerTests ZmxClient doubles for
the remote closure arity from this branch.
@thdxg thdxg marked this pull request as ready for review July 6, 2026 05:55
@thdxg thdxg enabled auto-merge (squash) July 6, 2026 05:55
@thdxg thdxg merged commit ed17bc3 into main Jul 6, 2026
8 checks passed
@thdxg thdxg deleted the remote-projects-ssh branch July 6, 2026 05:55
@github-actions github-actions Bot added the benchmark:improvement CI benchmark: significant resource improvement vs main label Jul 6, 2026
armyers pushed a commit to armyers/CYOTE-arm that referenced this pull request Jul 15, 2026
)

* Paste clipboard images into TUIs instead of dropping them (thdxg#109)

* Fix activity detection for in-place terminal output (thdxg#108)

* fix: consistent slider width for each settings screen (thdxg#116)

* fix: correct off-by-one in search match counter (thdxg#122)

* fix: render CJK IME preedit text (thdxg#119)

* Turn off continuous rendering for panes that are not visible in the app. (thdxg#115)

Co-authored-by: Aryeh <aryeh@studio.local>

* Skip quiet-settle for occluded panes so parked renderers can't fake completion (thdxg#123)

* Bundle zmx and add the session client library (no behavior change) (thdxg#124)

* Poll foreground processes adaptively instead of at a fixed 250ms (thdxg#126)

* Tick libghostty on wakeup instead of a 120Hz timer (thdxg#125)

* Isolate UserDefaults under test so runs can't corrupt the developer's app state (thdxg#128)

* Wrap terminal surfaces in zmx sessions with daemon-side foreground resolution (thdxg#127)

* Silence Swift 6 non-Sendable warnings on AppState's poll-event observers (thdxg#130)

* Benchmark window-state resource usage in CI and compare PRs against main (thdxg#129)

* Persist zmx sessions across quit and reattach on relaunch (thdxg#131)

* Spell out the noise floors in the benchmark footnote (thdxg#132)

* Surface zmx unavailability in Settings and document session persistence (thdxg#133)

* Break the title-replay render loop for good (thdxg#134)

* Make Unload Project stop the project's shells again (keeping layout) (thdxg#135)

* Fix arrow-key shortcuts never matching live keypresses (thdxg#136)

* Centralize project layout files in ~/.config/macterm/projects (thdxg#114) (thdxg#137)

* Add a control socket and bundled macterm CLI (read-only verbs) (thdxg#139)

* Add mutation verbs to the macterm CLI (create, split, run, grid) (thdxg#142)

* Sample the benchmark under a real workload via the macterm CLI (thdxg#141)

* Redesign website landing + docs pages (thdxg#143)

* Add CLI reference to the docs site (thdxg#145)

* Remote projects over SSH backed by zmx (thdxg#104) (thdxg#138)

* Cache build artifacts across CI to cut cold-build time (thdxg#146)

* Fix release export failing on Xcode 26 (mac-application method rejected) (thdxg#147)

* Refresh user docs, README, and landing page for remote projects, CLI, and persistence (thdxg#149)

* Keep main-window hotkeys from firing while another window is key (thdxg#148)

* fix: open existing project when same path is selected (thdxg#150)

* Track the system appearance instead of reading back our own pinned one (thdxg#152)

* chore(deps): bump the actions group with 3 updates (thdxg#151)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Pin the website footer to the viewport bottom on short pages (thdxg#153)

* Gate appcast and Homebrew tap on the release prerelease flag (thdxg#155)

* Add a settings slider for unfocused split pane dimming (thdxg#156)

* Report benchmark results from a workflow_run so fork PRs can be labeled (thdxg#157)

* Stop wide docs code blocks and tables from overflowing the page (thdxg#162)

* Audit remediation + sidebar tab/project drag-and-drop, OSC 2 title fixes (thdxg#158)

* Add a Copy Session ID action for handing a pane's zmx name to an LLM (thdxg#163)

* Make the window-state benchmark tolerant of shared-runner CPU noise (thdxg#164)

* Terminal-state introspection + control-socket verbs (thdxg#165thdxg#167) and a fork-drift guard (thdxg#168) (thdxg#169)

* Re-graft reboot-resurrect onto the sessionName-keyed model (stage 2)

Stage 2 of the upstream v1.20.4 merge: restore the fork's post-reboot
resurrect (capture color scrollback + restartable command, replay into
fresh sessions after a reboot), re-keyed from the old bare-UUID sessionID
to upstream's persisted `sessionName`, and driven through upstream's
off-main zmx seam.

Correctness fix (the reason it couldn't just be re-wired): resurrect's
history/print/send must hit the SAME daemon the panes attach through.
ZmxService now resolves the bundled zmx (Contents/Resources/zmx/zmx)
first and pins ZMX_DIR = ZmxSocketBudget.socketDir() in run(), matching
ZmxClient/ZmxAttach — the old fork ZmxService used homebrew paths and no
ZMX_DIR, so it would have talked to the wrong (or no) daemon.

- ZmxService: bundled binary + ZMX_DIR pin; history/print/send params
  renamed sessionID -> sessionName.
- ResurrectStore + SessionResurrect: keyed by sessionName (macterm-slug-hex
  is a safe filename); seed guard drops the vestigial sessionPersistence
  toggle (upstream always persists) — gates on didReboot + zmx only.
- PaneSnapshot.resurrectCommand + a transient Pane.resurrectCommand, kept
  distinct from Pane.command (layout run:) so a plain-quit reattach never
  re-runs it — only a genuine reboot does.
- AppState: ~15s capture timer started once at launch (independent of the
  poll cadence), capturing off-main via zmx.sessionListSnapshot leaders +
  ProcessInspector.foregroundCommand + ZmxService.history; skipped under
  XCTest. saveWorkspaces threads the cache into the snapshot.
- SplitNode.ensureNSView seeds local non-ephemeral panes post-reboot.
- Settings: re-added the "Resurrect scrollback after a reboot" toggle +
  per-pane restore-MB stepper (upstream @State style).

Verified end-to-end via the forceReboot sim (pkill zmx + relaunch):
capture wrote 44KB-504KB color .vt files keyed by sessionName, and the
seed replayed them into fresh sessions (e.g. 504KB in 172 paced chunks)
with no timeouts. 58 test suites pass; format + lint clean.

* Name zmx sessions after the context, show context in session list (stage 3)

Stage 3 of the upstream v1.20.4 merge: make session naming context-centric
(the fork's unit of work is the Context = Project chosen in the cmd-shift-p
picker, not a git repo), and surface the live context in the control CLI.

Part A - context-name slug. A pane's zmx session name derived its slug from
projectPath.lastPathComponent, so a context named "Billing API" opened at
/code/billing-svc became macterm-billingsvc-<hex>, ignoring the chosen name.
Now the project's context name is threaded as the slug source (Pane.init
already took sessionSlug; the gap was callers passing nil):
- Workspace.init / createTab gain sessionSlug, forwarded to the pane.
- AppState.ensureWorkspace(contextName:) + createTab(sessionSlug:) pass
  project.name at every interactive open/new-tab path.
- ControlHandler.tabNew passes project.name too.
- Splits already inherit the source pane's slug; restored panes keep their
  persisted name verbatim (rename-proof, since context is stable at creation).
The path-basename remains the fallback when no context name is supplied.

Part B - context column in `session list`. ControlSessionInfo gains a
`context` field, populated by joining sessionName -> live pane -> project
name (authoritative and rename-proof, not parsed from the slug). Rendered
as a `ctx:<name>` column (`ctx:-` for an orphan session with no live pane);
also present in --json. Docs updated.

Verified end-to-end: a new tab in context "ecs-managed-instances" (path
basename "code") spawned macterm-ecsmanagedin-<hex>, and `session list`
showed `ctx:ecs-managed-instances` on every attached session including the
old basename-slugged restored ones. 59 test suites pass; format + lint clean.

* Verify bundled zmx has no single-print truncation; document (stage 4)

Stage 4 of the upstream v1.20.4 merge: validate whether the bundled zmx
still needs resurrect's defensive print-chunking.

Empirically probed the bundled thdxg/zmx 0.6.0 with isolated single prints
(throwaway ZMX_DIR, detached `zmx run` session): rendered content scales
linearly past 4KB — 3KB→50/50 lines, 9KB→145/150, 18KB→287/300 — with no
hard ~4096-byte cliff (the <5% loss grows with size, i.e. scrollback-window
trimming, not IPC truncation). So the old stock-zmx bug (a single `print` in
the ~4-8KB band silently dropped after one 4096-byte read) is NOT present in
the binary Macterm ships.

Decision: keep the sub-4KB `chunkOnLines` + pacing anyway. It's belt-and-
suspenders — the `zmxPath` override can point resurrect at an older/stock
daemon that still truncates, and the reboot sim already showed chunked replay
handles 500KB cleanly. No behavior change; comments in ZmxService.print and
SessionResurrect updated to reflect the measured reality (bundled = no cliff,
chunking retained defensively for override/older binaries).

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Dejan Žegarac <dejanzegarac4@gmail.com>
Co-authored-by: Mateusz Urban <mateuszurban2@gmail.com>
Co-authored-by: Jungwoong Kim <48355099+jungwngkim@users.noreply.github.com>
Co-authored-by: Juno Choi <junochoi2003@gmail.com>
Co-authored-by: takumaru <49429291+takuma-ru@users.noreply.github.com>
Co-authored-by: aryeh <aryeh@users.noreply.github.com>
Co-authored-by: Aryeh <aryeh@studio.local>
Co-authored-by: Ethan (Taehoon) Lee <48551278+thdxg@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:docs Documentation area:palette Command palette area:state AppState, models, persistence area:terminal Terminal surface, ghostty integration area:tests Test changes area:ui Views, Settings UI benchmark:improvement CI benchmark: significant resource improvement vs main

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Remote projects over SSH backed by zmx

1 participant