Skip to content

remote-tmux: multiple servers in one linked-view window - #7022

Closed
ejc3 wants to merge 38 commits into
manaflow-ai:mainfrom
ejc3:ejc3/remote-tmux-linked-view
Closed

ejc3 wants to merge 38 commits into
manaflow-ai:mainfrom
ejc3:ejc3/remote-tmux-linked-view

Conversation

@ejc3

@ejc3 ejc3 commented Jun 28, 2026 •

Copy link
Copy Markdown
Contributor

Important

Depends on #7023 (merge it first). Both touch the observer registration in
RemoteTmuxSessionMirror, so this branch merges #7023 in and keeps both hunks — the
linked-view managesOwnLifecycle guard in onExit and #7023's onConnectionStateChanged
title-filter reset. Once #7023 lands this rebases onto main cleanly.

Summary

Builds on the linked-view mode (#7021) to aggregate multiple servers into one window and refine how mirrored origins are presented. Connect a second (or third) host and its sessions join an existing window so you drive several servers side-by-side; you can aggregate into a dedicated linked-view window or a regular window (alongside local workspaces). New Workspace / New Tab / close / rename each ride the selected workspace's server.

Highlights

  • Multiple servers in one window. RemoteTmuxWindowRegistry maps one window → many hosts (each host still maps to one window), with per-host bind/unbind and consistency guards. mirrorHostInNewWindow(intoWindowId:) aggregates a host's coordinator into an existing window's manager instead of opening a new window; failure cleanup never discards a window another host (or local workspaces) has joined.
  • Aggregate into a regular window, not just a dedicated one. A host can join a normal cmux window beside local workspaces; per-host teardown leaves the others (and any local tabs) intact.
  • Browser opens as a tab in the mirror workspace. The new-browser button now opens a local browser tab inside the mirror workspace (superseding core's "new local workspace" approach); a local browser tab splits locally, while mirror workspaces stay single-pane.
  • Per-origin sidebar color rail. Multi-origin windows get a per-origin color rail in the sidebar — gray for local plus a muted palette per host — so you can tell which server a workspace belongs to. Ships a toned-down palette plus a design doc (docs/remote-tmux-origin-rail-palette.html).
  • Host name in titles. The host name appears in the window title and in cmux's own title bar / command label for mirror workspaces.
  • Entrypoints. Exposed via the remote.tmux.window socket (into_window / into_workspace) and cmux ssh-tmux <host> --into-window <id|current> (current resolves the caller's window from CMUX_WORKSPACE_ID).

Hardening

Adversarial-review fixes across the browser-tab and aggregate-into-regular-window paths; reorder affects only mirror tabs (local tabs stay put); a shared transport isn't dropped while another mode still uses it; pastel() simplified to NSColor.blended(withFraction:of:).

Tests

RemoteTmuxWindowRegistry multi-host unit tests; live-verified on two distinct MaxSessions 1 servers (aggregation, per-host new-tab/close, rename + re-key).


Stacked on #7021 (linked-view mode) and #7020 (proxy-retry). This PR's actual change is the commits on top of those; until the lower PRs merge, the compare here also includes theirs.


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Aggregate multiple remote servers into one linked-view window. cmux ssh-tmux now reuses your current window by default (regular windows supported), and each linked tmux window is sized to its cmux pane.

  • New Features

    • Aggregate a host into an existing linked-view or regular window: cmux ssh-tmux <host> reuses the caller’s current window by default; override with --new-window or --into-window <id|current> (also via the remote.tmux.window socket: into_window, into_workspace). New Workspace / New Tab ride the selected workspace’s host in multi-host windows. Settings → Beta Features adds a “Remote tmux linked view” toggle.
    • New-browser button opens a local tab inside a mirror workspace; Browser New Workspace/Tab stays local in the same window.
    • UI cues for multi-origin windows: per-origin sidebar color rail (muted, tuned palette) and the host name in the window title and cmux’s title/command label; includes a small palette doc.
  • Bug Fixes

    • Leaving the last host no longer discards a regular window that still has local workspaces; teardown is per-host and preserves a shared SSH master when another mode still uses it.
    • Reorder affects only mirror tabs; local tabs stay in place. Local browser tab splits in a mirror split locally; mirror workspaces stay single-pane.
    • Strip the GNU screen/tmux ESC k window-title escape from mirrored output to prevent titles rendering as text (handles chunk splits).
    • Better SSH ProxyCommand error handling: only trigger an interactive retry on truly silent proxy closures; DNS failures (including “could not resolve hostname”/getaddrinfo), Linux connect timeouts, banner-exchange, and other explained closures now surface real errors instead.

Written for commit 6100b33. Summary will update on new commits.

Review in cubic

@vercel

vercel Bot commented Jun 28, 2026

Copy link
Copy Markdown

@ejc3 is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jun 28, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ee5878fd-8ed5-45c9-9f9a-8083cb6c2882

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

ejc3 added 18 commits June 30, 2026 18:08
…etry recoverable

A BatchMode=yes discovery probe through an ssh `ProxyCommand` whose own
pre-handshake auth or 2FA leg silently aborts (no tty to prompt on) closes the
proxy pipe before SSH emits any auth-failure string. Catch this stderr signature
(OpenSSH's `to/by UNKNOWN port 65535` placeholder for pipe transports) and route
it to the same interactive ssh retry already used for `Permission denied` /
host-key TOFU / MFA.

Introduces `indicatesProxyCommandTransportClosed` next to the existing
`indicatesAuthRequired` and a composed `indicatesInteractiveRetryWillHelp` so
the three RemoteTmuxController routing sites that previously each spelled out
`indicatesAuthRequired` (mirrorHostInNewWindow's discovery catch,
preflightControlAttach's catch arm, and authRequiredAttachArgv) now go through
a single name — preventing the asymmetry where only one entrypoint would have
gotten the new recovery and the others silently regressed.
… recoverable

OpenSSH's `to/by UNKNOWN port 65535` placeholder is also emitted when a
ProxyCommand / ProxyJump fails for reasons no interactive ssh retry can fix:
target unreachable behind the jumphost, `nc` to a refused port, stdio
forwarding teardown, target spoke no SSH on the negotiated port, DNS NXDOMAIN.
Those failures stamp explicit diagnostic markers (`connect failed:`,
`: open failed:`, `stdio forwarding failed`, `kex_exchange_identification:`,
`Connection refused`, `No route to host`, etc.) into stderr alongside the
placeholder; route them to the real error instead of bouncing the user
through a futile interactive prompt.

Tightens indicatesProxyCommandTransportClosed to require the placeholder AND
no diagnostic marker before firing. Adds positive tests for the SILENT
closures we still need to catch and negative tests for the EXPLAINED closures
the predicate must now skip — including the precise stderr codex's review
reproduced via `ssh -J nowhere.invalid` and `ssh -oProxyCommand='nc localhost 9'`.
…classification

`xcodebuild test` against `cmuxTests/RemoteTmuxAuthTests` flagged that the
`Could not resolve hostname …\nConnection closed by UNKNOWN port 65535` stderr
slipped past the silent-closure check — OpenSSH wraps every `getaddrinfo`
failure with that prefix (across macOS / Linux / Windows getaddrinfo strerrors)
so the proxy DNS NXDOMAIN looked silent to the predicate.

Adds `could not resolve hostname` to the non-recoverable marker list alongside
the existing `name or service not known` / `temporary failure in name
resolution` constants (which only cover the underlying getaddrinfo wording,
not the OpenSSH wrapper).
…t-proxy-close

Second codex review pass reproduced a remaining false positive: when a
`ProxyCommand` uses `nc` and `nc` itself fails DNS, BSD/macOS netcat emits
`nc: getaddrinfo: nodename nor servname provided, or not known` raw —
OpenSSH's `Could not resolve hostname` wrapper only fires when OpenSSH does
the resolution, not when an inferior `ProxyCommand` does. The resulting
stderr has the proxy placeholder and no exclusion marker, so the predicate
returned true and routed the user through a futile interactive retry.

Adds `nodename nor servname provided` to the non-recoverable marker list and
extends `doesNotClassifyExplainedProxyClosures` with the exact stderr codex
reproduced via `ssh -o ProxyCommand='nc nonexistent.invalid 22'`.
…osures as non-recoverable

Review follow-up: two more explained ProxyCommand/ProxyJump closures were
slipping past the silent-closure check and routing the user through a futile
interactive retry:

- Linux TCP connect timeouts phrase it "Connection timed out" (only the
  BSD/macOS "Operation timed out" was covered), so an nc-based ProxyCommand
  timing out on Linux looked silent.
- "ssh_exchange_identification:" banner-exchange closures (the inner target
  dropping the connection pre-auth: fail2ban, tcpwrappers, not-SSH-on-port)
  were only excluded when a second marker happened to co-occur.

Adds both to nonRecoverableProxyMarkers with isolated negative tests (no
co-present marker) so each is genuinely exercised.
First step of the linked-view transport for MaxSessions=1 hosts (2FA devservers),
where only one concurrent SSH session is allowed so cmux cannot open a tmux -CC
control client per remote session. The plan: attach ONE control client to a
hidden, cmux-owned aggregate "view" session and link-window every mirrored
session's windows into it, so all workspaces stream over the single connection
while the real sessions stay intact for tmux ls / tmux attach interop.

This commit lays the foundation, no behavior change yet:
- `betaFeatures.remoteTmuxLinkedView` flag (off; requires remoteTmux). While off,
  remote tmux uses the existing per-session control connections unchanged.
- `RemoteTmuxViewReconciler`: a PURE, deterministic diff of desired vs actual view
  contents into minimal link/unlink actions. Correctness is a reconciliation, not
  optimistic link-window side effects (per design review). Safety invariants:
  never unlink the view's placeholder window; never touch windows cmux did not
  link itself; idempotent; stable command ordering.
- 9 unit tests covering link/unlink/idempotence and every safety invariant.

Empirically de-risked over a real ssh + MaxSessions=1 harness (18/18 assertions:
link→live %output for all linked windows, links survive abrupt transport loss,
window-size manual holds independent per-window sizes, new-session-over-stream is
the New Workspace fix, unlink-view-copy keeps the real session). Subsequent commits
add the view connection, reconcile driver, sizing, lifecycle, and UI aggregation.
The linked-view transport creates a real, visible aggregate "view" tmux session
per (host, cmux owner). Because it is remote state shared with the user's own
tmux, cmux must own it precisely: never collide with another cmux install, always
reattach its own across reconnect/relaunch, garbage-collect only its own stale
views, and never touch a session it does not own or that a user created.

`RemoteTmuxViewSession` (pure value type):
- deterministic, sanitized session name (`cmux-view-<owner>`),
- create commands that stamp `@cmux_view` / `@cmux_view_owner` / `@cmux_view_version`
  and use explicit `-x/-y` (no 80x24 placeholder flash),
- `list-sessions -F` row parsing,
- classification predicates: isOwnView / isOwnStaleView / isForeignView — the
  safety surface that bounds what cmux may reattach or collect.

15 unit tests (16 total in the suite incl. the reconciler) cover naming, option
stamping, parsing, and that own/foreign are mutually exclusive and stale-collection
never includes another owner's or a non-view session. Build + tests green.
In linked-view mode one -CC client carries windows from many home sessions (all
linked into the view). cmux must regroup that flat window set back into per-session
workspaces. `RemoteTmuxLinkedWorkspaceModel` (pure) turns `list-windows -a` rows
into: ordered workspaces (home session -> its windows as tabs, by index), the
desired-linked-window set fed to the reconciler, and window->home-session routing
for %output. The view session and its placeholder are always excluded; view-only
windows (no home session) are dropped. 6 unit tests. Build + tests green.
…tor)

`RemoteTmuxLinkedViewPlan.plan(view:snapshot:)` composes the three tested layers
into one pure decision: given list-sessions + list-windows -a snapshots and the
view's current contents/ownership, it returns whether to create the view, which
stale same-owner views to kill (never foreign), the reconcile link/unlink actions,
and the resulting workspace grouping. The live coordinator becomes a thin I/O
shell: snapshot over the stream → plan(...) → apply. 5 unit tests covering first
run (create+link+group), steady state, new-workspace add, closed-session unlink,
and stale-vs-foreign view handling. Build + tests green.
A codex adversarial pass on the pure decision core surfaced real safety holes
before any live wiring. Fixes (+ regression tests for each):

- Exclude ALL view sessions, not just our own: the workspace model takes the full
  set of view session names so a FOREIGN cmux install's `cmux-view-*` is never
  surfaced as a workspace nor its windows linked. (was: excluded only our name)
- Collision-resistant view-session naming: append an FNV-1a/64 hash of the raw
  owner id so distinct owners (e.g. `a.b` vs `a:b`, which sanitize alike) never
  share a view name and fight over one session.
- Name-prefix guard on view classification: isAnyView/isOwnView/isOwnStaleView/
  isForeignView all require the reserved `cmux-view-` prefix, so a real session
  that merely has the `@cmux_view` option copied onto it can never be reused or
  killed as a view.
- Deterministic single home per window: a window linked into multiple real
  sessions is attributed to the lexicographically smallest home, so it appears in
  exactly one workspace and %output routing is stable regardless of tmux row order.
- Reconciler never empties the view: if unlinking would remove the view's last
  window (nil/stale placeholder), keep one linked — unlinking the last window
  kills the view session and churns the mirror.
- Robust parsing: free-text session_name placed LAST in list formats and split
  with a maxSplits limit, so a separator inside a name can't drop/corrupt a row.

All four suites green (37 tests). No live behavior yet; this hardens the core the
live coordinator will build on.
… format-bump, FNV reuse)

A high-effort /code-review pass on the hardened core found four more issues; all
fixed with tests:

- CROSS-HOST CORRECTNESS: the new list-* parsers used a non-printable (\u1f)
  field delimiter. The codebase already learned (RemoteTmuxSessionListParser) that
  tmux's utf8_sanitize() rewrites non-printable bytes to `_` for non-UTF-8 SSH
  clients (e.g. Amazon Linux 2023), which would collapse fields and silently drop
  EVERY row. Switched both view-session and window parsers to the printable `:`
  delimiter with the free-text name LAST and remainder-rejoin, matching the
  existing parser. + tests asserting the format carries no control byte and that
  a `:` inside a name is preserved.
- CORRECTNESS (format bump): on a view formatVersion bump the stale view shares
  our name and is killed+recreated, but `actual` was computed from its windows so
  they were treated as present and never re-linked → empty view after upgrade.
  Plan now treats `actual` as empty whenever needsViewCreate, so all windows
  re-link into the fresh view. + regression test.
- REUSE: extracted RemoteTmuxHost.fnv1a64Hex shared by connectionHash and the
  linked-view owner hash (was a duplicated FNV-1a/64 copy).
- ROBUSTNESS: sanitizeOwner now strips newlines/control chars (was only
  CharacterSet.whitespaces), so an owner id with \n can't corrupt the session name.

All four suites green.
…r bug

/simplify pass (reuse/simplification/altitude):
- Extracted RemoteTmuxSessionListParser.splitRows (shared tmux -F row splitter:
  newline split + last-field rejoin) and routed all three parsers through it
  (session-list, view-session, window model) — removes 3 copies of the
  split/CR-strip/rejoin skeleton and both ad-hoc fieldDelimiter constants.
- workspaces(): collapsed the two-pass home resolution into one O(n) fold
  (was O(W*R) via per-window homeSession); same result, clearer.
- Reconciler: named the never-empty-view guard (wouldEmptyView/keepAlive).

Incidental correctness fix (pre-existing, surfaced by routing parse() through the
shared splitter): the parser split lines with `split(separator: "\n")` and
stripped `\r` via `line.last == "\r"`, but Swift clusters `\r\n` as ONE grapheme,
so CRLF line endings were neither split nor stripped — leaving a trailing `\r\n`
on the last field. splitRows now splits on `Character.isNewline` (matches `\n`,
`\r`, and the `\r\n` grapheme), fixing the long-failing
preservesNameWhitespaceAndStripsLineTerminator test. All 45 tests green.
…ew keystone)

On MaxSessions=1 hosts the single -CC client holds the one allowed SSH session,
so the linked-view coordinator cannot run list-sessions/list-windows -a as
separate one-shot ssh commands — they must travel over the live control stream.

Adds RemoteTmuxControlConnection.query(_:) async -> [String]?: sends a command and
returns its %begin/%end reply body, correlated positionally via the existing
pending-command FIFO (a new .query(UUID) command kind), exactly mirroring the
.activityQuery pattern. Resolves nil on %error or when the stream becomes unusable
(failPendingQueries() wired alongside failPendingActivityQueries at every
teardown/reconnect/exit site) so an awaiting coordinator never hangs. Build green;
no behavior change until the coordinator uses it.
RemoteTmuxViewConnection drives the linked-view transport for one host:
- start(): pre-attach one-shots over the shared master (list-sessions to GC our
  own stale views, create the owned view at explicit -x/-y, record its placeholder
  window), then attach the single tmux -CC client to the view.
- reconcile(): on connect and every %topology change, snapshot the server OVER the
  control stream via connection.query() (a 2nd one-shot ssh is refused under
  MaxSessions=1), run RemoteTmuxLinkedViewPlan.plan(), apply link/unlink, and
  publish the regrouped workspaces (serialized so overlapping events don't
  interleave). Unlink targets the view's copy by index so the real session keeps
  the window.
- newWorkspace(): new-session -d over the stream + reconcile, so it links in and
  surfaces as a new workspace with no new SSH session — "new workspace rides the
  linking".

View creation switched to raw arg-vectors (createArgvs) for the one-shot transport
path (which quotes each token), replacing the shell-string form. Composes the
tested pure layers; build + 37 tests green. Not yet wired to the controller/UI.
Adds an optional windowIdFilter to RemoteTmuxSessionMirror so several mirrors can
share ONE control connection (the aggregate view session) and each render only its
home session's tmux windows. nil (the default, per-session transport) is unchanged
— renders every window. rebuild() and the close-tabs liveWindows set now iterate
the filtered `mirroredWindowOrder`; updateWindowIdFilter(_:) re-scopes + rebuilds
as the coordinator regroups (e.g. a new tab in this session). %output already
self-filters via the per-panel maps, so no output routing change is needed. Build
green; nil-filter path behavior-identical.
Behind betaFeatures.remoteTmuxLinkedView, host attach now drives the shared view
connection instead of one control connection per session:

- mirrorHostInNewWindow: after auth/discovery + window creation, if linkedView is
  on, startLinkedView() builds a RemoteTmuxViewConnection (per-install owner id
  persisted in UserDefaults) and returns; the per-session loop is skipped.
- syncLinkedWorkspaces (the coordinator's onWorkspacesChanged): projects the
  regrouped workspaces into cmux workspaces + filtered SessionMirrors that all
  SHARE the host's single view connection — creates mirrors for new home sessions,
  re-scopes existing ones (a new tab adds a window id), removes ones whose session
  is gone, and drops the bootstrap workspace once a real one exists.
- New Workspace in such a window routes to coordinator.newWorkspace() (new-session
  over the stream → links in → republishes) — "new workspace rides the linking".
- Linked-view mirrors are constructed with managesOwnLifecycle:false so a shared-
  connection %exit doesn't trigger per-session teardown; the coordinator owns
  lifecycle (onEnded → teardownLinkedView closes the window). detachAll() stops
  coordinators + mirrors on quit (servers keep running for resume).

SessionMirror gained the managesOwnLifecycle flag for this. Build green; the
flag-off path (per-session transport) is unchanged.
Final adversarial pass (codex xhigh + review agents) on the complete
linked-view feature surfaced lifecycle gaps; fix the contained ones:

- Window close / app quit now tear down the shared `-CC` coordinator.
  `handleRemoteWindowClosed` and `killMarkedSessionsBeforeTerminate` only
  walked per-session `sessionMirrors`, so closing a linked-view window
  leaked the single shared SSH connection (teardownLinkedView was only
  reachable from the remote-stream-ended callback). New `stopLinkedView`
  helper, called from both paths, gated on LIVE coordinator state (not the
  beta flag, so toggling the flag off mid-session still tears down).
- Quit-with-kill kills the host's real sessions over the live view stream
  (a one-shot ssh would be refused under MaxSessions=1) with a round-trip
  barrier so the kills land before the connection is stopped.
- Closing a linked workspace kills its real session over the stream so the
  reconcile drops it; previously the workspace closed only locally and the
  next reconcile re-added the tab (session still alive).
- Last real session gone -> tear down the view + dedicated window, since
  TabManager.closeWorkspace refuses to remove a window's final workspace.
- New Workspace in a linked window gates on the ACTIVE workspace being a
  remote mirror, matching the non-linked path (a dragged-in local
  workspace stays local).
- Shared-connection mirrors no longer drive the view connection's identity
  on %session-changed (same ownership gate as onExit).
- View placeholder parsing is CRLF-safe; reused-view windows from a prior
  run are adopted as owned so reconcile can unlink dead linked copies.
The per-action handlers (new-tab, split, tab-split, close-tab, reorder,
rename-window, rename-workspace, paste, image-upload) only searched
`sessionMirrors`, so on a linked-view workspace they found nothing and
silently no-op'd ("inert handlers"). Linked mirrors live in a separate
`linkedMirrors` map sharing one view `-CC` connection.

Unify the lookup so every handler reaches a workspace's mirror in either
mode:
- `actionMirror(forWorkspaceId:)` / `actionMirrors` / `isLinkedMirror(_:)`.
- Most commands already address tmux's GLOBAL ids (`@windowId`, `%paneId`),
  which route correctly over any connection to the server, so split,
  tab-split, close-tab, rename-window, reorder, paste and image-upload work
  over the shared view connection once they find the linked mirror.
- New-tab anchors on the home session's last window for linked mirrors
  (the shared connection's `{end}` would resolve in the hidden view
  session); non-linked keeps `{end}`, correct for its per-session stream.
- Rename-workspace targets the home session by name for linked mirrors
  (the shared connection's session id is the view's, not the home's).

New accessor `RemoteTmuxSessionMirror.orderedWindowIds` exposes the
filtered window order for the new-tab anchor.
ejc3 added 20 commits June 30, 2026 18:08
Live-testing on a MaxSessions=1 harness exposed a bug in the prior commit:
a new tab created in a linked-view workspace landed in the hidden VIEW
session, not the home session. A window linked into both the home session
and the view has an ambiguous `@id`, so `new-window -a -t @id` over the
shared view connection resolves to the view (the client's current session).

Fix: for linked mirrors, anchor `new-window` on the home session BY NAME
(`'<session>:{end}'`) so the tab lands in the right session and surfaces in
the correct workspace. Non-linked keeps window-id placement (its connection
is attached to that one session, so `@id` is unambiguous). Exact
after-panel placement is not preserved for linked tabs — correct session
beats exact position. Factor the shared `-c <wd>` tail into
`appendWorkingDirectory`.
Live-testing showed the new tab correctly landed in the home session but
never appeared as a cmux tab: the new window isn't yet linked into the view,
so the shared view stream receives no %window-add and nothing triggers a
reconcile. Nudge the coordinator to reconcile after a linked new-tab (same
pattern as new-workspace's new-session), so it links the window into the
view and surfaces it as a tab.
In a remote-tmux window two browser entry points misbehaved on a mirror
workspace (a 1:1 tmux view that refuses local browser surfaces):

- "New Browser Workspace" (⌥⌘N / palette / menu) was intercepted by the
  remote new-workspace route and turned into a remote tmux session — the
  browser intent was lost. The remote route now fires only for TERMINAL
  new-workspaces; a browser one falls through and is created locally.
- The globe tab-strip button (and "New Browser Tab") no-op'd, because
  newBrowserSurface refuses a mirror workspace. It now falls back to creating
  a new LOCAL browser workspace in the same window.

The browser is always a local WebKit surface (never tunneled), so it lives as
a normal local workspace alongside the window's remote mirrors.
The linked-view mode shares ONE `tmux -CC` client across every mirrored
window, so the per-session `refresh-client -C` path can't size windows
independently — they stayed at the view's fixed creation grid, clipping
full-screen TUIs and ignoring cmux-pane resizes.

Size each window explicitly instead:
- The view session runs `window-size manual` so per-window sizes stick.
- New `RemoteTmuxControlConnection.resizeWindow(windowId:columns:rows:)`
  sends `resize-window -t @id -x C -y R`, debounced (coalescing SwiftUI
  layout-settle oscillation) and re-applied on reconnect.
- `RemoteTmuxSessionMirror` / `RemoteTmuxWindowMirror` gain `perWindowSizing`
  (set for linked mirrors): single-pane, multi-pane, and initial-sizing paths
  route to `resizeWindow` instead of `setClientSize`. Non-linked mirrors keep
  `refresh-client -C` (their client owns one session).

Verified live on a MaxSessions=1 server: mirrored windows resize to the cmux
grid (e.g. 99x35) instead of the default.
…irror entrypoints

The mirror-workspace browser guard lived in `newBrowserSurface` (return nil) and
was special-cased at only one entrypoint, so the globe tab-strip button and the
command palette silently failed on a mirror workspace (a 1:1 tmux view that can't
host a local browser pane).

Centralize it at the shared `newBrowserSurface` chokepoint: on a mirror, an
INTERACTIVE (`.userInitiated`) browser request now opens the browser in a NEW
LOCAL workspace in the same window (via `addWorkspace(initialSurface: .browser)`,
navigating to the URL or focusing the address bar) and returns its panel — so the
globe button and the command palette behave identically. Removes the now-redundant
per-entrypoint special-case in the tab-bar `.newBrowser` handler.

Crucially the redirect is gated to `.userInitiated`. Restoration (`.restoration`)
and automation/socket (`.automationPreload`) creation still return nil on a mirror
(the long-standing "mirror refuses a local browser" behavior), because their
callers require the returned panel to live in THIS workspace: restoration's
replaceSurface would otherwise close the mirror's own pane and re-home it
elsewhere, and the socket new-surface/browser handlers report the created surface
under this workspace's id. A cross-workspace panel would corrupt them.

(`performNewWorkspaceCreationAction` still gates the remote new-workspace route to
`.terminal` so ⌥⌘N New Browser Workspace stays a local browser.)
`remoteTmux.linkedView.beta.enabled` had a catalog key and a runtime accessor
but no Settings row, so it was only reachable by editing config. Add a
"Remote tmux linked view" toggle to the Beta Features section, bound to the same
catalog key the runtime reads, with English and Japanese strings. The toggle is
disabled while "Remote tmux" is off, since linked view requires it.
…ndow

The remote-tmux docs page didn't cover linked-view mode (one SSH connection for
hosts that cap each connection to a single session, e.g. per-connection 2FA) or
mirroring multiple hosts into one window. Add a "Linked view" section explaining
the single-connection transport and how to enable it, plus a "Multiple servers
in one window" subsection covering `--into-window`. English and Japanese message
catalogs (the locales this page supports) updated.
Lets a second (or third) devserver's sessions aggregate into an existing
linked-view window, so you can drive multiple servers from one cmux window —
"new connection from another dev server expands the links."

- RemoteTmuxWindowRegistry now maps a window to MULTIPLE hosts (each host
  still maps to one window). bind appends; unbind(hostHash:) removes just
  that host (dropping the window entry once empty); unbind(windowId:) clears
  all; adds hosts(forWindowId:). +5 unit tests.
- mirrorHostInNewWindow gains `intoWindowId`: when it names a live
  linked-view window, the host's coordinator renders into that window's
  manager (no new window, no bootstrap workspace) instead of a fresh window.
  startLinkedView failure unbinds the host and only discards a window we
  created (never an aggregated one).
- New Workspace / New Tab route to the SELECTED workspace's host coordinator
  (linkedViewCoordinator resolves via the selected mirror), so a new
  workspace rides the right server in a multi-server window.
- Teardown is per-host: handleRemoteWindowClosed stops every aggregated
  host; killMarkedSessionsBeforeTerminate loops the window's hosts;
  teardownLinkedView closes only the ended host's workspaces and discards
  the window only when no other host remains.
- Socket `remote.tmux.window` accepts `into_window` (window id) or
  `into_workspace` (resolved to its window); `cmux ssh-tmux --into-window
  <id|current>` exposes it (`current` uses the caller's CMUX_WORKSPACE_ID).
Second adversarial pass (codex xhigh + review agent) on the multi-server
work surfaced these; fix the real ones:

- New Workspace routing now gates on LIVE coordinator state, not the beta
  flag, so toggling the flag off mid-session still routes a new workspace to
  the selected workspace's host (was falling through to the first host or
  failing under MaxSessions=1).
- Linked workspace rename nudges a reconcile so the mirror re-keys to the new
  session name; without it later new-tab/close targeted the old name.
- startLinkedView failure now stops the half-started coordinator via
  stopLinkedView (was leaking it in linkedViews) and discards the window only
  when no other host has aggregated into it (a concurrent attach can join
  while we await) — previously it could tear down a second server's window.
- Transport/ControlMaster teardown is guarded by a shared hostStillInUse
  check across BOTH modes, so tearing down a host's linked view doesn't pull
  the master out from under a per-session mirror of the same host (possible
  when the beta flag is toggled mid-session), and vice-versa.
- Registry bind() drops a stale reverse entry when a host is re-bound to a
  different window, keeping windowIdByHost and hostsByWindowId consistent.
- teardownLinkedView stops the coordinator before closing the host's
  workspaces, so the close can't re-enter the kill path. +1 registry test.
…pace

The globe / new-browser button refused to work in a remote-tmux mirror workspace
(newBrowserSurface returned nil), on the assumption that the mirror's rebuild()
would reconcile away any non-tmux surface. It doesn't: the mirror reconcile is
id-ownership based — it only adds/keeps/removes the surfaces it tracks in
panelIdByWindow/panelIdByPane and never enumerates or prunes surfaces it doesn't
own, and every routing decision (close/split/rename/reorder) keys off "is this a
tmux window tab?" and falls through to local handling otherwise.

So just remove the special case: a browser now opens as a normal local tab in the
mirror workspace's pane, coexisting with the tmux-window tabs and surviving
reconcile. This also makes the socket new-surface/browser handlers correct (the
returned surface genuinely lives in the reported workspace) instead of needing a
guard.
…a dedicated one

"Multiple servers in one window" only aggregated into a window that was already a
linked-view window; `cmux ssh-tmux <host> --into-window current` aimed at a regular
main window fell through and opened a separate dedicated window instead.

Relax the aggregation guard to also accept a window with no remote host bound yet
(`!windowRegistry.isDedicatedWindow`), so a user can pull hosts into their main
window alongside local workspaces. Per-session dedicated windows are still excluded
(they keep the single-host invariant); aggregation still requires linked-view.
Registry binding, window-close/quit teardown, and linkedViewCoordinator routing are
already multi-host + mixed-content safe (they filter mirrors by workspace id and
no-op for local workspaces), so no other change is needed.
…ndows

When a window aggregates more than one origin (Local + one or more remote tmux
hosts, or multiple hosts), draw a thin pastel color rail on each workspace row's
left edge so it's clear which origin each workspace belongs to. A single-origin
window shows no rail.

- Origin = Local (a non-mirror workspace) or a specific remote host (resolved via
  the new RemoteTmuxController.remoteTmuxHost(forWorkspaceId:), keyed by
  connectionHash). Same host twice = same origin = same color.
- Local = muted gray. Hosts = palette colors assigned in lexicographic order of
  destination, softened toward pastel, skipping the app accent color and any
  user-chosen custom workspace color so an auto color never collides.
- Threaded as an immutable per-row value (WorkspaceListRenderContext ->
  TabItemView.originColor) to respect the sidebar snapshot-boundary rule; the row
  is Equatable on it. Reuses the existing left-rail capsule shape.
…spaces

When the selected workspace is a remote tmux mirror, prefix the window title with
the host destination ("host — workspace"). Hidden-titlebar main windows still
surface this in the Window menu / Mission Control, so windows that aggregate
remote sessions (incl. local + remote in one window) are identifiable. Local
workspaces are unaffected.
…review

Adversarial review (code-review + codex) of the browser-as-tab and
aggregate-into-a-regular-window changes surfaced three real issues:

- HIGH: teardownLinkedView discarded the whole window when the last host left.
  For a host aggregated into the user's REGULAR window that also holds local
  workspaces, that destroyed local work. Now skip the discard when the window
  still has local (non-mirror) workspaces — matching the per-session path's
  existing ownedByEndingHost guard.
- MEDIUM: a local browser tab in a mirror pane defeated tmux-driven tab reorder
  (mirrorTabReorder required the request to cover every tab). Now it reorders only
  the mirror tabs and pins non-mirror (local) tabs in place.
- MEDIUM: splitting a local browser tab in a mirror was vetoed unconditionally.
  Now veto only when the split actually routed to tmux (a mirror window tab); a
  local browser tab splits locally.

Also corrects the now-stale newBrowserSplit comment (split stays refused in a
mirror because a second local pane would break the single-pane reorder invariant;
tabs are allowed, splits are not).
…le-pane

- Replace the vivid workspace-tag palette (whose first entry pastelized to a hot
  pink) with a dedicated, calm origin-rail palette — muted, distinguishable hues
  ordered quietest-first (soft blue, teal, sage, sand, lavender, ...). Lighter
  pastel blend since the palette is already muted.
- Keep mirror workspaces single-pane: always veto a local split (a browser tab
  can't be split in a mirror; mirror-window-tab splits still route to tmux
  split-window). A second bonsplit pane would break the single-pane tmux-reorder
  invariant and could capture a newly-mirrored window — so disallow it rather than
  add fragile multi-pane placement logic.
…el too

The host-name prefix for mirror workspaces was only applied to NSWindow.title
(Window menu / Mission Control). cmux hides the native title bar and draws its own
title (next to the folder drag icon) plus a "Cmd:" label, which still showed only
the bare workspace name. Apply withRemoteHostPrefix (now internal) to both so a
mirror workspace reads "host — workspace" in cmux's chrome as well.
…sign doc

Replace the rail palette with the optimized "toned-down" set (clay, gold, green,
teal, indigo, rose) — 6 muted hues chosen to maximize the minimum CIEDE2000
distance between every pair AND from the built-in colors (accent, selection blue,
the grey/near-black backgrounds), with blue excluded so a rail never reads like the
accent/selection. min pairwise ΔE ≈ 24.5; the rose is dusty, not a bright pink.
Drop the pastel wash — these are tuned to read raw on both backgrounds.

Adds docs/remote-tmux-origin-rail-palette.html, the comparison board used to pick
the palette (mock sidebars on the real grey + near-black, none/all selected).
…ror output

Implement the byte-stream filter in the CmuxRemoteSession package (pure logic,
no app-lifecycle deps) rather than the app target root, matching the established
pattern for lifted remote-session logic. Build into a [UInt8] buffer, and assert
on raw Data in tests so a byte-corruption regression fails fast.
… servers in one window)

Run from inside a cmux surface, ssh-tmux now aggregates the host into the caller's
current window by default instead of always opening a new dedicated one. Adds
--new-window to opt back into a fresh window; --into-window still targets one
explicitly (the two are mutually exclusive). Outside a cmux surface it opens a new
window as before.
@ejc3
ejc3 force-pushed the ejc3/remote-tmux-linked-view branch from 90247d6 to 6100b33 Compare July 1, 2026 01:08
@ejc3

ejc3 commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Closing for now. The linked-view lifecycle (mirror / disposable / kept-open state) is spread across several booleans and parallel registries — that caused a data-loss regression and turns every fix into another special case. Reworking it around a single explicit workspace state machine on a fresh branch; a clean replacement will follow.

@ejc3 ejc3 closed this Jul 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant