Remote projects over SSH backed by zmx (#104)#138
Merged
Conversation
Bootstrap commit for the draft PR; implementation lands in follow-up commits on this branch. Full plan in the PR description.
Contributor
Deploying with
|
| 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 |
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.
Contributor
Window-state benchmark
🎉 Labeled
|
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).
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.
# 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.
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#165–thdxg#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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.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:
Settled design
One hard constraint shapes the spawn path: nested zmx does not work (a local
zmx attachwrapping an ssh to a remotezmx attachbreaks — known upstream, cmux hit it too). So a remote pane's surface runssshdirectly 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.Project.pathstays a single string and is now allowed to be a remote spec ([user@]host:dir), parsed on demand byProjectPath(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/remoteSpecare computed. Panes already persistprojectPathverbatim in the snapshot, so restored remote panes know their host without new snapshot schema.createSurfacesetsconfig.commandto the ssh invocation and skips the zmxcommand_wrapperand theZMX_DIRenv pin (both are local-persistence machinery). The remote command iscd <dir> && exec zmx attach <sessionName>with the directory single-quote-escaped; built by a new pure, unit-testedRemoteSpawnhelper.initial_input(declaredrun:) is typed into the remote shell after connect, same semantics as local.ssh -tonly — no BatchMode for the pane itself, so password/2FA prompts appear in the pane and interactive auth just works.~/.ssh/configalias (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.PATH). No upload/install flow in this stage — a missing binary surfaces aszmx: command not foundin the pane (see failure handling). Settings gets a one-line docs pointer, mirroring the local zmx-unavailable messaging.ZMX_SESSIONbug 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.ZmxClientgains a transport — the existing local subprocess, orssh -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.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 tozmx kill— documented limitation until sessions carry a per-installation marker.ProcessInspectoris local-kernel sysctl and the pane's local foreground pid is justssh— so remote panes leave the local poll and get their own pipeline: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.ZmxForegroundResolverover ssh. OneBatchMode=yesssh exec per host per tick runs a small POSIX script resolving all of that host'smacterm-*sessions at once: remotezmx 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 à laZmxRefreshGate) — never the local 250ms burst. Covers title-silent TUIs (helix). Any failure degrades silently to tier 1.displayTitleprecedence 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).Project.pathwould corrupt the project), directory-source palette entries, and the layoutsaveof liverun:/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 sameLayoutReconciler, where remote panes match as idle leaves (the injected live-command lookups return nil).[user@]host(or ssh alias), and remote directory, validated byProjectPath.parseand composed into thepath:string. Both also becomeAppCommandpalette entries. Remote project rows get a small SF-symbol badge; colors fromMactermTheme.sessionID/sessionName/projectPathexactly like local ones and reattach on relaunch by re-running the same ssh command. Central project files with a remotepath: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).sshas a running process (it is — and closing it detaches, so the confirm is honest).Resolved open questions from #104
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— computedisRemote/remoteSpecviaProjectPath.parse; no stored-schema changeMacterm/Model/SplitNode.swift(Pane) — remote awareness derived fromprojectPath; skip local-zmx wrapping flags for remote panesGhosttyTerminalNSView.createSurface— remote branch:config.command= ssh invocation, nocommand_wrapper, noZMX_DIRpin;initial_inputunchangedMacterm/System/ZmxClient.swift— transport injection (local subprocess | ssh); kill/list for remote sessions; reaper hard-guarded local-only with rationale commentMacterm/App/AppState.swift— route session kills by project; exclude remote panes from the local foreground poll; host-name idle-title fallback; disablereplaceProjectPathWithCurrentDirfor remotePane.receiveReportedTitleremote branch keyed toTerminalExecutionTrackerstate (adopt while executing, expire on command end, discard at prompt) + testsMacterm/System/RemoteForegroundResolver.swift: batched per-host ssh probe (zmx ls→ps -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 testsAppCommand.newRemoteProject(palette + menu; the existingopenProjectstays the local path — no new hotkey, palette-only), sidebar New Project menu (Local Folder… / Remote Machine…), remote-machine sheet, network badge on remote rowsLayoutBuildercwd resolution against a remote root string;LayoutSerializer.saveemits plain leaves for remote panes; reconciler tests with nil live-lookupsassets/project.schema.json— drop "Remote projects are not yet supported"; document remote semanticssh -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)~/.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 toTERM=xterm-256colorwhen the remote can't resolvexterm-ghostty(both verified over loopback)devbox:~/dev/apioffers Add/Switch remote project, gated strictly (no whitespace, hostname-shaped host,~//-anchored dir) so colon-containing command queries are never swallowedmise run format && lint && testgreenmainmerged 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~/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.profileexec'ing zsh). The CMU host found four real bugs the green environments couldn't: dash rejectingsh -lc,~/binmissing 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 -lc→sh -c: older dash (common as/bin/sh) rejects the-llogin flag and silently drops to an interactive shell that ignores the command — panes closed with no error. Nowsh -c, sourcing/etc/profile/~/.profileourselves (best-effort) plus a fallback PATH list to reproduce what-lwas for.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 toprojects.json+ the layout file, is used verbatim and bypasses PATH entirely. Verified over the container withPATH=/nonexistent.~/bin/~/.cargo/bin; failed spawns print amacterm:diagnostic and drop to a shell instead of vanishing the pane.~/.profileends inexec zsh. Sourced inline under the preamble's>/dev/null 2>&1silencing, 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/nullstill 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 explicitzmxPathcovers 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/profilerenders,~/.profileis the poison, cwd and TERM innocent. Correction, closed: the earlier "zmx renders blind underTERM=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 conditionalinfocmpTERM fallback is restored — hosts lacking ghostty terminfo degrade toxterm-256color, hosts shipping it keep full capabilities.Verification plan
RemoteSpawnTestsquoting matrix; reconciler with remote lookups; command routing.localhost(ssh to self) as a remote project → panes spawn,zmx lson "the remote" showsmacterm-*sessions; quit Macterm → sessions stay alive with 0 clients; relaunch → panes reattach (scrollback intact); close tab → session killed.command not foundscreen; wrong dir →cderror visible.path:+tabs:→ add project → tabs spawn remotely per declaration; Apply Layout reconciles; Save Layout writes plain leaves.btopin a remote pane → tab showsbtop(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.Non-goals (this stage)
command not found, offer a one-click "Install zmx on this host" thatuname -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.🤖 Generated with Claude Code
Appendix: Docker test-host harness (reproduces the hostile-host conditions; session scratchpads are ephemeral so preserved here)
Run:
docker build -t macterm-remote-test . && docker run -d --name macterm-remote -p 2222:22 macterm-remote-test, then an ssh-config aliasHost macterm-docker / HostName localhost / Port 2222 / User dev. Bake stable host keys + your pubkey in for repeat runs (see PR discussion).