From 85cc7f54e84869ece44fbd2384857d5dc727e131 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 20:33:28 -0700 Subject: [PATCH 01/38] remote-tmux: classify ProxyCommand-closed transports as interactive-retry recoverable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Sources/RemoteTmuxController.swift | 23 +++---- Sources/RemoteTmuxError.swift | 3 +- Sources/RemoteTmuxSSHTransport.swift | 38 +++++++++++ cmuxTests/RemoteTmuxAuthTests.swift | 97 ++++++++++++++++++++++++++++ 4 files changed, 149 insertions(+), 12 deletions(-) diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index d7140d2987f1..8b0ade5f2a76 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -247,7 +247,7 @@ final class RemoteTmuxController { return nil } catch let error as RemoteTmuxError { if case .commandFailed(_, let stderr) = error, - RemoteTmuxSSHTransport.indicatesAuthRequired(stderr) { + RemoteTmuxSSHTransport.indicatesInteractiveRetryWillHelp(stderr) { return host.interactiveAuthInvocation() } throw error @@ -259,7 +259,7 @@ final class RemoteTmuxController { result: RemoteTmuxCommandResult ) -> [String]? { guard !result.succeeded, - RemoteTmuxSSHTransport.indicatesAuthRequired(result.stderr) else { + RemoteTmuxSSHTransport.indicatesInteractiveRetryWillHelp(result.stderr) else { return nil } return host.interactiveAuthInvocation() @@ -339,20 +339,21 @@ final class RemoteTmuxController { // prompt). A key/agent host — or one with an already-live master — succeeds // here and mirrors directly, with no interactive step, so it also works from // non-tty callers (scripts). A host that needs interactive auth fails here - // (BatchMode can't prompt); classify that and hand back the interactive - // `ssh` argv so the `cmux ssh-tmux` CLI authenticates in the user's terminal - // and retries — the retry then rides the now-open master. `transport.run()` - // creates the control-socket dir, so the returned auth `ssh` can open the - // master. No window has been created yet — nothing to tear down here. Both - // discovery calls (including the create-then-relist for an empty server) are - // inside the catch so an auth failure on any preflight/discovery command is - // classified uniformly. + // (BatchMode can't prompt); classify that via + // ``RemoteTmuxSSHTransport/indicatesInteractiveRetryWillHelp`` and hand back + // the interactive `ssh` argv so the `cmux ssh-tmux` CLI authenticates in the + // user's terminal and retries — the retry then rides the now-open master. + // `transport.run()` creates the control-socket dir, so the returned auth + // `ssh` can open the master. No window has been created yet — nothing to + // tear down here. Both discovery calls (including the create-then-relist + // for an empty server) are inside the catch so a recoverable failure on + // any preflight/discovery command is classified uniformly. let sessions: [RemoteTmuxSession] do { sessions = try await transport(for: host).discoverMirrorSessions(createIfEmpty: true) } catch let error as RemoteTmuxError { if case .commandFailed(_, let stderr) = error, - RemoteTmuxSSHTransport.indicatesAuthRequired(stderr) { + RemoteTmuxSSHTransport.indicatesInteractiveRetryWillHelp(stderr) { return .authRequired(sshArgv: host.interactiveAuthInvocation()) } throw error diff --git a/Sources/RemoteTmuxError.swift b/Sources/RemoteTmuxError.swift index 04f8e559080a..f2d1bed4173a 100644 --- a/Sources/RemoteTmuxError.swift +++ b/Sources/RemoteTmuxError.swift @@ -32,7 +32,8 @@ extension RemoteTmuxError { /// so a noisy or hostile remote can't inject control bytes or unbounded output into /// our error bodies. Only the rendered `message` is sanitized — the stored /// associated `stderr`/`detail` are left untouched for the stderr-classification - /// paths that pattern-match them (`indicatesNoServer`, `indicatesAuthRequired`). + /// paths that pattern-match them (`indicatesNoServer`, `indicatesAuthRequired`, + /// `indicatesProxyCommandTransportClosed`). var message: String { switch self { case let .commandFailed(exitCode, stderr): diff --git a/Sources/RemoteTmuxSSHTransport.swift b/Sources/RemoteTmuxSSHTransport.swift index 689693f5e685..5ead53e42206 100644 --- a/Sources/RemoteTmuxSSHTransport.swift +++ b/Sources/RemoteTmuxSSHTransport.swift @@ -388,6 +388,44 @@ actor RemoteTmuxSSHTransport { || lowered.contains("too many authentication failures") } + /// Whether a failed `BatchMode=yes` connect failed because the local + /// `ProxyCommand` closed the transport before SSH could surface an explicit + /// auth error string — a separate signal from ``indicatesAuthRequired``. + /// A `ProxyCommand` with its own pre-handshake authentication or 2FA leg + /// (jumphost wrappers, Cloudflare/Teleport-style brokers, corporate SSH + /// wrappers) can silently abort that leg under BatchMode because it has no + /// tty to prompt on, then drop the proxy pipe; an interactive retry where + /// the wrapper inherits the user's tty lets that prompt surface and the + /// connect then succeeds. + /// + /// Anchored to OpenSSH's canonical pipe-transport placeholders + /// (`to UNKNOWN port 65535`, `by UNKNOWN port 65535`) — those are the + /// exact phrasings OpenSSH emits when `getpeername(2)` returns no socket + /// address (the `ProxyCommand` case). A direct TCP failure surfaces a + /// real host:port instead and is treated as a genuine unreachable error, + /// and the anchored phrases are unlikely to appear verbatim in remote + /// stderr forwarded through the connection (`pam_motd`, `~/.ssh/rc`). + static func indicatesProxyCommandTransportClosed(_ stderr: String) -> Bool { + let lowered = stderr.lowercased() + return lowered.contains("to unknown port 65535") + || lowered.contains("by unknown port 65535") + } + + /// Convenience predicate composing the recovery rule the controller's + /// BatchMode-discovery catch sites share: a failure where re-running ssh + /// interactively (a real tty for password / host-key TOFU / MFA / FIDO + /// touch, *or* for a `ProxyCommand`'s own pre-handshake auth leg) will + /// open the shared master and let the next batch probe succeed. + /// + /// Lives next to its two constituent predicates so all routing sites in + /// ``RemoteTmuxController`` go through one name — without this composition + /// a future signal added to either constituent would silently regress any + /// catch site that still spelled out only one of the two. + static func indicatesInteractiveRetryWillHelp(_ stderr: String) -> Bool { + indicatesAuthRequired(stderr) + || indicatesProxyCommandTransportClosed(stderr) + } + // MARK: - Process plumbing /// Launches a process and captures bounded stdout/stderr without blocking the actor. diff --git a/cmuxTests/RemoteTmuxAuthTests.swift b/cmuxTests/RemoteTmuxAuthTests.swift index 11c6f1c79844..642ece995d5e 100644 --- a/cmuxTests/RemoteTmuxAuthTests.swift +++ b/cmuxTests/RemoteTmuxAuthTests.swift @@ -67,6 +67,103 @@ import Testing #expect(RemoteTmuxSSHTransport.indicatesAuthRequired(stderr)) } + // MARK: - Proxy-closed transport classification + + /// Failures where the local `ProxyCommand` closes the transport before SSH + /// can emit an explicit auth-failure string. OpenSSH stamps these with the + /// `UNKNOWN port 65535` placeholder (no real socket address is known when + /// the transport is a pipe), and an interactive retry — where the wrapper + /// inherits the user's tty — recovers them. One mixed-case fixture guards + /// the predicate's `lowercased()` path against a future literal-only + /// refactor; OpenSSH itself emits the placeholder in caps. + @Test(arguments: [ + "Connection closed by UNKNOWN port 65535", + "ssh_dispatch_run_fatal: Connection to UNKNOWN port 65535: Broken pipe", + "kex_exchange_identification: Connection closed by remote host\nConnection closed by UNKNOWN port 65535", + "Connection closed by UnKnOwN port 65535", + ]) + func classifiesProxyCommandTransportClosed(_ stderr: String) { + #expect(RemoteTmuxSSHTransport.indicatesProxyCommandTransportClosed(stderr)) + } + + /// The match is anchored to OpenSSH's exact phrasings (`to UNKNOWN port + /// 65535` / `by UNKNOWN port 65535`) so remote-stderr noise that merely + /// mentions the words in a different order — `pam_motd`, `~/.ssh/rc`, or a + /// remote command spelling out the literal `UNKNOWN port 65535` without + /// OpenSSH's connection-formed preposition — does NOT misclassify into the + /// interactive-retry path. + @Test(arguments: [ + "MOTD: lab name is UNKNOWN port 65535 status board", + "remote warning: process listening on port 65535 with unknown owner", + "user note: 'unknown port 65535' is reserved", + ]) + func anchorsProxyClosedMatchToOpenSSHPhrasing(_ stderr: String) { + #expect(!RemoteTmuxSSHTransport.indicatesProxyCommandTransportClosed(stderr)) + } + + /// Real-port transport failures (a host that's actually unreachable on its + /// real port) MUST NOT be classified as proxy-closed — they will not be + /// fixed by an interactive retry and should surface as genuine errors + /// instead. Generic non-auth fixtures (empty stderr, unrelated text, + /// algorithm-negotiation) are covered by ``doesNotClassifyNonAuthFailures`` + /// for the sibling predicate; the cases here are predicate-specific + /// (proxy-shaped strings that must not collide with real-port stderr). + @Test(arguments: [ + "ssh: connect to host bad.example.com port 22: Connection refused", + "ssh: connect to host bad.example.com port 2222: Operation timed out", + "Connection closed by 10.0.0.5 port 22", + ]) + func doesNotClassifyRealPortClosuresAsProxyTransport(_ stderr: String) { + #expect(!RemoteTmuxSSHTransport.indicatesProxyCommandTransportClosed(stderr)) + } + + /// The proxy-closed and auth-required predicates are independent: the + /// proxy-closed stderr should NOT be treated as an explicit auth failure, + /// and a clean `Permission denied` should NOT trip the proxy-closed match. + /// The controller routes through the composed predicate + /// ``indicatesInteractiveRetryWillHelp`` precisely because they're + /// different signals that share a recovery (interactive ssh retry). + @Test func proxyClosedAndAuthRequiredAreDisjoint() { + let proxyOnly = "ssh_dispatch_run_fatal: Connection to UNKNOWN port 65535: Broken pipe" + #expect(RemoteTmuxSSHTransport.indicatesProxyCommandTransportClosed(proxyOnly)) + #expect(!RemoteTmuxSSHTransport.indicatesAuthRequired(proxyOnly)) + + let authOnly = "user@host: Permission denied (publickey,password)." + #expect(RemoteTmuxSSHTransport.indicatesAuthRequired(authOnly)) + #expect(!RemoteTmuxSSHTransport.indicatesProxyCommandTransportClosed(authOnly)) + } + + // MARK: - Composed routing predicate + + /// ``indicatesInteractiveRetryWillHelp`` is what every controller catch + /// site routes through. It MUST fire for every stderr either sibling + /// predicate fires for, so a future signal added to one constituent (or a + /// new third constituent) propagates to all routing sites at once instead + /// of silently regressing whichever catch still spells out only one of + /// the two. Asserted both directions for each stderr style. + @Test(arguments: [ + // Auth-required side + "user@host: Permission denied (publickey,password).", + "Host key verification failed.", + "Too many authentication failures", + // Proxy-closed side + "Connection closed by UNKNOWN port 65535", + "ssh_dispatch_run_fatal: Connection to UNKNOWN port 65535: Broken pipe", + ]) + func composedPredicateFiresForEitherRecoverableSignal(_ stderr: String) { + #expect(RemoteTmuxSSHTransport.indicatesInteractiveRetryWillHelp(stderr)) + } + + @Test(arguments: [ + "no server running on /tmp/tmux-501/default", + "no matching host key type found. their offer: ssh-rsa", + "ssh: connect to host bad.example.com port 22: Connection refused", + "", + ]) + func composedPredicateRejectsNonRecoverableFailures(_ stderr: String) { + #expect(!RemoteTmuxSSHTransport.indicatesInteractiveRetryWillHelp(stderr)) + } + @Test(arguments: [ "command refresh-client: unknown flag -B", "refresh-client: unknown option -- B", From 69d84ddd35aa1ef1f838382acf823d61a5ca298b Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 20:33:28 -0700 Subject: [PATCH 02/38] remote-tmux: only classify SILENT proxy closures as interactive-retry recoverable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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'`. --- Sources/RemoteTmuxSSHTransport.swift | 51 ++++++++++++++++++++++------ cmuxTests/RemoteTmuxAuthTests.swift | 40 +++++++++++++++++----- 2 files changed, 71 insertions(+), 20 deletions(-) diff --git a/Sources/RemoteTmuxSSHTransport.swift b/Sources/RemoteTmuxSSHTransport.swift index 5ead53e42206..c2edf8164d0c 100644 --- a/Sources/RemoteTmuxSSHTransport.swift +++ b/Sources/RemoteTmuxSSHTransport.swift @@ -389,28 +389,57 @@ actor RemoteTmuxSSHTransport { } /// Whether a failed `BatchMode=yes` connect failed because the local - /// `ProxyCommand` closed the transport before SSH could surface an explicit - /// auth error string — a separate signal from ``indicatesAuthRequired``. - /// A `ProxyCommand` with its own pre-handshake authentication or 2FA leg + /// `ProxyCommand` closed the transport *silently* — no other diagnostic + /// markers explaining the closure — before SSH could surface an explicit + /// auth error string. A separate signal from ``indicatesAuthRequired``: a + /// `ProxyCommand` with its own pre-handshake authentication or 2FA leg /// (jumphost wrappers, Cloudflare/Teleport-style brokers, corporate SSH /// wrappers) can silently abort that leg under BatchMode because it has no /// tty to prompt on, then drop the proxy pipe; an interactive retry where /// the wrapper inherits the user's tty lets that prompt surface and the /// connect then succeeds. /// - /// Anchored to OpenSSH's canonical pipe-transport placeholders - /// (`to UNKNOWN port 65535`, `by UNKNOWN port 65535`) — those are the - /// exact phrasings OpenSSH emits when `getpeername(2)` returns no socket - /// address (the `ProxyCommand` case). A direct TCP failure surfaces a - /// real host:port instead and is treated as a genuine unreachable error, - /// and the anchored phrases are unlikely to appear verbatim in remote - /// stderr forwarded through the connection (`pam_motd`, `~/.ssh/rc`). + /// Anchored to OpenSSH's canonical pipe-transport placeholders (`to UNKNOWN + /// port 65535`, `by UNKNOWN port 65535`) — those are the exact phrasings + /// OpenSSH emits when `getpeername(2)` returns no socket address (the + /// `ProxyCommand` case). A direct TCP failure surfaces a real host:port + /// instead and is treated as a genuine unreachable error. + /// + /// The same placeholder ALSO appears when the proxy closes for reasons no + /// interactive retry can fix — target unreachable behind a ProxyJump, + /// `nc` to a refused port, stdio forwarding setup failed, etc. Those + /// failures stamp diagnostic markers (`connect failed:`, `: open failed:`, + /// `stdio forwarding failed`, `kex_exchange_identification:`, etc.) into + /// stderr in addition to the placeholder; this predicate returns false + /// when any such marker is present so the controller surfaces the real + /// error instead of bouncing the user through a futile interactive retry. static func indicatesProxyCommandTransportClosed(_ stderr: String) -> Bool { let lowered = stderr.lowercased() - return lowered.contains("to unknown port 65535") + let hasProxyPlaceholder = lowered.contains("to unknown port 65535") || lowered.contains("by unknown port 65535") + guard hasProxyPlaceholder else { return false } + return !Self.nonRecoverableProxyMarkers.contains(where: { lowered.contains($0) }) } + /// Lowercase substrings that indicate a `ProxyCommand` / `ProxyJump` + /// closure was NOT silent — the proxy explained itself, and no + /// interactive ssh retry will reach a host that's refusing, unreachable, + /// or already past the auth boundary. Kept as a `static let` so the + /// predicate doesn't reallocate it on every cold-path call. + private static let nonRecoverableProxyMarkers: [String] = [ + "connect failed:", // ssh -W target connection refused/timeout + ": open failed:", // channel N: open failed: ... + "stdio forwarding failed", // ProxyJump -W teardown + "port forwarding failed", + "connection refused", + "no route to host", + "network is unreachable", + "operation timed out", + "name or service not known", // DNS NXDOMAIN + "temporary failure in name resolution", + "kex_exchange_identification:", // target spoke no SSH / closed during banner + ] + /// Convenience predicate composing the recovery rule the controller's /// BatchMode-discovery catch sites share: a failure where re-running ssh /// interactively (a real tty for password / host-key TOFU / MFA / FIDO diff --git a/cmuxTests/RemoteTmuxAuthTests.swift b/cmuxTests/RemoteTmuxAuthTests.swift index 642ece995d5e..839e86e03897 100644 --- a/cmuxTests/RemoteTmuxAuthTests.swift +++ b/cmuxTests/RemoteTmuxAuthTests.swift @@ -69,23 +69,41 @@ import Testing // MARK: - Proxy-closed transport classification - /// Failures where the local `ProxyCommand` closes the transport before SSH - /// can emit an explicit auth-failure string. OpenSSH stamps these with the - /// `UNKNOWN port 65535` placeholder (no real socket address is known when - /// the transport is a pipe), and an interactive retry — where the wrapper - /// inherits the user's tty — recovers them. One mixed-case fixture guards - /// the predicate's `lowercased()` path against a future literal-only - /// refactor; OpenSSH itself emits the placeholder in caps. + /// SILENT proxy closures — the placeholder is the only diagnostic in + /// stderr — fire the predicate. These are the cases an interactive retry + /// recovers because the proxy's own auth/2FA prompt was suppressed under + /// BatchMode and a tty lets it surface. One mixed-case fixture guards the + /// `lowercased()` path against a future literal-only refactor; OpenSSH + /// itself emits the placeholder in caps. @Test(arguments: [ "Connection closed by UNKNOWN port 65535", "ssh_dispatch_run_fatal: Connection to UNKNOWN port 65535: Broken pipe", - "kex_exchange_identification: Connection closed by remote host\nConnection closed by UNKNOWN port 65535", "Connection closed by UnKnOwN port 65535", ]) - func classifiesProxyCommandTransportClosed(_ stderr: String) { + func classifiesSilentProxyCommandClosures(_ stderr: String) { #expect(RemoteTmuxSSHTransport.indicatesProxyCommandTransportClosed(stderr)) } + /// The placeholder also stamps stderr when the proxy explains *why* it + /// closed: target unreachable behind a ProxyJump, `nc` to a refused port, + /// stdio forwarding teardown, target spoke no SSH on the negotiated port, + /// etc. None of those are recoverable by re-running ssh under a tty, so + /// the predicate MUST return false when any diagnostic marker is present + /// alongside the placeholder — otherwise the controller bounces the user + /// through a futile interactive prompt instead of surfacing the real + /// proxy error. + @Test(arguments: [ + "channel 0: open failed: connect failed: Connection refused\nstdio forwarding failed\nConnection closed by UNKNOWN port 65535", + "connect failed: Connection refused\nConnection closed by UNKNOWN port 65535", + "stdio forwarding failed\nssh_exchange_identification: Connection closed by remote host\nConnection closed by UNKNOWN port 65535", + "kex_exchange_identification: Connection closed by remote host\nConnection closed by UNKNOWN port 65535", + "ssh: Could not resolve hostname inner.invalid: nodename nor servname provided\nConnection closed by UNKNOWN port 65535", + "channel 1: open failed: administratively prohibited: open failed\nConnection closed by UNKNOWN port 65535", + ]) + func doesNotClassifyExplainedProxyClosures(_ stderr: String) { + #expect(!RemoteTmuxSSHTransport.indicatesProxyCommandTransportClosed(stderr)) + } + /// The match is anchored to OpenSSH's exact phrasings (`to UNKNOWN port /// 65535` / `by UNKNOWN port 65535`) so remote-stderr noise that merely /// mentions the words in a different order — `pam_motd`, `~/.ssh/rc`, or a @@ -159,6 +177,10 @@ import Testing "no matching host key type found. their offer: ssh-rsa", "ssh: connect to host bad.example.com port 22: Connection refused", "", + // An explained proxy closure (target unreachable behind ProxyJump) must + // not slip through the composed predicate either — surface the real + // error instead of bouncing the user through interactive auth. + "channel 0: open failed: connect failed: Connection refused\nstdio forwarding failed\nConnection closed by UNKNOWN port 65535", ]) func composedPredicateRejectsNonRecoverableFailures(_ stderr: String) { #expect(!RemoteTmuxSSHTransport.indicatesInteractiveRetryWillHelp(stderr)) From a1d36dc38755f016385fdbb689dd8a84491039df Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 20:33:29 -0700 Subject: [PATCH 03/38] remote-tmux: exclude DNS-resolution failures from silent-proxy-close classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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). --- Sources/RemoteTmuxSSHTransport.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/RemoteTmuxSSHTransport.swift b/Sources/RemoteTmuxSSHTransport.swift index c2edf8164d0c..ad82f36be105 100644 --- a/Sources/RemoteTmuxSSHTransport.swift +++ b/Sources/RemoteTmuxSSHTransport.swift @@ -435,7 +435,8 @@ actor RemoteTmuxSSHTransport { "no route to host", "network is unreachable", "operation timed out", - "name or service not known", // DNS NXDOMAIN + "could not resolve hostname", // OpenSSH DNS-resolution wrapper (all OSes) + "name or service not known", // Linux getaddrinfo NXDOMAIN "temporary failure in name resolution", "kex_exchange_identification:", // target spoke no SSH / closed during banner ] From d69bcadfa34e61a431f8dd8a8c13af0a1c7e27d9 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 20:33:29 -0700 Subject: [PATCH 04/38] remote-tmux: exclude BSD/macOS bare `getaddrinfo` NXDOMAIN from silent-proxy-close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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'`. --- Sources/RemoteTmuxSSHTransport.swift | 1 + cmuxTests/RemoteTmuxAuthTests.swift | 3 +++ 2 files changed, 4 insertions(+) diff --git a/Sources/RemoteTmuxSSHTransport.swift b/Sources/RemoteTmuxSSHTransport.swift index ad82f36be105..e94fa8c02b80 100644 --- a/Sources/RemoteTmuxSSHTransport.swift +++ b/Sources/RemoteTmuxSSHTransport.swift @@ -437,6 +437,7 @@ actor RemoteTmuxSSHTransport { "operation timed out", "could not resolve hostname", // OpenSSH DNS-resolution wrapper (all OSes) "name or service not known", // Linux getaddrinfo NXDOMAIN + "nodename nor servname provided", // BSD/macOS getaddrinfo NXDOMAIN (e.g. ProxyCommand `nc`) "temporary failure in name resolution", "kex_exchange_identification:", // target spoke no SSH / closed during banner ] diff --git a/cmuxTests/RemoteTmuxAuthTests.swift b/cmuxTests/RemoteTmuxAuthTests.swift index 839e86e03897..44ecf9fead64 100644 --- a/cmuxTests/RemoteTmuxAuthTests.swift +++ b/cmuxTests/RemoteTmuxAuthTests.swift @@ -98,6 +98,9 @@ import Testing "stdio forwarding failed\nssh_exchange_identification: Connection closed by remote host\nConnection closed by UNKNOWN port 65535", "kex_exchange_identification: Connection closed by remote host\nConnection closed by UNKNOWN port 65535", "ssh: Could not resolve hostname inner.invalid: nodename nor servname provided\nConnection closed by UNKNOWN port 65535", + // BSD/macOS `nc` ProxyCommand: nc's own getaddrinfo error propagates + // raw without OpenSSH's `Could not resolve hostname` wrapper. + "nc: getaddrinfo: nodename nor servname provided, or not known\nConnection closed by UNKNOWN port 65535", "channel 1: open failed: administratively prohibited: open failed\nConnection closed by UNKNOWN port 65535", ]) func doesNotClassifyExplainedProxyClosures(_ stderr: String) { From c2604041d158b28456619808122628e76072fbac Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 20:45:48 -0700 Subject: [PATCH 05/38] remote-tmux: treat Linux connect-timeout and banner-exchange proxy closures 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. --- Sources/RemoteTmuxSSHTransport.swift | 6 ++++-- cmuxTests/RemoteTmuxAuthTests.swift | 6 ++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Sources/RemoteTmuxSSHTransport.swift b/Sources/RemoteTmuxSSHTransport.swift index e94fa8c02b80..a8cbdec62e54 100644 --- a/Sources/RemoteTmuxSSHTransport.swift +++ b/Sources/RemoteTmuxSSHTransport.swift @@ -434,12 +434,14 @@ actor RemoteTmuxSSHTransport { "connection refused", "no route to host", "network is unreachable", - "operation timed out", + "operation timed out", // BSD/macOS TCP connect timeout + "connection timed out", // Linux TCP connect timeout (nc / OpenSSH) "could not resolve hostname", // OpenSSH DNS-resolution wrapper (all OSes) "name or service not known", // Linux getaddrinfo NXDOMAIN "nodename nor servname provided", // BSD/macOS getaddrinfo NXDOMAIN (e.g. ProxyCommand `nc`) "temporary failure in name resolution", - "kex_exchange_identification:", // target spoke no SSH / closed during banner + "kex_exchange_identification:", // target spoke no SSH / closed during key exchange + "ssh_exchange_identification:", // target closed during banner exchange (fail2ban, tcpwrappers, not-SSH) ] /// Convenience predicate composing the recovery rule the controller's diff --git a/cmuxTests/RemoteTmuxAuthTests.swift b/cmuxTests/RemoteTmuxAuthTests.swift index 44ecf9fead64..11128b4f72dd 100644 --- a/cmuxTests/RemoteTmuxAuthTests.swift +++ b/cmuxTests/RemoteTmuxAuthTests.swift @@ -102,6 +102,12 @@ import Testing // raw without OpenSSH's `Could not resolve hostname` wrapper. "nc: getaddrinfo: nodename nor servname provided, or not known\nConnection closed by UNKNOWN port 65535", "channel 1: open failed: administratively prohibited: open failed\nConnection closed by UNKNOWN port 65535", + // Linux `nc` ProxyCommand TCP connect timeout — phrased "Connection timed + // out" (not BSD/macOS's "Operation timed out"), with no other marker. + "nc: connect to inner.invalid port 22 (tcp) failed: Connection timed out\nConnection closed by UNKNOWN port 65535", + // Banner-exchange closure standing alone (no co-present marker): the inner + // target dropped us pre-auth (fail2ban / tcpwrappers / not-SSH-on-port). + "ssh_exchange_identification: Connection closed by remote host\nConnection closed by UNKNOWN port 65535", ]) func doesNotClassifyExplainedProxyClosures(_ stderr: String) { #expect(!RemoteTmuxSSHTransport.indicatesProxyCommandTransportClosed(stderr)) From f7bae0cd50d3d54cf751c6cba3411ba517eb2976 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 20:40:09 -0700 Subject: [PATCH 06/38] remote-tmux: linked-view beta flag + pure view reconciler (foundation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Keys/BetaFeaturesCatalogSection.swift | 17 +++ Sources/RemoteTmuxViewReconciler.swift | 93 ++++++++++++++ cmux.xcodeproj/project.pbxproj | 8 ++ cmuxTests/RemoteTmuxViewReconcilerTests.swift | 117 ++++++++++++++++++ 4 files changed, 235 insertions(+) create mode 100644 Sources/RemoteTmuxViewReconciler.swift create mode 100644 cmuxTests/RemoteTmuxViewReconcilerTests.swift diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift index ddafc840586a..8def15d29619 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift @@ -60,5 +60,22 @@ public struct BetaFeaturesCatalogSection: SettingCatalogSection { userDefaultsKey: "remoteTmux.beta.enabled" ) + /// Remote tmux linked-view mode: an experimental transport for hosts whose + /// `sshd` caps each connection to a single concurrent session + /// (`MaxSessions 1`, e.g. hosts with per-connection 2FA). Instead of one `tmux -CC` control + /// client per remote session — which needs one concurrent SSH session each + /// and so fails past the first — cmux drives ONE control client attached to a + /// hidden, cmux-owned aggregate "view" session and `link-window`s every + /// mirrored session's windows into it. All workspaces then stream live over + /// the single allowed connection (one auth), while the real tmux sessions are + /// preserved for `tmux ls` / `tmux attach` interop. Defaults off; requires + /// ``remoteTmux`` to be on. While off, remote tmux uses the per-session + /// control connections unchanged. + public let remoteTmuxLinkedView = DefaultsKey( + id: "remoteTmux.linkedView.beta.enabled", + defaultValue: false, + userDefaultsKey: "remoteTmux.linkedView.beta.enabled" + ) + public init() {} } diff --git a/Sources/RemoteTmuxViewReconciler.swift b/Sources/RemoteTmuxViewReconciler.swift new file mode 100644 index 000000000000..abb7406999a4 --- /dev/null +++ b/Sources/RemoteTmuxViewReconciler.swift @@ -0,0 +1,93 @@ +import Foundation + +/// Pure, declarative reconciliation for the remote-tmux **linked-view** transport +/// (the `remoteTmux.linkedView` beta). +/// +/// On hosts whose `sshd` caps each connection to one concurrent session +/// (`MaxSessions 1`), cmux cannot open a `tmux -CC` control client per remote +/// session. Instead it attaches ONE control client to a hidden, cmux-owned +/// aggregate **view** session and `link-window`s every mirrored session's windows +/// into it, so all workspaces stream over the single allowed connection. +/// +/// Correctness here is a *reconciliation*, never a pile of optimistic +/// `link-window` side effects: given the desired set of windows (the union of the +/// mirrored sessions' windows) and the view's actual contents, this computes the +/// minimal, safe set of link/unlink actions. It is intentionally pure (no I/O, no +/// tmux, no SSH) so the whole policy is unit-testable and deterministic. +/// +/// Safety invariants (enforced here, verified by tests): +/// - The view's own placeholder window (created with the view session) is never +/// unlinked — unlinking the last window would kill the view session. +/// - Only windows cmux itself linked (`cmuxOwnedWindowIds`) are ever unlinked. A +/// window cmux did not link (e.g. one a user manually put in the view) is left +/// untouched — cmux never mutates state it does not own. +/// - Output is sorted so a given (desired, actual) pair always yields the same +/// command order (testable, and stable for logging). +enum RemoteTmuxViewReconciler { + /// One reconciliation step against the view session. + enum Action: Equatable, CustomStringConvertible { + /// `link-window -s -t `: pull a mirrored window into the view. + case link(windowId: String) + /// `unlink-window` the view's copy of ``: drop a window cmux + /// previously linked but that is no longer desired (its session/window is + /// gone or was closed). The real (home) session keeps the window. + case unlinkFromView(windowId: String) + + var description: String { + switch self { + case .link(let id): return "link(\(id))" + case .unlinkFromView(let id): return "unlink(\(id))" + } + } + } + + /// Computes the minimal actions to make the view session's contents match + /// `desiredWindowIds`. + /// + /// - Parameters: + /// - desiredWindowIds: stable `@id`s of every window that should appear in + /// the view (the union across all mirrored source sessions). + /// - actualWindowIds: stable `@id`s currently present in the view session + /// (from `list-windows -t `), including the placeholder and any + /// not-cmux-owned windows. + /// - placeholderWindowId: the view's own initial window `@id`, or `nil` if + /// cmux has already replaced/closed it. Never unlinked. + /// - cmuxOwnedWindowIds: the `@id`s cmux has linked into the view itself. + /// Only these are eligible for unlinking; anything else is left untouched. + /// - Returns: links first (deterministic), then unlinks, each group sorted by id. + static func actions( + desiredWindowIds: Set, + actualWindowIds: Set, + placeholderWindowId: String?, + cmuxOwnedWindowIds: Set + ) -> [Action] { + // Link every desired window not already present. + let toLink = desiredWindowIds.subtracting(actualWindowIds) + + // Unlink only windows that (a) are actually present, (b) cmux owns, + // (c) are no longer desired, and (d) are not the placeholder. + var unlinkable = actualWindowIds + .intersection(cmuxOwnedWindowIds) + .subtracting(desiredWindowIds) + if let placeholderWindowId { + unlinkable.remove(placeholderWindowId) + } + + return toLink.sorted().map { Action.link(windowId: $0) } + + unlinkable.sorted().map { Action.unlinkFromView(windowId: $0) } + } + + /// Whether the view currently holds nothing but its placeholder (and/or + /// nothing) — i.e. no mirrored windows are present, so the dedicated mirror + /// window has no live workspaces to show. + static func viewHasNoMirroredWindows( + actualWindowIds: Set, + placeholderWindowId: String? + ) -> Bool { + var nonPlaceholder = actualWindowIds + if let placeholderWindowId { + nonPlaceholder.remove(placeholderWindowId) + } + return nonPlaceholder.isEmpty + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index f2579b883363..bd2e369874ca 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -822,6 +822,8 @@ 0E17C0DE0E17C0DE0E17C002 /* RemoteTmuxTransportRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E17C0DE0E17C0DE0E17C001 /* RemoteTmuxTransportRegistry.swift */; }; 0A17C0DE0A17C0DE0A17C902 /* RemoteTmuxVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A17C0DE0A17C0DE0A17C901 /* RemoteTmuxVersion.swift */; }; 0A17C0DE0A17C0DE0A17CA02 /* RemoteTmuxVersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A17C0DE0A17C0DE0A17CA01 /* RemoteTmuxVersionTests.swift */; }; + 0F00DEC0DE0F00DEC0DE0001 /* RemoteTmuxViewReconciler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F00DEC0DE0F00DEC0DE0002 /* RemoteTmuxViewReconciler.swift */; }; + 0F00DEC0DE0F00DEC0DE0003 /* RemoteTmuxViewReconcilerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F00DEC0DE0F00DEC0DE0004 /* RemoteTmuxViewReconcilerTests.swift */; }; A8C7BE33C1BD3C1AD4342CB1 /* RemoteTmuxWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB52610183419D424AE520B0 /* RemoteTmuxWindow.swift */; }; 826F6597BF05F1242E17F0C3 /* RemoteTmuxWindowMirror.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCE03473FCBD453C7DA78A09 /* RemoteTmuxWindowMirror.swift */; }; 063516351A6B6F642928B37D /* RemoteTmuxWindowMirrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E292DDF62C863C3553F4C9E7 /* RemoteTmuxWindowMirrorView.swift */; }; @@ -2057,6 +2059,8 @@ 0E17C0DE0E17C0DE0E17C001 /* RemoteTmuxTransportRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxTransportRegistry.swift; sourceTree = ""; }; 0A17C0DE0A17C0DE0A17C901 /* RemoteTmuxVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxVersion.swift; sourceTree = ""; }; 0A17C0DE0A17C0DE0A17CA01 /* RemoteTmuxVersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxVersionTests.swift; sourceTree = ""; }; + 0F00DEC0DE0F00DEC0DE0002 /* RemoteTmuxViewReconciler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxViewReconciler.swift; sourceTree = ""; }; + 0F00DEC0DE0F00DEC0DE0004 /* RemoteTmuxViewReconcilerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxViewReconcilerTests.swift; sourceTree = ""; }; AB52610183419D424AE520B0 /* RemoteTmuxWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxWindow.swift; sourceTree = ""; }; FCE03473FCBD453C7DA78A09 /* RemoteTmuxWindowMirror.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxWindowMirror.swift; sourceTree = ""; }; E292DDF62C863C3553F4C9E7 /* RemoteTmuxWindowMirrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxWindowMirrorView.swift; sourceTree = ""; }; @@ -3323,6 +3327,7 @@ E8F5DB43CBC1B7DF31B7A0C9 /* RemoteTmuxSSHTransport.swift */, AF44822DC080B2110CFECF88 /* RemoteTmuxError.swift */, E833A06BDA073CA66A46D7FA /* RemoteTmuxSessionListParser.swift */, + 0F00DEC0DE0F00DEC0DE0002 /* RemoteTmuxViewReconciler.swift */, 541716FF0D90CB510EE3004A /* RemoteTmuxSession.swift */, 71D411EFBE33FE85901B9E6A /* RemoteTmuxCommandResult.swift */, 02B8A9858C9804AC9C37D7B4 /* RemoteTmuxHost.swift */, @@ -3717,6 +3722,7 @@ FA00C0DE0002BEEF0002CAFE /* RemoteTmuxWindowRegistryTests.swift */, 0A17C0DE0A17C0DE0A17C003 /* RemoteTmuxAuthTests.swift */, 6732BEEF6732BEEF6732B001 /* RemoteTmuxMasterReadinessTests.swift */, + 0F00DEC0DE0F00DEC0DE0004 /* RemoteTmuxViewReconcilerTests.swift */, 0C7D0CDD0C7D0CDD0C7D0001 /* RemoteTmuxNewWindowCwdTests.swift */, 5F5553CA5553CA5553CA0002 /* RemoteTmuxCapabilitiesTests.swift */, C57570010000000000000001 /* RightSidebarPanelViewTestSupport.swift */, @@ -4707,6 +4713,7 @@ 4E9111628FAF490FBC51D350 /* RemoteTmuxStdoutPipeReader.swift in Sources */, 0E17C0DE0E17C0DE0E17C002 /* RemoteTmuxTransportRegistry.swift in Sources */, 0A17C0DE0A17C0DE0A17C902 /* RemoteTmuxVersion.swift in Sources */, + 0F00DEC0DE0F00DEC0DE0001 /* RemoteTmuxViewReconciler.swift in Sources */, A8C7BE33C1BD3C1AD4342CB1 /* RemoteTmuxWindow.swift in Sources */, 826F6597BF05F1242E17F0C3 /* RemoteTmuxWindowMirror.swift in Sources */, 063516351A6B6F642928B37D /* RemoteTmuxWindowMirrorView.swift in Sources */, @@ -5288,6 +5295,7 @@ 3AC9AB9046E742B93726A501 /* RemoteTmuxSessionRenameTitleTests.swift in Sources */, 9A5CF3AA2E6462E77144F9E6 /* RemoteTmuxSessionSnapshotTests.swift in Sources */, 0A17C0DE0A17C0DE0A17CA02 /* RemoteTmuxVersionTests.swift in Sources */, + 0F00DEC0DE0F00DEC0DE0003 /* RemoteTmuxViewReconcilerTests.swift in Sources */, FA00C0DE0001BEEF0001CAFE /* RemoteTmuxWindowRegistryTests.swift in Sources */, C58410010000000000000001 /* RenderableSystemSymbolTests.swift in Sources */, D36A00050000000000000001 /* RendererRealizationPlannerTests.swift in Sources */, diff --git a/cmuxTests/RemoteTmuxViewReconcilerTests.swift b/cmuxTests/RemoteTmuxViewReconcilerTests.swift new file mode 100644 index 000000000000..a5f3e1234b91 --- /dev/null +++ b/cmuxTests/RemoteTmuxViewReconcilerTests.swift @@ -0,0 +1,117 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Tests the pure linked-view reconciler (`remoteTmux.linkedView` beta). These +/// assert the link/unlink policy and its safety invariants — never touch the +/// placeholder, never touch windows cmux does not own — with no tmux/SSH. +@Suite struct RemoteTmuxViewReconcilerTests { + private typealias R = RemoteTmuxViewReconciler + private typealias A = RemoteTmuxViewReconciler.Action + + @Test func linksAllDesiredIntoEmptyView() { + let actions = R.actions( + desiredWindowIds: ["@1", "@2", "@3"], + actualWindowIds: ["@0"], // only the placeholder present + placeholderWindowId: "@0", + cmuxOwnedWindowIds: [] + ) + #expect(actions == [.link(windowId: "@1"), .link(windowId: "@2"), .link(windowId: "@3")]) + } + + @Test func noActionsWhenAlreadyReconciled() { + let actions = R.actions( + desiredWindowIds: ["@1", "@2"], + actualWindowIds: ["@0", "@1", "@2"], + placeholderWindowId: "@0", + cmuxOwnedWindowIds: ["@1", "@2"] + ) + #expect(actions.isEmpty) + } + + @Test func unlinksCmuxOwnedWindowThatIsNoLongerDesired() { + // @2's session was closed → @2 leaves `desired`; cmux owns it → unlink it. + let actions = R.actions( + desiredWindowIds: ["@1"], + actualWindowIds: ["@0", "@1", "@2"], + placeholderWindowId: "@0", + cmuxOwnedWindowIds: ["@1", "@2"] + ) + #expect(actions == [.unlinkFromView(windowId: "@2")]) + } + + @Test func neverUnlinksThePlaceholder() { + // Placeholder is present, not desired, but must never be unlinked + // (it's the view's last-resort window; unlinking it kills the session). + let actions = R.actions( + desiredWindowIds: ["@1"], + actualWindowIds: ["@0", "@1"], + placeholderWindowId: "@0", + cmuxOwnedWindowIds: ["@0", "@1"] // even if mislabeled owned + ) + #expect(actions.isEmpty) + } + + @Test func neverTouchesWindowsCmuxDoesNotOwn() { + // @9 is in the view but cmux didn't link it (e.g. user put it there). + // It is not desired, but cmux must leave it alone. + let actions = R.actions( + desiredWindowIds: ["@1"], + actualWindowIds: ["@0", "@1", "@9"], + placeholderWindowId: "@0", + cmuxOwnedWindowIds: ["@1"] + ) + #expect(actions.isEmpty) + } + + @Test func mixedLinkAndUnlinkAreBothEmittedDeterministically() { + // Desired adds @3, @4; @2 is owned + no longer desired → unlink. + let actions = R.actions( + desiredWindowIds: ["@1", "@3", "@4"], + actualWindowIds: ["@0", "@1", "@2"], + placeholderWindowId: "@0", + cmuxOwnedWindowIds: ["@1", "@2"] + ) + // Links first (sorted), then unlinks (sorted) — stable order. + #expect(actions == [ + .link(windowId: "@3"), + .link(windowId: "@4"), + .unlinkFromView(windowId: "@2"), + ]) + } + + @Test func handlesMissingPlaceholder() { + // After cmux closes the placeholder, an owned undesired window still unlinks. + let actions = R.actions( + desiredWindowIds: [], + actualWindowIds: ["@2"], + placeholderWindowId: nil, + cmuxOwnedWindowIds: ["@2"] + ) + #expect(actions == [.unlinkFromView(windowId: "@2")]) + } + + @Test func reconcileIsIdempotentAcrossRepeatedRuns() { + // Applying the actions then reconciling again yields no further actions. + let desired: Set = ["@1", "@2"] + var actual: Set = ["@0"] + let owned = desired + let first = R.actions(desiredWindowIds: desired, actualWindowIds: actual, + placeholderWindowId: "@0", cmuxOwnedWindowIds: owned) + for case let .link(id) in first { actual.insert(id) } + let second = R.actions(desiredWindowIds: desired, actualWindowIds: actual, + placeholderWindowId: "@0", cmuxOwnedWindowIds: owned) + #expect(second.isEmpty) + } + + @Test func viewHasNoMirroredWindowsDetectsEmptyMirror() { + #expect(R.viewHasNoMirroredWindows(actualWindowIds: ["@0"], placeholderWindowId: "@0")) + #expect(R.viewHasNoMirroredWindows(actualWindowIds: [], placeholderWindowId: nil)) + #expect(!R.viewHasNoMirroredWindows(actualWindowIds: ["@0", "@1"], placeholderWindowId: "@0")) + } +} From 91e9c129581e6c72e04ffd8c107f75bf4991b0d3 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 20:40:09 -0700 Subject: [PATCH 07/38] remote-tmux: owned view-session identity for linked-view mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-`), - 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. --- Sources/RemoteTmuxViewSession.swift | 119 +++++++++++++++++++++ cmux.xcodeproj/project.pbxproj | 8 ++ cmuxTests/RemoteTmuxViewSessionTests.swift | 88 +++++++++++++++ 3 files changed, 215 insertions(+) create mode 100644 Sources/RemoteTmuxViewSession.swift create mode 100644 cmuxTests/RemoteTmuxViewSessionTests.swift diff --git a/Sources/RemoteTmuxViewSession.swift b/Sources/RemoteTmuxViewSession.swift new file mode 100644 index 000000000000..86d66427f80b --- /dev/null +++ b/Sources/RemoteTmuxViewSession.swift @@ -0,0 +1,119 @@ +import Foundation + +/// Identity and ownership for the hidden aggregate **view** session used by the +/// remote-tmux linked-view transport (`remoteTmux.linkedView` beta). +/// +/// The view session is cmux-created remote state: one per (host, cmux owner). The +/// single `tmux -CC` control client attaches to it and every mirrored window is +/// `link-window`ed in. Because it is real, visible remote state, it must be: +/// - **uniquely named** so two cmux owners (or a re-launch) never collide, +/// - **tagged** with tmux user options so discovery can tell a view from a real +/// workspace session and tell *our* view from one owned by another cmux, +/// - **reattachable** by the same owner across reconnect/relaunch, +/// - **safe to garbage-collect** when stale, while *never* touching a session +/// that is not a cmux view or is owned by someone else. +/// +/// Pure value type: builds names, the tmux option-set commands, and the +/// classification predicates. No tmux/SSH here so the policy is unit-testable. +struct RemoteTmuxViewSession: Equatable { + /// Stable per-cmux-install owner id (e.g. a UUID persisted in defaults). Lets + /// us reattach our own view and avoid other cmux installs' views. + let ownerId: String + + /// Session name format version, so a future incompatible view layout can be + /// recognized and recreated rather than reused. + static let formatVersion = 1 + + /// tmux user-option keys stamped on the view session (read via + /// `#{@cmux_view}` etc.). User options must start with `@`. + static let optView = "@cmux_view" + static let optOwner = "@cmux_view_owner" + static let optVersion = "@cmux_view_version" + + /// Prefix all view sessions share, for a cheap first-pass filter and so a + /// human running `tmux ls` can tell what these are. + static let namePrefix = "cmux-view-" + + /// The deterministic view session name for this owner. Sanitized to tmux-safe + /// characters (tmux session names disallow `.`, `:` and whitespace). + var sessionName: String { + Self.namePrefix + Self.sanitizeOwner(ownerId) + } + + /// tmux commands (control-mode safe, one per line) that create the view + /// detached at an explicit size and stamp ownership options. Creating with an + /// explicit `-x/-y` avoids an 80x24 placeholder flash before the first resize. + /// + /// `new-session -d -s ` is idempotent only if guarded; callers use + /// ``hasSessionGuardedCreateCommands(cols:rows:)`` which create-or-noops. + func createCommands(cols: Int, rows: Int) -> [String] { + let n = sessionName + return [ + "new-session -d -s \(Self.q(n)) -x \(cols) -y \(rows)", + "set-option -t \(Self.q(n)) \(Self.optView) 1", + "set-option -t \(Self.q(n)) \(Self.optOwner) \(Self.q(ownerId))", + "set-option -t \(Self.q(n)) \(Self.optVersion) \(Self.formatVersion)", + ] + } + + /// Format string for `list-sessions -F` that surfaces enough to classify each + /// session: name + the three ownership options. + static let listFormat = + "#{session_name}\u{1f}#{\(optView)}\u{1f}#{\(optOwner)}\u{1f}#{\(optVersion)}" + + /// One parsed `list-sessions` row (from ``listFormat``). + struct SessionRow: Equatable { + let name: String + let isView: Bool // @cmux_view == "1" + let owner: String // @cmux_view_owner ("" if unset) + let version: Int? // @cmux_view_version + } + + static func parseRows(_ output: String) -> [SessionRow] { + output.split(separator: "\n", omittingEmptySubsequences: true).compactMap { line in + let f = line.components(separatedBy: "\u{1f}") + guard f.count == 4 else { return nil } + return SessionRow( + name: f[0], + isView: f[1] == "1", + owner: f[2], + version: Int(f[3]) + ) + } + } + + // MARK: - Classification (the safety surface) + + /// This row is *our* view: tagged as a view, owned by us, current format. + /// Only a row matching this is ever reattached/reused. + func isOwnView(_ row: SessionRow) -> Bool { + row.isView && row.owner == ownerId && row.version == Self.formatVersion + && row.name == sessionName + } + + /// A stale view owned by *us* that we may garbage-collect: tagged as a view, + /// our owner, but a different name/version than the one we use now (e.g. an + /// old format left by a previous build). Never includes other owners' views. + func isOwnStaleView(_ row: SessionRow) -> Bool { + row.isView && row.owner == ownerId && !isOwnView(row) + } + + /// A view owned by a *different* cmux install. Must never be touched. + static func isForeignView(_ row: SessionRow, ownerId: String) -> Bool { + row.isView && !row.owner.isEmpty && row.owner != ownerId + } + + // MARK: - Helpers + + /// tmux session names cannot contain `.`, `:`, or whitespace; map them out so + /// an arbitrary owner id yields a valid, stable session name. + static func sanitizeOwner(_ owner: String) -> String { + String(owner.unicodeScalars.map { s -> Character in + if s == "." || s == ":" || CharacterSet.whitespaces.contains(s) { return "-" } + return Character(s) + }) + } + + /// Single-quote for a control-mode command argument. + private static func q(_ v: String) -> String { RemoteTmuxHost.shellSingleQuoted(v) } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index bd2e369874ca..d8f4abf2a959 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -824,6 +824,8 @@ 0A17C0DE0A17C0DE0A17CA02 /* RemoteTmuxVersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A17C0DE0A17C0DE0A17CA01 /* RemoteTmuxVersionTests.swift */; }; 0F00DEC0DE0F00DEC0DE0001 /* RemoteTmuxViewReconciler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F00DEC0DE0F00DEC0DE0002 /* RemoteTmuxViewReconciler.swift */; }; 0F00DEC0DE0F00DEC0DE0003 /* RemoteTmuxViewReconcilerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F00DEC0DE0F00DEC0DE0004 /* RemoteTmuxViewReconcilerTests.swift */; }; + 0F00DEC0DE0F00DEC0DE0005 /* RemoteTmuxViewSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F00DEC0DE0F00DEC0DE0006 /* RemoteTmuxViewSession.swift */; }; + 0F00DEC0DE0F00DEC0DE0007 /* RemoteTmuxViewSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F00DEC0DE0F00DEC0DE0008 /* RemoteTmuxViewSessionTests.swift */; }; A8C7BE33C1BD3C1AD4342CB1 /* RemoteTmuxWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB52610183419D424AE520B0 /* RemoteTmuxWindow.swift */; }; 826F6597BF05F1242E17F0C3 /* RemoteTmuxWindowMirror.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCE03473FCBD453C7DA78A09 /* RemoteTmuxWindowMirror.swift */; }; 063516351A6B6F642928B37D /* RemoteTmuxWindowMirrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E292DDF62C863C3553F4C9E7 /* RemoteTmuxWindowMirrorView.swift */; }; @@ -2061,6 +2063,8 @@ 0A17C0DE0A17C0DE0A17CA01 /* RemoteTmuxVersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxVersionTests.swift; sourceTree = ""; }; 0F00DEC0DE0F00DEC0DE0002 /* RemoteTmuxViewReconciler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxViewReconciler.swift; sourceTree = ""; }; 0F00DEC0DE0F00DEC0DE0004 /* RemoteTmuxViewReconcilerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxViewReconcilerTests.swift; sourceTree = ""; }; + 0F00DEC0DE0F00DEC0DE0006 /* RemoteTmuxViewSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxViewSession.swift; sourceTree = ""; }; + 0F00DEC0DE0F00DEC0DE0008 /* RemoteTmuxViewSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxViewSessionTests.swift; sourceTree = ""; }; AB52610183419D424AE520B0 /* RemoteTmuxWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxWindow.swift; sourceTree = ""; }; FCE03473FCBD453C7DA78A09 /* RemoteTmuxWindowMirror.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxWindowMirror.swift; sourceTree = ""; }; E292DDF62C863C3553F4C9E7 /* RemoteTmuxWindowMirrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxWindowMirrorView.swift; sourceTree = ""; }; @@ -3328,6 +3332,7 @@ AF44822DC080B2110CFECF88 /* RemoteTmuxError.swift */, E833A06BDA073CA66A46D7FA /* RemoteTmuxSessionListParser.swift */, 0F00DEC0DE0F00DEC0DE0002 /* RemoteTmuxViewReconciler.swift */, + 0F00DEC0DE0F00DEC0DE0006 /* RemoteTmuxViewSession.swift */, 541716FF0D90CB510EE3004A /* RemoteTmuxSession.swift */, 71D411EFBE33FE85901B9E6A /* RemoteTmuxCommandResult.swift */, 02B8A9858C9804AC9C37D7B4 /* RemoteTmuxHost.swift */, @@ -3723,6 +3728,7 @@ 0A17C0DE0A17C0DE0A17C003 /* RemoteTmuxAuthTests.swift */, 6732BEEF6732BEEF6732B001 /* RemoteTmuxMasterReadinessTests.swift */, 0F00DEC0DE0F00DEC0DE0004 /* RemoteTmuxViewReconcilerTests.swift */, + 0F00DEC0DE0F00DEC0DE0008 /* RemoteTmuxViewSessionTests.swift */, 0C7D0CDD0C7D0CDD0C7D0001 /* RemoteTmuxNewWindowCwdTests.swift */, 5F5553CA5553CA5553CA0002 /* RemoteTmuxCapabilitiesTests.swift */, C57570010000000000000001 /* RightSidebarPanelViewTestSupport.swift */, @@ -4714,6 +4720,7 @@ 0E17C0DE0E17C0DE0E17C002 /* RemoteTmuxTransportRegistry.swift in Sources */, 0A17C0DE0A17C0DE0A17C902 /* RemoteTmuxVersion.swift in Sources */, 0F00DEC0DE0F00DEC0DE0001 /* RemoteTmuxViewReconciler.swift in Sources */, + 0F00DEC0DE0F00DEC0DE0005 /* RemoteTmuxViewSession.swift in Sources */, A8C7BE33C1BD3C1AD4342CB1 /* RemoteTmuxWindow.swift in Sources */, 826F6597BF05F1242E17F0C3 /* RemoteTmuxWindowMirror.swift in Sources */, 063516351A6B6F642928B37D /* RemoteTmuxWindowMirrorView.swift in Sources */, @@ -5296,6 +5303,7 @@ 9A5CF3AA2E6462E77144F9E6 /* RemoteTmuxSessionSnapshotTests.swift in Sources */, 0A17C0DE0A17C0DE0A17CA02 /* RemoteTmuxVersionTests.swift in Sources */, 0F00DEC0DE0F00DEC0DE0003 /* RemoteTmuxViewReconcilerTests.swift in Sources */, + 0F00DEC0DE0F00DEC0DE0007 /* RemoteTmuxViewSessionTests.swift in Sources */, FA00C0DE0001BEEF0001CAFE /* RemoteTmuxWindowRegistryTests.swift in Sources */, C58410010000000000000001 /* RenderableSystemSymbolTests.swift in Sources */, D36A00050000000000000001 /* RendererRealizationPlannerTests.swift in Sources */, diff --git a/cmuxTests/RemoteTmuxViewSessionTests.swift b/cmuxTests/RemoteTmuxViewSessionTests.swift new file mode 100644 index 000000000000..5539d37fcff3 --- /dev/null +++ b/cmuxTests/RemoteTmuxViewSessionTests.swift @@ -0,0 +1,88 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Tests view-session identity/ownership for the linked-view beta: naming, +/// option stamping, row parsing, and the classification predicates that keep cmux +/// from ever reusing/garbage-collecting a session it does not own. +@Suite struct RemoteTmuxViewSessionTests { + private func v(_ owner: String = "owner-ABC") -> RemoteTmuxViewSession { + RemoteTmuxViewSession(ownerId: owner) + } + + @Test func sessionNameIsPrefixedAndSanitized() { + let s = RemoteTmuxViewSession(ownerId: "ab.cd ef:gh") + #expect(s.sessionName == "cmux-view-ab-cd-ef-gh") // . : space → - + #expect(s.sessionName.hasPrefix(RemoteTmuxViewSession.namePrefix)) + } + + @Test func createCommandsUseExplicitSizeAndStampOwnership() { + let cmds = v("o1").createCommands(cols: 120, rows: 40) + #expect(cmds[0].contains("new-session -d -s 'cmux-view-o1' -x 120 -y 40")) + #expect(cmds.contains { $0.contains("@cmux_view 1") }) + #expect(cmds.contains { $0.contains("@cmux_view_owner 'o1'") }) + #expect(cmds.contains { $0.contains("@cmux_view_version 1") }) + } + + @Test func parsesListRows() { + let out = [ + "cmux-view-o1\u{1f}1\u{1f}o1\u{1f}1", + "work\u{1f}\u{1f}\u{1f}", // a normal session: no view options + "cmux-view-other\u{1f}1\u{1f}o2\u{1f}1", + ].joined(separator: "\n") + let rows = RemoteTmuxViewSession.parseRows(out) + #expect(rows.count == 3) + #expect(rows[0] == .init(name: "cmux-view-o1", isView: true, owner: "o1", version: 1)) + #expect(rows[1] == .init(name: "work", isView: false, owner: "", version: nil)) + #expect(rows[2].owner == "o2") + } + + @Test func isOwnViewOnlyForExactOwnerNameAndVersion() { + let s = v("o1") + #expect(s.isOwnView(.init(name: "cmux-view-o1", isView: true, owner: "o1", version: 1))) + // wrong owner + #expect(!s.isOwnView(.init(name: "cmux-view-o1", isView: true, owner: "o2", version: 1))) + // not tagged a view + #expect(!s.isOwnView(.init(name: "cmux-view-o1", isView: false, owner: "o1", version: 1))) + // wrong version + #expect(!s.isOwnView(.init(name: "cmux-view-o1", isView: true, owner: "o1", version: 99))) + } + + @Test func staleViewIsOnlyOurOwnNonCurrent() { + let s = v("o1") + // our owner, old version → stale (collectible) + #expect(s.isOwnStaleView(.init(name: "cmux-view-o1-old", isView: true, owner: "o1", version: 0))) + // our current view → NOT stale + #expect(!s.isOwnStaleView(.init(name: "cmux-view-o1", isView: true, owner: "o1", version: 1))) + // another owner's view → NEVER stale-collectible by us + #expect(!s.isOwnStaleView(.init(name: "cmux-view-o2", isView: true, owner: "o2", version: 0))) + // a normal session → not a view, not collectible + #expect(!s.isOwnStaleView(.init(name: "work", isView: false, owner: "", version: nil))) + } + + @Test func foreignViewDetection() { + #expect(RemoteTmuxViewSession.isForeignView( + .init(name: "cmux-view-o2", isView: true, owner: "o2", version: 1), ownerId: "o1")) + #expect(!RemoteTmuxViewSession.isForeignView( + .init(name: "cmux-view-o1", isView: true, owner: "o1", version: 1), ownerId: "o1")) + // a view with no owner stamped is not attributed to anyone → not foreign + #expect(!RemoteTmuxViewSession.isForeignView( + .init(name: "cmux-view-x", isView: true, owner: "", version: 1), ownerId: "o1")) + // a normal session is never foreign-view + #expect(!RemoteTmuxViewSession.isForeignView( + .init(name: "work", isView: false, owner: "", version: nil), ownerId: "o1")) + } + + @Test func ownerAndForeignAreMutuallyExclusive() { + let s = v("o1") + let mine = RemoteTmuxViewSession.SessionRow(name: "cmux-view-o1", isView: true, owner: "o1", version: 1) + let theirs = RemoteTmuxViewSession.SessionRow(name: "cmux-view-o2", isView: true, owner: "o2", version: 1) + #expect(s.isOwnView(mine) && !RemoteTmuxViewSession.isForeignView(mine, ownerId: "o1")) + #expect(RemoteTmuxViewSession.isForeignView(theirs, ownerId: "o1") && !s.isOwnView(theirs)) + } +} From 7c6ea6ac7d1525b689ebb7a0b95c8d5bb9d62298 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 20:40:09 -0700 Subject: [PATCH 08/38] remote-tmux: window->workspace regrouping model for linked-view 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. --- Sources/RemoteTmuxLinkedWorkspaceModel.swift | 79 +++++++++++++++++++ cmux.xcodeproj/project.pbxproj | 8 ++ .../RemoteTmuxLinkedWorkspaceModelTests.swift | 75 ++++++++++++++++++ 3 files changed, 162 insertions(+) create mode 100644 Sources/RemoteTmuxLinkedWorkspaceModel.swift create mode 100644 cmuxTests/RemoteTmuxLinkedWorkspaceModelTests.swift diff --git a/Sources/RemoteTmuxLinkedWorkspaceModel.swift b/Sources/RemoteTmuxLinkedWorkspaceModel.swift new file mode 100644 index 000000000000..0b231775d56b --- /dev/null +++ b/Sources/RemoteTmuxLinkedWorkspaceModel.swift @@ -0,0 +1,79 @@ +import Foundation + +/// Pure mapping from the linked-view's flat window set back to cmux workspaces. +/// +/// In linked-view mode a single `tmux -CC` client is attached to the hidden view +/// session, which contains windows linked in from many real sessions. The control +/// stream therefore delivers `%output`/`%window-add` for windows belonging to +/// different home sessions. cmux must regroup them: each real (home) session is a +/// workspace; that session's windows are its tabs, in tmux index order. +/// +/// This type turns `list-windows -a` rows into that grouping. It is pure (no tmux) +/// so the regrouping policy is unit-testable and deterministic. A window linked +/// into the view appears under BOTH its home session and the view session; it is +/// always attributed to its home (non-view) session, and the view session itself +/// is never surfaced as a workspace. +enum RemoteTmuxLinkedWorkspaceModel { + /// One `list-windows -a -F` row. `sessionName` is the session this row is + /// listed under (a linked window yields one row per session it's in). + struct WindowRow: Equatable { + let sessionName: String + let windowId: String // stable @id + let windowIndex: Int + } + + /// Recommended format for `list-windows -a -F` (session-unit-separated; name last + /// is not needed since session_name has no separator char here, but we keep a + /// non-printable separator so window names can't corrupt parsing). + static let listFormat = "#{session_name}\u{1f}#{window_id}\u{1f}#{window_index}" + + static func parseRows(_ output: String) -> [WindowRow] { + output.split(separator: "\n", omittingEmptySubsequences: true).compactMap { line in + let f = line.components(separatedBy: "\u{1f}") + guard f.count == 3, let idx = Int(f[2]) else { return nil } + return WindowRow(sessionName: f[0], windowId: f[1], windowIndex: idx) + } + } + + /// A workspace = a home session and its windows (tabs) in tmux index order. + struct Workspace: Equatable { + let sessionName: String + let windowIds: [String] // ordered by window index + } + + /// Groups rows into workspaces, excluding the view session entirely. + /// + /// - Parameters: + /// - rows: every `list-windows -a` row for the host's tmux server. + /// - viewSessionName: the hidden view session to exclude from workspaces. + /// - Returns: workspaces sorted by session name; each workspace's window ids + /// sorted by (windowIndex, windowId) for a stable tab order. A window that + /// only exists in the view (no home session row) is dropped — cmux only ever + /// shows windows that have a real home session. + static func workspaces(rows: [WindowRow], viewSessionName: String) -> [Workspace] { + // Collect, per home session, its (index, id) windows. Exclude the view. + var bySession: [String: [(idx: Int, id: String)]] = [:] + for r in rows where r.sessionName != viewSessionName { + bySession[r.sessionName, default: []].append((r.windowIndex, r.windowId)) + } + return bySession.keys.sorted().map { name in + let ordered = bySession[name]! + .sorted { ($0.idx, $0.id) < ($1.idx, $1.id) } + .map(\.id) + return Workspace(sessionName: name, windowIds: ordered) + } + } + + /// The set of window ids that SHOULD be linked into the view = every window + /// that has a real home session (i.e. all non-view windows). This is the + /// `desiredWindowIds` fed to ``RemoteTmuxViewReconciler``. + static func desiredLinkedWindowIds(rows: [WindowRow], viewSessionName: String) -> Set { + Set(rows.filter { $0.sessionName != viewSessionName }.map(\.windowId)) + } + + /// home session for a given window id (its non-view session), or nil if the + /// window has no home (view-only) — used to route `%output` to a workspace. + static func homeSession(forWindowId id: String, rows: [WindowRow], viewSessionName: String) -> String? { + rows.first { $0.windowId == id && $0.sessionName != viewSessionName }?.sessionName + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index d8f4abf2a959..07c3ebe03654 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -799,6 +799,8 @@ 00B223273DE0DBED98437C7A /* RemoteTmuxLayoutContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A1B00F615F44EA3BA234483 /* RemoteTmuxLayoutContainer.swift */; }; B12E809B546C64DC359C54E3 /* RemoteTmuxLayoutContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7A830D507D913809EB02B0 /* RemoteTmuxLayoutContent.swift */; }; E54DED0FDC24F51BF1470A1A /* RemoteTmuxLayoutNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A5717357D55291686F99625 /* RemoteTmuxLayoutNode.swift */; }; + 0F00DEC0DE0F00DEC0DE0009 /* RemoteTmuxLinkedWorkspaceModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F00DEC0DE0F00DEC0DE000A /* RemoteTmuxLinkedWorkspaceModel.swift */; }; + 0F00DEC0DE0F00DEC0DE000B /* RemoteTmuxLinkedWorkspaceModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F00DEC0DE0F00DEC0DE000C /* RemoteTmuxLinkedWorkspaceModelTests.swift */; }; 6732BEEF6732BEEF6732B002 /* RemoteTmuxMasterReadinessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6732BEEF6732BEEF6732B001 /* RemoteTmuxMasterReadinessTests.swift */; }; F11ED7AB0004000400040004 /* RemoteTmuxMirrorNewTabPlacement.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11ED7AB0003000300030003 /* RemoteTmuxMirrorNewTabPlacement.swift */; }; F11ED7AB0001000100010001 /* RemoteTmuxMirrorNewTabPlacementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11ED7AB0002000200020002 /* RemoteTmuxMirrorNewTabPlacementTests.swift */; }; @@ -2038,6 +2040,8 @@ 3A1B00F615F44EA3BA234483 /* RemoteTmuxLayoutContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxLayoutContainer.swift; sourceTree = ""; }; 4B7A830D507D913809EB02B0 /* RemoteTmuxLayoutContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxLayoutContent.swift; sourceTree = ""; }; 8A5717357D55291686F99625 /* RemoteTmuxLayoutNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxLayoutNode.swift; sourceTree = ""; }; + 0F00DEC0DE0F00DEC0DE000A /* RemoteTmuxLinkedWorkspaceModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxLinkedWorkspaceModel.swift; sourceTree = ""; }; + 0F00DEC0DE0F00DEC0DE000C /* RemoteTmuxLinkedWorkspaceModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxLinkedWorkspaceModelTests.swift; sourceTree = ""; }; 6732BEEF6732BEEF6732B001 /* RemoteTmuxMasterReadinessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMasterReadinessTests.swift; sourceTree = ""; }; F11ED7AB0003000300030003 /* RemoteTmuxMirrorNewTabPlacement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorNewTabPlacement.swift; sourceTree = ""; }; F11ED7AB0002000200020002 /* RemoteTmuxMirrorNewTabPlacementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorNewTabPlacementTests.swift; sourceTree = ""; }; @@ -3333,6 +3337,7 @@ E833A06BDA073CA66A46D7FA /* RemoteTmuxSessionListParser.swift */, 0F00DEC0DE0F00DEC0DE0002 /* RemoteTmuxViewReconciler.swift */, 0F00DEC0DE0F00DEC0DE0006 /* RemoteTmuxViewSession.swift */, + 0F00DEC0DE0F00DEC0DE000A /* RemoteTmuxLinkedWorkspaceModel.swift */, 541716FF0D90CB510EE3004A /* RemoteTmuxSession.swift */, 71D411EFBE33FE85901B9E6A /* RemoteTmuxCommandResult.swift */, 02B8A9858C9804AC9C37D7B4 /* RemoteTmuxHost.swift */, @@ -3729,6 +3734,7 @@ 6732BEEF6732BEEF6732B001 /* RemoteTmuxMasterReadinessTests.swift */, 0F00DEC0DE0F00DEC0DE0004 /* RemoteTmuxViewReconcilerTests.swift */, 0F00DEC0DE0F00DEC0DE0008 /* RemoteTmuxViewSessionTests.swift */, + 0F00DEC0DE0F00DEC0DE000C /* RemoteTmuxLinkedWorkspaceModelTests.swift */, 0C7D0CDD0C7D0CDD0C7D0001 /* RemoteTmuxNewWindowCwdTests.swift */, 5F5553CA5553CA5553CA0002 /* RemoteTmuxCapabilitiesTests.swift */, C57570010000000000000001 /* RightSidebarPanelViewTestSupport.swift */, @@ -4704,6 +4710,7 @@ 00B223273DE0DBED98437C7A /* RemoteTmuxLayoutContainer.swift in Sources */, B12E809B546C64DC359C54E3 /* RemoteTmuxLayoutContent.swift in Sources */, E54DED0FDC24F51BF1470A1A /* RemoteTmuxLayoutNode.swift in Sources */, + 0F00DEC0DE0F00DEC0DE0009 /* RemoteTmuxLinkedWorkspaceModel.swift in Sources */, F11ED7AB0004000400040004 /* RemoteTmuxMirrorNewTabPlacement.swift in Sources */, F861B8D7C62A0BCB252A523A /* RemoteTmuxMirrorTabActivity.swift in Sources */, E9A8CC35D632F14A8669B7D1 /* RemoteTmuxPaneForegroundState.swift in Sources */, @@ -5294,6 +5301,7 @@ 5F5553CA5553CA5553CA0001 /* RemoteTmuxCapabilitiesTests.swift in Sources */, B2FDE62450514C4C27FBD8F1 /* RemoteTmuxControlParserTests.swift in Sources */, B0555301B0555301B0555301 /* RemoteTmuxControlStreamParserBudgetTests.swift in Sources */, + 0F00DEC0DE0F00DEC0DE000B /* RemoteTmuxLinkedWorkspaceModelTests.swift in Sources */, 6732BEEF6732BEEF6732B002 /* RemoteTmuxMasterReadinessTests.swift in Sources */, F11ED7AB0001000100010001 /* RemoteTmuxMirrorNewTabPlacementTests.swift in Sources */, D4F8A2E61C5B39707A8E6F12 /* RemoteTmuxMirrorSplitRoutingTests.swift in Sources */, diff --git a/cmuxTests/RemoteTmuxLinkedWorkspaceModelTests.swift b/cmuxTests/RemoteTmuxLinkedWorkspaceModelTests.swift new file mode 100644 index 000000000000..0c9040edd113 --- /dev/null +++ b/cmuxTests/RemoteTmuxLinkedWorkspaceModelTests.swift @@ -0,0 +1,75 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Tests regrouping the linked-view's flat window set back into per-session +/// workspaces: view excluded, home-session attribution, stable tab order. +@Suite struct RemoteTmuxLinkedWorkspaceModelTests { + private typealias M = RemoteTmuxLinkedWorkspaceModel + private typealias Row = RemoteTmuxLinkedWorkspaceModel.WindowRow + + // A realistic list-windows -a: sessions A (2 windows) and B (1), all also + // linked into the view session (so each appears under "view" too). + private let rows: [Row] = [ + Row(sessionName: "A", windowId: "@1", windowIndex: 0), + Row(sessionName: "A", windowId: "@2", windowIndex: 1), + Row(sessionName: "B", windowId: "@3", windowIndex: 0), + Row(sessionName: "cmux-view-o1", windowId: "@0", windowIndex: 0), // placeholder + Row(sessionName: "cmux-view-o1", windowId: "@1", windowIndex: 1), + Row(sessionName: "cmux-view-o1", windowId: "@2", windowIndex: 2), + Row(sessionName: "cmux-view-o1", windowId: "@3", windowIndex: 3), + ] + + @Test func parsesRows() { + let out = "A\u{1f}@1\u{1f}0\nB\u{1f}@3\u{1f}0" + #expect(M.parseRows(out) == [ + Row(sessionName: "A", windowId: "@1", windowIndex: 0), + Row(sessionName: "B", windowId: "@3", windowIndex: 0), + ]) + } + + @Test func groupsByHomeSessionExcludingView() { + let ws = M.workspaces(rows: rows, viewSessionName: "cmux-view-o1") + #expect(ws == [ + .init(sessionName: "A", windowIds: ["@1", "@2"]), + .init(sessionName: "B", windowIds: ["@3"]), + ]) + // the view session is never a workspace, and its placeholder @0 never appears + #expect(!ws.contains { $0.sessionName.hasPrefix("cmux-view") }) + #expect(!ws.flatMap(\.windowIds).contains("@0")) + } + + @Test func tabOrderFollowsWindowIndex() { + // Same session, windows given out of order → sorted by index. + let r = [ + Row(sessionName: "A", windowId: "@7", windowIndex: 5), + Row(sessionName: "A", windowId: "@4", windowIndex: 1), + Row(sessionName: "A", windowId: "@9", windowIndex: 3), + ] + let ws = M.workspaces(rows: r, viewSessionName: "view") + #expect(ws == [.init(sessionName: "A", windowIds: ["@4", "@9", "@7"])]) + } + + @Test func desiredLinkedWindowsAreAllNonViewWindows() { + #expect(M.desiredLinkedWindowIds(rows: rows, viewSessionName: "cmux-view-o1") + == ["@1", "@2", "@3"]) + } + + @Test func homeSessionRoutesOutputToWorkspace() { + #expect(M.homeSession(forWindowId: "@2", rows: rows, viewSessionName: "cmux-view-o1") == "A") + #expect(M.homeSession(forWindowId: "@3", rows: rows, viewSessionName: "cmux-view-o1") == "B") + // a window that exists only in the view has no home → nil (not shown) + #expect(M.homeSession(forWindowId: "@0", rows: rows, viewSessionName: "cmux-view-o1") == nil) + } + + @Test func emptyWhenOnlyViewExists() { + let r = [Row(sessionName: "cmux-view-o1", windowId: "@0", windowIndex: 0)] + #expect(M.workspaces(rows: r, viewSessionName: "cmux-view-o1").isEmpty) + #expect(M.desiredLinkedWindowIds(rows: r, viewSessionName: "cmux-view-o1").isEmpty) + } +} From 3fb8a21608c97f0b6faf950b339f70374c55e4ef Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 20:40:09 -0700 Subject: [PATCH 09/38] remote-tmux: composed linked-view planner (brain of the live coordinator) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- Sources/RemoteTmuxLinkedViewPlan.swift | 70 +++++++++++ cmux.xcodeproj/project.pbxproj | 8 ++ cmuxTests/RemoteTmuxLinkedViewPlanTests.swift | 111 ++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 Sources/RemoteTmuxLinkedViewPlan.swift create mode 100644 cmuxTests/RemoteTmuxLinkedViewPlanTests.swift diff --git a/Sources/RemoteTmuxLinkedViewPlan.swift b/Sources/RemoteTmuxLinkedViewPlan.swift new file mode 100644 index 000000000000..79f437aeefa0 --- /dev/null +++ b/Sources/RemoteTmuxLinkedViewPlan.swift @@ -0,0 +1,70 @@ +import Foundation + +/// The pure "brain" of the linked-view coordinator: given a snapshot of the +/// remote tmux server (its sessions + windows) and the view's current contents, +/// it produces everything the live coordinator must do — whether to (re)create the +/// view, which windows to link/unlink, and the resulting workspace grouping. +/// +/// Composing the three tested layers (``RemoteTmuxViewSession``, +/// ``RemoteTmuxViewReconciler``, ``RemoteTmuxLinkedWorkspaceModel``) here keeps the +/// live coordinator a thin I/O shell: it gathers snapshots over the single control +/// stream, calls ``plan(...)``, and applies the result. Pure and deterministic so +/// the whole policy stays unit-testable without tmux/SSH. +enum RemoteTmuxLinkedViewPlan { + struct Snapshot { + /// `list-sessions -F RemoteTmuxViewSession.listFormat` rows. + let sessions: [RemoteTmuxViewSession.SessionRow] + /// `list-windows -a -F RemoteTmuxLinkedWorkspaceModel.listFormat` rows. + let windows: [RemoteTmuxLinkedWorkspaceModel.WindowRow] + /// Window ids cmux has itself linked into the view so far (ownership for + /// safe unlinking). Empty on first plan. + let cmuxOwnedWindowIds: Set + /// The view's placeholder window id, if known. + let placeholderWindowId: String? + } + + struct Plan: Equatable { + /// True when no live, current-format, owned view session exists yet and + /// the coordinator must create one (via `view.createCommands`). + let needsViewCreate: Bool + /// Stale same-owner views to garbage-collect (`kill-session`), never + /// including the current view or any foreign view. + let staleViewsToKill: [String] + /// Reconciliation actions to bring the view's contents to the desired set. + let reconcileActions: [RemoteTmuxViewReconciler.Action] + /// The resulting cmux workspaces (home session → ordered window ids). + let workspaces: [RemoteTmuxLinkedWorkspaceModel.Workspace] + } + + static func plan(view: RemoteTmuxViewSession, snapshot: Snapshot) -> Plan { + let viewName = view.sessionName + + // View lifecycle: does our current view exist? what stale ones are ours? + let needsCreate = !snapshot.sessions.contains { view.isOwnView($0) } + let stale = snapshot.sessions.filter { view.isOwnStaleView($0) }.map(\.name) + + // Desired links = every non-view window with a real home session. + let desired = RemoteTmuxLinkedWorkspaceModel.desiredLinkedWindowIds( + rows: snapshot.windows, viewSessionName: viewName) + + // Actual = window ids currently inside the view session. + let actual = Set(snapshot.windows + .filter { $0.sessionName == viewName } + .map(\.windowId)) + + let actions = RemoteTmuxViewReconciler.actions( + desiredWindowIds: desired, + actualWindowIds: actual, + placeholderWindowId: snapshot.placeholderWindowId, + cmuxOwnedWindowIds: snapshot.cmuxOwnedWindowIds) + + let workspaces = RemoteTmuxLinkedWorkspaceModel.workspaces( + rows: snapshot.windows, viewSessionName: viewName) + + return Plan( + needsViewCreate: needsCreate, + staleViewsToKill: stale, + reconcileActions: actions, + workspaces: workspaces) + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 07c3ebe03654..d8eb75e0d924 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -799,6 +799,8 @@ 00B223273DE0DBED98437C7A /* RemoteTmuxLayoutContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A1B00F615F44EA3BA234483 /* RemoteTmuxLayoutContainer.swift */; }; B12E809B546C64DC359C54E3 /* RemoteTmuxLayoutContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7A830D507D913809EB02B0 /* RemoteTmuxLayoutContent.swift */; }; E54DED0FDC24F51BF1470A1A /* RemoteTmuxLayoutNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A5717357D55291686F99625 /* RemoteTmuxLayoutNode.swift */; }; + 0F00DEC0DE0F00DEC0DE000D /* RemoteTmuxLinkedViewPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F00DEC0DE0F00DEC0DE000E /* RemoteTmuxLinkedViewPlan.swift */; }; + 0F00DEC0DE0F00DEC0DE000F /* RemoteTmuxLinkedViewPlanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F00DEC0DE0F00DEC0DE0010 /* RemoteTmuxLinkedViewPlanTests.swift */; }; 0F00DEC0DE0F00DEC0DE0009 /* RemoteTmuxLinkedWorkspaceModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F00DEC0DE0F00DEC0DE000A /* RemoteTmuxLinkedWorkspaceModel.swift */; }; 0F00DEC0DE0F00DEC0DE000B /* RemoteTmuxLinkedWorkspaceModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F00DEC0DE0F00DEC0DE000C /* RemoteTmuxLinkedWorkspaceModelTests.swift */; }; 6732BEEF6732BEEF6732B002 /* RemoteTmuxMasterReadinessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6732BEEF6732BEEF6732B001 /* RemoteTmuxMasterReadinessTests.swift */; }; @@ -2040,6 +2042,8 @@ 3A1B00F615F44EA3BA234483 /* RemoteTmuxLayoutContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxLayoutContainer.swift; sourceTree = ""; }; 4B7A830D507D913809EB02B0 /* RemoteTmuxLayoutContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxLayoutContent.swift; sourceTree = ""; }; 8A5717357D55291686F99625 /* RemoteTmuxLayoutNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxLayoutNode.swift; sourceTree = ""; }; + 0F00DEC0DE0F00DEC0DE000E /* RemoteTmuxLinkedViewPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxLinkedViewPlan.swift; sourceTree = ""; }; + 0F00DEC0DE0F00DEC0DE0010 /* RemoteTmuxLinkedViewPlanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxLinkedViewPlanTests.swift; sourceTree = ""; }; 0F00DEC0DE0F00DEC0DE000A /* RemoteTmuxLinkedWorkspaceModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxLinkedWorkspaceModel.swift; sourceTree = ""; }; 0F00DEC0DE0F00DEC0DE000C /* RemoteTmuxLinkedWorkspaceModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxLinkedWorkspaceModelTests.swift; sourceTree = ""; }; 6732BEEF6732BEEF6732B001 /* RemoteTmuxMasterReadinessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMasterReadinessTests.swift; sourceTree = ""; }; @@ -3338,6 +3342,7 @@ 0F00DEC0DE0F00DEC0DE0002 /* RemoteTmuxViewReconciler.swift */, 0F00DEC0DE0F00DEC0DE0006 /* RemoteTmuxViewSession.swift */, 0F00DEC0DE0F00DEC0DE000A /* RemoteTmuxLinkedWorkspaceModel.swift */, + 0F00DEC0DE0F00DEC0DE000E /* RemoteTmuxLinkedViewPlan.swift */, 541716FF0D90CB510EE3004A /* RemoteTmuxSession.swift */, 71D411EFBE33FE85901B9E6A /* RemoteTmuxCommandResult.swift */, 02B8A9858C9804AC9C37D7B4 /* RemoteTmuxHost.swift */, @@ -3735,6 +3740,7 @@ 0F00DEC0DE0F00DEC0DE0004 /* RemoteTmuxViewReconcilerTests.swift */, 0F00DEC0DE0F00DEC0DE0008 /* RemoteTmuxViewSessionTests.swift */, 0F00DEC0DE0F00DEC0DE000C /* RemoteTmuxLinkedWorkspaceModelTests.swift */, + 0F00DEC0DE0F00DEC0DE0010 /* RemoteTmuxLinkedViewPlanTests.swift */, 0C7D0CDD0C7D0CDD0C7D0001 /* RemoteTmuxNewWindowCwdTests.swift */, 5F5553CA5553CA5553CA0002 /* RemoteTmuxCapabilitiesTests.swift */, C57570010000000000000001 /* RightSidebarPanelViewTestSupport.swift */, @@ -4710,6 +4716,7 @@ 00B223273DE0DBED98437C7A /* RemoteTmuxLayoutContainer.swift in Sources */, B12E809B546C64DC359C54E3 /* RemoteTmuxLayoutContent.swift in Sources */, E54DED0FDC24F51BF1470A1A /* RemoteTmuxLayoutNode.swift in Sources */, + 0F00DEC0DE0F00DEC0DE000D /* RemoteTmuxLinkedViewPlan.swift in Sources */, 0F00DEC0DE0F00DEC0DE0009 /* RemoteTmuxLinkedWorkspaceModel.swift in Sources */, F11ED7AB0004000400040004 /* RemoteTmuxMirrorNewTabPlacement.swift in Sources */, F861B8D7C62A0BCB252A523A /* RemoteTmuxMirrorTabActivity.swift in Sources */, @@ -5301,6 +5308,7 @@ 5F5553CA5553CA5553CA0001 /* RemoteTmuxCapabilitiesTests.swift in Sources */, B2FDE62450514C4C27FBD8F1 /* RemoteTmuxControlParserTests.swift in Sources */, B0555301B0555301B0555301 /* RemoteTmuxControlStreamParserBudgetTests.swift in Sources */, + 0F00DEC0DE0F00DEC0DE000F /* RemoteTmuxLinkedViewPlanTests.swift in Sources */, 0F00DEC0DE0F00DEC0DE000B /* RemoteTmuxLinkedWorkspaceModelTests.swift in Sources */, 6732BEEF6732BEEF6732B002 /* RemoteTmuxMasterReadinessTests.swift in Sources */, F11ED7AB0001000100010001 /* RemoteTmuxMirrorNewTabPlacementTests.swift in Sources */, diff --git a/cmuxTests/RemoteTmuxLinkedViewPlanTests.swift b/cmuxTests/RemoteTmuxLinkedViewPlanTests.swift new file mode 100644 index 000000000000..564ab544e546 --- /dev/null +++ b/cmuxTests/RemoteTmuxLinkedViewPlanTests.swift @@ -0,0 +1,111 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Tests the composed linked-view planner: view lifecycle (create/stale-kill), +/// reconcile actions, and workspace grouping in one decision. +@Suite struct RemoteTmuxLinkedViewPlanTests { + private typealias P = RemoteTmuxLinkedViewPlan + private typealias SRow = RemoteTmuxViewSession.SessionRow + private typealias WRow = RemoteTmuxLinkedWorkspaceModel.WindowRow + private let view = RemoteTmuxViewSession(ownerId: "o1") // name: cmux-view-o1 + + @Test func firstRunCreatesViewLinksEverythingAndGroups() { + // No view yet; two real sessions A,B exist. + let snap = P.Snapshot( + sessions: [ + SRow(name: "A", isView: false, owner: "", version: nil), + SRow(name: "B", isView: false, owner: "", version: nil), + ], + windows: [ + WRow(sessionName: "A", windowId: "@1", windowIndex: 0), + WRow(sessionName: "A", windowId: "@2", windowIndex: 1), + WRow(sessionName: "B", windowId: "@3", windowIndex: 0), + ], + cmuxOwnedWindowIds: [], + placeholderWindowId: nil) + let plan = P.plan(view: view, snapshot: snap) + #expect(plan.needsViewCreate) + #expect(plan.staleViewsToKill.isEmpty) + #expect(plan.reconcileActions == [ + .link(windowId: "@1"), .link(windowId: "@2"), .link(windowId: "@3"), + ]) + #expect(plan.workspaces == [ + .init(sessionName: "A", windowIds: ["@1", "@2"]), + .init(sessionName: "B", windowIds: ["@3"]), + ]) + } + + @Test func steadyStateNoActionsWhenAllLinked() { + let snap = P.Snapshot( + sessions: [ + SRow(name: "cmux-view-o1", isView: true, owner: "o1", version: 1), + SRow(name: "A", isView: false, owner: "", version: nil), + ], + windows: [ + WRow(sessionName: "A", windowId: "@1", windowIndex: 0), + WRow(sessionName: "cmux-view-o1", windowId: "@0", windowIndex: 0), + WRow(sessionName: "cmux-view-o1", windowId: "@1", windowIndex: 1), + ], + cmuxOwnedWindowIds: ["@1"], + placeholderWindowId: "@0") + let plan = P.plan(view: view, snapshot: snap) + #expect(!plan.needsViewCreate) + #expect(plan.reconcileActions.isEmpty) + #expect(plan.workspaces == [.init(sessionName: "A", windowIds: ["@1"])]) + } + + @Test func newWorkspaceAddsLinkForNewSessionWindow() { + // A new-session W2 (@9) appeared; it should be linked, becoming a workspace. + let snap = P.Snapshot( + sessions: [SRow(name: "cmux-view-o1", isView: true, owner: "o1", version: 1)], + windows: [ + WRow(sessionName: "A", windowId: "@1", windowIndex: 0), + WRow(sessionName: "W2", windowId: "@9", windowIndex: 0), + WRow(sessionName: "cmux-view-o1", windowId: "@0", windowIndex: 0), + WRow(sessionName: "cmux-view-o1", windowId: "@1", windowIndex: 1), + ], + cmuxOwnedWindowIds: ["@1"], + placeholderWindowId: "@0") + let plan = P.plan(view: view, snapshot: snap) + #expect(plan.reconcileActions == [.link(windowId: "@9")]) + #expect(plan.workspaces.map(\.sessionName) == ["A", "W2"]) + } + + @Test func closedSessionUnlinksOwnedWindow() { + // B's window @3 is in the view + owned, but B no longer has a home row → unlink. + let snap = P.Snapshot( + sessions: [SRow(name: "cmux-view-o1", isView: true, owner: "o1", version: 1)], + windows: [ + WRow(sessionName: "A", windowId: "@1", windowIndex: 0), + WRow(sessionName: "cmux-view-o1", windowId: "@0", windowIndex: 0), + WRow(sessionName: "cmux-view-o1", windowId: "@1", windowIndex: 1), + WRow(sessionName: "cmux-view-o1", windowId: "@3", windowIndex: 2), // orphan in view + ], + cmuxOwnedWindowIds: ["@1", "@3"], + placeholderWindowId: "@0") + let plan = P.plan(view: view, snapshot: snap) + #expect(plan.reconcileActions == [.unlinkFromView(windowId: "@3")]) + #expect(plan.workspaces == [.init(sessionName: "A", windowIds: ["@1"])]) + } + + @Test func staleOwnViewIsCollectedButForeignViewUntouched() { + let snap = P.Snapshot( + sessions: [ + SRow(name: "cmux-view-o1", isView: true, owner: "o1", version: 1), // current + SRow(name: "cmux-view-o1-old", isView: true, owner: "o1", version: 0), // our stale + SRow(name: "cmux-view-o2", isView: true, owner: "o2", version: 1), // foreign + ], + windows: [WRow(sessionName: "cmux-view-o1", windowId: "@0", windowIndex: 0)], + cmuxOwnedWindowIds: [], + placeholderWindowId: "@0") + let plan = P.plan(view: view, snapshot: snap) + #expect(plan.staleViewsToKill == ["cmux-view-o1-old"]) + #expect(!plan.needsViewCreate) + } +} From 33ab6975891e684bd0f5e7119185c0f9a9a1d3dc Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 20:40:09 -0700 Subject: [PATCH 10/38] remote-tmux: harden linked-view core against adversarial review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Sources/RemoteTmuxLinkedViewPlan.swift | 15 ++- Sources/RemoteTmuxLinkedWorkspaceModel.swift | 84 ++++++++----- Sources/RemoteTmuxViewReconciler.swift | 11 ++ Sources/RemoteTmuxViewSession.swift | 73 ++++++++--- cmuxTests/RemoteTmuxLinkedViewPlanTests.swift | 46 +++---- .../RemoteTmuxLinkedWorkspaceModelTests.swift | 86 +++++++++---- cmuxTests/RemoteTmuxViewReconcilerTests.swift | 31 ++++- cmuxTests/RemoteTmuxViewSessionTests.swift | 118 ++++++++++-------- 8 files changed, 308 insertions(+), 156 deletions(-) diff --git a/Sources/RemoteTmuxLinkedViewPlan.swift b/Sources/RemoteTmuxLinkedViewPlan.swift index 79f437aeefa0..d4cf489a6000 100644 --- a/Sources/RemoteTmuxLinkedViewPlan.swift +++ b/Sources/RemoteTmuxLinkedViewPlan.swift @@ -43,11 +43,20 @@ enum RemoteTmuxLinkedViewPlan { let needsCreate = !snapshot.sessions.contains { view.isOwnView($0) } let stale = snapshot.sessions.filter { view.isOwnStaleView($0) }.map(\.name) + // Exclude EVERY view session (ours, stale, and any foreign cmux install's) + // from workspace grouping and from the desired-link set — never surface or + // link another owner's hidden state. + let excluded = Set(snapshot.sessions + .filter { RemoteTmuxViewSession.isAnyView($0) } + .map(\.name)) + // Desired links = every non-view window with a real home session. let desired = RemoteTmuxLinkedWorkspaceModel.desiredLinkedWindowIds( - rows: snapshot.windows, viewSessionName: viewName) + rows: snapshot.windows, excludedSessions: excluded) - // Actual = window ids currently inside the view session. + // Actual = window ids currently inside OUR view session (by exact name; the + // owned-view check above guarantees this name is ours, not a foreign/real + // session that merely collides — names are collision-resistant). let actual = Set(snapshot.windows .filter { $0.sessionName == viewName } .map(\.windowId)) @@ -59,7 +68,7 @@ enum RemoteTmuxLinkedViewPlan { cmuxOwnedWindowIds: snapshot.cmuxOwnedWindowIds) let workspaces = RemoteTmuxLinkedWorkspaceModel.workspaces( - rows: snapshot.windows, viewSessionName: viewName) + rows: snapshot.windows, excludedSessions: excluded) return Plan( needsViewCreate: needsCreate, diff --git a/Sources/RemoteTmuxLinkedWorkspaceModel.swift b/Sources/RemoteTmuxLinkedWorkspaceModel.swift index 0b231775d56b..1cd5b1d0583a 100644 --- a/Sources/RemoteTmuxLinkedWorkspaceModel.swift +++ b/Sources/RemoteTmuxLinkedWorkspaceModel.swift @@ -9,10 +9,17 @@ import Foundation /// workspace; that session's windows are its tabs, in tmux index order. /// /// This type turns `list-windows -a` rows into that grouping. It is pure (no tmux) -/// so the regrouping policy is unit-testable and deterministic. A window linked -/// into the view appears under BOTH its home session and the view session; it is -/// always attributed to its home (non-view) session, and the view session itself -/// is never surfaced as a workspace. +/// so the regrouping policy is unit-testable and deterministic. +/// +/// Two correctness properties enforced here (hardened after adversarial review): +/// - **All view sessions are excluded**, not just our own — a *foreign* cmux +/// install's `cmux-view-*` session must never be surfaced as a workspace nor +/// have its windows treated as desired. Callers pass the full set of view +/// session names (from `RemoteTmuxViewSession.isAnyView`). +/// - **Each window has exactly one deterministic home.** A window linked into +/// several real sessions is attributed to the lexicographically smallest +/// non-excluded session containing it, so it appears in exactly one workspace +/// and `%output` routing is stable regardless of tmux row order. enum RemoteTmuxLinkedWorkspaceModel { /// One `list-windows -a -F` row. `sessionName` is the session this row is /// listed under (a linked window yields one row per session it's in). @@ -22,16 +29,16 @@ enum RemoteTmuxLinkedWorkspaceModel { let windowIndex: Int } - /// Recommended format for `list-windows -a -F` (session-unit-separated; name last - /// is not needed since session_name has no separator char here, but we keep a - /// non-printable separator so window names can't corrupt parsing). - static let listFormat = "#{session_name}\u{1f}#{window_id}\u{1f}#{window_index}" + /// Recommended format for `list-windows -a -F`. The controlled fields + /// (`window_id` = `@N`, `window_index` = int) come first; the free-text + /// `session_name` is LAST so a separator inside a name can't drop the row. + static let listFormat = "#{window_id}\u{1f}#{window_index}\u{1f}#{session_name}" static func parseRows(_ output: String) -> [WindowRow] { output.split(separator: "\n", omittingEmptySubsequences: true).compactMap { line in - let f = line.components(separatedBy: "\u{1f}") - guard f.count == 3, let idx = Int(f[2]) else { return nil } - return WindowRow(sessionName: f[0], windowId: f[1], windowIndex: idx) + let f = line.split(separator: "\u{1f}", maxSplits: 2, omittingEmptySubsequences: false) + guard f.count == 3, let idx = Int(f[1]) else { return nil } + return WindowRow(sessionName: String(f[2]), windowId: String(f[0]), windowIndex: idx) } } @@ -41,19 +48,40 @@ enum RemoteTmuxLinkedWorkspaceModel { let windowIds: [String] // ordered by window index } - /// Groups rows into workspaces, excluding the view session entirely. + /// The deterministic home session for a window: the lexicographically smallest + /// non-excluded session that contains it, or `nil` if it only exists in + /// excluded (view) sessions. Used both to route `%output` and to assign a + /// window to exactly one workspace. + static func homeSession( + forWindowId id: String, + rows: [WindowRow], + excludedSessions: Set + ) -> String? { + rows.lazy + .filter { $0.windowId == id && !excludedSessions.contains($0.sessionName) } + .map(\.sessionName) + .min() + } + + /// Groups rows into workspaces, excluding the given (view) sessions and + /// assigning each window to exactly one home. /// - /// - Parameters: - /// - rows: every `list-windows -a` row for the host's tmux server. - /// - viewSessionName: the hidden view session to exclude from workspaces. /// - Returns: workspaces sorted by session name; each workspace's window ids - /// sorted by (windowIndex, windowId) for a stable tab order. A window that - /// only exists in the view (no home session row) is dropped — cmux only ever - /// shows windows that have a real home session. - static func workspaces(rows: [WindowRow], viewSessionName: String) -> [Workspace] { - // Collect, per home session, its (index, id) windows. Exclude the view. + /// sorted by (windowIndex, windowId) for a stable tab order. + static func workspaces(rows: [WindowRow], excludedSessions: Set) -> [Workspace] { + // Resolve each window's single home once (id → home session). + var homeByWindow: [String: String] = [:] + for r in rows where !excludedSessions.contains(r.sessionName) { + if homeByWindow[r.windowId] == nil { + homeByWindow[r.windowId] = homeSession( + forWindowId: r.windowId, rows: rows, excludedSessions: excludedSessions) + } + } + // Collect each home session's windows, taking the index from the row that + // belongs to that home session (not a different session's linked copy). var bySession: [String: [(idx: Int, id: String)]] = [:] - for r in rows where r.sessionName != viewSessionName { + for r in rows where !excludedSessions.contains(r.sessionName) + && homeByWindow[r.windowId] == r.sessionName { bySession[r.sessionName, default: []].append((r.windowIndex, r.windowId)) } return bySession.keys.sorted().map { name in @@ -65,15 +93,9 @@ enum RemoteTmuxLinkedWorkspaceModel { } /// The set of window ids that SHOULD be linked into the view = every window - /// that has a real home session (i.e. all non-view windows). This is the - /// `desiredWindowIds` fed to ``RemoteTmuxViewReconciler``. - static func desiredLinkedWindowIds(rows: [WindowRow], viewSessionName: String) -> Set { - Set(rows.filter { $0.sessionName != viewSessionName }.map(\.windowId)) - } - - /// home session for a given window id (its non-view session), or nil if the - /// window has no home (view-only) — used to route `%output` to a workspace. - static func homeSession(forWindowId id: String, rows: [WindowRow], viewSessionName: String) -> String? { - rows.first { $0.windowId == id && $0.sessionName != viewSessionName }?.sessionName + /// that has a real (non-excluded) home session. Fed to the reconciler as + /// `desiredWindowIds`. + static func desiredLinkedWindowIds(rows: [WindowRow], excludedSessions: Set) -> Set { + Set(rows.filter { !excludedSessions.contains($0.sessionName) }.map(\.windowId)) } } diff --git a/Sources/RemoteTmuxViewReconciler.swift b/Sources/RemoteTmuxViewReconciler.swift index abb7406999a4..1b8e58ec4772 100644 --- a/Sources/RemoteTmuxViewReconciler.swift +++ b/Sources/RemoteTmuxViewReconciler.swift @@ -73,6 +73,17 @@ enum RemoteTmuxViewReconciler { unlinkable.remove(placeholderWindowId) } + // Safety net: never empty the view. Unlinking the view's last window kills + // the view session (and churns the whole mirror). If nothing would link + // this pass and the unlinks would remove every remaining window, keep the + // last one linked until a placeholder/new window exists. The live layer + // also guarantees a placeholder, but the pure policy must not depend on + // that being accurate (a nil/stale placeholder must still be safe). + let survivors = actualWindowIds.subtracting(unlinkable) + if toLink.isEmpty, survivors.isEmpty, let keep = unlinkable.min() { + unlinkable.remove(keep) + } + return toLink.sorted().map { Action.link(windowId: $0) } + unlinkable.sorted().map { Action.unlinkFromView(windowId: $0) } } diff --git a/Sources/RemoteTmuxViewSession.swift b/Sources/RemoteTmuxViewSession.swift index 86d66427f80b..8edddc76ec08 100644 --- a/Sources/RemoteTmuxViewSession.swift +++ b/Sources/RemoteTmuxViewSession.swift @@ -34,10 +34,16 @@ struct RemoteTmuxViewSession: Equatable { /// human running `tmux ls` can tell what these are. static let namePrefix = "cmux-view-" - /// The deterministic view session name for this owner. Sanitized to tmux-safe - /// characters (tmux session names disallow `.`, `:` and whitespace). + /// The deterministic view session name for this owner. + /// + /// tmux session names disallow `.`, `:` and whitespace, so the owner id is + /// mapped to a safe token. The mapping is **collision-resistant**: distinct + /// owner ids never produce the same session name (a naive char-replace would + /// collapse `a.b` and `a:b` to the same name and let two installs fight over + /// one view). We append a short FNV-1a/64 hash of the *raw* owner id so the + /// readable part stays human-friendly while uniqueness is preserved. var sessionName: String { - Self.namePrefix + Self.sanitizeOwner(ownerId) + Self.namePrefix + Self.sanitizeOwner(ownerId) + "-" + Self.ownerHash(ownerId) } /// tmux commands (control-mode safe, one per line) that create the view @@ -57,9 +63,11 @@ struct RemoteTmuxViewSession: Equatable { } /// Format string for `list-sessions -F` that surfaces enough to classify each - /// session: name + the three ownership options. + /// session: the three (controlled, separator-free) ownership options first, + /// then the free-text `session_name` LAST so a separator inside a name can't + /// shift fields or drop the row. static let listFormat = - "#{session_name}\u{1f}#{\(optView)}\u{1f}#{\(optOwner)}\u{1f}#{\(optVersion)}" + "#{\(optView)}\u{1f}#{\(optOwner)}\u{1f}#{\(optVersion)}\u{1f}#{session_name}" /// One parsed `list-sessions` row (from ``listFormat``). struct SessionRow: Equatable { @@ -71,42 +79,57 @@ struct RemoteTmuxViewSession: Equatable { static func parseRows(_ output: String) -> [SessionRow] { output.split(separator: "\n", omittingEmptySubsequences: true).compactMap { line in - let f = line.components(separatedBy: "\u{1f}") + // maxSplits=3 keeps the trailing (free-text) name intact even if it + // contains the unit separator. + let f = line.split(separator: "\u{1f}", maxSplits: 3, omittingEmptySubsequences: false) guard f.count == 4 else { return nil } return SessionRow( - name: f[0], - isView: f[1] == "1", - owner: f[2], - version: Int(f[3]) + name: String(f[3]), + isView: f[0] == "1", + owner: String(f[1]), + version: Int(f[2]) ) } } // MARK: - Classification (the safety surface) - /// This row is *our* view: tagged as a view, owned by us, current format. - /// Only a row matching this is ever reattached/reused. + /// Any session that is one of cmux's hidden view sessions — tagged with the + /// `@cmux_view` option AND carrying the reserved name prefix. Both are required + /// so a real user session that merely inherited/copied the option (tmux user + /// options can be set on anything) is never mistaken for a view. The set of + /// these is what the workspace model excludes, so a *foreign* owner's view is + /// never surfaced as a workspace nor linked. + static func isAnyView(_ row: SessionRow) -> Bool { + row.isView && row.name.hasPrefix(namePrefix) + } + + /// This row is *our* view: a cmux view (tagged + prefixed), owned by us, the + /// current format, with the exact name we use. Only a match is reattached. func isOwnView(_ row: SessionRow) -> Bool { - row.isView && row.owner == ownerId && row.version == Self.formatVersion + Self.isAnyView(row) && row.owner == ownerId && row.version == Self.formatVersion && row.name == sessionName } - /// A stale view owned by *us* that we may garbage-collect: tagged as a view, - /// our owner, but a different name/version than the one we use now (e.g. an - /// old format left by a previous build). Never includes other owners' views. + /// A stale view owned by *us* that we may garbage-collect: a cmux view + /// (tagged + prefixed), our owner, but a different name/version than the one we + /// use now (e.g. an old format from a previous build). The prefix requirement + /// means a non-view session can never be collected even if its options were + /// copied; the owner requirement means another install's view is never ours. func isOwnStaleView(_ row: SessionRow) -> Bool { - row.isView && row.owner == ownerId && !isOwnView(row) + Self.isAnyView(row) && row.owner == ownerId && !isOwnView(row) } /// A view owned by a *different* cmux install. Must never be touched. static func isForeignView(_ row: SessionRow, ownerId: String) -> Bool { - row.isView && !row.owner.isEmpty && row.owner != ownerId + isAnyView(row) && !row.owner.isEmpty && row.owner != ownerId } // MARK: - Helpers /// tmux session names cannot contain `.`, `:`, or whitespace; map them out so - /// an arbitrary owner id yields a valid, stable session name. + /// an arbitrary owner id yields a valid, readable session-name fragment. Lossy + /// on purpose (readability); uniqueness is restored by ``ownerHash(_:)``. static func sanitizeOwner(_ owner: String) -> String { String(owner.unicodeScalars.map { s -> Character in if s == "." || s == ":" || CharacterSet.whitespaces.contains(s) { return "-" } @@ -114,6 +137,18 @@ struct RemoteTmuxViewSession: Equatable { }) } + /// A short, stable, collision-resistant hex digest of the raw owner id + /// (FNV-1a/64 → 16 hex chars), so two distinct owners can never share a view + /// session name even if their sanitized fragments collide. + static func ownerHash(_ owner: String) -> String { + var hash: UInt64 = 0xcbf2_9ce4_8422_2325 + for byte in owner.utf8 { + hash ^= UInt64(byte) + hash = hash &* 0x0000_0100_0000_01b3 + } + return String(format: "%016llx", hash) + } + /// Single-quote for a control-mode command argument. private static func q(_ v: String) -> String { RemoteTmuxHost.shellSingleQuoted(v) } } diff --git a/cmuxTests/RemoteTmuxLinkedViewPlanTests.swift b/cmuxTests/RemoteTmuxLinkedViewPlanTests.swift index 564ab544e546..9ce9e60ccf71 100644 --- a/cmuxTests/RemoteTmuxLinkedViewPlanTests.swift +++ b/cmuxTests/RemoteTmuxLinkedViewPlanTests.swift @@ -13,10 +13,11 @@ import Testing private typealias P = RemoteTmuxLinkedViewPlan private typealias SRow = RemoteTmuxViewSession.SessionRow private typealias WRow = RemoteTmuxLinkedWorkspaceModel.WindowRow - private let view = RemoteTmuxViewSession(ownerId: "o1") // name: cmux-view-o1 + private let view = RemoteTmuxViewSession(ownerId: "o1") + private var vname: String { view.sessionName } + private func ownView() -> SRow { SRow(name: vname, isView: true, owner: "o1", version: 1) } @Test func firstRunCreatesViewLinksEverythingAndGroups() { - // No view yet; two real sessions A,B exist. let snap = P.Snapshot( sessions: [ SRow(name: "A", isView: false, owner: "", version: nil), @@ -43,14 +44,11 @@ import Testing @Test func steadyStateNoActionsWhenAllLinked() { let snap = P.Snapshot( - sessions: [ - SRow(name: "cmux-view-o1", isView: true, owner: "o1", version: 1), - SRow(name: "A", isView: false, owner: "", version: nil), - ], + sessions: [ownView(), SRow(name: "A", isView: false, owner: "", version: nil)], windows: [ WRow(sessionName: "A", windowId: "@1", windowIndex: 0), - WRow(sessionName: "cmux-view-o1", windowId: "@0", windowIndex: 0), - WRow(sessionName: "cmux-view-o1", windowId: "@1", windowIndex: 1), + WRow(sessionName: vname, windowId: "@0", windowIndex: 0), + WRow(sessionName: vname, windowId: "@1", windowIndex: 1), ], cmuxOwnedWindowIds: ["@1"], placeholderWindowId: "@0") @@ -61,14 +59,13 @@ import Testing } @Test func newWorkspaceAddsLinkForNewSessionWindow() { - // A new-session W2 (@9) appeared; it should be linked, becoming a workspace. let snap = P.Snapshot( - sessions: [SRow(name: "cmux-view-o1", isView: true, owner: "o1", version: 1)], + sessions: [ownView()], windows: [ WRow(sessionName: "A", windowId: "@1", windowIndex: 0), WRow(sessionName: "W2", windowId: "@9", windowIndex: 0), - WRow(sessionName: "cmux-view-o1", windowId: "@0", windowIndex: 0), - WRow(sessionName: "cmux-view-o1", windowId: "@1", windowIndex: 1), + WRow(sessionName: vname, windowId: "@0", windowIndex: 0), + WRow(sessionName: vname, windowId: "@1", windowIndex: 1), ], cmuxOwnedWindowIds: ["@1"], placeholderWindowId: "@0") @@ -78,14 +75,13 @@ import Testing } @Test func closedSessionUnlinksOwnedWindow() { - // B's window @3 is in the view + owned, but B no longer has a home row → unlink. let snap = P.Snapshot( - sessions: [SRow(name: "cmux-view-o1", isView: true, owner: "o1", version: 1)], + sessions: [ownView()], windows: [ WRow(sessionName: "A", windowId: "@1", windowIndex: 0), - WRow(sessionName: "cmux-view-o1", windowId: "@0", windowIndex: 0), - WRow(sessionName: "cmux-view-o1", windowId: "@1", windowIndex: 1), - WRow(sessionName: "cmux-view-o1", windowId: "@3", windowIndex: 2), // orphan in view + WRow(sessionName: vname, windowId: "@0", windowIndex: 0), + WRow(sessionName: vname, windowId: "@1", windowIndex: 1), + WRow(sessionName: vname, windowId: "@3", windowIndex: 2), // orphan in view ], cmuxOwnedWindowIds: ["@1", "@3"], placeholderWindowId: "@0") @@ -94,18 +90,24 @@ import Testing #expect(plan.workspaces == [.init(sessionName: "A", windowIds: ["@1"])]) } - @Test func staleOwnViewIsCollectedButForeignViewUntouched() { + @Test func staleOwnViewCollectedForeignViewNeverTouchedNorSurfaced() { let snap = P.Snapshot( sessions: [ - SRow(name: "cmux-view-o1", isView: true, owner: "o1", version: 1), // current + ownView(), SRow(name: "cmux-view-o1-old", isView: true, owner: "o1", version: 0), // our stale - SRow(name: "cmux-view-o2", isView: true, owner: "o2", version: 1), // foreign + SRow(name: "cmux-view-bob", isView: true, owner: "bob", version: 1), // foreign + ], + windows: [ + WRow(sessionName: vname, windowId: "@0", windowIndex: 0), + WRow(sessionName: "cmux-view-bob", windowId: "@8", windowIndex: 0), // foreign window ], - windows: [WRow(sessionName: "cmux-view-o1", windowId: "@0", windowIndex: 0)], cmuxOwnedWindowIds: [], placeholderWindowId: "@0") let plan = P.plan(view: view, snapshot: snap) - #expect(plan.staleViewsToKill == ["cmux-view-o1-old"]) + #expect(plan.staleViewsToKill == ["cmux-view-o1-old"]) // never includes foreign #expect(!plan.needsViewCreate) + // foreign view's window @8 must NOT be linked, and the foreign view is not a workspace + #expect(!plan.reconcileActions.contains(.link(windowId: "@8"))) + #expect(plan.workspaces.isEmpty) } } diff --git a/cmuxTests/RemoteTmuxLinkedWorkspaceModelTests.swift b/cmuxTests/RemoteTmuxLinkedWorkspaceModelTests.swift index 0c9040edd113..7c697e4a47e3 100644 --- a/cmuxTests/RemoteTmuxLinkedWorkspaceModelTests.swift +++ b/cmuxTests/RemoteTmuxLinkedWorkspaceModelTests.swift @@ -8,68 +8,100 @@ import Testing #endif /// Tests regrouping the linked-view's flat window set back into per-session -/// workspaces: view excluded, home-session attribution, stable tab order. +/// workspaces: all view sessions excluded, deterministic single home, stable tab +/// order, robust parsing. @Suite struct RemoteTmuxLinkedWorkspaceModelTests { private typealias M = RemoteTmuxLinkedWorkspaceModel private typealias Row = RemoteTmuxLinkedWorkspaceModel.WindowRow - // A realistic list-windows -a: sessions A (2 windows) and B (1), all also - // linked into the view session (so each appears under "view" too). - private let rows: [Row] = [ + private let view = "cmux-view-o1" + private lazy var rows: [Row] = [ Row(sessionName: "A", windowId: "@1", windowIndex: 0), Row(sessionName: "A", windowId: "@2", windowIndex: 1), Row(sessionName: "B", windowId: "@3", windowIndex: 0), - Row(sessionName: "cmux-view-o1", windowId: "@0", windowIndex: 0), // placeholder - Row(sessionName: "cmux-view-o1", windowId: "@1", windowIndex: 1), - Row(sessionName: "cmux-view-o1", windowId: "@2", windowIndex: 2), - Row(sessionName: "cmux-view-o1", windowId: "@3", windowIndex: 3), + Row(sessionName: view, windowId: "@0", windowIndex: 0), // placeholder + Row(sessionName: view, windowId: "@1", windowIndex: 1), + Row(sessionName: view, windowId: "@2", windowIndex: 2), + Row(sessionName: view, windowId: "@3", windowIndex: 3), ] @Test func parsesRows() { - let out = "A\u{1f}@1\u{1f}0\nB\u{1f}@3\u{1f}0" + // format is window_id ⋮ window_index ⋮ session_name + let out = "@1\u{1f}0\u{1f}A\n@3\u{1f}0\u{1f}B" #expect(M.parseRows(out) == [ Row(sessionName: "A", windowId: "@1", windowIndex: 0), Row(sessionName: "B", windowId: "@3", windowIndex: 0), ]) } - @Test func groupsByHomeSessionExcludingView() { - let ws = M.workspaces(rows: rows, viewSessionName: "cmux-view-o1") + @Test func parsingKeepsSessionNamesContainingSeparator() { + // A name containing the unit separator must not drop or corrupt the row + // (free-text field is last + maxSplits). + let weird = "we\u{1f}ird" + let out = "@5\u{1f}2\u{1f}\(weird)" + #expect(M.parseRows(out) == [Row(sessionName: weird, windowId: "@5", windowIndex: 2)]) + } + + @Test mutating func groupsByHomeSessionExcludingView() { + let ws = M.workspaces(rows: rows, excludedSessions: [view]) #expect(ws == [ .init(sessionName: "A", windowIds: ["@1", "@2"]), .init(sessionName: "B", windowIds: ["@3"]), ]) - // the view session is never a workspace, and its placeholder @0 never appears - #expect(!ws.contains { $0.sessionName.hasPrefix("cmux-view") }) - #expect(!ws.flatMap(\.windowIds).contains("@0")) + #expect(!ws.flatMap(\.windowIds).contains("@0")) // placeholder excluded + } + + @Test func excludesForeignViewSessionsToo() { + // A foreign cmux install's view must be excluded as well — never a workspace. + let r = [ + Row(sessionName: "A", windowId: "@1", windowIndex: 0), + Row(sessionName: "cmux-view-bob", windowId: "@8", windowIndex: 0), + ] + let ws = M.workspaces(rows: r, excludedSessions: ["cmux-view-o1", "cmux-view-bob"]) + #expect(ws == [.init(sessionName: "A", windowIds: ["@1"])]) + #expect(!M.desiredLinkedWindowIds(rows: r, excludedSessions: ["cmux-view-o1", "cmux-view-bob"]).contains("@8")) + } + + @Test func windowInMultipleSessionsHasDeterministicSingleHome() { + // @12 is linked into both B and A (a user cross-link). It must land in + // exactly one workspace, the lexicographically smallest home (A). + let r = [ + Row(sessionName: "B", windowId: "@12", windowIndex: 0), + Row(sessionName: "A", windowId: "@12", windowIndex: 5), + ] + let ws = M.workspaces(rows: r, excludedSessions: []) + let appearances = ws.flatMap(\.windowIds).filter { $0 == "@12" } + #expect(appearances == ["@12"]) // exactly once + #expect(M.homeSession(forWindowId: "@12", rows: r, excludedSessions: []) == "A") + #expect(ws.first { $0.sessionName == "A" }?.windowIds == ["@12"]) // in A, not B + // B's only window is homed to A, so B has no exclusively-owned window and + // does not appear as a workspace (a tmux session always has >=1 window, so + // this only arises from a manual cross-link). + #expect(ws.first { $0.sessionName == "B" } == nil) } @Test func tabOrderFollowsWindowIndex() { - // Same session, windows given out of order → sorted by index. let r = [ Row(sessionName: "A", windowId: "@7", windowIndex: 5), Row(sessionName: "A", windowId: "@4", windowIndex: 1), Row(sessionName: "A", windowId: "@9", windowIndex: 3), ] - let ws = M.workspaces(rows: r, viewSessionName: "view") - #expect(ws == [.init(sessionName: "A", windowIds: ["@4", "@9", "@7"])]) + #expect(M.workspaces(rows: r, excludedSessions: []) == [.init(sessionName: "A", windowIds: ["@4", "@9", "@7"])]) } - @Test func desiredLinkedWindowsAreAllNonViewWindows() { - #expect(M.desiredLinkedWindowIds(rows: rows, viewSessionName: "cmux-view-o1") - == ["@1", "@2", "@3"]) + @Test mutating func desiredLinkedWindowsAreAllNonViewWindows() { + #expect(M.desiredLinkedWindowIds(rows: rows, excludedSessions: [view]) == ["@1", "@2", "@3"]) } - @Test func homeSessionRoutesOutputToWorkspace() { - #expect(M.homeSession(forWindowId: "@2", rows: rows, viewSessionName: "cmux-view-o1") == "A") - #expect(M.homeSession(forWindowId: "@3", rows: rows, viewSessionName: "cmux-view-o1") == "B") - // a window that exists only in the view has no home → nil (not shown) - #expect(M.homeSession(forWindowId: "@0", rows: rows, viewSessionName: "cmux-view-o1") == nil) + @Test mutating func homeSessionRoutesOutputToWorkspace() { + #expect(M.homeSession(forWindowId: "@2", rows: rows, excludedSessions: [view]) == "A") + #expect(M.homeSession(forWindowId: "@3", rows: rows, excludedSessions: [view]) == "B") + #expect(M.homeSession(forWindowId: "@0", rows: rows, excludedSessions: [view]) == nil) } @Test func emptyWhenOnlyViewExists() { let r = [Row(sessionName: "cmux-view-o1", windowId: "@0", windowIndex: 0)] - #expect(M.workspaces(rows: r, viewSessionName: "cmux-view-o1").isEmpty) - #expect(M.desiredLinkedWindowIds(rows: r, viewSessionName: "cmux-view-o1").isEmpty) + #expect(M.workspaces(rows: r, excludedSessions: ["cmux-view-o1"]).isEmpty) + #expect(M.desiredLinkedWindowIds(rows: r, excludedSessions: ["cmux-view-o1"]).isEmpty) } } diff --git a/cmuxTests/RemoteTmuxViewReconcilerTests.swift b/cmuxTests/RemoteTmuxViewReconcilerTests.swift index a5f3e1234b91..241ae7feda54 100644 --- a/cmuxTests/RemoteTmuxViewReconcilerTests.swift +++ b/cmuxTests/RemoteTmuxViewReconcilerTests.swift @@ -85,15 +85,40 @@ import Testing ]) } - @Test func handlesMissingPlaceholder() { - // After cmux closes the placeholder, an owned undesired window still unlinks. + @Test func neverEmptiesTheViewWhenPlaceholderIsMissing() { + // The only window is owned + undesired, and there's no placeholder. Unlinking + // it would kill the view session, so the safety net keeps the last window. let actions = R.actions( desiredWindowIds: [], actualWindowIds: ["@2"], placeholderWindowId: nil, cmuxOwnedWindowIds: ["@2"] ) - #expect(actions == [.unlinkFromView(windowId: "@2")]) + #expect(actions.isEmpty) + } + + @Test func unlinksUndesiredButKeepsLastSurvivorWithoutPlaceholder() { + // @2 and @3 owned+undesired, no placeholder → unlink one, keep one alive. + let actions = R.actions( + desiredWindowIds: [], + actualWindowIds: ["@2", "@3"], + placeholderWindowId: nil, + cmuxOwnedWindowIds: ["@2", "@3"] + ) + // keeps the min (@2), unlinks the rest + #expect(actions == [.unlinkFromView(windowId: "@3")]) + } + + @Test func unlinksAllUndesiredWhenPlaceholderSurvives() { + // With a (non-owned) placeholder present, the view never empties, so all + // owned+undesired windows can unlink. + let actions = R.actions( + desiredWindowIds: [], + actualWindowIds: ["@0", "@2", "@3"], + placeholderWindowId: "@0", + cmuxOwnedWindowIds: ["@2", "@3"] + ) + #expect(actions == [.unlinkFromView(windowId: "@2"), .unlinkFromView(windowId: "@3")]) } @Test func reconcileIsIdempotentAcrossRepeatedRuns() { diff --git a/cmuxTests/RemoteTmuxViewSessionTests.swift b/cmuxTests/RemoteTmuxViewSessionTests.swift index 5539d37fcff3..8d9875f3f910 100644 --- a/cmuxTests/RemoteTmuxViewSessionTests.swift +++ b/cmuxTests/RemoteTmuxViewSessionTests.swift @@ -7,82 +7,98 @@ import Testing @testable import cmux #endif -/// Tests view-session identity/ownership for the linked-view beta: naming, -/// option stamping, row parsing, and the classification predicates that keep cmux -/// from ever reusing/garbage-collecting a session it does not own. +/// Tests view-session identity/ownership for the linked-view beta: collision-safe +/// naming, option stamping, robust parsing, and the classification predicates that +/// keep cmux from reusing/garbage-collecting a session it does not own. @Suite struct RemoteTmuxViewSessionTests { - private func v(_ owner: String = "owner-ABC") -> RemoteTmuxViewSession { - RemoteTmuxViewSession(ownerId: owner) + private typealias VS = RemoteTmuxViewSession + private typealias Row = RemoteTmuxViewSession.SessionRow + private func v(_ owner: String = "o1") -> VS { VS(ownerId: owner) } + + // Build a list row string in the current format: view ⋮ owner ⋮ version ⋮ name + private func rowString(view: String, owner: String, version: String, name: String) -> String { + [view, owner, version, name].joined(separator: "\u{1f}") + } + + @Test func sessionNameIsPrefixedSanitizedAndHashed() { + let s = VS(ownerId: "ab.cd ef:gh") + #expect(s.sessionName.hasPrefix("cmux-view-ab-cd-ef-gh-")) // sanitized fragment + #expect(!s.sessionName.contains(".")) + #expect(!s.sessionName.contains(":")) + #expect(!s.sessionName.contains(" ")) } - @Test func sessionNameIsPrefixedAndSanitized() { - let s = RemoteTmuxViewSession(ownerId: "ab.cd ef:gh") - #expect(s.sessionName == "cmux-view-ab-cd-ef-gh") // . : space → - - #expect(s.sessionName.hasPrefix(RemoteTmuxViewSession.namePrefix)) + @Test func distinctOwnersNeverCollideEvenWhenSanitizedFragmentMatches() { + // "a.b" and "a:b" sanitize to the same fragment but must yield distinct + // session names (collision-resistant hash suffix). + #expect(VS(ownerId: "a.b").sessionName != VS(ownerId: "a:b").sessionName) + #expect(VS(ownerId: "a.b").sessionName == VS(ownerId: "a.b").sessionName) // stable } @Test func createCommandsUseExplicitSizeAndStampOwnership() { - let cmds = v("o1").createCommands(cols: 120, rows: 40) - #expect(cmds[0].contains("new-session -d -s 'cmux-view-o1' -x 120 -y 40")) + let s = v("o1") + let cmds = s.createCommands(cols: 120, rows: 40) + #expect(cmds[0].contains("new-session -d -s '\(s.sessionName)' -x 120 -y 40")) #expect(cmds.contains { $0.contains("@cmux_view 1") }) #expect(cmds.contains { $0.contains("@cmux_view_owner 'o1'") }) #expect(cmds.contains { $0.contains("@cmux_view_version 1") }) } - @Test func parsesListRows() { + @Test func parsesListRowsWithFreeTextNameLast() { let out = [ - "cmux-view-o1\u{1f}1\u{1f}o1\u{1f}1", - "work\u{1f}\u{1f}\u{1f}", // a normal session: no view options - "cmux-view-other\u{1f}1\u{1f}o2\u{1f}1", + rowString(view: "1", owner: "o1", version: "1", name: "cmux-view-o1-ab"), + rowString(view: "", owner: "", version: "", name: "work"), + rowString(view: "1", owner: "o2", version: "1", name: "cmux-view-o2-cd"), ].joined(separator: "\n") - let rows = RemoteTmuxViewSession.parseRows(out) + let rows = VS.parseRows(out) #expect(rows.count == 3) - #expect(rows[0] == .init(name: "cmux-view-o1", isView: true, owner: "o1", version: 1)) - #expect(rows[1] == .init(name: "work", isView: false, owner: "", version: nil)) + #expect(rows[0] == Row(name: "cmux-view-o1-ab", isView: true, owner: "o1", version: 1)) + #expect(rows[1] == Row(name: "work", isView: false, owner: "", version: nil)) #expect(rows[2].owner == "o2") } - @Test func isOwnViewOnlyForExactOwnerNameAndVersion() { + @Test func parsingKeepsSessionNameContainingSeparator() { + let out = rowString(view: "", owner: "", version: "", name: "we\u{1f}ird") + #expect(VS.parseRows(out) == [Row(name: "we\u{1f}ird", isView: false, owner: "", version: nil)]) + } + + @Test func isAnyViewRequiresBothTagAndPrefix() { + // tagged + prefixed → a view + #expect(VS.isAnyView(Row(name: "cmux-view-x", isView: true, owner: "x", version: 1))) + // tagged but NOT prefixed (a real session that copied the option) → NOT a view + #expect(!VS.isAnyView(Row(name: "prod", isView: true, owner: "x", version: 1))) + // prefixed but not tagged → NOT a view + #expect(!VS.isAnyView(Row(name: "cmux-view-x", isView: false, owner: "x", version: 1))) + } + + @Test func isOwnViewOnlyForExactOwnerNameVersionAndPrefix() { let s = v("o1") - #expect(s.isOwnView(.init(name: "cmux-view-o1", isView: true, owner: "o1", version: 1))) - // wrong owner - #expect(!s.isOwnView(.init(name: "cmux-view-o1", isView: true, owner: "o2", version: 1))) - // not tagged a view - #expect(!s.isOwnView(.init(name: "cmux-view-o1", isView: false, owner: "o1", version: 1))) - // wrong version - #expect(!s.isOwnView(.init(name: "cmux-view-o1", isView: true, owner: "o1", version: 99))) + let n = s.sessionName + #expect(s.isOwnView(Row(name: n, isView: true, owner: "o1", version: 1))) + #expect(!s.isOwnView(Row(name: n, isView: true, owner: "o2", version: 1))) // wrong owner + #expect(!s.isOwnView(Row(name: n, isView: false, owner: "o1", version: 1))) // not tagged + #expect(!s.isOwnView(Row(name: n, isView: true, owner: "o1", version: 99))) // wrong version + #expect(!s.isOwnView(Row(name: "other", isView: true, owner: "o1", version: 1))) // wrong name } - @Test func staleViewIsOnlyOurOwnNonCurrent() { + @Test func staleViewIsOnlyOurOwnPrefixedNonCurrent() { let s = v("o1") - // our owner, old version → stale (collectible) - #expect(s.isOwnStaleView(.init(name: "cmux-view-o1-old", isView: true, owner: "o1", version: 0))) + // our owner, prefixed, old version → stale + #expect(s.isOwnStaleView(Row(name: "cmux-view-o1-old", isView: true, owner: "o1", version: 0))) // our current view → NOT stale - #expect(!s.isOwnStaleView(.init(name: "cmux-view-o1", isView: true, owner: "o1", version: 1))) - // another owner's view → NEVER stale-collectible by us - #expect(!s.isOwnStaleView(.init(name: "cmux-view-o2", isView: true, owner: "o2", version: 0))) - // a normal session → not a view, not collectible - #expect(!s.isOwnStaleView(.init(name: "work", isView: false, owner: "", version: nil))) + #expect(!s.isOwnStaleView(Row(name: s.sessionName, isView: true, owner: "o1", version: 1))) + // a NON-prefixed real session with our owner+option copied → NEVER collectible + #expect(!s.isOwnStaleView(Row(name: "prod", isView: true, owner: "o1", version: 0))) + // another owner's view → never ours to collect + #expect(!s.isOwnStaleView(Row(name: "cmux-view-o2", isView: true, owner: "o2", version: 0))) } @Test func foreignViewDetection() { - #expect(RemoteTmuxViewSession.isForeignView( - .init(name: "cmux-view-o2", isView: true, owner: "o2", version: 1), ownerId: "o1")) - #expect(!RemoteTmuxViewSession.isForeignView( - .init(name: "cmux-view-o1", isView: true, owner: "o1", version: 1), ownerId: "o1")) - // a view with no owner stamped is not attributed to anyone → not foreign - #expect(!RemoteTmuxViewSession.isForeignView( - .init(name: "cmux-view-x", isView: true, owner: "", version: 1), ownerId: "o1")) + #expect(VS.isForeignView(Row(name: "cmux-view-o2", isView: true, owner: "o2", version: 1), ownerId: "o1")) + #expect(!VS.isForeignView(Row(name: "cmux-view-o1", isView: true, owner: "o1", version: 1), ownerId: "o1")) + // a non-prefixed session is never a foreign view even if tagged + #expect(!VS.isForeignView(Row(name: "prod", isView: true, owner: "o2", version: 1), ownerId: "o1")) // a normal session is never foreign-view - #expect(!RemoteTmuxViewSession.isForeignView( - .init(name: "work", isView: false, owner: "", version: nil), ownerId: "o1")) - } - - @Test func ownerAndForeignAreMutuallyExclusive() { - let s = v("o1") - let mine = RemoteTmuxViewSession.SessionRow(name: "cmux-view-o1", isView: true, owner: "o1", version: 1) - let theirs = RemoteTmuxViewSession.SessionRow(name: "cmux-view-o2", isView: true, owner: "o2", version: 1) - #expect(s.isOwnView(mine) && !RemoteTmuxViewSession.isForeignView(mine, ownerId: "o1")) - #expect(RemoteTmuxViewSession.isForeignView(theirs, ownerId: "o1") && !s.isOwnView(theirs)) + #expect(!VS.isForeignView(Row(name: "work", isView: false, owner: "", version: nil), ownerId: "o1")) } } From 3daec9915e85f140e1b26fbd7703b5a6fdb47a02 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 20:40:09 -0700 Subject: [PATCH 11/38] =?UTF-8?q?remote-tmux:=20linked-view=20core=20?= =?UTF-8?q?=E2=80=94=20fix=20/code-review=20findings=20(delimiter,=20forma?= =?UTF-8?q?t-bump,=20FNV=20reuse)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Sources/RemoteTmuxHost.swift | 9 +++- Sources/RemoteTmuxLinkedViewPlan.swift | 17 +++++-- Sources/RemoteTmuxLinkedWorkspaceModel.swift | 26 +++++++---- Sources/RemoteTmuxViewSession.swift | 46 ++++++++++--------- cmuxTests/RemoteTmuxLinkedViewPlanTests.swift | 27 +++++++++++ .../RemoteTmuxLinkedWorkspaceModelTests.swift | 19 ++++---- cmuxTests/RemoteTmuxViewSessionTests.swift | 18 ++++++-- 7 files changed, 114 insertions(+), 48 deletions(-) diff --git a/Sources/RemoteTmuxHost.swift b/Sources/RemoteTmuxHost.swift index f9179625c185..5af6fc899b26 100644 --- a/Sources/RemoteTmuxHost.swift +++ b/Sources/RemoteTmuxHost.swift @@ -61,8 +61,15 @@ struct RemoteTmuxHost: Sendable, Equatable, Identifiable { /// command to the wrong server. var connectionHash: String { let fingerprint = "\(destination)\u{1f}\(port.map(String.init) ?? "")\u{1f}\(identityFile ?? "")" + return Self.fnv1a64Hex(fingerprint) + } + + /// Stable, deterministic FNV-1a/64 digest of a string as 16 lowercase hex + /// chars. Shared by ``connectionHash`` and the linked-view owner hash so the + /// digest algorithm lives in exactly one place. + static func fnv1a64Hex(_ string: String) -> String { var hash: UInt64 = 0xcbf2_9ce4_8422_2325 // FNV offset basis - for byte in fingerprint.utf8 { + for byte in string.utf8 { hash ^= UInt64(byte) hash = hash &* 0x0000_0100_0000_01b3 // FNV prime } diff --git a/Sources/RemoteTmuxLinkedViewPlan.swift b/Sources/RemoteTmuxLinkedViewPlan.swift index d4cf489a6000..45649b88784d 100644 --- a/Sources/RemoteTmuxLinkedViewPlan.swift +++ b/Sources/RemoteTmuxLinkedViewPlan.swift @@ -55,11 +55,18 @@ enum RemoteTmuxLinkedViewPlan { rows: snapshot.windows, excludedSessions: excluded) // Actual = window ids currently inside OUR view session (by exact name; the - // owned-view check above guarantees this name is ours, not a foreign/real - // session that merely collides — names are collision-resistant). - let actual = Set(snapshot.windows - .filter { $0.sessionName == viewName } - .map(\.windowId)) + // owned-view check guarantees this name is ours, not a foreign/real session + // that merely collides — names are collision-resistant). + // + // When we must (re)create the view (first run, or a stale/old-format view + // sharing our name that the live layer will kill+recreate), the windows + // listed under that name belong to the about-to-be-destroyed session, so we + // must NOT treat them as already present — otherwise they'd be excluded from + // `toLink` and never re-linked into the fresh view, leaving it empty. Treat + // actual as empty so every desired window links into the new view. + let actual: Set = needsCreate + ? [] + : Set(snapshot.windows.filter { $0.sessionName == viewName }.map(\.windowId)) let actions = RemoteTmuxViewReconciler.actions( desiredWindowIds: desired, diff --git a/Sources/RemoteTmuxLinkedWorkspaceModel.swift b/Sources/RemoteTmuxLinkedWorkspaceModel.swift index 1cd5b1d0583a..67df3b62ab35 100644 --- a/Sources/RemoteTmuxLinkedWorkspaceModel.swift +++ b/Sources/RemoteTmuxLinkedWorkspaceModel.swift @@ -29,16 +29,26 @@ enum RemoteTmuxLinkedWorkspaceModel { let windowIndex: Int } - /// Recommended format for `list-windows -a -F`. The controlled fields - /// (`window_id` = `@N`, `window_index` = int) come first; the free-text - /// `session_name` is LAST so a separator inside a name can't drop the row. - static let listFormat = "#{window_id}\u{1f}#{window_index}\u{1f}#{session_name}" + /// Format for `list-windows -a -F`. Uses the printable `:` delimiter (like + /// ``RemoteTmuxSessionListParser``), NOT a control byte: tmux's + /// `utf8_sanitize()` rewrites non-printable bytes to `_` for non-UTF-8 clients, + /// which would drop every row. Controlled fields (`window_id` = `@N`, + /// `window_index` = int) come first; the free-text `session_name` is LAST and + /// rejoined from the remainder so a `:` in a name can't shift fields. + static let listFormat = "#{window_id}:#{window_index}:#{session_name}" + + private static let fieldDelimiter = ":" static func parseRows(_ output: String) -> [WindowRow] { - output.split(separator: "\n", omittingEmptySubsequences: true).compactMap { line in - let f = line.split(separator: "\u{1f}", maxSplits: 2, omittingEmptySubsequences: false) - guard f.count == 3, let idx = Int(f[1]) else { return nil } - return WindowRow(sessionName: String(f[2]), windowId: String(f[0]), windowIndex: idx) + output.split(separator: "\n", omittingEmptySubsequences: true).compactMap { rawLine in + var line = String(rawLine) + if line.last == "\r" { line.removeLast() } + let f = line.components(separatedBy: fieldDelimiter) + guard f.count >= 3, let idx = Int(f[1]) else { return nil } + return WindowRow( + sessionName: f[2...].joined(separator: fieldDelimiter), + windowId: f[0], + windowIndex: idx) } } diff --git a/Sources/RemoteTmuxViewSession.swift b/Sources/RemoteTmuxViewSession.swift index 8edddc76ec08..e0bde03bbb51 100644 --- a/Sources/RemoteTmuxViewSession.swift +++ b/Sources/RemoteTmuxViewSession.swift @@ -62,12 +62,19 @@ struct RemoteTmuxViewSession: Equatable { ] } - /// Format string for `list-sessions -F` that surfaces enough to classify each - /// session: the three (controlled, separator-free) ownership options first, - /// then the free-text `session_name` LAST so a separator inside a name can't - /// shift fields or drop the row. + /// Format string for `list-sessions -F`. Uses the printable `:` delimiter (the + /// same one ``RemoteTmuxSessionListParser`` uses) — NOT a control byte: when the + /// remote tmux client is not flagged UTF-8 (common on non-interactive SSH to a + /// non-UTF-8-locale host), tmux runs `-F` output through `utf8_sanitize()` which + /// rewrites every non-printable byte to `_`, which would collapse the fields and + /// drop every row. The controlled fields (`@cmux_view` = "1"/"" , owner = + /// colon-free id, version = int) come first; the free-text `session_name` is + /// LAST and is rejoined from the remainder, so a `:` in a name (tmux already + /// rewrites those to `_`) can't shift fields. static let listFormat = - "#{\(optView)}\u{1f}#{\(optOwner)}\u{1f}#{\(optVersion)}\u{1f}#{session_name}" + "#{\(optView)}:#{\(optOwner)}:#{\(optVersion)}:#{session_name}" + + private static let fieldDelimiter = ":" /// One parsed `list-sessions` row (from ``listFormat``). struct SessionRow: Equatable { @@ -78,15 +85,15 @@ struct RemoteTmuxViewSession: Equatable { } static func parseRows(_ output: String) -> [SessionRow] { - output.split(separator: "\n", omittingEmptySubsequences: true).compactMap { line in - // maxSplits=3 keeps the trailing (free-text) name intact even if it - // contains the unit separator. - let f = line.split(separator: "\u{1f}", maxSplits: 3, omittingEmptySubsequences: false) - guard f.count == 4 else { return nil } + output.split(separator: "\n", omittingEmptySubsequences: true).compactMap { rawLine in + var line = String(rawLine) + if line.last == "\r" { line.removeLast() } + let f = line.components(separatedBy: fieldDelimiter) + guard f.count >= 4 else { return nil } return SessionRow( - name: String(f[3]), + name: f[3...].joined(separator: fieldDelimiter), // free-text remainder isView: f[0] == "1", - owner: String(f[1]), + owner: f[1], version: Int(f[2]) ) } @@ -131,22 +138,19 @@ struct RemoteTmuxViewSession: Equatable { /// an arbitrary owner id yields a valid, readable session-name fragment. Lossy /// on purpose (readability); uniqueness is restored by ``ownerHash(_:)``. static func sanitizeOwner(_ owner: String) -> String { - String(owner.unicodeScalars.map { s -> Character in - if s == "." || s == ":" || CharacterSet.whitespaces.contains(s) { return "-" } + let forbidden = CharacterSet.whitespacesAndNewlines.union(.controlCharacters) + return String(owner.unicodeScalars.map { s -> Character in + if s == "." || s == ":" || forbidden.contains(s) { return "-" } return Character(s) }) } /// A short, stable, collision-resistant hex digest of the raw owner id /// (FNV-1a/64 → 16 hex chars), so two distinct owners can never share a view - /// session name even if their sanitized fragments collide. + /// session name even if their sanitized fragments collide. Reuses the shared + /// digest helper so the FNV algorithm isn't duplicated. static func ownerHash(_ owner: String) -> String { - var hash: UInt64 = 0xcbf2_9ce4_8422_2325 - for byte in owner.utf8 { - hash ^= UInt64(byte) - hash = hash &* 0x0000_0100_0000_01b3 - } - return String(format: "%016llx", hash) + RemoteTmuxHost.fnv1a64Hex(owner) } /// Single-quote for a control-mode command argument. diff --git a/cmuxTests/RemoteTmuxLinkedViewPlanTests.swift b/cmuxTests/RemoteTmuxLinkedViewPlanTests.swift index 9ce9e60ccf71..fd7400f932db 100644 --- a/cmuxTests/RemoteTmuxLinkedViewPlanTests.swift +++ b/cmuxTests/RemoteTmuxLinkedViewPlanTests.swift @@ -90,6 +90,33 @@ import Testing #expect(plan.workspaces == [.init(sessionName: "A", windowIds: ["@1"])]) } + @Test func formatBumpKeepsRelinkingAllWindowsIntoFreshView() { + // A view with OUR name but an old format version: needsViewCreate is true and + // the stale same-name view is scheduled for kill+recreate. Its windows must + // be re-linked into the fresh view — i.e. `actual` is treated as empty so + // they all appear in reconcileActions (regression: previously they were seen + // as already-present and never re-linked → empty view after upgrade). + let staleSameName = SRow(name: vname, isView: true, owner: "o1", version: 0) + let snap = P.Snapshot( + sessions: [staleSameName, SRow(name: "A", isView: false, owner: "", version: nil)], + windows: [ + WRow(sessionName: "A", windowId: "@1", windowIndex: 0), + WRow(sessionName: "A", windowId: "@2", windowIndex: 1), + // the old view still lists these linked windows under our name: + WRow(sessionName: vname, windowId: "@0", windowIndex: 0), + WRow(sessionName: vname, windowId: "@1", windowIndex: 1), + WRow(sessionName: vname, windowId: "@2", windowIndex: 2), + ], + cmuxOwnedWindowIds: ["@1", "@2"], + placeholderWindowId: "@0") + let plan = P.plan(view: view, snapshot: snap) + #expect(plan.needsViewCreate) + #expect(plan.staleViewsToKill == [vname]) + // BOTH real windows re-link despite being listed in the (doomed) old view. + #expect(plan.reconcileActions == [.link(windowId: "@1"), .link(windowId: "@2")]) + #expect(plan.workspaces == [.init(sessionName: "A", windowIds: ["@1", "@2"])]) + } + @Test func staleOwnViewCollectedForeignViewNeverTouchedNorSurfaced() { let snap = P.Snapshot( sessions: [ diff --git a/cmuxTests/RemoteTmuxLinkedWorkspaceModelTests.swift b/cmuxTests/RemoteTmuxLinkedWorkspaceModelTests.swift index 7c697e4a47e3..acf586c71bf1 100644 --- a/cmuxTests/RemoteTmuxLinkedWorkspaceModelTests.swift +++ b/cmuxTests/RemoteTmuxLinkedWorkspaceModelTests.swift @@ -26,20 +26,23 @@ import Testing ] @Test func parsesRows() { - // format is window_id ⋮ window_index ⋮ session_name - let out = "@1\u{1f}0\u{1f}A\n@3\u{1f}0\u{1f}B" + // format is window_id : window_index : session_name + let out = "@1:0:A\n@3:0:B" #expect(M.parseRows(out) == [ Row(sessionName: "A", windowId: "@1", windowIndex: 0), Row(sessionName: "B", windowId: "@3", windowIndex: 0), ]) } - @Test func parsingKeepsSessionNamesContainingSeparator() { - // A name containing the unit separator must not drop or corrupt the row - // (free-text field is last + maxSplits). - let weird = "we\u{1f}ird" - let out = "@5\u{1f}2\u{1f}\(weird)" - #expect(M.parseRows(out) == [Row(sessionName: weird, windowId: "@5", windowIndex: 2)]) + @Test func parsingKeepsSessionNamesContainingDelimiter() { + // A `:` in the (free-text, last) name must not drop or corrupt the row. + let out = "@5:2:we:ird:name" + #expect(M.parseRows(out) == [Row(sessionName: "we:ird:name", windowId: "@5", windowIndex: 2)]) + } + + @Test func usesPrintableDelimiterNotControlByte() { + #expect(!M.listFormat.unicodeScalars.contains { CharacterSet.controlCharacters.contains($0) }) + #expect(M.listFormat.contains(":")) } @Test mutating func groupsByHomeSessionExcludingView() { diff --git a/cmuxTests/RemoteTmuxViewSessionTests.swift b/cmuxTests/RemoteTmuxViewSessionTests.swift index 8d9875f3f910..8d80d314c5ec 100644 --- a/cmuxTests/RemoteTmuxViewSessionTests.swift +++ b/cmuxTests/RemoteTmuxViewSessionTests.swift @@ -15,9 +15,9 @@ import Testing private typealias Row = RemoteTmuxViewSession.SessionRow private func v(_ owner: String = "o1") -> VS { VS(ownerId: owner) } - // Build a list row string in the current format: view ⋮ owner ⋮ version ⋮ name + // Build a list row string in the current format: view : owner : version : name private func rowString(view: String, owner: String, version: String, name: String) -> String { - [view, owner, version, name].joined(separator: "\u{1f}") + [view, owner, version, name].joined(separator: ":") } @Test func sessionNameIsPrefixedSanitizedAndHashed() { @@ -57,9 +57,17 @@ import Testing #expect(rows[2].owner == "o2") } - @Test func parsingKeepsSessionNameContainingSeparator() { - let out = rowString(view: "", owner: "", version: "", name: "we\u{1f}ird") - #expect(VS.parseRows(out) == [Row(name: "we\u{1f}ird", isView: false, owner: "", version: nil)]) + @Test func parsingKeepsSessionNameContainingDelimiter() { + // A `:` in the (free-text, last) name is preserved via remainder-rejoin. + let out = rowString(view: "", owner: "", version: "", name: "we:ird:name") + #expect(VS.parseRows(out) == [Row(name: "we:ird:name", isView: false, owner: "", version: nil)]) + } + + @Test func usesPrintableDelimiterNotControlByte() { + // Guards the cross-host fix: the format must not embed a non-printable byte + // (tmux's utf8_sanitize would rewrite it to `_` on non-UTF-8 clients). + #expect(!VS.listFormat.unicodeScalars.contains { CharacterSet.controlCharacters.contains($0) }) + #expect(VS.listFormat.contains(":")) } @Test func isAnyViewRequiresBothTagAndPrefix() { From c35b6302102f6344941d1e52aadae5c375287751 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 20:40:09 -0700 Subject: [PATCH 12/38] remote-tmux: /simplify linked-view core + fix pre-existing CRLF parser bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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. --- Sources/RemoteTmuxLinkedWorkspaceModel.swift | 27 +++----- Sources/RemoteTmuxSessionListParser.swift | 68 +++++++++++--------- Sources/RemoteTmuxViewReconciler.swift | 6 +- Sources/RemoteTmuxViewSession.swift | 15 +---- 4 files changed, 52 insertions(+), 64 deletions(-) diff --git a/Sources/RemoteTmuxLinkedWorkspaceModel.swift b/Sources/RemoteTmuxLinkedWorkspaceModel.swift index 67df3b62ab35..8e75605bec44 100644 --- a/Sources/RemoteTmuxLinkedWorkspaceModel.swift +++ b/Sources/RemoteTmuxLinkedWorkspaceModel.swift @@ -37,18 +37,10 @@ enum RemoteTmuxLinkedWorkspaceModel { /// rejoined from the remainder so a `:` in a name can't shift fields. static let listFormat = "#{window_id}:#{window_index}:#{session_name}" - private static let fieldDelimiter = ":" - static func parseRows(_ output: String) -> [WindowRow] { - output.split(separator: "\n", omittingEmptySubsequences: true).compactMap { rawLine in - var line = String(rawLine) - if line.last == "\r" { line.removeLast() } - let f = line.components(separatedBy: fieldDelimiter) - guard f.count >= 3, let idx = Int(f[1]) else { return nil } - return WindowRow( - sessionName: f[2...].joined(separator: fieldDelimiter), - windowId: f[0], - windowIndex: idx) + RemoteTmuxSessionListParser.splitRows(output, fieldCount: 3).compactMap { f in + guard let idx = Int(f[1]) else { return nil } + return WindowRow(sessionName: f[2], windowId: f[0], windowIndex: idx) } } @@ -79,19 +71,20 @@ enum RemoteTmuxLinkedWorkspaceModel { /// - Returns: workspaces sorted by session name; each workspace's window ids /// sorted by (windowIndex, windowId) for a stable tab order. static func workspaces(rows: [WindowRow], excludedSessions: Set) -> [Workspace] { - // Resolve each window's single home once (id → home session). + // One pass: each window's home is the lexicographically smallest non-excluded + // session that contains it (deterministic single home). var homeByWindow: [String: String] = [:] for r in rows where !excludedSessions.contains(r.sessionName) { - if homeByWindow[r.windowId] == nil { - homeByWindow[r.windowId] = homeSession( - forWindowId: r.windowId, rows: rows, excludedSessions: excludedSessions) + if let existing = homeByWindow[r.windowId] { + if r.sessionName < existing { homeByWindow[r.windowId] = r.sessionName } + } else { + homeByWindow[r.windowId] = r.sessionName } } // Collect each home session's windows, taking the index from the row that // belongs to that home session (not a different session's linked copy). var bySession: [String: [(idx: Int, id: String)]] = [:] - for r in rows where !excludedSessions.contains(r.sessionName) - && homeByWindow[r.windowId] == r.sessionName { + for r in rows where homeByWindow[r.windowId] == r.sessionName { bySession[r.sessionName, default: []].append((r.windowIndex, r.windowId)) } return bySession.keys.sorted().map { name in diff --git a/Sources/RemoteTmuxSessionListParser.swift b/Sources/RemoteTmuxSessionListParser.swift index 3dcb609432b9..65a3171af1af 100644 --- a/Sources/RemoteTmuxSessionListParser.swift +++ b/Sources/RemoteTmuxSessionListParser.swift @@ -32,6 +32,32 @@ enum RemoteTmuxSessionListParser { /// any leading field value. static let fieldDelimiter = ":" + /// Splits raw `tmux …list… -F` output (delimited with ``fieldDelimiter``) into + /// rows of exactly `fieldCount` fields, where the LAST field is the free-text + /// remainder (rejoined so an embedded delimiter is preserved). Strips a + /// trailing `\r`, skips blank/short lines. Shared by every tmux `-F` row parser + /// so the line-split / CR-strip / last-field-rejoin invariant lives in one place + /// (the cross-host reason for the printable delimiter is documented above). + static func splitRows(_ output: String, fieldCount: Int) -> [[String]] { + precondition(fieldCount >= 1) + // Split on any newline via `Character.isNewline`, which matches `\n`, `\r`, + // AND the `\r\n` grapheme cluster. A plain `split(separator: "\n")` misses + // `\r\n` (Swift clusters it as one Character, so it isn't equal to `\n`), + // which previously left a stray `\r` on the last field of CRLF output. + return output.split(omittingEmptySubsequences: true, whereSeparator: \.isNewline) + .compactMap { rawLine in + let line = String(rawLine) + if line.isEmpty { return nil } + let fields = line.components(separatedBy: fieldDelimiter) + guard fields.count >= fieldCount else { return nil } + // Keep the first fieldCount-1 fields verbatim; rejoin the rest as the + // trailing free-text field. + var row = Array(fields[0..<(fieldCount - 1)]) + row.append(fields[(fieldCount - 1)...].joined(separator: fieldDelimiter)) + return row + } + } + /// The `-F` format string this parser expects, ordered to match ``parse(_:)`` /// with the free-text `session_name` last. static let formatString = @@ -42,38 +68,18 @@ enum RemoteTmuxSessionListParser { /// - Parameter output: the raw stdout from the remote `tmux list-sessions`. /// - Returns: one ``RemoteTmuxSession`` per well-formed line, in input order. static func parse(_ output: String) -> [RemoteTmuxSession] { - var sessions: [RemoteTmuxSession] = [] - for rawLine in output.split(separator: "\n", omittingEmptySubsequences: true) { - var line = String(rawLine) - if line.last == "\r" { - line.removeLast() - } - if line.isEmpty { continue } - // Unbounded split: the first four fields are id/windows/attached/ - // created, and the name (which may itself contain `:`) is reassembled - // from the remainder below via `fields[4...].joined`, so a name with - // embedded delimiters is preserved rather than truncated here. - let fields = line.components(separatedBy: fieldDelimiter) - // Need at least id + windows + attached + created + name. - guard fields.count >= 5 else { continue } - let id = fields[0].trimmingCharacters(in: .whitespaces) - guard !id.isEmpty else { continue } - let windowCount = Int(fields[1].trimmingCharacters(in: .whitespaces)) ?? 0 - let attached = (Int(fields[2].trimmingCharacters(in: .whitespaces)) ?? 0) > 0 - let createdUnix = Int(fields[3].trimmingCharacters(in: .whitespaces)) - // The name is the remainder, rejoined so an embedded delimiter (should - // one ever survive) is preserved rather than truncating the name. - let name = fields[4...].joined(separator: fieldDelimiter) - sessions.append( - RemoteTmuxSession( - id: id, - name: name, - windowCount: windowCount, - attached: attached, - createdUnix: createdUnix - ) + // Shared row split (id, windows, attached, created, name-remainder); the + // name is the rejoined remainder so an embedded `:` survives. + return splitRows(output, fieldCount: 5).compactMap { f in + let id = f[0].trimmingCharacters(in: .whitespaces) + guard !id.isEmpty else { return nil } + return RemoteTmuxSession( + id: id, + name: f[4], + windowCount: Int(f[1].trimmingCharacters(in: .whitespaces)) ?? 0, + attached: (Int(f[2].trimmingCharacters(in: .whitespaces)) ?? 0) > 0, + createdUnix: Int(f[3].trimmingCharacters(in: .whitespaces)) ) } - return sessions } } diff --git a/Sources/RemoteTmuxViewReconciler.swift b/Sources/RemoteTmuxViewReconciler.swift index 1b8e58ec4772..79895da0e0fc 100644 --- a/Sources/RemoteTmuxViewReconciler.swift +++ b/Sources/RemoteTmuxViewReconciler.swift @@ -79,9 +79,9 @@ enum RemoteTmuxViewReconciler { // last one linked until a placeholder/new window exists. The live layer // also guarantees a placeholder, but the pure policy must not depend on // that being accurate (a nil/stale placeholder must still be safe). - let survivors = actualWindowIds.subtracting(unlinkable) - if toLink.isEmpty, survivors.isEmpty, let keep = unlinkable.min() { - unlinkable.remove(keep) + let wouldEmptyView = toLink.isEmpty && actualWindowIds.subtracting(unlinkable).isEmpty + if wouldEmptyView, let keepAlive = unlinkable.min() { + unlinkable.remove(keepAlive) } return toLink.sorted().map { Action.link(windowId: $0) } diff --git a/Sources/RemoteTmuxViewSession.swift b/Sources/RemoteTmuxViewSession.swift index e0bde03bbb51..8daf5f383f95 100644 --- a/Sources/RemoteTmuxViewSession.swift +++ b/Sources/RemoteTmuxViewSession.swift @@ -74,8 +74,6 @@ struct RemoteTmuxViewSession: Equatable { static let listFormat = "#{\(optView)}:#{\(optOwner)}:#{\(optVersion)}:#{session_name}" - private static let fieldDelimiter = ":" - /// One parsed `list-sessions` row (from ``listFormat``). struct SessionRow: Equatable { let name: String @@ -85,17 +83,8 @@ struct RemoteTmuxViewSession: Equatable { } static func parseRows(_ output: String) -> [SessionRow] { - output.split(separator: "\n", omittingEmptySubsequences: true).compactMap { rawLine in - var line = String(rawLine) - if line.last == "\r" { line.removeLast() } - let f = line.components(separatedBy: fieldDelimiter) - guard f.count >= 4 else { return nil } - return SessionRow( - name: f[3...].joined(separator: fieldDelimiter), // free-text remainder - isView: f[0] == "1", - owner: f[1], - version: Int(f[2]) - ) + RemoteTmuxSessionListParser.splitRows(output, fieldCount: 4).map { f in + SessionRow(name: f[3], isView: f[0] == "1", owner: f[1], version: Int(f[2])) } } From 00b24fe37d672d5a9eb0bffd82250faf24afb5b8 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 20:40:10 -0700 Subject: [PATCH 13/38] remote-tmux: add query() channel to the control connection (linked-view keystone) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Sources/RemoteTmuxControlCommandKind.swift | 5 +++ Sources/RemoteTmuxControlConnection.swift | 45 ++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/Sources/RemoteTmuxControlCommandKind.swift b/Sources/RemoteTmuxControlCommandKind.swift index 5310cca18fe8..d646811cd99e 100644 --- a/Sources/RemoteTmuxControlCommandKind.swift +++ b/Sources/RemoteTmuxControlCommandKind.swift @@ -8,5 +8,10 @@ enum RemoteTmuxControlCommandKind: Equatable { case paneReflow(Int) case paneAltScreen(Int) case activityQuery(UUID) + /// A generic command whose `%begin`/`%end` reply body is returned to the + /// caller (the linked-view coordinator runs `list-sessions`/`list-windows -a` + /// over the single control stream because MaxSessions=1 forbids a second + /// concurrent ssh for one-shots). + case query(UUID) case other } diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index 723ed7488112..a1c3cffb0acc 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -78,6 +78,10 @@ final class RemoteTmuxControlConnection { /// the cached classification instead of hanging until a reconnect that may /// never come. private var activityQueryCompletions: [UUID: ([Int: PaneForegroundState]?) -> Void] = [:] + /// In-flight generic ``query(_:)`` completions, keyed by token. Resolved with + /// the reply body lines, or `nil` if the command errored or the stream became + /// unusable — so an awaiting caller never hangs. + private var queryCompletions: [UUID: ([String]?) -> Void] = [:] private var process: Process? private var stdinWriter: RemoteTmuxControlPipeWriter? @@ -324,6 +328,7 @@ final class RemoteTmuxControlConnection { // Normally already flushed by beginReconnecting; kept here so a future // caller of spawnProcess can't strand a close decision. failPendingActivityQueries() + failPendingQueries() attachBlockDrained = false stderrBuffer = "" enterReceived = false @@ -777,6 +782,37 @@ final class RemoteTmuxControlConnection { sendActivityQuery(Self.paneActivityQueryCommand(paneId: paneId), completion: completion) } + /// Runs `command` over the live control stream and returns its `%begin`/`%end` + /// reply body (one string per line), or `nil` if the command errored or the + /// stream became unusable. This is how the linked-view coordinator snapshots + /// the server (`list-sessions`, `list-windows -a`) without a second concurrent + /// ssh — required on MaxSessions=1 hosts where the `-CC` client holds the one + /// allowed session. Replies correlate positionally via the pending-command + /// FIFO, exactly like the other typed commands. + func query(_ command: String) async -> [String]? { + await withCheckedContinuation { (continuation: CheckedContinuation<[String]?, Never>) in + guard connectionState == .connected else { + continuation.resume(returning: nil) + return + } + let token = UUID() + queryCompletions[token] = { continuation.resume(returning: $0) } + guard sendInternal(command, kind: .query(token)) else { + queryCompletions.removeValue(forKey: token)?(nil) + return + } + } + } + + /// Fails every in-flight ``query(_:)`` with `nil` — called whenever the control + /// stream becomes unusable, so awaiting coordinators don't hang. + private func failPendingQueries() { + guard !queryCompletions.isEmpty else { return } + let completions = Array(queryCompletions.values) + queryCompletions.removeAll() + for completion in completions { completion(nil) } + } + private func sendActivityQuery( _ command: String, completion: @escaping ([Int: PaneForegroundState]?) -> Void ) { @@ -898,6 +934,7 @@ final class RemoteTmuxControlConnection { /// (``stop()``) and a genuine remote end (`%exit`). private func cancelScheduledWork() { failPendingActivityQueries() + failPendingQueries() reconnectTask?.cancel() reconnectTask = nil clientSizeDebounceTask?.cancel() @@ -1021,6 +1058,7 @@ final class RemoteTmuxControlConnection { // The stream is dead: a close decision awaiting an activity query must // not hang for the whole backoff window — fail it onto the cache now. failPendingActivityQueries() + failPendingQueries() teardownProcessHandles() reconnectAttemptCount = 0 connectionState = .reconnecting @@ -1259,6 +1297,10 @@ final class RemoteTmuxControlConnection { let completion = activityQueryCompletions.removeValue(forKey: token) { completion(nil) } + if case let .query(token) = kind, + let completion = queryCompletions.removeValue(forKey: token) { + completion(nil) + } // Errors are dropped by design (results correlate positionally), but // an invisible %error has already hidden one real bug — an unquoted // refresh-client -B that never subscribed — so leave a trace. @@ -1384,6 +1426,9 @@ final class RemoteTmuxControlConnection { } else { observers.emitPaneOutput(paneId, Self.altScreenExitSequence) } + case let .query(token): + // Return the raw reply body to the awaiting coordinator. + queryCompletions.removeValue(forKey: token)?(lines) case .other: break } From 986c965b7f6a8b6240c26fc7313acd1d6f6a7cd4 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 20:40:10 -0700 Subject: [PATCH 14/38] remote-tmux: live view-connection coordinator for linked-view mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Sources/RemoteTmuxViewConnection.swift | 178 +++++++++++++++++++++ Sources/RemoteTmuxViewSession.swift | 24 ++- cmux.xcodeproj/project.pbxproj | 4 + cmuxTests/RemoteTmuxViewSessionTests.swift | 12 +- 4 files changed, 198 insertions(+), 20 deletions(-) create mode 100644 Sources/RemoteTmuxViewConnection.swift diff --git a/Sources/RemoteTmuxViewConnection.swift b/Sources/RemoteTmuxViewConnection.swift new file mode 100644 index 000000000000..4e22308d4982 --- /dev/null +++ b/Sources/RemoteTmuxViewConnection.swift @@ -0,0 +1,178 @@ +import Foundation + +/// Live coordinator for the linked-view transport (`remoteTmux.linkedView` beta). +/// +/// Owns the hidden aggregate view session for one host and drives ONE +/// `tmux -CC` control client attached to it, so every mirrored session's windows +/// stream over the single SSH session a MaxSessions=1 host allows. It is the I/O +/// shell around the tested pure layers: it gathers snapshots and applies the plan +/// produced by ``RemoteTmuxLinkedViewPlan``. +/// +/// Lifecycle: +/// 1. `start()` — BEFORE attaching, run sequential one-shots over the shared +/// master (allowed: no `-CC` holds the session yet) to discover existing +/// sessions, garbage-collect our own stale views, and create the owned view at +/// an explicit size. Then attach the single `-CC` client to the view. +/// 2. On connect / every `%topology` change — `reconcile()` queries the server +/// OVER the control stream (a second one-shot ssh would be refused) and applies +/// link/unlink actions, then publishes the regrouped workspaces. +/// 3. `newWorkspace()` — create a detached session over the stream and reconcile, +/// so it links in and surfaces as a new workspace ("new workspace rides the +/// linking"). +@MainActor +final class RemoteTmuxViewConnection { + let host: RemoteTmuxHost + let view: RemoteTmuxViewSession + + /// The single live `-CC` control connection attached to the view session. + private(set) var connection: RemoteTmuxControlConnection? + /// The current regrouped workspaces (home session → ordered window ids). + private(set) var workspaces: [RemoteTmuxLinkedWorkspaceModel.Workspace] = [] + /// Fires after `workspaces` changes (the controller rebuilds cmux workspaces). + var onWorkspacesChanged: (() -> Void)? + /// Fires when the view connection permanently ends. + var onEnded: (() -> Void)? + + private let transport: RemoteTmuxSSHTransport + private let initialCols: Int + private let initialRows: Int + /// Window ids cmux has itself linked into the view (ownership for safe unlink). + private var ownedWindowIds: Set = [] + private var placeholderWindowId: String? + private var observerToken: RemoteTmuxControlConnection.ObserverToken? + /// Serializes reconciles so overlapping topology events don't interleave. + private var reconcileInFlight = false + private var reconcileQueued = false + + init( + host: RemoteTmuxHost, + ownerId: String, + transport: RemoteTmuxSSHTransport, + initialCols: Int = 120, + initialRows: Int = 40 + ) { + self.host = host + self.view = RemoteTmuxViewSession(ownerId: ownerId) + self.transport = transport + self.initialCols = initialCols + self.initialRows = initialRows + } + + // MARK: - Lifecycle + + /// Ensures the owned view exists (creating it and GC'ing our stale views via + /// pre-attach one-shots), then attaches the single `-CC` client and runs the + /// first reconcile. Throws if the view can't be created or the stream can't attach. + func start() async throws { + try await ensureViewSession() + let conn = RemoteTmuxControlConnection( + host: host, sessionName: view.sessionName, createIfMissing: false) + observerToken = conn.addObserver( + onTopologyChanged: { [weak self] in self?.scheduleReconcile() }, + onExit: { [weak self] in self?.handleEnded() }, + onConnectionStateChanged: { [weak self] state in + if state == .connected { self?.scheduleReconcile() } + }) + try conn.start() + conn.setClientSize(columns: initialCols, rows: initialRows) + connection = conn + } + + func stop() { + if let observerToken { connection?.removeObserver(observerToken); self.observerToken = nil } + connection?.stop() + connection = nil + } + + /// Creates a new remote tmux session over the live stream and reconciles, so it + /// links into the view and appears as a new workspace. No new SSH session. + func newWorkspace() { + guard let conn = connection, conn.connectionState == .connected else { return } + // Detached, explicit-size so it never flashes at 80x24 before linking. + _ = conn.send("new-session -d -x \(initialCols) -y \(initialRows)") + scheduleReconcile() + } + + // MARK: - View creation (pre-attach one-shots) + + private func ensureViewSession() async throws { + // List existing sessions to find our stale views and whether ours exists. + let listOut = await runOneShot(["list-sessions", "-F", RemoteTmuxViewSession.listFormat]) + let rows = RemoteTmuxViewSession.parseRows(listOut) + for stale in rows.filter({ view.isOwnStaleView($0) }) { + _ = await runOneShot(["kill-session", "-t", stale.name]) + } + if !rows.contains(where: { view.isOwnView($0) }) { + for argv in view.createArgvs(cols: initialCols, rows: initialRows) { + _ = await runOneShot(argv) + } + } + // Record the view's placeholder window so reconcile never unlinks it. + let phOut = await runOneShot(["list-windows", "-t", view.sessionName, "-F", "#{window_id}"]) + placeholderWindowId = phOut.split(separator: "\n").first.map(String.init) + } + + /// A pre-attach `tmux` one-shot over the shared master, returning stdout (or "" + /// on failure). Only safe before the `-CC` client attaches (MaxSessions=1). + private func runOneShot(_ argv: [String]) async -> String { + (try? await transport.runTmux(argv))?.stdout ?? "" + } + + // MARK: - Reconcile (over the live stream) + + private func scheduleReconcile() { + Task { @MainActor in await self.reconcile() } + } + + func reconcile() async { + guard let conn = connection, conn.connectionState == .connected else { return } + if reconcileInFlight { reconcileQueued = true; return } + reconcileInFlight = true + defer { + reconcileInFlight = false + if reconcileQueued { reconcileQueued = false; scheduleReconcile() } + } + + guard + let sessOut = await conn.query("list-sessions -F \(quoted(RemoteTmuxViewSession.listFormat))"), + let winOut = await conn.query("list-windows -a -F \(quoted(RemoteTmuxLinkedWorkspaceModel.listFormat))") + else { return } + + let snapshot = RemoteTmuxLinkedViewPlan.Snapshot( + sessions: RemoteTmuxViewSession.parseRows(sessOut.joined(separator: "\n")), + windows: RemoteTmuxLinkedWorkspaceModel.parseRows(winOut.joined(separator: "\n")), + cmuxOwnedWindowIds: ownedWindowIds, + placeholderWindowId: placeholderWindowId) + let plan = RemoteTmuxLinkedViewPlan.plan(view: view, snapshot: snapshot) + + for action in plan.reconcileActions { + switch action { + case let .link(windowId): + if conn.send("link-window -s \(windowId) -t \(quoted(view.sessionName))") { + ownedWindowIds.insert(windowId) + } + case let .unlinkFromView(windowId): + // Unlink the view's COPY (by its index in the view), so the real + // session keeps the window. + if let idx = snapshot.windows.first(where: { + $0.sessionName == view.sessionName && $0.windowId == windowId + })?.windowIndex { + _ = conn.send("unlink-window -t \(quoted(view.sessionName)):\(idx)") + } + ownedWindowIds.remove(windowId) + } + } + + if plan.workspaces != workspaces { + workspaces = plan.workspaces + onWorkspacesChanged?() + } + } + + private func handleEnded() { + workspaces = [] + onEnded?() + } + + private func quoted(_ v: String) -> String { RemoteTmuxHost.shellSingleQuoted(v) } +} diff --git a/Sources/RemoteTmuxViewSession.swift b/Sources/RemoteTmuxViewSession.swift index 8daf5f383f95..f17a77c4d6f7 100644 --- a/Sources/RemoteTmuxViewSession.swift +++ b/Sources/RemoteTmuxViewSession.swift @@ -46,19 +46,18 @@ struct RemoteTmuxViewSession: Equatable { Self.namePrefix + Self.sanitizeOwner(ownerId) + "-" + Self.ownerHash(ownerId) } - /// tmux commands (control-mode safe, one per line) that create the view - /// detached at an explicit size and stamp ownership options. Creating with an - /// explicit `-x/-y` avoids an 80x24 placeholder flash before the first resize. - /// - /// `new-session -d -s ` is idempotent only if guarded; callers use - /// ``hasSessionGuardedCreateCommands(cols:rows:)`` which create-or-noops. - func createCommands(cols: Int, rows: Int) -> [String] { + /// Raw `tmux` argument vectors (NOT shell-quoted — the one-shot transport + /// quotes each token) that create the view detached at an explicit size and + /// stamp the ownership options. Run as sequential pre-attach one-shots, before + /// any `-CC` client holds the host's single session. Explicit `-x/-y` avoids an + /// 80x24 placeholder flash before the first resize. + func createArgvs(cols: Int, rows: Int) -> [[String]] { let n = sessionName return [ - "new-session -d -s \(Self.q(n)) -x \(cols) -y \(rows)", - "set-option -t \(Self.q(n)) \(Self.optView) 1", - "set-option -t \(Self.q(n)) \(Self.optOwner) \(Self.q(ownerId))", - "set-option -t \(Self.q(n)) \(Self.optVersion) \(Self.formatVersion)", + ["new-session", "-d", "-s", n, "-x", String(cols), "-y", String(rows)], + ["set-option", "-t", n, Self.optView, "1"], + ["set-option", "-t", n, Self.optOwner, ownerId], + ["set-option", "-t", n, Self.optVersion, String(Self.formatVersion)], ] } @@ -141,7 +140,4 @@ struct RemoteTmuxViewSession: Equatable { static func ownerHash(_ owner: String) -> String { RemoteTmuxHost.fnv1a64Hex(owner) } - - /// Single-quote for a control-mode command argument. - private static func q(_ v: String) -> String { RemoteTmuxHost.shellSingleQuoted(v) } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index d8eb75e0d924..174bb2c94bdd 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -826,6 +826,7 @@ 0E17C0DE0E17C0DE0E17C002 /* RemoteTmuxTransportRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E17C0DE0E17C0DE0E17C001 /* RemoteTmuxTransportRegistry.swift */; }; 0A17C0DE0A17C0DE0A17C902 /* RemoteTmuxVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A17C0DE0A17C0DE0A17C901 /* RemoteTmuxVersion.swift */; }; 0A17C0DE0A17C0DE0A17CA02 /* RemoteTmuxVersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A17C0DE0A17C0DE0A17CA01 /* RemoteTmuxVersionTests.swift */; }; + 0F00DEC0DE0F00DEC0DE0011 /* RemoteTmuxViewConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F00DEC0DE0F00DEC0DE0012 /* RemoteTmuxViewConnection.swift */; }; 0F00DEC0DE0F00DEC0DE0001 /* RemoteTmuxViewReconciler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F00DEC0DE0F00DEC0DE0002 /* RemoteTmuxViewReconciler.swift */; }; 0F00DEC0DE0F00DEC0DE0003 /* RemoteTmuxViewReconcilerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F00DEC0DE0F00DEC0DE0004 /* RemoteTmuxViewReconcilerTests.swift */; }; 0F00DEC0DE0F00DEC0DE0005 /* RemoteTmuxViewSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F00DEC0DE0F00DEC0DE0006 /* RemoteTmuxViewSession.swift */; }; @@ -2069,6 +2070,7 @@ 0E17C0DE0E17C0DE0E17C001 /* RemoteTmuxTransportRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxTransportRegistry.swift; sourceTree = ""; }; 0A17C0DE0A17C0DE0A17C901 /* RemoteTmuxVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxVersion.swift; sourceTree = ""; }; 0A17C0DE0A17C0DE0A17CA01 /* RemoteTmuxVersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxVersionTests.swift; sourceTree = ""; }; + 0F00DEC0DE0F00DEC0DE0012 /* RemoteTmuxViewConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxViewConnection.swift; sourceTree = ""; }; 0F00DEC0DE0F00DEC0DE0002 /* RemoteTmuxViewReconciler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxViewReconciler.swift; sourceTree = ""; }; 0F00DEC0DE0F00DEC0DE0004 /* RemoteTmuxViewReconcilerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxViewReconcilerTests.swift; sourceTree = ""; }; 0F00DEC0DE0F00DEC0DE0006 /* RemoteTmuxViewSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxViewSession.swift; sourceTree = ""; }; @@ -3343,6 +3345,7 @@ 0F00DEC0DE0F00DEC0DE0006 /* RemoteTmuxViewSession.swift */, 0F00DEC0DE0F00DEC0DE000A /* RemoteTmuxLinkedWorkspaceModel.swift */, 0F00DEC0DE0F00DEC0DE000E /* RemoteTmuxLinkedViewPlan.swift */, + 0F00DEC0DE0F00DEC0DE0012 /* RemoteTmuxViewConnection.swift */, 541716FF0D90CB510EE3004A /* RemoteTmuxSession.swift */, 71D411EFBE33FE85901B9E6A /* RemoteTmuxCommandResult.swift */, 02B8A9858C9804AC9C37D7B4 /* RemoteTmuxHost.swift */, @@ -4733,6 +4736,7 @@ 4E9111628FAF490FBC51D350 /* RemoteTmuxStdoutPipeReader.swift in Sources */, 0E17C0DE0E17C0DE0E17C002 /* RemoteTmuxTransportRegistry.swift in Sources */, 0A17C0DE0A17C0DE0A17C902 /* RemoteTmuxVersion.swift in Sources */, + 0F00DEC0DE0F00DEC0DE0011 /* RemoteTmuxViewConnection.swift in Sources */, 0F00DEC0DE0F00DEC0DE0001 /* RemoteTmuxViewReconciler.swift in Sources */, 0F00DEC0DE0F00DEC0DE0005 /* RemoteTmuxViewSession.swift in Sources */, A8C7BE33C1BD3C1AD4342CB1 /* RemoteTmuxWindow.swift in Sources */, diff --git a/cmuxTests/RemoteTmuxViewSessionTests.swift b/cmuxTests/RemoteTmuxViewSessionTests.swift index 8d80d314c5ec..200fc3eb8e30 100644 --- a/cmuxTests/RemoteTmuxViewSessionTests.swift +++ b/cmuxTests/RemoteTmuxViewSessionTests.swift @@ -35,13 +35,13 @@ import Testing #expect(VS(ownerId: "a.b").sessionName == VS(ownerId: "a.b").sessionName) // stable } - @Test func createCommandsUseExplicitSizeAndStampOwnership() { + @Test func createArgvsUseExplicitSizeAndStampOwnership() { let s = v("o1") - let cmds = s.createCommands(cols: 120, rows: 40) - #expect(cmds[0].contains("new-session -d -s '\(s.sessionName)' -x 120 -y 40")) - #expect(cmds.contains { $0.contains("@cmux_view 1") }) - #expect(cmds.contains { $0.contains("@cmux_view_owner 'o1'") }) - #expect(cmds.contains { $0.contains("@cmux_view_version 1") }) + let argvs = s.createArgvs(cols: 120, rows: 40) + #expect(argvs[0] == ["new-session", "-d", "-s", s.sessionName, "-x", "120", "-y", "40"]) + #expect(argvs.contains(["set-option", "-t", s.sessionName, "@cmux_view", "1"])) + #expect(argvs.contains(["set-option", "-t", s.sessionName, "@cmux_view_owner", "o1"])) + #expect(argvs.contains(["set-option", "-t", s.sessionName, "@cmux_view_version", "1"])) } @Test func parsesListRowsWithFreeTextNameLast() { From 10234df38b81186cdd1fa1a0a69897a1f629e0c8 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 20:40:10 -0700 Subject: [PATCH 15/38] remote-tmux: SessionMirror window-id filter for linked-view fan-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Sources/RemoteTmuxSessionMirror.swift | 39 ++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index 58f0004d3d62..a1bb29a079a7 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -62,18 +62,31 @@ final class RemoteTmuxSessionMirror { /// remote at ssh's default 80×24. Removed in ``detachObserver()``. private var surfaceReadyObservers: [NSObjectProtocol] = [] + /// Restricts which of the connection's windows this mirror renders. + /// + /// `nil` (the default, used by the per-session transport) renders every window + /// of the connection's attached session. In linked-view mode several mirrors + /// share ONE control connection attached to the aggregate view session, so each + /// mirror is scoped to its home session's tmux window ids — output for other + /// windows is already ignored (no panel mapping), and this also bounds tab + /// creation/teardown to this workspace's windows. Updated as the coordinator + /// regroups (a new tab in this session adds an id) via ``updateWindowIdFilter``. + private var windowIdFilter: Set? + init( host: RemoteTmuxHost, sessionName: String, connection: RemoteTmuxControlConnection, tabManager: TabManager, - workspace: Workspace + workspace: Workspace, + windowIdFilter: Set? = nil ) { self.host = host self.sessionName = sessionName self.connection = connection self.tabManager = tabManager self.workspace = workspace + self.windowIdFilter = windowIdFilter self.defaultPanelIds = Array(workspace.panels.keys) // Register as one of possibly several observers — never overwrite a @@ -183,10 +196,24 @@ final class RemoteTmuxSessionMirror { /// tab titles after a tmux rename, activates/reconciles the in-tab multi-pane /// renderer for multi-pane windows, then closes the workspace's original /// local tab(s) once at least one remote tab exists. + /// Updates the window-id scope (linked-view regrouping) and rebuilds. A no-op + /// when unchanged so coordinator republishes don't churn the UI. + func updateWindowIdFilter(_ filter: Set?) { + guard filter != windowIdFilter else { return } + windowIdFilter = filter + rebuild() + } + + /// The connection windows this mirror renders, honoring ``windowIdFilter``. + private var mirroredWindowOrder: [Int] { + guard let windowIdFilter else { return connection.windowOrder } + return connection.windowOrder.filter { windowIdFilter.contains($0) } + } + func rebuild() { guard let workspace else { return } var createdNewPanel = false - for windowId in connection.windowOrder { + for windowId in mirroredWindowOrder { guard let window = connection.windowsByID[windowId], let firstPaneId = window.paneIDsInOrder.first else { continue } let title = Self.tabTitle(for: window) @@ -223,9 +250,9 @@ final class RemoteTmuxSessionMirror { reconcileWindowMirror(windowId: windowId, panelId: panelId, window: window, in: workspace) } if createdNewPanel { scheduleInitialClientSizing() } - // Close tabs for windows tmux removed, so a closed remote window doesn't - // leave a frozen tab behind. - let liveWindows = Set(connection.windowOrder) + // Close tabs for windows tmux removed (or that left this mirror's scope), + // so a closed/unlinked remote window doesn't leave a frozen tab behind. + let liveWindows = Set(mirroredWindowOrder) for (windowId, panelId) in panelIdByWindow where !liveWindows.contains(windowId) { if let mirror = windowMirrorByWindowId[windowId] { workspace.setRemoteTmuxWindowMirror(nil, forPanelId: panelId) @@ -247,7 +274,7 @@ final class RemoteTmuxSessionMirror { // stale. Reorder to match tmux's reported order, preserving focus. The // cmux→tmux drag direction is handled by handleMirrorWindowsReordered and // already matches, so this no-ops there. - let desiredPanelOrder = connection.windowOrder.compactMap { panelIdByWindow[$0] } + let desiredPanelOrder = mirroredWindowOrder.compactMap { panelIdByWindow[$0] } if desiredPanelOrder.count > 1 { workspace.reorderRemoteTmuxMirrorTabs(toPanelOrder: desiredPanelOrder) } From 6e32b55bc2fd16f89a7b5b0c8d5fdbfbad52c3b2 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 20:40:10 -0700 Subject: [PATCH 16/38] remote-tmux: wire linked-view mode end-to-end through the controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Sources/RemoteTmuxController.swift | 169 ++++++++++++++++++++++++++ Sources/RemoteTmuxSessionMirror.swift | 16 ++- 2 files changed, 183 insertions(+), 2 deletions(-) diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 8b0ade5f2a76..fdd8609d5b85 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -44,6 +44,27 @@ final class RemoteTmuxController { return Bool.decodeFromUserDefaults(UserDefaults.standard.object(forKey: key.userDefaultsKey)) ?? key.defaultValue } + /// Synchronous read of the `remoteTmux.linkedView` beta flag (same pattern as + /// ``isEnabled``). When on, host attach uses the single shared view connection + /// instead of one control connection per session (for MaxSessions=1 hosts). + nonisolated static var linkedViewEnabled: Bool { + let key = SettingCatalog().betaFeatures.remoteTmuxLinkedView + return Bool.decodeFromUserDefaults(UserDefaults.standard.object(forKey: key.userDefaultsKey)) ?? key.defaultValue + } + + /// A stable per-install owner id for cmux's hidden view sessions, persisted in + /// UserDefaults so the same cmux reattaches its own views across relaunch and + /// never collides with another install's. + static var linkedViewOwnerId: String { + let defaultsKey = "remoteTmux.linkedView.ownerId" + if let existing = UserDefaults.standard.string(forKey: defaultsKey), !existing.isEmpty { + return existing + } + let fresh = UUID().uuidString + UserDefaults.standard.set(fresh, forKey: defaultsKey) + return fresh + } + /// Returns (creating if needed) the transport for a host. func transport(for host: RemoteTmuxHost) -> RemoteTmuxSSHTransport { transportRegistry.transport(for: host) @@ -271,6 +292,16 @@ final class RemoteTmuxController { /// (see ``connectionKey(host:sessionName:)``). private var sessionMirrors: [String: RemoteTmuxSessionMirror] = [:] + /// Linked-view coordinators, one per host (keyed by ``RemoteTmuxHost/connectionHash``), + /// used when the `remoteTmux.linkedView` beta is on. Each owns the host's single + /// `-CC` view stream; ``syncLinkedWorkspaces(host:manager:)`` projects its + /// regrouped workspaces into cmux workspaces + filtered mirrors. + private var linkedViews: [String: RemoteTmuxViewConnection] = [:] + /// Linked-view mirrors keyed `connectionHash\u{1}session`, sharing the host's + /// view connection (distinct from ``sessionMirrors``, which own per-session + /// connections). + private var linkedMirrors: [String: RemoteTmuxSessionMirror] = [:] + /// Dedicated-window bindings (host↔window) and the in-flight-attach guard for /// the "one cmux window per remote endpoint" mirror mode (Option 1), owned by /// ``RemoteTmuxController`` and delegated to. @@ -388,6 +419,19 @@ final class RemoteTmuxController { windowRegistry.bind(host: host, windowId: windowId) let bootstrapWorkspaceId = manager.tabs.first?.id + + // Linked-view mode: one shared `-CC` view stream for the whole host (for + // MaxSessions=1 hosts). The coordinator discovers/links sessions and + // drives workspace creation asynchronously via `syncLinkedWorkspaces`; the + // window populates as its first reconcile lands (the per-session loop and + // its synchronous empty-window guard below are skipped). + if Self.linkedViewEnabled { + try await startLinkedView( + host: host, windowId: windowId, manager: manager, + bootstrapWorkspaceId: bootstrapWorkspaceId) + return .mirrored(windowId: windowId) + } + for session in sessions { do { try mirrorSession(host: host, sessionName: session.name, into: manager) @@ -475,6 +519,118 @@ final class RemoteTmuxController { return true } + // MARK: - Linked-view orchestration + + /// Parses a tmux `@N` window id string to the Int id the control connection uses. + private static func windowIntId(_ id: String) -> Int? { + RemoteTmuxControlStreamParser.id(Substring(id), sigil: "@") + } + + /// Starts the host's linked-view coordinator and wires it to drive workspace + /// creation in `manager`. The coordinator creates/owns the view, attaches the + /// single `-CC` client, and republishes its regrouped workspaces; each republish + /// reconciles cmux workspaces + filtered mirrors via ``syncLinkedWorkspaces``. + private func startLinkedView( + host: RemoteTmuxHost, + windowId: UUID, + manager: TabManager, + bootstrapWorkspaceId: UUID? + ) async throws { + let coordinator = RemoteTmuxViewConnection( + host: host, ownerId: Self.linkedViewOwnerId, transport: transport(for: host)) + coordinator.onWorkspacesChanged = { [weak self] in + self?.syncLinkedWorkspaces( + host: host, manager: manager, bootstrapWorkspaceId: bootstrapWorkspaceId) + } + coordinator.onEnded = { [weak self] in + self?.teardownLinkedView(host: host, windowId: windowId) + } + linkedViews[host.connectionHash] = coordinator + try await coordinator.start() + } + + /// Projects a coordinator's regrouped workspaces into cmux workspaces + filtered + /// mirrors 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), and + /// removes those whose session is gone. Closes the window's bootstrap workspace + /// once a real one exists. + private func syncLinkedWorkspaces( + host: RemoteTmuxHost, + manager: TabManager, + bootstrapWorkspaceId: UUID? + ) { + guard let coordinator = linkedViews[host.connectionHash], + let connection = coordinator.connection else { return } + let desired = coordinator.workspaces + let desiredNames = Set(desired.map(\.sessionName)) + + // Remove mirrors whose session no longer exists. + for (key, mirror) in linkedMirrors + where mirror.host.connectionHash == host.connectionHash + && !desiredNames.contains(mirror.sessionName) { + mirror.detachObserver() + if let workspaceId = mirror.mirroredWorkspaceId, + let workspace = manager.tabs.first(where: { $0.id == workspaceId }) { + manager.closeWorkspace(workspace, recordHistory: false) + } + linkedMirrors[key] = nil + } + + // Create or re-scope a mirror for each desired workspace. + for workspaceGroup in desired { + let key = Self.connectionKey(host: host, sessionName: workspaceGroup.sessionName) + let windowIds = Set(workspaceGroup.windowIds.compactMap { Self.windowIntId($0) }) + if let existing = linkedMirrors[key] { + existing.updateWindowIdFilter(windowIds) + } else { + let workspace = manager.addWorkspace( + title: workspaceGroup.sessionName, select: false, autoWelcomeIfNeeded: false) + workspace.isRemoteTmuxMirror = true + linkedMirrors[key] = RemoteTmuxSessionMirror( + host: host, + sessionName: workspaceGroup.sessionName, + connection: connection, + tabManager: manager, + workspace: workspace, + windowIdFilter: windowIds, + managesOwnLifecycle: false) + } + } + + // Drop the bootstrap (local welcome) workspace once a real mirror exists. + if linkedMirrors.values.contains(where: { $0.host.connectionHash == host.connectionHash }), + let bootstrapWorkspaceId, + manager.tabs.count > 1, + let bootstrap = manager.tabs.first(where: { $0.id == bootstrapWorkspaceId }), + !bootstrap.isRemoteTmuxMirror { + manager.closeWorkspace(bootstrap, recordHistory: false) + } + } + + /// Tears down a host's linked-view coordinator and all its mirrors (the view + /// stream ended for good), then closes the dedicated window. + private func teardownLinkedView(host: RemoteTmuxHost, windowId: UUID) { + for (key, mirror) in linkedMirrors + where mirror.host.connectionHash == host.connectionHash { + mirror.detachObserver() + linkedMirrors[key] = nil + } + linkedViews[host.connectionHash]?.stop() + linkedViews[host.connectionHash] = nil + windowRegistry.unbind(hostHash: host.connectionHash) + if let appDelegate = AppDelegate.shared, + appDelegate.windowForMainWindowId(windowId) != nil { + appDelegate.discardMainWindowWithoutClosedHistory(windowId: windowId) + } + } + + /// Whether `windowId` is a dedicated linked-view window (its host has a live + /// coordinator). Used to route New Workspace through the coordinator. + private func linkedViewCoordinator(forWindowId windowId: UUID) -> RemoteTmuxViewConnection? { + guard let host = windowRegistry.host(forWindowId: windowId) else { return nil } + return linkedViews[host.connectionHash] + } + // MARK: - Create / destroy propagation (P5) /// A new tab was requested in a mirrored workspace → create a tmux window in @@ -860,6 +1016,13 @@ final class RemoteTmuxController { /// mirror (caller suppresses local creation); `false` otherwise — e.g. a dedicated /// window whose active tab is a dragged-in local one, so the caller goes local. func handleRemoteWindowNewWorkspaceRequested(windowId: UUID) -> Bool { + // Linked-view: a new workspace is a new tmux session created over the shared + // view stream (no new SSH session); it links in and surfaces via the + // coordinator's republish. "New workspace rides the linking." + if Self.linkedViewEnabled, let coordinator = linkedViewCoordinator(forWindowId: windowId) { + coordinator.newWorkspace() + return true + } // The registry stores the full host (destination + port + identity), so // the new session reuses the exact connection details of the window's host. guard let host = windowRegistry.host(forWindowId: windowId) else { return false } @@ -1180,6 +1343,12 @@ final class RemoteTmuxController { /// CLI's `ssh -f` left them persistent). Does NOT kill any remote tmux /// server/session — only the local control clients and masters. func detachAll() { + // Stop linked-view coordinators (each owns a shared view connection) and + // drop their mirrors; the remote tmux servers keep running for resume. + for (_, mirror) in linkedMirrors { mirror.detachObserver() } + linkedMirrors.removeAll() + for (_, coordinator) in linkedViews { coordinator.stop() } + linkedViews.removeAll() let connections = Array(connectionsByHostSession.keys).compactMap { removeCachedConnection(forKey: $0) } for connection in connections { connection.stop() } // Fire-and-forget `ssh -O exit` per endpoint: it hits the local control diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index a1bb29a079a7..2ff1d333d2fd 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -73,13 +73,23 @@ final class RemoteTmuxSessionMirror { /// regroups (a new tab in this session adds an id) via ``updateWindowIdFilter``. private var windowIdFilter: Set? + /// Whether this mirror owns its connection's lifecycle. `true` (per-session + /// transport): a connection `%exit` means THIS session ended, so route to + /// `handleSessionEndedRemotely`. `false` (linked-view): the connection is the + /// SHARED view stream owned by `RemoteTmuxViewConnection`; a single session + /// ending is a reconcile unlink (the coordinator removes this mirror), and a + /// connection `%exit` means the whole view ended (the coordinator tears down + /// every mirror) — so this mirror must NOT self-trigger per-session teardown. + private let managesOwnLifecycle: Bool + init( host: RemoteTmuxHost, sessionName: String, connection: RemoteTmuxControlConnection, tabManager: TabManager, workspace: Workspace, - windowIdFilter: Set? = nil + windowIdFilter: Set? = nil, + managesOwnLifecycle: Bool = true ) { self.host = host self.sessionName = sessionName @@ -87,6 +97,7 @@ final class RemoteTmuxSessionMirror { self.tabManager = tabManager self.workspace = workspace self.windowIdFilter = windowIdFilter + self.managesOwnLifecycle = managesOwnLifecycle self.defaultPanelIds = Array(workspace.panels.keys) // Register as one of possibly several observers — never overwrite a @@ -111,7 +122,8 @@ final class RemoteTmuxSessionMirror { self?.rebuild() }, onExit: { [weak self] in - self?.handleConnectionExited() + guard let self, self.managesOwnLifecycle else { return } + self.handleConnectionExited() } ) rebuild() From 8dfef3eb537dc3629759db9199659409a7122c0c Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 20:40:10 -0700 Subject: [PATCH 17/38] remote-tmux: fix linked-view lifecycle gaps from adversarial review 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. --- Sources/RemoteTmuxController.swift | 61 +++++++++++++++++++++++--- Sources/RemoteTmuxSessionMirror.swift | 6 ++- Sources/RemoteTmuxViewConnection.swift | 32 +++++++++++++- 3 files changed, 92 insertions(+), 7 deletions(-) diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index fdd8609d5b85..601876275adf 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -562,6 +562,16 @@ final class RemoteTmuxController { guard let coordinator = linkedViews[host.connectionHash], let connection = coordinator.connection else { return } let desired = coordinator.workspaces + // No real sessions remain (e.g. the last was killed out-of-band): tear down + // the view + dedicated window. `TabManager.closeWorkspace` refuses to remove + // a window's final workspace, so the per-mirror removal below cannot clear an + // empty window — this matches the non-linked last-session-ends behavior. + if desired.isEmpty { + if let windowId = windowRegistry.windowId(forHostHash: host.connectionHash) { + teardownLinkedView(host: host, windowId: windowId) + } + return + } let desiredNames = Set(desired.map(\.sessionName)) // Remove mirrors whose session no longer exists. @@ -607,9 +617,14 @@ final class RemoteTmuxController { } } - /// Tears down a host's linked-view coordinator and all its mirrors (the view - /// stream ended for good), then closes the dedicated window. - private func teardownLinkedView(host: RemoteTmuxHost, windowId: UUID) { + /// Stops a host's linked-view coordinator (its shared `-CC` stream) and removes + /// the host's mirrors, WITHOUT touching the dedicated window — callers running + /// during window close must not re-discard it. Also drops the host's transport + /// and exits the shared ControlMaster, matching the non-linked close path. + /// Returns `true` if a coordinator was present. + @discardableResult + private func stopLinkedView(host: RemoteTmuxHost) -> Bool { + guard linkedViews[host.connectionHash] != nil else { return false } for (key, mirror) in linkedMirrors where mirror.host.connectionHash == host.connectionHash { mirror.detachObserver() @@ -618,6 +633,15 @@ final class RemoteTmuxController { linkedViews[host.connectionHash]?.stop() linkedViews[host.connectionHash] = nil windowRegistry.unbind(hostHash: host.connectionHash) + transportRegistry.remove(connectionHash: host.connectionHash) + RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) + return true + } + + /// Tears down a host's linked-view coordinator and all its mirrors (the view + /// stream ended for good), then closes the dedicated window. + private func teardownLinkedView(host: RemoteTmuxHost, windowId: UUID) { + stopLinkedView(host: host) if let appDelegate = AppDelegate.shared, appDelegate.windowForMainWindowId(windowId) != nil { appDelegate.discardMainWindowWithoutClosedHistory(windowId: windowId) @@ -1018,8 +1042,11 @@ final class RemoteTmuxController { func handleRemoteWindowNewWorkspaceRequested(windowId: UUID) -> Bool { // Linked-view: a new workspace is a new tmux session created over the shared // view stream (no new SSH session); it links in and surfaces via the - // coordinator's republish. "New workspace rides the linking." - if Self.linkedViewEnabled, let coordinator = linkedViewCoordinator(forWindowId: windowId) { + // coordinator's republish. "New workspace rides the linking." Gate on the + // ACTIVE workspace being a remote mirror — a dragged-in local workspace that + // happens to be active must create a local workspace, not a remote session. + if Self.linkedViewEnabled, let coordinator = linkedViewCoordinator(forWindowId: windowId), + AppDelegate.shared?.tabManagerFor(windowId: windowId)?.selectedTab?.isRemoteTmuxMirror == true { coordinator.newWorkspace() return true } @@ -1228,6 +1255,15 @@ final class RemoteTmuxController { for windowId in windowRegistry.windowsMarkedForKillOnClose() { guard windowRegistry.consumeKillSessionsOnClose(windowId: windowId), let host = windowRegistry.host(forWindowId: windowId) else { continue } + // Linked-view: the host's real sessions are reachable only over the live + // view stream (a one-shot ssh would be refused under MaxSessions=1). Kill + // them there with a round-trip barrier so the kills land before we stop + // the shared coordinator. Gated on live state, not the beta flag. + if let coordinator = linkedViews[host.connectionHash] { + await coordinator.killAllWorkspaceSessions() + stopLinkedView(host: host) + continue + } let closingWorkspaceIds = Set(AppDelegate.shared?.tabManagerFor(windowId: windowId)?.tabs.map(\.id) ?? []) let transport = transport(for: host) let mirrorsInWindow = sessionMirrors.filter { _, mirror in @@ -1251,6 +1287,12 @@ final class RemoteTmuxController { /// in other windows keep their control streams. func handleRemoteWindowClosed(windowId: UUID) { guard let host = windowRegistry.host(forWindowId: windowId) else { return } + // Linked-view: a single shared `-CC` coordinator backs the whole window, so + // stop it (and drop its mirrors) instead of walking per-session mirrors. The + // window is already closing, so `stopLinkedView` must not re-discard it. + // Gated on live coordinator state — NOT the beta flag — so a window opened + // while the flag was on still tears down if the user toggles it off. + if stopLinkedView(host: host) { return } let closingWorkspaceIds = Set(AppDelegate.shared?.tabManagerFor(windowId: windowId)?.tabs.map(\.id) ?? []) windowRegistry.unbind(windowId: windowId) let mirrorsInWindow = sessionMirrors.filter { _, mirror in @@ -1282,6 +1324,15 @@ final class RemoteTmuxController { /// User-initiated mirrored workspace close detaches locally and kills the remote session. func handleWorkspaceClosed(workspaceId: UUID) { + // Linked-view: kill the real session over the shared view stream. The + // coordinator's reconcile then drops the mirror + workspace (and tears down + // the window when it was the last). Without this the workspace closes only + // locally and the next reconcile re-adds it, since the session still exists. + if let entry = linkedMirrors.first(where: { $0.value.mirroredWorkspaceId == workspaceId }) { + linkedViews[entry.value.host.connectionHash]? + .killWorkspaceSession(named: entry.value.sessionName) + return + } guard let entry = sessionMirrors.first(where: { $0.value.mirroredWorkspaceId == workspaceId }) else { return } let mirror = entry.value diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index 2ff1d333d2fd..98cd8dea6b4c 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -116,7 +116,11 @@ final class RemoteTmuxSessionMirror { self?.handleActivePaneChanged(windowId: windowId, paneId: paneId) }, onSessionChanged: { [weak self] oldName, newName in - self?.handleSessionNameChanged(oldName: oldName, newName: newName) + // Shared-connection (linked-view) mirrors must not drive the view + // connection's identity: the coordinator owns it. Same ownership + // gate as `onExit`. + guard let self, self.managesOwnLifecycle else { return } + self.handleSessionNameChanged(oldName: oldName, newName: newName) }, onTopologyChanged: { [weak self] in self?.rebuild() diff --git a/Sources/RemoteTmuxViewConnection.swift b/Sources/RemoteTmuxViewConnection.swift index 4e22308d4982..f5ebb4bd3be2 100644 --- a/Sources/RemoteTmuxViewConnection.swift +++ b/Sources/RemoteTmuxViewConnection.swift @@ -93,6 +93,28 @@ final class RemoteTmuxViewConnection { scheduleReconcile() } + /// Kills every mirrored home session over the live stream — used on the + /// quit-with-kill path, where a one-shot ssh would be refused under + /// MaxSessions=1. Awaits a round-trip so the kills land before the caller stops + /// the control connection. The hidden view session is left for `stop()` to drop. + func killAllWorkspaceSessions() async { + guard let conn = connection, conn.connectionState == .connected else { return } + for workspace in workspaces { + _ = conn.send("kill-session -t \(quoted(workspace.sessionName))") + } + // Barrier: the reply only arrives after the server has processed the kills. + _ = await conn.query("display-message -p ok") + } + + /// Kills one mirrored home session over the live stream and reconciles, so the + /// coordinator drops its mirror + workspace (and tears down the window when it + /// was the last). Used by the user-initiated workspace-close path. + func killWorkspaceSession(named name: String) { + guard let conn = connection, conn.connectionState == .connected else { return } + _ = conn.send("kill-session -t \(quoted(name))") + scheduleReconcile() + } + // MARK: - View creation (pre-attach one-shots) private func ensureViewSession() async throws { @@ -109,7 +131,15 @@ final class RemoteTmuxViewConnection { } // Record the view's placeholder window so reconcile never unlinks it. let phOut = await runOneShot(["list-windows", "-t", view.sessionName, "-F", "#{window_id}"]) - placeholderWindowId = phOut.split(separator: "\n").first.map(String.init) + let viewWindowIds = phOut + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + placeholderWindowId = viewWindowIds.first + // Adopt windows already linked into a reused view (from a prior cmux run) as + // owned, so reconcile can unlink any whose real session has since died — + // otherwise dead linked copies accumulate in the persistent view session. + ownedWindowIds = Set(viewWindowIds.dropFirst()) } /// A pre-attach `tmux` one-shot over the shared master, returning stdout (or "" From dd409736b15bbf158cccf0d2ef5449d2fee64252 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 20:40:10 -0700 Subject: [PATCH 18/38] remote-tmux: wire linked-view mirrors into all per-action handlers 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. --- Sources/RemoteTmuxController.swift | 66 ++++++++++++++++++++------- Sources/RemoteTmuxSessionMirror.swift | 5 ++ 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 601876275adf..a196e1bfbfdc 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -655,6 +655,28 @@ final class RemoteTmuxController { return linkedViews[host.connectionHash] } + // MARK: - Action mirror lookup (both modes) + + /// A workspace's mirror, whether per-session (non-linked) or shared (linked-view). + /// Action handlers use this so they work in both modes: a linked mirror shares one + /// view `-CC` connection but addresses windows/panes by tmux's GLOBAL ids, so + /// commands targeting `@windowId`/`%paneId` route correctly over it. + private func actionMirror(forWorkspaceId workspaceId: UUID) -> RemoteTmuxSessionMirror? { + sessionMirrors.values.first { $0.mirroredWorkspaceId == workspaceId } + ?? linkedMirrors.values.first { $0.mirroredWorkspaceId == workspaceId } + } + + /// All action mirrors across both modes, for surface-based lookups. + private var actionMirrors: [RemoteTmuxSessionMirror] { + Array(sessionMirrors.values) + Array(linkedMirrors.values) + } + + /// Whether `mirror` is a shared linked-view mirror (its connection is the view + /// stream, not a per-session one) — used to anchor session-relative commands. + private func isLinkedMirror(_ mirror: RemoteTmuxSessionMirror) -> Bool { + linkedMirrors.values.contains { $0 === mirror } + } + // MARK: - Create / destroy propagation (P5) /// A new tab was requested in a mirrored workspace → create a tmux window in @@ -685,12 +707,16 @@ final class RemoteTmuxController { workingDirectory: String?, workingDirectorySourcePanelId: UUID? ) -> Bool { - guard let mirror = sessionMirrors.values.first(where: { $0.mirroredWorkspaceId == workspaceId }), + guard let mirror = actionMirror(forWorkspaceId: workspaceId), mirror.connection.connectionState == .connected else { return false } let afterWindowId: Int? switch placement { case .end: - afterWindowId = nil + // A linked mirror shares the view connection, whose `{end}` resolves in + // the VIEW session; anchor on the home session's last window so the new + // tab lands in the right session. (Non-linked: nil → `{end}` is correct, + // since its connection is attached to that session.) + afterWindowId = isLinkedMirror(mirror) ? mirror.orderedWindowIds.last : nil case .afterPanel(let panelId): // nil (panel has no live window) falls back to end placement. afterWindowId = mirror.windowId(forPanel: panelId) @@ -755,16 +781,22 @@ final class RemoteTmuxController { /// tmux session name tracks the cmux workspace title. func handleMirrorWorkspaceRenamed(workspaceId: UUID, title: String?) { guard let name = RemoteTmuxHost.controlModeCommandName(title), - let entry = sessionMirrors.first(where: { $0.value.mirroredWorkspaceId == workspaceId }) + let mirror = actionMirror(forWorkspaceId: workspaceId) else { return } - let mirror = entry.value let oldName = mirror.sessionName guard name != oldName, mirror.connection.connectionState == .connected else { return } - // Target by the stable session id when known, so the rename can't race a - // prior rename's name. - guard let target = mirror.connection.sessionId.map({ "$\($0)" }) - ?? RemoteTmuxHost.controlModeLineSafeName(oldName).map(RemoteTmuxHost.shellSingleQuoted) - else { return } + // Non-linked: target by the stable session id when known, so the rename can't + // race a prior rename's name. Linked: the shared connection's session id is + // the VIEW session's, so target the home session by name instead. (The + // rename surfaces back through reconcile, which re-keys the linked mirror.) + let target: String? + if isLinkedMirror(mirror) { + target = RemoteTmuxHost.controlModeLineSafeName(oldName).map(RemoteTmuxHost.shellSingleQuoted) + } else { + target = mirror.connection.sessionId.map({ "$\($0)" }) + ?? RemoteTmuxHost.controlModeLineSafeName(oldName).map(RemoteTmuxHost.shellSingleQuoted) + } + guard let target else { return } _ = mirror.connection.send("rename-session -t \(target) \(RemoteTmuxHost.shellSingleQuoted(name))") // Do not re-key local state here. tmux can reject a rename (for example // duplicate session name); `%session-changed` is the confirmation point. @@ -815,7 +847,7 @@ final class RemoteTmuxController { /// `swap-window` only swaps two windows' indices (no unlink), so there is no /// churn. `-d` keeps the active window unchanged. func handleMirrorWindowsReordered(workspaceId: UUID, orderedPanelIds: [UUID]) { - guard let mirror = sessionMirrors.values.first(where: { $0.mirroredWorkspaceId == workspaceId }), + guard let mirror = actionMirror(forWorkspaceId: workspaceId), mirror.connection.connectionState == .connected else { return } let desired = orderedPanelIds.compactMap { mirror.windowId(forPanel: $0) } guard desired.count >= 2 else { return } @@ -849,7 +881,7 @@ final class RemoteTmuxController { /// `%layout-change`. Returns `true` if `surfaceId` is a mirror pane (the /// caller suppresses the local split). func handleMirrorSplitRequested(surfaceId: UUID, vertical: Bool) -> Bool { - for sessionMirror in sessionMirrors.values { + for sessionMirror in actionMirrors { if let match = sessionMirror.windowMirror(forSurfaceId: surfaceId) { return match.mirror.requestSplit(fromPane: match.tmuxPaneId, vertical: vertical) } @@ -860,7 +892,7 @@ final class RemoteTmuxController { /// Whether `surfaceId` is a pane of a mirrored multi-pane tmux window (used /// to keep the context-menu Split items enabled for mirror panes). func isMirrorPaneSurface(_ surfaceId: UUID) -> Bool { - for sessionMirror in sessionMirrors.values { + for sessionMirror in actionMirrors { if sessionMirror.windowMirror(forSurfaceId: surfaceId) != nil { return true } } return false @@ -884,7 +916,7 @@ final class RemoteTmuxController { private func pasteTarget(forSurfaceId surfaceId: UUID) -> (connection: RemoteTmuxControlConnection, paneId: Int)? { - for sessionMirror in sessionMirrors.values where sessionMirror.connection.connectionState == .connected { + for sessionMirror in actionMirrors where sessionMirror.connection.connectionState == .connected { if let paneId = sessionMirror.paneId(forSurfaceId: surfaceId) { return (sessionMirror.connection, paneId) } @@ -897,7 +929,7 @@ final class RemoteTmuxController { /// to the remote tmux host (and insert the remote path) instead of an /// unreadable macOS-local one. func remoteUploadTarget(forSurfaceId surfaceId: UUID) -> TerminalRemoteUploadTarget? { - for sessionMirror in sessionMirrors.values + for sessionMirror in actionMirrors where !sessionMirror.connection.exited && sessionMirror.ownsSurface(surfaceId) { return .detectedSSH(sessionMirror.host.detectedSSHSession()) } @@ -908,7 +940,7 @@ final class RemoteTmuxController { /// bonsplit-level split) → propagate to tmux `split-window`. Covers both /// single-pane mirror windows and multi-pane ones. Returns `true` if handled. func handleMirrorTabSplitRequested(workspaceId: UUID, panelId: UUID, vertical: Bool) -> Bool { - guard let mirror = sessionMirrors.values.first(where: { $0.mirroredWorkspaceId == workspaceId }) + guard let mirror = actionMirror(forWorkspaceId: workspaceId) else { return false } return mirror.requestSplit(windowPanelId: panelId, vertical: vertical) } @@ -916,7 +948,7 @@ final class RemoteTmuxController { /// A mirrored window's tab was renamed → `rename-window` on the remote. func handleMirrorWindowRenamed(workspaceId: UUID, panelId: UUID, title: String?) { guard let name = RemoteTmuxHost.controlModeCommandName(title), - let mirror = sessionMirrors.values.first(where: { $0.mirroredWorkspaceId == workspaceId }), + let mirror = actionMirror(forWorkspaceId: workspaceId), mirror.connection.connectionState == .connected, let windowId = mirror.windowId(forPanel: panelId) else { return } _ = mirror.connection.send("rename-window -t @\(windowId) \(RemoteTmuxHost.shellSingleQuoted(name))") @@ -929,7 +961,7 @@ final class RemoteTmuxController { private func mirrorWindowTarget(workspaceId: UUID, panelId: UUID) -> (mirror: RemoteTmuxSessionMirror, windowId: Int)? { - guard let mirror = sessionMirrors.values.first(where: { $0.mirroredWorkspaceId == workspaceId }), + guard let mirror = actionMirror(forWorkspaceId: workspaceId), let windowId = mirror.windowId(forPanel: panelId) else { return nil } return (mirror, windowId) } diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index 98cd8dea6b4c..55b98d216fd7 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -226,6 +226,11 @@ final class RemoteTmuxSessionMirror { return connection.windowOrder.filter { windowIdFilter.contains($0) } } + /// This mirror's window ids in display order. Lets the controller anchor a + /// linked-view new-tab on the home session's last window (the shared view + /// connection's `{end}` would otherwise resolve in the view session). + var orderedWindowIds: [Int] { mirroredWindowOrder } + func rebuild() { guard let workspace else { return } var createdNewPanel = false From 06ed8bee383ae5f24c8d72bf143b1ed12594530a Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 20:40:10 -0700 Subject: [PATCH 19/38] remote-tmux: anchor linked-view new-tab on the home session by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (`':{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 ` tail into `appendWorkingDirectory`. --- Sources/RemoteTmuxController.swift | 42 ++++++++++++++++++++------- Sources/RemoteTmuxSessionMirror.swift | 5 ---- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index a196e1bfbfdc..a8f75a5d312d 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -709,23 +709,27 @@ final class RemoteTmuxController { ) -> Bool { guard let mirror = actionMirror(forWorkspaceId: workspaceId), mirror.connection.connectionState == .connected else { return false } + let commandWorkingDirectory = Self.liveMirrorWindowWorkingDirectory( + workingDirectory, + sourcePanelId: workingDirectorySourcePanelId, + windowIdForPanel: mirror.windowId(forPanel:) + ) + // Linked-view: anchor on the home session by name — a window id is ambiguous + // when linked into both the home session and the hidden view, so it would + // create the tab in the view over the shared connection. + if isLinkedMirror(mirror) { + return mirror.connection.send( + Self.newWindowCommandInSession(mirror.sessionName, workingDirectory: commandWorkingDirectory) + ) + } let afterWindowId: Int? switch placement { case .end: - // A linked mirror shares the view connection, whose `{end}` resolves in - // the VIEW session; anchor on the home session's last window so the new - // tab lands in the right session. (Non-linked: nil → `{end}` is correct, - // since its connection is attached to that session.) - afterWindowId = isLinkedMirror(mirror) ? mirror.orderedWindowIds.last : nil + afterWindowId = nil case .afterPanel(let panelId): // nil (panel has no live window) falls back to end placement. afterWindowId = mirror.windowId(forPanel: panelId) } - let commandWorkingDirectory = Self.liveMirrorWindowWorkingDirectory( - workingDirectory, - sourcePanelId: workingDirectorySourcePanelId, - windowIdForPanel: mirror.windowId(forPanel:) - ) return mirror.connection.send( Self.newWindowCommand(afterWindowId: afterWindowId, workingDirectory: commandWorkingDirectory) ) @@ -769,12 +773,28 @@ final class RemoteTmuxController { /// terminate the command line is dropped, leaving the placement-only command. nonisolated static func newWindowCommand(afterWindowId: Int?, workingDirectory: String?) -> String { var command = afterWindowId.map { "new-window -a -t @\($0)" } ?? "new-window -a -t '{end}'" + appendWorkingDirectory(&command, workingDirectory) + return command + } + + /// `new-window` for a linked-view mirror, anchored on the home session BY NAME. + /// A bare window id (`@id`) is ambiguous when the window is linked into both the + /// home session and the hidden view, so over the shared view connection + /// `new-window -t @id` creates the window in the VIEW. `':{end}'` forces + /// it into the home session, at the end. (Exact after-panel placement is not + /// preserved for linked tabs; correct session beats exact position.) + nonisolated static func newWindowCommandInSession(_ sessionName: String, workingDirectory: String?) -> String { + var command = "new-window -a -t \(RemoteTmuxHost.shellSingleQuoted("\(sessionName):{end}"))" + appendWorkingDirectory(&command, workingDirectory) + return command + } + + private nonisolated static func appendWorkingDirectory(_ command: inout String, _ workingDirectory: String?) { if let directory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines), !directory.isEmpty, RemoteTmuxHost.controlModeLineSafeName(directory) != nil { command += " -c \(RemoteTmuxHost.shellSingleQuoted(directory))" } - return command } /// A mirrored workspace was renamed → `rename-session` on the remote so the diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index 55b98d216fd7..98cd8dea6b4c 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -226,11 +226,6 @@ final class RemoteTmuxSessionMirror { return connection.windowOrder.filter { windowIdFilter.contains($0) } } - /// This mirror's window ids in display order. Lets the controller anchor a - /// linked-view new-tab on the home session's last window (the shared view - /// connection's `{end}` would otherwise resolve in the view session). - var orderedWindowIds: [Int] { mirroredWindowOrder } - func rebuild() { guard let workspace else { return } var createdNewPanel = false From e1cecc2853a4604427392a96d96404d1c1b182db Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 20:40:10 -0700 Subject: [PATCH 20/38] remote-tmux: reconcile after linked-view new-tab so it surfaces 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. --- Sources/RemoteTmuxController.swift | 7 ++++++- Sources/RemoteTmuxViewConnection.swift | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index a8f75a5d312d..b0bc4db6545f 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -718,9 +718,14 @@ final class RemoteTmuxController { // when linked into both the home session and the hidden view, so it would // create the tab in the view over the shared connection. if isLinkedMirror(mirror) { - return mirror.connection.send( + let sent = mirror.connection.send( Self.newWindowCommandInSession(mirror.sessionName, workingDirectory: commandWorkingDirectory) ) + // The new window is in the home session, not yet linked into the view, so + // the view stream gets no %window-add — nudge a reconcile to link it and + // surface it as a tab. + if sent { linkedViews[mirror.host.connectionHash]?.requestReconcile() } + return sent } let afterWindowId: Int? switch placement { diff --git a/Sources/RemoteTmuxViewConnection.swift b/Sources/RemoteTmuxViewConnection.swift index f5ebb4bd3be2..ba937e9e770b 100644 --- a/Sources/RemoteTmuxViewConnection.swift +++ b/Sources/RemoteTmuxViewConnection.swift @@ -106,6 +106,11 @@ final class RemoteTmuxViewConnection { _ = await conn.query("display-message -p ok") } + /// Triggers a reconcile after an out-of-band change the view stream won't notify + /// on its own — e.g. a new tab is a `new-window` created in a home session that + /// isn't yet linked into the view, so no `%window-add` arrives on this stream. + func requestReconcile() { scheduleReconcile() } + /// Kills one mirrored home session over the live stream and reconciles, so the /// coordinator drops its mirror + workspace (and tears down the window when it /// was the last). Used by the user-initiated workspace-close path. From f3c0f028e57a08f2c55e71d4840ef867b38a3d9d Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 21:14:41 -0700 Subject: [PATCH 21/38] remote-tmux: keep browser New-Workspace/button local in a mirror window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Sources/AppDelegate.swift | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 9838baef1aac..3c30757bd39c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -7390,10 +7390,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let context = livePreferredContext ?? preferredMainWindowContextForWorkspaceCreation(event: event, debugSource: debugSource) - // In a dedicated remote-tmux window, a new workspace means "create a new - // tmux session on that host" — route it to the remote and mirror it into - // this window instead of creating a local workspace. - if let context, + // In a dedicated remote-tmux window, a new TERMINAL workspace means "create a + // new tmux session on that host" — route it to the remote and mirror it into + // this window. A new BROWSER workspace has no remote analogue (the browser is + // a local WebKit surface), so it skips the remote route and is created locally + // in the same window. + if initialSurface == .terminal, + let context, remoteTmuxController.handleRemoteWindowNewWorkspaceRequested(windowId: context.windowId) { return true } @@ -15283,6 +15286,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent onExecuted?() return true case .newBrowser: + // A mirror workspace is a 1:1 tmux view and refuses a local browser + // surface, so opening a browser tab there would no-op. Fall back to a + // NEW LOCAL browser workspace in the same window instead. + if context.tabManager.selectedTab?.isRemoteTmuxMirror == true { + guard performNewBrowserWorkspaceAction( + tabManager: context.tabManager, debugSource: "tabBar.newBrowser.mirrorFallback" + ) else { return false } + onExecuted?() + return true + } let previousTabManager = tabManager tabManager = context.tabManager defer { tabManager = previousTabManager } From a64dc7b0e6913d38223897464e2b68a74f8ccc1b Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 21:11:52 -0700 Subject: [PATCH 22/38] remote-tmux: size each linked-view window to its cmux pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Sources/RemoteTmuxControlConnection.swift | 48 ++++++++++++++++++++++ Sources/RemoteTmuxController.swift | 3 +- Sources/RemoteTmuxSessionMirror.swift | 43 +++++++++++++------ Sources/RemoteTmuxViewSession.swift | 4 ++ Sources/RemoteTmuxWindowMirror.swift | 13 +++++- cmuxTests/RemoteTmuxViewSessionTests.swift | 3 ++ 6 files changed, 100 insertions(+), 14 deletions(-) diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index a1c3cffb0acc..da4f17d529f8 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -132,6 +132,16 @@ final class RemoteTmuxControlConnection { private var lastClientSize: (columns: Int, rows: Int)? private var pendingPostAttachAction: PostAttachAction? + /// Per-window desired sizes for the linked-view path, where ONE control client + /// is shared by every mirrored window so a single `refresh-client -C` can't size + /// them independently. Each window is sized explicitly via `resize-window -t @id` + /// (the view session runs `window-size manual`). `pending` is the latest request + /// per window; `applied` is what we last sent, so the debounced flush skips + /// no-ops; both are re-applied after a reconnect. + private var pendingWindowSizes: [Int: (columns: Int, rows: Int)] = [:] + private var appliedWindowSizes: [Int: (columns: Int, rows: Int)] = [:] + private var windowResizeDebounceTask: Task? + /// Trailing-edge debounce for `refresh-client -C`. SwiftUI layout settle makes the /// rendered grid oscillate (e.g. cols 154→155→156→161→…, ~15 distinct grids in /// ~1.3s), and each previously sent its own `refresh-client -C` → ~15 SIGWINCH / @@ -433,6 +443,40 @@ final class RemoteTmuxControlConnection { sendInternal(command, kind: .other) } + /// Sizes ONE mirrored window to `columns`×`rows` cells via `resize-window -t @id` + /// — the linked-view analogue of ``setClientSize(columns:rows:)``. The shared + /// view client can't size each window via `refresh-client -C`, so windows are + /// resized explicitly (the view session runs `window-size manual`). Records the + /// size for reconnect reseed; debounced so SwiftUI's layout-settle oscillation + /// coalesces into one `resize-window` per window. + func resizeWindow(windowId: Int, columns: Int, rows: Int) { + guard columns > 0, rows > 0 else { return } + pendingWindowSizes[windowId] = (columns, rows) + guard connectionState == .connected else { return } + windowResizeDebounceTask?.cancel() + windowResizeDebounceTask = Task { @MainActor [weak self] in + do { + try await ContinuousClock().sleep(for: .milliseconds(Self.clientSizeDebounceMs)) + } catch { + return + } + guard let self, self.connectionState == .connected else { return } + self.flushWindowResizes() + } + } + + /// Sends `resize-window` for every window whose desired size differs from what we + /// last applied (skipping no-ops). Used by the debounce and by reconnect reseed. + private func flushWindowResizes() { + for (windowId, size) in pendingWindowSizes + where appliedWindowSizes[windowId]?.columns != size.columns + || appliedWindowSizes[windowId]?.rows != size.rows { + if send("resize-window -t @\(windowId) -x \(size.columns) -y \(size.rows)") { + appliedWindowSizes[windowId] = size + } + } + } + /// Sizes the tmux control client to `columns`×`rows` cells (tmux /// `refresh-client -C`) so the remote windows/panes reflow to the rendered /// cmux grid. Without this a freshly attached session stays at ssh's default @@ -1114,6 +1158,10 @@ final class RemoteTmuxControlConnection { if let size = lastClientSize { send("refresh-client -C \(size.columns)x\(size.rows)") } + // Linked-view per-window sizes: a fresh client reverts windows to default, so + // re-apply every recorded `resize-window` (clear applied → all resend). + appliedWindowSizes.removeAll() + flushWindowResizes() // The re-applied size is usually a no-op (the server kept the window at our // size across the transport drop), so TUIs get no SIGWINCH — kick them so // they repaint over the re-seeded (possibly stale) frame. FIFO-safe: the diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index b0bc4db6545f..13634e721369 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -603,7 +603,8 @@ final class RemoteTmuxController { tabManager: manager, workspace: workspace, windowIdFilter: windowIds, - managesOwnLifecycle: false) + managesOwnLifecycle: false, + perWindowSizing: true) } } diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index 98cd8dea6b4c..ba8fcb4ce7b0 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -82,6 +82,12 @@ final class RemoteTmuxSessionMirror { /// every mirror) — so this mirror must NOT self-trigger per-session teardown. private let managesOwnLifecycle: Bool + /// Linked-view mirrors share ONE control client across many windows, so they size + /// each window with `resize-window -t @id` (``RemoteTmuxControlConnection/resizeWindow(windowId:columns:rows:)``) + /// instead of the per-session `refresh-client -C`. Non-linked mirrors own their + /// client and keep using `setClientSize`. + private let perWindowSizing: Bool + init( host: RemoteTmuxHost, sessionName: String, @@ -89,7 +95,8 @@ final class RemoteTmuxSessionMirror { tabManager: TabManager, workspace: Workspace, windowIdFilter: Set? = nil, - managesOwnLifecycle: Bool = true + managesOwnLifecycle: Bool = true, + perWindowSizing: Bool = false ) { self.host = host self.sessionName = sessionName @@ -98,6 +105,7 @@ final class RemoteTmuxSessionMirror { self.workspace = workspace self.windowIdFilter = windowIdFilter self.managesOwnLifecycle = managesOwnLifecycle + self.perWindowSizing = perWindowSizing self.defaultPanelIds = Array(workspace.panels.keys) // Register as one of possibly several observers — never overwrite a @@ -251,8 +259,12 @@ final class RemoteTmuxSessionMirror { // TUI runs) doesn't stay at ssh's default 80×24 and render // mangled. The multi-pane path handles this via the window // mirror's own geometry. - onResize: { [weak connection] columns, rows in - connection?.setClientSize(columns: columns, rows: rows) + onResize: { [weak connection, perWindowSizing] columns, rows in + if perWindowSizing { + connection?.resizeWindow(windowId: windowId, columns: columns, rows: rows) + } else { + connection?.setClientSize(columns: columns, rows: rows) + } } ) else { continue } panelIdByWindow[windowId] = panel.id @@ -331,17 +343,23 @@ final class RemoteTmuxSessionMirror { /// windows are skipped — their mirror view owns client sizing). private func pushInitialClientSize() -> Bool { guard let workspace else { return true } - let singlePanePanelIds = panelIdByWindow - .filter { windowMirrorByWindowId[$0.key] == nil } - .values - guard !singlePanePanelIds.isEmpty else { return true } - for panelId in singlePanePanelIds { + let singlePaneWindows = panelIdByWindow.filter { windowMirrorByWindowId[$0.key] == nil } + guard !singlePaneWindows.isEmpty else { return true } + // Non-linked: one client size suffices, so the first ready grid finishes. + // Linked (perWindowSizing): size EVERY single-pane window, and only report + // "done" once they all have a rendered grid — otherwise keep retrying. + var allSized = true + for (windowId, panelId) in singlePaneWindows { guard let panel = workspace.panels[panelId] as? TerminalPanel, - let grid = panel.surface.renderedGridCells() else { continue } - connection.setClientSize(columns: grid.columns, rows: grid.rows) - return true + let grid = panel.surface.renderedGridCells() else { allSized = false; continue } + if perWindowSizing { + connection.resizeWindow(windowId: windowId, columns: grid.columns, rows: grid.rows) + } else { + connection.setClientSize(columns: grid.columns, rows: grid.rows) + return true + } } - return false + return perWindowSizing ? allSized : false } /// Creates the in-tab multi-pane renderer the first time a window has more @@ -364,6 +382,7 @@ final class RemoteTmuxSessionMirror { panelId: panelId, connection: connection, layout: window.layout, + perWindowSizing: perWindowSizing, makePanel: { [weak workspace, weak connection] tmuxPaneId in workspace?.makeRemoteTmuxPanePanel(onInput: { data in Task { @MainActor in connection?.sendKeys(paneId: tmuxPaneId, data: data) } diff --git a/Sources/RemoteTmuxViewSession.swift b/Sources/RemoteTmuxViewSession.swift index f17a77c4d6f7..2c8c74cb1cee 100644 --- a/Sources/RemoteTmuxViewSession.swift +++ b/Sources/RemoteTmuxViewSession.swift @@ -58,6 +58,10 @@ struct RemoteTmuxViewSession: Equatable { ["set-option", "-t", n, Self.optView, "1"], ["set-option", "-t", n, Self.optOwner, ownerId], ["set-option", "-t", n, Self.optVersion, String(Self.formatVersion)], + // The single shared `-CC` client can't size each linked window via + // `refresh-client -C`; `window-size manual` lets the coordinator size + // every window explicitly with `resize-window -t @id`. + ["set-option", "-t", n, "window-size", "manual"], ] } diff --git a/Sources/RemoteTmuxWindowMirror.swift b/Sources/RemoteTmuxWindowMirror.swift index 3475f879f1c8..149741d790e7 100644 --- a/Sources/RemoteTmuxWindowMirror.swift +++ b/Sources/RemoteTmuxWindowMirror.swift @@ -39,16 +39,23 @@ final class RemoteTmuxWindowMirror { /// minted at panel-creation time so the view body is a pure read. @ObservationIgnored private var syntheticPaneIds: [Int: PaneID] = [:] + /// Linked-view windows share one control client, so this window is sized via + /// `resize-window -t @id` rather than `refresh-client -C`. See + /// ``RemoteTmuxSessionMirror`` `perWindowSizing`. + private let perWindowSizing: Bool + init( windowId: Int, panelId: UUID, connection: RemoteTmuxControlConnection, layout: RemoteTmuxLayoutNode, + perWindowSizing: Bool = false, makePanel: @escaping (_ tmuxPaneId: Int) -> TerminalPanel? ) { self.windowId = windowId self.panelId = panelId self.connection = connection + self.perWindowSizing = perWindowSizing self.makePanel = makePanel self.layout = layout reconcile(layout: layout) @@ -121,7 +128,11 @@ final class RemoteTmuxWindowMirror { let rows = max(5, Int(contentSizePoints.height / cell.height)) guard lastClientSize?.cols != cols || lastClientSize?.rows != rows else { return true } lastClientSize = (cols, rows) - connection?.setClientSize(columns: cols, rows: rows) + if perWindowSizing { + connection?.resizeWindow(windowId: windowId, columns: cols, rows: rows) + } else { + connection?.setClientSize(columns: cols, rows: rows) + } return true } diff --git a/cmuxTests/RemoteTmuxViewSessionTests.swift b/cmuxTests/RemoteTmuxViewSessionTests.swift index 200fc3eb8e30..cf5f245a6d0a 100644 --- a/cmuxTests/RemoteTmuxViewSessionTests.swift +++ b/cmuxTests/RemoteTmuxViewSessionTests.swift @@ -42,6 +42,9 @@ import Testing #expect(argvs.contains(["set-option", "-t", s.sessionName, "@cmux_view", "1"])) #expect(argvs.contains(["set-option", "-t", s.sessionName, "@cmux_view_owner", "o1"])) #expect(argvs.contains(["set-option", "-t", s.sessionName, "@cmux_view_version", "1"])) + // The shared client can't size each linked window via refresh-client, so the + // view session must run window-size manual for per-window resize-window. + #expect(argvs.contains(["set-option", "-t", s.sessionName, "window-size", "manual"])) } @Test func parsesListRowsWithFreeTextNameLast() { From c688d6a7316d9e55eaf6f2c037db24dddfbd2fb1 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 23:18:41 -0700 Subject: [PATCH 23/38] remote-tmux: open browser in a new local workspace from interactive mirror entrypoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.) --- Sources/AppDelegate.swift | 15 ++++----------- Sources/Workspace.swift | 37 ++++++++++++++++++++++++++++++++----- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 3c30757bd39c..74facebad472 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -7464,7 +7464,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent /// Routes first focus of a freshly created browser-initial workspace into /// the address bar so the user can type a URL immediately. - private func focusInitialBrowserAddressBar(in workspace: Workspace) { + func focusInitialBrowserAddressBar(in workspace: Workspace) { guard let browserPanel = workspace.focusedSurfaceId.flatMap({ workspace.browserPanel(for: $0) }) ?? workspace.panels.values.compactMap({ $0 as? BrowserPanel }).first else { return @@ -15286,16 +15286,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent onExecuted?() return true case .newBrowser: - // A mirror workspace is a 1:1 tmux view and refuses a local browser - // surface, so opening a browser tab there would no-op. Fall back to a - // NEW LOCAL browser workspace in the same window instead. - if context.tabManager.selectedTab?.isRemoteTmuxMirror == true { - guard performNewBrowserWorkspaceAction( - tabManager: context.tabManager, debugSource: "tabBar.newBrowser.mirrorFallback" - ) else { return false } - onExecuted?() - return true - } + // Mirror-workspace handling is centralized in `newBrowserSurface` + // (which redirects to a new local browser workspace), so every browser + // entrypoint behaves the same — no per-entrypoint special-case here. let previousTabManager = tabManager tabManager = context.tabManager defer { tabManager = previousTabManager } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 291a07bde279..1a61d522b177 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -7897,11 +7897,38 @@ final class Workspace: Identifiable, ObservableObject { transparentBackground: Bool = false, bypassRemoteProxy: Bool = false ) -> BrowserPanel? { - // A remote tmux mirror workspace is a 1:1 view of a tmux session (which - // has no browser concept). A local browser tab here would be an orphan - // that the mirror's rebuild() never reconciles, breaking the 1:1 - // invariant — so refuse browser creation in a mirror workspace. - if isRemoteTmuxMirror { return nil } + // A remote tmux mirror workspace is a 1:1 view of a tmux session (no browser + // concept) — a local browser pane here would be an orphan the mirror's + // rebuild() never reconciles. For an explicit user action (globe tab-strip + // button via splitTabBar, command palette via openBrowser) open the browser in + // a NEW LOCAL workspace in the same window instead of no-op'ing, so every + // interactive "new browser" entrypoint behaves the same. The fresh workspace + // isn't a mirror, so this doesn't recurse. + // + // Restoration and automation/socket creation deliberately fall through to the + // `return nil` below: their callers require the returned panel to live in THIS + // workspace. Restoration's replaceSurface would otherwise `closePanel` the + // mirror's own pane and re-home it into a different workspace; the socket + // `new-surface`/`browser` handlers report the created surface under THIS + // workspace's id, so a cross-workspace panel would make them lie. For those a + // clean nil (the long-standing "mirror refuses a local browser" behavior) is + // correct. + if isRemoteTmuxMirror { + guard creationPolicy == .userInitiated, + let manager = owningTabManager ?? AppDelegate.shared?.tabManagerFor(tabId: id) + else { return nil } + let browserWorkspace = manager.addWorkspace(initialSurface: .browser, select: focus ?? true) + let panel = browserWorkspace.panels.values.compactMap { $0 as? BrowserPanel }.first + if let url, let panel { + panel.navigate(to: url) + } else { + // No URL → blank browser: focus the address bar so the user can type, + // matching performNewBrowserWorkspaceAction's behavior (the globe + // button otherwise lands on a blank tab with nothing focused). + AppDelegate.shared?.focusInitialBrowserAddressBar(in: browserWorkspace) + } + return panel + } let browserEnabled = BrowserAvailabilitySettings.isEnabled() guard browserEnabled || creationPolicy.permitsCreationWhenBrowserDisabled else { if let externalURL = url ?? initialRequest?.url { From 3b690080700986d721f61c5af6ab834c62e67cf3 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 23:49:41 -0700 Subject: [PATCH 24/38] remote-tmux: add a Settings toggle for the linked-view beta flag `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. --- .../Sections/BetaFeaturesSection.swift | 23 + Resources/Localizable.xcstrings | 1079 +++++++++++++++++ 2 files changed, 1102 insertions(+) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift index 01baa0f90743..86bf2565d6c6 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift @@ -12,6 +12,7 @@ public struct BetaFeaturesSection: View { @State private var extensions: DefaultsValueModel @State private var customSidebars: DefaultsValueModel @State private var remoteTmux: DefaultsValueModel + @State private var remoteTmuxLinkedView: DefaultsValueModel public init(defaultsStore: UserDefaultsSettingsStore, catalog: SettingCatalog) { _feed = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.rightSidebarFeed)) @@ -19,6 +20,7 @@ public struct BetaFeaturesSection: View { _extensions = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.extensions)) _customSidebars = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.customSidebars)) _remoteTmux = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.remoteTmux)) + _remoteTmuxLinkedView = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.remoteTmuxLinkedView)) } public var body: some View { @@ -38,6 +40,8 @@ public struct BetaFeaturesSection: View { customSidebarsRow SettingsCardDivider() remoteTmuxRow + SettingsCardDivider() + remoteTmuxLinkedViewRow } } .task { startObservingSettings() } @@ -50,6 +54,7 @@ public struct BetaFeaturesSection: View { extensions, customSidebars, remoteTmux, + remoteTmuxLinkedView, ] models.forEach { $0.startObserving() } } @@ -138,6 +143,24 @@ public struct BetaFeaturesSection: View { .accessibilityIdentifier("SettingsBetaRemoteTmuxToggle") } } + + @ViewBuilder + private var remoteTmuxLinkedViewRow: some View { + SettingsCardRow( + configurationReview: .settingsOnly, + searchAnchorID: "setting:betaFeatures:remoteTmuxLinkedView", + String(localized: "settings.betaFeatures.remoteTmuxLinkedView", defaultValue: "Remote tmux linked view"), + subtitle: remoteTmuxLinkedView.current + ? String(localized: "settings.betaFeatures.remoteTmuxLinkedView.subtitleOn", defaultValue: "Mirrors a host's tmux sessions over a single ssh connection by linking every session's windows into one cmux-owned view. Use for hosts that allow only one session per connection (e.g. per-connection 2FA). Requires Remote tmux.") + : String(localized: "settings.betaFeatures.remoteTmuxLinkedView.subtitleOff", defaultValue: "Uses a separate ssh connection per remote tmux session until you enable this. Requires Remote tmux.") + ) { + Toggle("", isOn: Binding(get: { remoteTmuxLinkedView.current }, set: { remoteTmuxLinkedView.set($0) })) + .labelsHidden() + .controlSize(.small) + .disabled(!remoteTmux.current) + .accessibilityIdentifier("SettingsBetaRemoteTmuxLinkedViewToggle") + } + } } /// Small warning callout with a yellow triangle, used at the top of diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 4e8469384bc6..6a8a0f0bfcba 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -142793,6 +142793,1085 @@ } } }, + "settings.betaFeatures.remoteTmux": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remote tmux" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモート tmux" + } + } + } + }, + "settings.betaFeatures.remoteTmux.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hides remote tmux mirroring until you enable it here." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ここで有効にするまで、リモート tmux のミラーリングを表示しません。" + } + } + } + }, + "settings.betaFeatures.remoteTmux.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mirrors a remote host's tmux sessions in the sidebar over ssh tmux -CC; sessions become workspaces and windows become tabs. Quitting cmux leaves the remote tmux server running." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ssh tmux -CC でリモートホストの tmux セッションをサイドバーにミラーリングします。セッションはワークスペースに、ウィンドウはタブになります。cmux を終了してもリモートの tmux サーバーは動作し続けます。" + } + } + } + }, + "settings.betaFeatures.remoteTmuxLinkedView": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remote tmux linked view" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモート tmux リンクビュー" + } + } + } + }, + "settings.betaFeatures.remoteTmuxLinkedView.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mirrors a host's tmux sessions over a single ssh connection by linking every session's windows into one cmux-owned view. Use for hosts that allow only one session per connection (e.g. per-connection 2FA). Requires Remote tmux." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1 つの ssh 接続で、各セッションのウィンドウを cmux 専用のビューにリンクして、ホストの tmux セッションをミラーリングします。1 接続につき 1 セッションのみ許可するホスト(例: 接続ごとの 2 要素認証)で使用します。リモート tmux が必要です。" + } + } + } + }, + "settings.betaFeatures.remoteTmuxLinkedView.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Uses a separate ssh connection per remote tmux session until you enable this. Requires Remote tmux." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "有効にするまで、リモート tmux セッションごとに個別の ssh 接続を使用します。リモート tmux が必要です。" + } + } + } + }, + "remoteTmux.tab.pane": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "tmux pane" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "tmux ペイン" + } + } + } + }, + "remoteTmux.tab.window": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "tmux window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "tmux ウィンドウ" + } + } + } + }, + "remoteTmux.pane.splitRight": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Split Right" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右に分割" + } + } + } + }, + "remoteTmux.pane.splitDown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Split Down" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "下に分割" + } + } + } + }, + "remoteTmux.pane.close": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Pane" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペインを閉じる" + } + } + } + }, + "remoteTmux.error.commandFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "remote command failed (exit %d)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートコマンドに失敗しました(終了コード %d)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "远程命令失败(退出代码 %d)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "遠端命令失敗(結束代碼 %d)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원격 명령이 실패했습니다(종료 코드 %d)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Remote-Befehl fehlgeschlagen (Exit-Code %d)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El comando remoto falló (código de salida %d)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La commande distante a échoué (code de sortie %d)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Comando remoto non riuscito (codice di uscita %d)" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fjernkommandoen mislykkedes (afslutningskode %d)" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Polecenie zdalne nie powiodło się (kod zakończenia %d)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удаленная команда завершилась с ошибкой (код выхода %d)" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Udaljena komanda nije uspjela (izlazni kod %d)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل الأمر البعيد (رمز الخروج %d)" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ekstern kommando mislyktes (avslutningskode %d)" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O comando remoto falhou (código de saída %d)" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คำสั่งระยะไกลล้มเหลว (รหัสออก %d)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Uzak komut başarısız oldu (çıkış kodu %d)" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Віддалена команда завершилася з помилкою (код виходу %d)" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ពាក្យ​បញ្ជា​ពីចម្ងាយ​បាន​បរាជ័យ (កូដ​ចេញ %d)" + } + } + } + }, + "remoteTmux.error.commandFailedWithDetail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "remote command failed (exit %d): %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートコマンドに失敗しました(終了コード %d): %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "远程命令失败(退出代码 %d):%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "遠端命令失敗(結束代碼 %d):%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원격 명령이 실패했습니다(종료 코드 %d): %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Remote-Befehl fehlgeschlagen (Exit-Code %d): %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El comando remoto falló (código de salida %d): %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La commande distante a échoué (code de sortie %d) : %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Comando remoto non riuscito (codice di uscita %d): %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fjernkommandoen mislykkedes (afslutningskode %d): %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Polecenie zdalne nie powiodło się (kod zakończenia %d): %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удаленная команда завершилась с ошибкой (код выхода %d): %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Udaljena komanda nije uspjela (izlazni kod %d): %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل الأمر البعيد (رمز الخروج %d): %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ekstern kommando mislyktes (avslutningskode %d): %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O comando remoto falhou (código de saída %d): %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คำสั่งระยะไกลล้มเหลว (รหัสออก %d): %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Uzak komut başarısız oldu (çıkış kodu %d): %@" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Віддалена команда завершилася з помилкою (код виходу %d): %@" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ពាក្យ​បញ្ជា​ពីចម្ងាយ​បាន​បរាជ័យ (កូដ​ចេញ %d): %@" + } + } + } + }, + "remoteTmux.error.controlSocketPathTooLong": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH control socket path is too long for a Unix domain socket (%lld > %lld bytes); home directory path is too long" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH 制御ソケットパスが Unix ドメインソケットには長すぎます(%lld > %lld バイト)。ホームディレクトリのパスが長すぎます" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "SSH 控制套接字路径对于 Unix 域套接字过长(%lld > %lld 字节);主目录路径过长" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "SSH 控制通訊端路徑對 Unix 網域通訊端而言太長(%lld > %lld 位元組);主目錄路徑太長" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "SSH 제어 소켓 경로가 Unix 도메인 소켓에 너무 깁니다(%lld > %lld바이트). 홈 디렉터리 경로가 너무 깁니다" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der SSH-Steuer-Socket-Pfad ist für einen Unix-Domain-Socket zu lang (%lld > %lld Byte); der Pfad zum Home-Verzeichnis ist zu lang" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La ruta del socket de control SSH es demasiado larga para un socket de dominio Unix (%lld > %lld bytes); la ruta del directorio de inicio es demasiado larga" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le chemin du socket de contrôle SSH est trop long pour un socket de domaine Unix (%lld > %lld octets) ; le chemin du dossier de départ est trop long" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il percorso del socket di controllo SSH è troppo lungo per un socket di dominio Unix (%lld > %lld byte); il percorso della directory home è troppo lungo" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "SSH-kontrolsocketstien er for lang til en Unix-domænesocket (%lld > %lld byte); stien til hjemmemappen er for lang" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ścieżka gniazda sterującego SSH jest za długa dla gniazda domeny Unix (%lld > %lld bajtów); ścieżka katalogu domowego jest za długa" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Путь к управляющему сокету SSH слишком длинный для доменного сокета Unix (%lld > %lld байт); путь к домашнему каталогу слишком длинный" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Putanja SSH kontrolnog socketa je preduga za Unix domenski socket (%lld > %lld bajtova); putanja kućnog direktorija je preduga" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مسار مقبس تحكم SSH طويل جدًا لمقبس نطاق Unix (%lld > %lld بايت)؛ مسار دليل المنزل طويل جدًا" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "SSH-kontrollsocketbanen er for lang for en Unix-domenesocket (%lld > %lld byte); banen til hjemmemappen er for lang" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O caminho do soquete de controle SSH é longo demais para um soquete de domínio Unix (%lld > %lld bytes); o caminho do diretório inicial é longo demais" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "พาธซ็อกเก็ตควบคุม SSH ยาวเกินไปสำหรับซ็อกเก็ตโดเมน Unix (%lld > %lld ไบต์); พาธไดเรกทอรีโฮมยาวเกินไป" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "SSH denetim soketi yolu Unix etki alanı soketi için çok uzun (%lld > %lld bayt); ana dizin yolu çok uzun" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Шлях до керівного сокета SSH задовгий для доменного сокета Unix (%lld > %lld байтів); шлях до домашнього каталогу задовгий" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ផ្លូវ​សុកកែត​ត្រួតពិនិត្យ SSH វែងពេក​សម្រាប់​សុកកែត​ដែន Unix (%lld > %lld បៃ); ផ្លូវ​ថត​ផ្ទះ​វែងពេក" + } + } + } + }, + "remoteTmux.error.launchFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "failed to launch SSH: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH の起動に失敗しました: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法启动 SSH:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法啟動 SSH:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "SSH를 시작하지 못했습니다: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "SSH konnte nicht gestartet werden: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo iniciar SSH: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de lancer SSH : %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile avviare SSH: %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke starte SSH: %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się uruchomić SSH: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось запустить SSH: %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nije moguće pokrenuti SSH: %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر تشغيل SSH: %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke starte SSH: %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível iniciar o SSH: %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถเปิดใช้ SSH ได้: %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "SSH başlatılamadı: %@" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдалося запустити SSH: %@" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "មិន​អាច​បើកដំណើរការ SSH បានទេ: %@" + } + } + } + }, + "remoteTmux.error.unreachable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "host unreachable: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ホストに到達できません: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法访问主机:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法連線到主機:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "호스트에 연결할 수 없습니다: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Host nicht erreichbar: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se puede acceder al host: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Hôte inaccessible : %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Host non raggiungibile: %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Værten kan ikke nås: %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Host jest nieosiągalny: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Хост недоступен: %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Host nije dostupan: %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتعذر الوصول إلى المضيف: %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Verten kan ikke nås: %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Host inacessível: %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถเข้าถึงโฮสต์ได้: %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ana makineye ulaşılamıyor: %@" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Хост недоступний: %@" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "មិន​អាច​ចូល​ដល់​ម៉ាស៊ីន​បានទេ: %@" + } + } + } + }, + "remoteTmux.error.unsupportedVersion": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "remote session server is too old (found %@; cmux needs version %@ or newer)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートセッションサーバーが古すぎます(検出: %@、cmux にはバージョン %@ 以降が必要です)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "远程会话服务器过旧(检测到 %@;cmux 需要版本 %@ 或更高版本)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "遠端工作階段伺服器太舊(偵測到 %@;cmux 需要版本 %@ 或更新版本)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원격 세션 서버가 너무 오래되었습니다(감지됨: %@, cmux에는 버전 %@ 이상이 필요함)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Remote-Sitzungsserver ist zu alt (gefunden: %@; cmux benötigt Version %@ oder neuer)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El servidor de sesión remota es demasiado antiguo (se encontró %@; cmux necesita la versión %@ o posterior)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le serveur de session distant est trop ancien (version détectée : %@ ; cmux nécessite la version %@ ou ultérieure)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il server di sessione remoto è troppo vecchio (trovato %@; cmux richiede la versione %@ o successiva)" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Den eksterne sessionsserver er for gammel (fandt %@; cmux kræver version %@ eller nyere)" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zdalny serwer sesji jest za stary (wykryto %@; cmux wymaga wersji %@ lub nowszej)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удаленный сервер сеансов слишком старый (обнаружено %@; cmux требуется версия %@ или новее)" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Udaljeni server sesije je prestar (pronađeno %@; cmux zahtijeva verziju %@ ili noviju)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "خادم الجلسة البعيد قديم جدًا (تم العثور على %@؛ يتطلب cmux الإصدار %@ أو أحدث)" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Den eksterne øktserveren er for gammel (fant %@; cmux krever versjon %@ eller nyere)" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O servidor de sessão remota é antigo demais (encontrado %@; o cmux precisa da versão %@ ou mais recente)" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เซิร์ฟเวอร์เซสชันระยะไกลเก่าเกินไป (พบ %@; cmux ต้องใช้เวอร์ชัน %@ หรือใหม่กว่า)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Uzak oturum sunucusu çok eski (bulunan %@; cmux sürüm %@ veya daha yenisini gerektirir)" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Віддалений сервер сеансів застарий (виявлено %@; cmux потребує версії %@ або новішої)" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ម៉ាស៊ីនបម្រើ​សម័យ​ពីចម្ងាយ​ចាស់​ពេក (បាន​រកឃើញ %@; cmux ត្រូវការ​កំណែ %@ ឬ​ថ្មី​ជាងនេះ)" + } + } + } + }, + "remoteTmux.version.unknown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "unknown" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알 수 없음" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "unbekannt" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "desconocida" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "inconnue" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "sconosciuta" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "ukendt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "nieznana" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "неизвестно" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "nepoznato" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير معروف" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "ukjent" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "desconhecida" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่ทราบ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "bilinmiyor" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "невідомо" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "មិន​ស្គាល់" + } + } + } + }, + "sidebar.extensions.action.remoteTmuxWindowRequested": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remote tmux window requested" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモート tmux ウィンドウを要求しました" + } + } + } + }, "settings.betaFeatures.dock": { "extractionState": "manual", "localizations": { From 99129635d33229a6fa38e8cd41f025a8567b6dc4 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 23:52:55 -0700 Subject: [PATCH 25/38] docs: document remote-tmux linked view and multiple servers in one window 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. --- web/app/[locale]/docs/remote-tmux/page.tsx | 8 ++++++++ web/messages/en.json | 5 +++++ web/messages/ja.json | 5 +++++ 3 files changed, 18 insertions(+) diff --git a/web/app/[locale]/docs/remote-tmux/page.tsx b/web/app/[locale]/docs/remote-tmux/page.tsx index 46bb2526bb00..e2c1aa1904a1 100644 --- a/web/app/[locale]/docs/remote-tmux/page.tsx +++ b/web/app/[locale]/docs/remote-tmux/page.tsx @@ -82,6 +82,14 @@ export default async function RemoteTmuxPage({ {t("howTitle")}

{t("howDesc")}

+ {t("linkedViewTitle")} +

{t("linkedViewIntro")}

+

{t("linkedViewEnable")}

+ + {t("multiServerTitle")} +

{t("multiServerDesc")}

+ {`# Attach another host into the current window\ncmux ssh-tmux other-host --into-window current`} + {t("behaviorTitle")}
  • {t("behaviorSize")}
  • diff --git a/web/messages/en.json b/web/messages/en.json index 749ec037d206..1f35be0d74b9 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -733,6 +733,11 @@ "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh doesn't log you in, cmux ssh-tmux won't either.", "howTitle": "How it works", "howDesc": "cmux spawns ssh … tmux -CC attach and parses the control-mode stream itself rather than relying on a built-in tmux viewer, so the protocol and %begin/%end command correlation are fully owned by cmux. Each remote pane renders into a dedicated terminal surface fed by tmux %output, and your input — keystrokes and mouse — is forwarded with tmux send-keys. The remote tmux server owns pane sizing and reflow; cmux keeps its surfaces in lock-step with it and never reflows locally.", + "linkedViewTitle": "Linked view (single connection)", + "linkedViewIntro": "Some hosts cap each SSH connection to a single concurrent session (sshd's MaxSessions 1, common where every connection needs its own 2FA approval). Opening one tmux -CC control client per session then fails past the first. Linked view instead drives a single control client attached to a hidden, cmux-owned aggregate session and links every mirrored session's windows into it, so all of a host's sessions stream over one connection (one authentication). The real tmux sessions are left intact for tmux ls and tmux attach.", + "linkedViewEnable": "Enable it in Settings -> Beta Features -> “Remote tmux linked view” (it requires Remote tmux to be on). While off, remote tmux opens a separate connection per session.", + "multiServerTitle": "Multiple servers in one window", + "multiServerDesc": "Linked view aggregates per window, so you can mirror more than one host side by side. Attach another host into an existing window and its sessions' windows link in alongside the first host's; each workspace routes input and resizing to its own host. Pass --into-window to choose the target window (current targets the focused one).", "behaviorTitle": "What it supports", "behaviorSize": "Sizing: the remote client is resized to the rendered grid (refresh-client -C), so TUIs aren't stuck at tmux's default 80×24.", "behaviorSplit": "Splits: splitting or closing a pane in a mirrored window is propagated to tmux with split-window, so the cmux layout and the tmux layout stay in sync. Programmatic splits (cmux new-split or surface.split over the socket) report accepted with no surface id — the new pane arrives asynchronously once tmux confirms the layout change. Requests carrying options the routed split cannot honor (startup command, working directory, divider position, left/up placement) are rejected up front, before the remote session is mutated.", diff --git a/web/messages/ja.json b/web/messages/ja.json index dd1ab4dea623..018c752ff33f 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -689,6 +689,11 @@ "troubleshootFallback": "または cmux ssh-tmux dev@my-ssh-alias のようにユーザーを明示指定してください。素の ssh <宛先> でログインできない場合は、cmux ssh-tmux <宛先> でも接続できません。", "howTitle": "仕組み", "howDesc": "cmux は ssh … tmux -CC attach を起動し、組み込みの tmux ビューアーに頼らずコントロールモードのストリームを自前で解析します。そのためプロトコルや %begin/%end のコマンド対応付けは完全に cmux が管理します。各リモートペインは tmux の %output を流し込む専用のターミナルサーフェスに描画され、キー入力やマウスなどの入力は tmux の send-keys でリモートへ送られます。ペインのサイズやリフローはリモートの tmux サーバーが管理し、cmux はそれに追従してローカルではリフローしません。", + "linkedViewTitle": "リンクビュー(単一接続)", + "linkedViewIntro": "ホストによっては、SSH 接続ごとに同時セッションを 1 つに制限しています(sshd の MaxSessions 1。接続ごとに個別の 2 要素認証が必要な環境でよく見られます)。この場合、セッションごとに tmux -CC 制御クライアントを開くと 2 つ目以降が失敗します。リンクビューでは代わりに、cmux 専用の隠し集約セッションに接続した単一の制御クライアントを使い、ミラーリングする各セッションのウィンドウをそこにリンクします。これにより、ホストのすべてのセッションが 1 つの接続(1 回の認証)でストリーミングされます。実際の tmux セッションは tmux ls や tmux attach のためにそのまま残ります。", + "linkedViewEnable": "設定 -> ベータ機能 -> “リモート tmux リンクビュー” で有効にできます(リモート tmux が有効である必要があります)。無効の間は、セッションごとに個別の接続を開きます。", + "multiServerTitle": "1 つのウィンドウに複数のサーバー", + "multiServerDesc": "リンクビューはウィンドウ単位で集約するため、複数のホストを並べてミラーリングできます。既存のウィンドウに別のホストをアタッチすると、そのセッションのウィンドウが最初のホストのものと並んでリンクされます。各ワークスペースは入力とリサイズをそれぞれのホストにルーティングします。--into-window で対象ウィンドウを指定します(current はフォーカス中のウィンドウを指します)。", "behaviorTitle": "サポートしている機能", "behaviorSize": "サイズ調整: リモートクライアントを描画中のグリッドに合わせてリサイズします(refresh-client -C)。TUI が tmux のデフォルトの 80×24 のままになりません。", "behaviorSplit": "分割: ミラーされたウィンドウでペインを分割・クローズすると tmux の split-window に伝播し、cmux と tmux のレイアウトが同期します。プログラムからの分割(cmux new-split やソケット経由の surface.split)は accepted を返し、surface id は含まれません。新しいペインは tmux がレイアウト変更を確定した後に非同期で追加されます。ルーティングされる分割が適用できないオプション(起動コマンド、作業ディレクトリ、分割位置、左/上への配置)を含むリクエストは、リモートセッションを変更する前に拒否されます。", From 3c0a5e6c3eb5748c561484d22606673a5d6d0b24 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 20:40:10 -0700 Subject: [PATCH 26/38] remote-tmux: multiple servers in one linked-view window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` exposes it (`current` uses the caller's CMUX_WORKSPACE_ID). --- CLI/cmux.swift | 22 +++ ...Delegate+RecoverableMainWindowRoutes.swift | 12 ++ Sources/RemoteTmuxController.swift | 154 +++++++++++++----- Sources/RemoteTmuxWindowRegistry.swift | 60 ++++--- Sources/TerminalController+RemoteTmux.swift | 19 ++- cmuxTests/RemoteTmuxWindowRegistryTests.swift | 67 ++++++++ 6 files changed, 268 insertions(+), 66 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 17b748d7efaa..858541ac2cf0 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -8499,6 +8499,7 @@ struct CMUXCLI { var port: Int? var identityFile: String? var noFocus = false + var intoWindow: String? // Intentional subset of parseSSHCommandOptions: the mirror verb has a // different pipeline (no relay/cmuxd bootstrap, no `--` passthrough, no @@ -8526,6 +8527,12 @@ struct CMUXCLI { case "--no-focus": noFocus = true index += 1 + case "--into-window": + guard index + 1 < commandArgs.count else { + throw CLIError(message: "ssh-tmux: --into-window requires a window id or 'current'") + } + intoWindow = commandArgs[index + 1] + index += 2 default: if arg.hasPrefix("-") { throw CLIError( @@ -8549,6 +8556,21 @@ struct CMUXCLI { if let port { params["port"] = port } if let identityFile, !identityFile.isEmpty { params["identity_file"] = identityFile } if noFocus { params["activate"] = false } + // Aggregate into an existing linked-view window ("multiple servers in one + // window"). `current` resolves the caller's window from CMUX_WORKSPACE_ID + // (surfaces export the workspace id, not the window id); otherwise pass a + // window id (e.g. from a prior `ssh-tmux` JSON result or `list-windows`). + if let intoWindow { + if intoWindow == "current" { + guard let workspaceId = ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"], + !workspaceId.isEmpty else { + throw CLIError(message: "ssh-tmux: --into-window current must be run from inside a cmux surface") + } + params["into_workspace"] = workspaceId + } else { + params["into_window"] = intoWindow + } + } // The first call runs a non-interactive (BatchMode) discovery in the app, // which can take a couple of seconds; show progress so it doesn't look idle. diff --git a/Sources/AppDelegate+RecoverableMainWindowRoutes.swift b/Sources/AppDelegate+RecoverableMainWindowRoutes.swift index c9c9b5fa5f32..a8757525ec47 100644 --- a/Sources/AppDelegate+RecoverableMainWindowRoutes.swift +++ b/Sources/AppDelegate+RecoverableMainWindowRoutes.swift @@ -196,6 +196,18 @@ extension AppDelegate { return recoverableMainWindowRouteSnapshots().first(where: { $0.tabManager === tabManager })?.windowId } + /// The id of the main window whose workspace list contains `workspaceId`, if any. + /// Lets the `cmux ssh-tmux --into-window current` flow resolve the caller's window + /// from its `CMUX_WORKSPACE_ID` (surfaces export the workspace id, not the window). + func windowId(forWorkspaceId workspaceId: UUID) -> UUID? { + if let context = mainWindowContexts.values.first( + where: { $0.tabManager.tabs.contains(where: { $0.id == workspaceId }) }) { + return context.windowId + } + return recoverableMainWindowRouteSnapshots() + .first(where: { $0.tabManager.tabs.contains(where: { $0.id == workspaceId }) })?.windowId + } + func mainWindowContainingWorkspace(_ workspaceId: UUID) -> NSWindow? { for context in mainWindowContexts.values where context.tabManager.tabs.contains(where: { $0.id == workspaceId }) { if let window = context.window ?? windowForMainWindowId(context.windowId) { diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 13634e721369..b0506ac6c04c 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -348,7 +348,8 @@ final class RemoteTmuxController { @discardableResult func mirrorHostInNewWindow( host: RemoteTmuxHost, - activateWindow: Bool = true + activateWindow: Bool = true, + intoWindowId: UUID? = nil ) async throws -> RemoteTmuxAttachOutcome { guard let appDelegate = AppDelegate.shared else { throw RemoteTmuxError.unreachable("app not ready") @@ -412,23 +413,55 @@ final class RemoteTmuxController { // failure (or cancellation) throws here and leaks no orphaned window. try await ensureControlMasterReadyForBurst(host: host) - let windowId = appDelegate.createMainWindow(shouldActivate: activateWindow) - guard let manager = appDelegate.tabManagerFor(windowId: windowId) else { - throw RemoteTmuxError.unreachable("could not create window") + // Aggregate into an existing linked-view window when requested ("multiple + // servers in one window"), otherwise open a fresh dedicated window. Only + // linked-view aggregates: its one shared stream per host keeps a window's + // hosts independent; per-session mirror mode stays single-host. + let windowId: UUID + let manager: TabManager + let bootstrapWorkspaceId: UUID? + let createdNewWindow: Bool + if Self.linkedViewEnabled, let intoWindowId, + isLinkedViewWindow(intoWindowId), + let existingManager = appDelegate.tabManagerFor(windowId: intoWindowId) { + windowId = intoWindowId + manager = existingManager + bootstrapWorkspaceId = nil // window already holds the first host's workspaces + createdNewWindow = false + if activateWindow { appDelegate.windowForMainWindowId(intoWindowId)?.makeKeyAndOrderFront(nil) } + } else { + let newWindowId = appDelegate.createMainWindow(shouldActivate: activateWindow) + guard let newManager = appDelegate.tabManagerFor(windowId: newWindowId) else { + throw RemoteTmuxError.unreachable("could not create window") + } + windowId = newWindowId + manager = newManager + bootstrapWorkspaceId = newManager.tabs.first?.id + createdNewWindow = true } windowRegistry.bind(host: host, windowId: windowId) - let bootstrapWorkspaceId = manager.tabs.first?.id - // Linked-view mode: one shared `-CC` view stream for the whole host (for // MaxSessions=1 hosts). The coordinator discovers/links sessions and // drives workspace creation asynchronously via `syncLinkedWorkspaces`; the // window populates as its first reconcile lands (the per-session loop and // its synchronous empty-window guard below are skipped). if Self.linkedViewEnabled { - try await startLinkedView( - host: host, windowId: windowId, manager: manager, - bootstrapWorkspaceId: bootstrapWorkspaceId) + do { + try await startLinkedView( + host: host, windowId: windowId, manager: manager, + bootstrapWorkspaceId: bootstrapWorkspaceId) + } catch { + // Unbind this host; discard the window only if we created it for this + // host (never an aggregated window that still holds other hosts). + windowRegistry.unbind(hostHash: host.connectionHash) + transportRegistry.remove(connectionHash: host.connectionHash) + RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) + if createdNewWindow { + appDelegate.discardMainWindowWithoutClosedHistory(windowId: windowId) + } + throw error + } return .mirrored(windowId: windowId) } @@ -640,18 +673,45 @@ final class RemoteTmuxController { } /// Tears down a host's linked-view coordinator and all its mirrors (the view - /// stream ended for good), then closes the dedicated window. + /// stream ended for good). Closes the host's workspaces and, when no other + /// aggregated host remains in the window, discards the window. private func teardownLinkedView(host: RemoteTmuxHost, windowId: UUID) { + // Close this host's workspaces (when the window holds others, e.g. a second + // server) so an ended host's tabs don't linger; the window's FINAL workspace + // can't be closed, so a single-host window is discarded below instead. + if let manager = AppDelegate.shared?.tabManagerFor(windowId: windowId) { + for mirror in linkedMirrors.values + where mirror.host.connectionHash == host.connectionHash { + guard manager.tabs.count > 1, + let workspaceId = mirror.mirroredWorkspaceId, + let workspace = manager.tabs.first(where: { $0.id == workspaceId }) else { continue } + manager.closeWorkspace(workspace, recordHistory: false) + } + } stopLinkedView(host: host) - if let appDelegate = AppDelegate.shared, + if windowRegistry.hosts(forWindowId: windowId).isEmpty, + let appDelegate = AppDelegate.shared, appDelegate.windowForMainWindowId(windowId) != nil { appDelegate.discardMainWindowWithoutClosedHistory(windowId: windowId) } } - /// Whether `windowId` is a dedicated linked-view window (its host has a live - /// coordinator). Used to route New Workspace through the coordinator. + /// Whether `windowId` is a live linked-view window (at least one of its hosts has + /// a running coordinator) — the only kind that can aggregate another server. + private func isLinkedViewWindow(_ windowId: UUID) -> Bool { + windowRegistry.hosts(forWindowId: windowId).contains { linkedViews[$0.connectionHash] != nil } + } + + /// The linked-view coordinator a New Workspace / New Tab in `windowId` should + /// ride. A window can aggregate several servers, so route to the SELECTED + /// workspace's host (its mirror); fall back to the window's first host when no + /// remote mirror is selected yet. private func linkedViewCoordinator(forWindowId windowId: UUID) -> RemoteTmuxViewConnection? { + if let manager = AppDelegate.shared?.tabManagerFor(windowId: windowId), + let selected = manager.selectedTab, + let mirror = linkedMirrors.values.first(where: { $0.mirroredWorkspaceId == selected.id }) { + return linkedViews[mirror.host.connectionHash] + } guard let host = windowRegistry.host(forWindowId: windowId) else { return nil } return linkedViews[host.connectionHash] } @@ -1311,32 +1371,35 @@ final class RemoteTmuxController { func killMarkedSessionsBeforeTerminate(timeout: Duration = .seconds(3)) async { var jobs: [(transport: RemoteTmuxSSHTransport, target: String)] = [] for windowId in windowRegistry.windowsMarkedForKillOnClose() { - guard windowRegistry.consumeKillSessionsOnClose(windowId: windowId), - let host = windowRegistry.host(forWindowId: windowId) else { continue } - // Linked-view: the host's real sessions are reachable only over the live - // view stream (a one-shot ssh would be refused under MaxSessions=1). Kill - // them there with a round-trip barrier so the kills land before we stop - // the shared coordinator. Gated on live state, not the beta flag. - if let coordinator = linkedViews[host.connectionHash] { - await coordinator.killAllWorkspaceSessions() - stopLinkedView(host: host) - continue - } + guard windowRegistry.consumeKillSessionsOnClose(windowId: windowId) else { continue } let closingWorkspaceIds = Set(AppDelegate.shared?.tabManagerFor(windowId: windowId)?.tabs.map(\.id) ?? []) - let transport = transport(for: host) - let mirrorsInWindow = sessionMirrors.filter { _, mirror in - mirror.host.connectionHash == host.connectionHash - && mirror.mirroredWorkspaceId.map(closingWorkspaceIds.contains) == true - } - for (key, mirror) in mirrorsInWindow { - sessionMirrors.removeValue(forKey: key) - mirror.detachObserver() - detach(host: host, sessionName: mirror.sessionName) // removes the connection too - jobs.append((transport, mirror.connection.sessionId.map { "$\($0)" } ?? mirror.sessionName)) + // A window can aggregate several hosts (linked-view multi-server); kill + // each host's sessions. + for host in windowRegistry.hosts(forWindowId: windowId) { + // Linked-view: the host's real sessions are reachable only over the + // live view stream (a one-shot ssh would be refused under + // MaxSessions=1). Kill them there with a round-trip barrier so the + // kills land before we stop the shared coordinator. + if let coordinator = linkedViews[host.connectionHash] { + await coordinator.killAllWorkspaceSessions() + stopLinkedView(host: host) + continue + } + let transport = transport(for: host) + let mirrorsInWindow = sessionMirrors.filter { _, mirror in + mirror.host.connectionHash == host.connectionHash + && mirror.mirroredWorkspaceId.map(closingWorkspaceIds.contains) == true + } + for (key, mirror) in mirrorsInWindow { + sessionMirrors.removeValue(forKey: key) + mirror.detachObserver() + detach(host: host, sessionName: mirror.sessionName) // removes the connection too + jobs.append((transport, mirror.connection.sessionId.map { "$\($0)" } ?? mirror.sessionName)) + } + let hostHasOtherMirrors = sessionMirrors.values.contains { $0.host.connectionHash == host.connectionHash } + if !hostHasOtherMirrors { windowRegistry.unbind(hostHash: host.connectionHash) } + if !hostHasOtherMirrors, !connectionsByHostSession.values.contains(where: { $0.host.connectionHash == host.connectionHash }) { transportRegistry.remove(connectionHash: host.connectionHash) } } - let hostHasOtherMirrors = sessionMirrors.values.contains { $0.host.connectionHash == host.connectionHash } - if !hostHasOtherMirrors { windowRegistry.unbind(hostHash: host.connectionHash) } - if !hostHasOtherMirrors, !connectionsByHostSession.values.contains(where: { $0.host.connectionHash == host.connectionHash }) { transportRegistry.remove(connectionHash: host.connectionHash) } } await RemoteTmuxSSHTransport.killSessions(jobs, timeout: timeout) } @@ -1344,13 +1407,18 @@ final class RemoteTmuxController { /// Dedicated window close detaches only that window's mirrors; same-host mirrors /// in other windows keep their control streams. func handleRemoteWindowClosed(windowId: UUID) { + // Linked-view: shared `-CC` coordinators back the window — stop EVERY + // aggregated host's coordinator (a window can hold several servers) instead + // of walking per-session mirrors. The window is already closing, so + // `stopLinkedView` must not re-discard it. Gated on live coordinator state — + // NOT the beta flag — so a window opened while the flag was on still tears + // down if the user toggles it off. + var stoppedLinked = false + for host in windowRegistry.hosts(forWindowId: windowId) where stopLinkedView(host: host) { + stoppedLinked = true + } + if stoppedLinked { return } guard let host = windowRegistry.host(forWindowId: windowId) else { return } - // Linked-view: a single shared `-CC` coordinator backs the whole window, so - // stop it (and drop its mirrors) instead of walking per-session mirrors. The - // window is already closing, so `stopLinkedView` must not re-discard it. - // Gated on live coordinator state — NOT the beta flag — so a window opened - // while the flag was on still tears down if the user toggles it off. - if stopLinkedView(host: host) { return } let closingWorkspaceIds = Set(AppDelegate.shared?.tabManagerFor(windowId: windowId)?.tabs.map(\.id) ?? []) windowRegistry.unbind(windowId: windowId) let mirrorsInWindow = sessionMirrors.filter { _, mirror in diff --git a/Sources/RemoteTmuxWindowRegistry.swift b/Sources/RemoteTmuxWindowRegistry.swift index 1fcab3aee6a2..bdc33602273c 100644 --- a/Sources/RemoteTmuxWindowRegistry.swift +++ b/Sources/RemoteTmuxWindowRegistry.swift @@ -1,22 +1,28 @@ import Foundation /// Owns the dedicated-window bookkeeping ``RemoteTmuxController`` uses for the -/// "one cmux window per remote endpoint" mirror mode (Option 1): the host↔window -/// bindings and the in-flight-attach guard set. +/// remote-tmux mirror mode: the host↔window bindings and the in-flight-attach +/// guard set. /// -/// Factored out of the controller so the two-way binding (and its always-paired +/// Each host maps to exactly ONE window, but a window can mirror SEVERAL hosts — +/// the linked-view "multiple servers in one window" mode aggregates more than one +/// endpoint's sessions into a single cmux window. (Plain per-session mirror mode +/// uses a single host per window.) +/// +/// Factored out of the controller so the binding (and its always-paired /// insert/remove) plus the re-entrant-attach guard live behind one small /// `@MainActor` surface. ``beginAttach(hostHash:)`` is a synchronous /// check-and-insert so callers can guard an `await` gap without an extra /// suspension point. @MainActor final class RemoteTmuxWindowRegistry { - /// ``RemoteTmuxHost/connectionHash`` → the dedicated cmux window mirroring that - /// endpoint (Option 1). + /// ``RemoteTmuxHost/connectionHash`` → the cmux window mirroring that endpoint. private var windowIdByHost: [String: UUID] = [:] - /// Reverse map: cmux window id → the full host it mirrors (for window-close - /// detach and new-session-in-window, which need the endpoint's port/identity). - private var hostByWindowId: [UUID: RemoteTmuxHost] = [:] + /// Reverse map: cmux window id → the hosts it mirrors, in attach order (for + /// window-close detach and new-session-in-window, which need the endpoint's + /// port/identity). Usually one host; more than one in aggregated linked-view + /// windows. + private var hostsByWindowId: [UUID: [RemoteTmuxHost]] = [:] /// Endpoint ``RemoteTmuxHost/connectionHash`` values with an in-flight /// `mirrorHostInNewWindow(host:activateWindow:)`, so a re-entrant call across /// the `await` gap can't open a second window for the same endpoint. @@ -33,23 +39,35 @@ final class RemoteTmuxWindowRegistry { /// Used by the session-snapshot path to exclude these windows: a mirror window /// needs a live SSH connection and can't be restored from a generic snapshot. func isDedicatedWindow(_ windowId: UUID) -> Bool { - hostByWindowId[windowId] != nil + !(hostsByWindowId[windowId]?.isEmpty ?? true) } - /// Binds `host` to its dedicated `windowId` (both directions). + /// Binds `host` to `windowId` (both directions). A window can hold several + /// hosts; re-binding the same host is idempotent. func bind(host: RemoteTmuxHost, windowId: UUID) { windowIdByHost[host.connectionHash] = windowId - hostByWindowId[windowId] = host + var hosts = hostsByWindowId[windowId] ?? [] + if !hosts.contains(where: { $0.connectionHash == host.connectionHash }) { + hosts.append(host) + } + hostsByWindowId[windowId] = hosts } - /// The dedicated window currently bound to `hostHash`, if any (the reuse check). + /// The window currently bound to `hostHash`, if any (the reuse check). func windowId(forHostHash hostHash: String) -> UUID? { windowIdByHost[hostHash] } - /// The full host bound to `windowId`, if any (carries port/identity). + /// The first host bound to `windowId`, if any (carries port/identity). For + /// single-host (per-session) windows this is the only host; aggregated + /// linked-view windows should use ``hosts(forWindowId:)``. func host(forWindowId windowId: UUID) -> RemoteTmuxHost? { - hostByWindowId[windowId] + hostsByWindowId[windowId]?.first + } + + /// All hosts bound to `windowId`, in attach order. + func hosts(forWindowId windowId: UUID) -> [RemoteTmuxHost] { + hostsByWindowId[windowId] ?? [] } /// Atomically records an in-flight attach for `hostHash`; returns `false` if one @@ -67,18 +85,22 @@ final class RemoteTmuxWindowRegistry { } /// Removes the binding for `hostHash` in BOTH directions, returning the window id - /// that was bound (if any). + /// that was bound (if any). Only this host is removed; other hosts aggregated in + /// the same window keep their bindings (the window entry is dropped once empty). @discardableResult func unbind(hostHash: String) -> UUID? { guard let windowId = windowIdByHost.removeValue(forKey: hostHash) else { return nil } - hostByWindowId.removeValue(forKey: windowId) + if var hosts = hostsByWindowId[windowId] { + hosts.removeAll { $0.connectionHash == hostHash } + hostsByWindowId[windowId] = hosts.isEmpty ? nil : hosts + } return windowId } - /// Removes the binding for `windowId` in BOTH directions. + /// Removes ALL host bindings for `windowId` in both directions. func unbind(windowId: UUID) { - guard let host = hostByWindowId.removeValue(forKey: windowId) else { return } - windowIdByHost.removeValue(forKey: host.connectionHash) + guard let hosts = hostsByWindowId.removeValue(forKey: windowId) else { return } + for host in hosts { windowIdByHost.removeValue(forKey: host.connectionHash) } } /// Marks `windowId`'s impending close as a tab/session close that should kill diff --git a/Sources/TerminalController+RemoteTmux.swift b/Sources/TerminalController+RemoteTmux.swift index 252ab5b0356a..03752a378b38 100644 --- a/Sources/TerminalController+RemoteTmux.swift +++ b/Sources/TerminalController+RemoteTmux.swift @@ -157,15 +157,26 @@ extension TerminalController { return v2Error(id: id, code: "invalid_params", message: String(localized: "socket.remoteTmux.hostRequired", defaultValue: "host is required")) } let activate = (params["activate"] as? Bool) ?? true + // Optional: aggregate this host into an existing linked-view window ("multiple + // servers in one window") instead of opening a new one. Accept the window id + // directly, or a workspace id (the CLI's `--into-window current` passes the + // caller's CMUX_WORKSPACE_ID, since surfaces don't export a window id). + // Ignored if it doesn't resolve to a current linked-view window. + let explicitIntoWindowId = (params["into_window"] as? String).flatMap(UUID.init(uuidString:)) + let intoWorkspaceId = (params["into_workspace"] as? String).flatMap(UUID.init(uuidString:)) // 60s (the CLI waits longer still) so a slow-but-valid BatchMode probe // completes instead of the app timing out first and turning an // auth-required result into an opaque timeout error. return v2VmCall(id: id, timeoutSeconds: 60) { - guard let controller = await MainActor.run(body: { AppDelegate.shared?.remoteTmuxController }) - else { - throw RemoteTmuxError.unreachable("app not ready") + let resolved: (controller: RemoteTmuxController, intoWindowId: UUID?)? = await MainActor.run { + guard let appDelegate = AppDelegate.shared else { return nil } + let intoWindowId = explicitIntoWindowId + ?? intoWorkspaceId.flatMap { appDelegate.windowId(forWorkspaceId: $0) } + return (appDelegate.remoteTmuxController, intoWindowId) } - let outcome = try await controller.mirrorHostInNewWindow(host: host, activateWindow: activate) + guard let resolved else { throw RemoteTmuxError.unreachable("app not ready") } + let outcome = try await resolved.controller.mirrorHostInNewWindow( + host: host, activateWindow: activate, intoWindowId: resolved.intoWindowId) switch outcome { case .mirrored(let windowId): return [ diff --git a/cmuxTests/RemoteTmuxWindowRegistryTests.swift b/cmuxTests/RemoteTmuxWindowRegistryTests.swift index 40df79a5b28b..663091c2632c 100644 --- a/cmuxTests/RemoteTmuxWindowRegistryTests.swift +++ b/cmuxTests/RemoteTmuxWindowRegistryTests.swift @@ -53,4 +53,71 @@ import Testing // A subsequent close commit must not kill. #expect(registry.consumeKillSessionsOnClose(windowId: windowId) == false) } + + // MARK: - Multi-host bindings ("multiple servers in one window") + + private func host(_ destination: String) -> RemoteTmuxHost { RemoteTmuxHost(destination: destination) } + + /// A window can hold several hosts (linked-view aggregation); each host still + /// maps to that one window, and `hosts(forWindowId:)` returns them in attach order. + @Test func aggregatesMultipleHostsInOneWindow() { + let registry = RemoteTmuxWindowRegistry() + let windowId = UUID() + let a = host("user@a"), b = host("user@b") + registry.bind(host: a, windowId: windowId) + registry.bind(host: b, windowId: windowId) + #expect(registry.windowId(forHostHash: a.connectionHash) == windowId) + #expect(registry.windowId(forHostHash: b.connectionHash) == windowId) + #expect(registry.hosts(forWindowId: windowId).map(\.connectionHash) == [a.connectionHash, b.connectionHash]) + #expect(registry.host(forWindowId: windowId)?.connectionHash == a.connectionHash) // first + #expect(registry.isDedicatedWindow(windowId)) + } + + /// Re-binding the same host to a window is idempotent (no duplicate entry). + @Test func rebindingSameHostIsIdempotent() { + let registry = RemoteTmuxWindowRegistry() + let windowId = UUID() + let a = host("user@a") + registry.bind(host: a, windowId: windowId) + registry.bind(host: a, windowId: windowId) + #expect(registry.hosts(forWindowId: windowId).count == 1) + } + + /// Unbinding one host leaves the other aggregated hosts (and the window) bound. + @Test func unbindHostHashRemovesOnlyThatHost() { + let registry = RemoteTmuxWindowRegistry() + let windowId = UUID() + let a = host("user@a"), b = host("user@b") + registry.bind(host: a, windowId: windowId) + registry.bind(host: b, windowId: windowId) + registry.unbind(hostHash: a.connectionHash) + #expect(registry.windowId(forHostHash: a.connectionHash) == nil) + #expect(registry.windowId(forHostHash: b.connectionHash) == windowId) + #expect(registry.hosts(forWindowId: windowId).map(\.connectionHash) == [b.connectionHash]) + #expect(registry.isDedicatedWindow(windowId)) + } + + /// Unbinding the last host drops the window entry entirely. + @Test func unbindingLastHostClearsWindow() { + let registry = RemoteTmuxWindowRegistry() + let windowId = UUID() + let a = host("user@a") + registry.bind(host: a, windowId: windowId) + registry.unbind(hostHash: a.connectionHash) + #expect(registry.hosts(forWindowId: windowId).isEmpty) + #expect(!registry.isDedicatedWindow(windowId)) + } + + /// Unbinding by window id removes ALL of the window's hosts in both directions. + @Test func unbindWindowRemovesAllHosts() { + let registry = RemoteTmuxWindowRegistry() + let windowId = UUID() + let a = host("user@a"), b = host("user@b") + registry.bind(host: a, windowId: windowId) + registry.bind(host: b, windowId: windowId) + registry.unbind(windowId: windowId) + #expect(registry.windowId(forHostHash: a.connectionHash) == nil) + #expect(registry.windowId(forHostHash: b.connectionHash) == nil) + #expect(registry.hosts(forWindowId: windowId).isEmpty) + } } From b7ed525ce465f957c1ffed17dc55d9a27e89987c Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 20:40:10 -0700 Subject: [PATCH 27/38] remote-tmux: fix multi-server findings from adversarial review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Sources/RemoteTmuxController.swift | 70 +++++++++++++------ Sources/RemoteTmuxWindowRegistry.swift | 8 ++- cmuxTests/RemoteTmuxWindowRegistryTests.swift | 14 ++++ 3 files changed, 71 insertions(+), 21 deletions(-) diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index b0506ac6c04c..4ee3be730b6a 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -452,12 +452,16 @@ final class RemoteTmuxController { host: host, windowId: windowId, manager: manager, bootstrapWorkspaceId: bootstrapWorkspaceId) } catch { - // Unbind this host; discard the window only if we created it for this - // host (never an aggregated window that still holds other hosts). - windowRegistry.unbind(hostHash: host.connectionHash) - transportRegistry.remove(connectionHash: host.connectionHash) - RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) - if createdNewWindow { + // `startLinkedView` stores the coordinator in `linkedViews` before its + // throwing `start()`, so stop it through `stopLinkedView` (which also + // unbinds the host, drops the transport, and exits the ControlMaster) — + // a bare unbind would leak the half-started coordinator. Discard the + // window only if we created it for this host (never an aggregated window + // that still holds other hosts). + stopLinkedView(host: host) + // Discard only a window we created for this host that no OTHER host has + // since aggregated into (a concurrent attach can join while we await). + if createdNewWindow, windowRegistry.hosts(forWindowId: windowId).isEmpty { appDelegate.discardMainWindowWithoutClosedHistory(windowId: windowId) } throw error @@ -667,28 +671,47 @@ final class RemoteTmuxController { linkedViews[host.connectionHash]?.stop() linkedViews[host.connectionHash] = nil windowRegistry.unbind(hostHash: host.connectionHash) - transportRegistry.remove(connectionHash: host.connectionHash) - RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) + // Drop the shared transport/ControlMaster only if no per-session mirror still + // needs it (a host can hold both modes if the beta flag is toggled mid-session). + if !hostStillInUse(host) { + transportRegistry.remove(connectionHash: host.connectionHash) + RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) + } return true } + /// Whether `host`'s shared transport/ControlMaster is still needed by ANY live + /// mirror in either mode — guards transport teardown so tearing one mode down + /// doesn't pull the master out from under the other (possible when the beta flag + /// is toggled mid-session so a host has both a linked view and per-session mirrors). + private func hostStillInUse(_ host: RemoteTmuxHost) -> Bool { + linkedViews[host.connectionHash] != nil + || sessionMirrors.values.contains { $0.host.connectionHash == host.connectionHash } + || connectionsByHostSession.values.contains { $0.host.connectionHash == host.connectionHash } + } + /// Tears down a host's linked-view coordinator and all its mirrors (the view /// stream ended for good). Closes the host's workspaces and, when no other /// aggregated host remains in the window, discards the window. private func teardownLinkedView(host: RemoteTmuxHost, windowId: UUID) { + let manager = AppDelegate.shared?.tabManagerFor(windowId: windowId) + // Capture this host's workspace ids before stopLinkedView drops the mirrors, + // then stop FIRST so closing the workspaces below can't re-enter the kill path + // (handleWorkspaceClosed finds no mirror and no-ops). + let workspaceIds = linkedMirrors.values + .filter { $0.host.connectionHash == host.connectionHash } + .compactMap(\.mirroredWorkspaceId) + stopLinkedView(host: host) // Close this host's workspaces (when the window holds others, e.g. a second // server) so an ended host's tabs don't linger; the window's FINAL workspace // can't be closed, so a single-host window is discarded below instead. - if let manager = AppDelegate.shared?.tabManagerFor(windowId: windowId) { - for mirror in linkedMirrors.values - where mirror.host.connectionHash == host.connectionHash { + if let manager { + for workspaceId in workspaceIds { guard manager.tabs.count > 1, - let workspaceId = mirror.mirroredWorkspaceId, let workspace = manager.tabs.first(where: { $0.id == workspaceId }) else { continue } manager.closeWorkspace(workspace, recordHistory: false) } } - stopLinkedView(host: host) if windowRegistry.hosts(forWindowId: windowId).isEmpty, let appDelegate = AppDelegate.shared, appDelegate.windowForMainWindowId(windowId) != nil { @@ -883,7 +906,13 @@ final class RemoteTmuxController { ?? RemoteTmuxHost.controlModeLineSafeName(oldName).map(RemoteTmuxHost.shellSingleQuoted) } guard let target else { return } - _ = mirror.connection.send("rename-session -t \(target) \(RemoteTmuxHost.shellSingleQuoted(name))") + let sent = mirror.connection.send("rename-session -t \(target) \(RemoteTmuxHost.shellSingleQuoted(name))") + // Linked mirrors suppress %session-changed (the coordinator owns the view + // connection's identity), so nudge a reconcile to re-key this host's mirror to + // the new session name — otherwise later new-tab/close target the old name. + if sent, isLinkedMirror(mirror) { + linkedViews[mirror.host.connectionHash]?.requestReconcile() + } // Do not re-key local state here. tmux can reject a rename (for example // duplicate session name); `%session-changed` is the confirmation point. } @@ -1160,10 +1189,12 @@ final class RemoteTmuxController { func handleRemoteWindowNewWorkspaceRequested(windowId: UUID) -> Bool { // Linked-view: a new workspace is a new tmux session created over the shared // view stream (no new SSH session); it links in and surfaces via the - // coordinator's republish. "New workspace rides the linking." Gate on the - // ACTIVE workspace being a remote mirror — a dragged-in local workspace that - // happens to be active must create a local workspace, not a remote session. - if Self.linkedViewEnabled, let coordinator = linkedViewCoordinator(forWindowId: windowId), + // coordinator's republish. "New workspace rides the linking." Gate on LIVE + // coordinator state (not the beta flag, so toggling it off mid-session still + // routes correctly) AND on the ACTIVE workspace being a remote mirror — a + // dragged-in local workspace that's active must create a local workspace. The + // coordinator is the SELECTED workspace's host, so it rides the right server. + if let coordinator = linkedViewCoordinator(forWindowId: windowId), AppDelegate.shared?.tabManagerFor(windowId: windowId)?.selectedTab?.isRemoteTmuxMirror == true { coordinator.newWorkspace() return true @@ -1430,8 +1461,7 @@ final class RemoteTmuxController { sessionMirrors.removeValue(forKey: key) removeCachedConnection(forKey: key)?.stop() } - let stillUsed = sessionMirrors.values.contains { $0.host.connectionHash == host.connectionHash } || connectionsByHostSession.values.contains { $0.host.connectionHash == host.connectionHash } - if !stillUsed { + if !hostStillInUse(host) { transportRegistry.remove(connectionHash: host.connectionHash) RemoteTmuxSSHTransport.spawnControlMasterExit(host: host) } diff --git a/Sources/RemoteTmuxWindowRegistry.swift b/Sources/RemoteTmuxWindowRegistry.swift index bdc33602273c..6563c035f18a 100644 --- a/Sources/RemoteTmuxWindowRegistry.swift +++ b/Sources/RemoteTmuxWindowRegistry.swift @@ -43,8 +43,14 @@ final class RemoteTmuxWindowRegistry { } /// Binds `host` to `windowId` (both directions). A window can hold several - /// hosts; re-binding the same host is idempotent. + /// hosts; re-binding the same host to the same window is idempotent. Re-binding a + /// host that was bound to a DIFFERENT window first drops it from that window's + /// list, so the two maps never diverge. func bind(host: RemoteTmuxHost, windowId: UUID) { + if let previous = windowIdByHost[host.connectionHash], previous != windowId { + hostsByWindowId[previous]?.removeAll { $0.connectionHash == host.connectionHash } + if hostsByWindowId[previous]?.isEmpty == true { hostsByWindowId[previous] = nil } + } windowIdByHost[host.connectionHash] = windowId var hosts = hostsByWindowId[windowId] ?? [] if !hosts.contains(where: { $0.connectionHash == host.connectionHash }) { diff --git a/cmuxTests/RemoteTmuxWindowRegistryTests.swift b/cmuxTests/RemoteTmuxWindowRegistryTests.swift index 663091c2632c..4074fac09fa0 100644 --- a/cmuxTests/RemoteTmuxWindowRegistryTests.swift +++ b/cmuxTests/RemoteTmuxWindowRegistryTests.swift @@ -108,6 +108,20 @@ import Testing #expect(!registry.isDedicatedWindow(windowId)) } + /// Re-binding a host to a different window moves it (no stale entry left in the + /// old window), keeping the two maps consistent. + @Test func rebindingHostToAnotherWindowMovesIt() { + let registry = RemoteTmuxWindowRegistry() + let w1 = UUID(), w2 = UUID() + let a = host("user@a") + registry.bind(host: a, windowId: w1) + registry.bind(host: a, windowId: w2) + #expect(registry.windowId(forHostHash: a.connectionHash) == w2) + #expect(registry.hosts(forWindowId: w1).isEmpty) + #expect(!registry.isDedicatedWindow(w1)) + #expect(registry.hosts(forWindowId: w2).map(\.connectionHash) == [a.connectionHash]) + } + /// Unbinding by window id removes ALL of the window's hosts in both directions. @Test func unbindWindowRemovesAllHosts() { let registry = RemoteTmuxWindowRegistry() From d659c633b0173b08a30067e7dc10ecce3b4d5b9c Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sun, 28 Jun 2026 09:49:00 -0700 Subject: [PATCH 28/38] remote-tmux: open the new-browser button as a tab in the mirror workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Sources/Workspace.swift | 40 ++++++++-------------------------------- 1 file changed, 8 insertions(+), 32 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 1a61d522b177..8cb1396eb977 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -7897,38 +7897,14 @@ final class Workspace: Identifiable, ObservableObject { transparentBackground: Bool = false, bypassRemoteProxy: Bool = false ) -> BrowserPanel? { - // A remote tmux mirror workspace is a 1:1 view of a tmux session (no browser - // concept) — a local browser pane here would be an orphan the mirror's - // rebuild() never reconciles. For an explicit user action (globe tab-strip - // button via splitTabBar, command palette via openBrowser) open the browser in - // a NEW LOCAL workspace in the same window instead of no-op'ing, so every - // interactive "new browser" entrypoint behaves the same. The fresh workspace - // isn't a mirror, so this doesn't recurse. - // - // Restoration and automation/socket creation deliberately fall through to the - // `return nil` below: their callers require the returned panel to live in THIS - // workspace. Restoration's replaceSurface would otherwise `closePanel` the - // mirror's own pane and re-home it into a different workspace; the socket - // `new-surface`/`browser` handlers report the created surface under THIS - // workspace's id, so a cross-workspace panel would make them lie. For those a - // clean nil (the long-standing "mirror refuses a local browser" behavior) is - // correct. - if isRemoteTmuxMirror { - guard creationPolicy == .userInitiated, - let manager = owningTabManager ?? AppDelegate.shared?.tabManagerFor(tabId: id) - else { return nil } - let browserWorkspace = manager.addWorkspace(initialSurface: .browser, select: focus ?? true) - let panel = browserWorkspace.panels.values.compactMap { $0 as? BrowserPanel }.first - if let url, let panel { - panel.navigate(to: url) - } else { - // No URL → blank browser: focus the address bar so the user can type, - // matching performNewBrowserWorkspaceAction's behavior (the globe - // button otherwise lands on a blank tab with nothing focused). - AppDelegate.shared?.focusInitialBrowserAddressBar(in: browserWorkspace) - } - return panel - } + // A remote tmux mirror workspace may host a LOCAL browser surface as a normal + // tab alongside its tmux-window tabs. The mirror's rebuild() reconciles only + // the surfaces it owns (tracked by windowId/paneId in panelIdByWindow); it + // never enumerates or prunes surfaces it doesn't own, so a local browser tab + // survives reconcile. Every routing decision (close/split/rename/reorder) + // keys off "is this panel a tmux window tab?" and falls through to local + // handling for a browser. So no special case is needed here — fall through to + // normal browser creation, which adds the browser as a tab in this workspace. let browserEnabled = BrowserAvailabilitySettings.isEnabled() guard browserEnabled || creationPolicy.permitsCreationWhenBrowserDisabled else { if let externalURL = url ?? initialRequest?.url { From aa0c57ba257b984c938c7aaa26e39a833edad27f Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sun, 28 Jun 2026 09:51:50 -0700 Subject: [PATCH 29/38] remote-tmux: allow aggregating hosts into a regular window, not just a dedicated one "Multiple servers in one window" only aggregated into a window that was already a linked-view window; `cmux ssh-tmux --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. --- Sources/RemoteTmuxController.swift | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 4ee3be730b6a..418ecfa1f934 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -413,16 +413,20 @@ final class RemoteTmuxController { // failure (or cancellation) throws here and leaks no orphaned window. try await ensureControlMasterReadyForBurst(host: host) - // Aggregate into an existing linked-view window when requested ("multiple - // servers in one window"), otherwise open a fresh dedicated window. Only - // linked-view aggregates: its one shared stream per host keeps a window's - // hosts independent; per-session mirror mode stays single-host. + // Aggregate into the requested window when possible ("multiple servers in one + // window"), otherwise open a fresh dedicated window. We aggregate into a + // window that is either already a linked-view window OR a regular window with + // no remote host bound yet (so a user can pull hosts into their main window + // alongside local workspaces). We never aggregate into a per-session dedicated + // window: per-session mirror mode keeps the single-host invariant. Only + // linked-view's one-shared-stream-per-host model keeps a window's hosts + // independent, so aggregation requires linked-view to be enabled. let windowId: UUID let manager: TabManager let bootstrapWorkspaceId: UUID? let createdNewWindow: Bool if Self.linkedViewEnabled, let intoWindowId, - isLinkedViewWindow(intoWindowId), + isLinkedViewWindow(intoWindowId) || !windowRegistry.isDedicatedWindow(intoWindowId), let existingManager = appDelegate.tabManagerFor(windowId: intoWindowId) { windowId = intoWindowId manager = existingManager From 44417635804fecee21fd6280e2a52095a9685bd1 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sun, 28 Jun 2026 10:24:04 -0700 Subject: [PATCH 30/38] remote-tmux: per-origin color rail in the sidebar for multi-origin windows 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. --- Sources/ContentView.swift | 153 ++++++++++++++++++++++++++++- Sources/RemoteTmuxController.swift | 7 ++ 2 files changed, 158 insertions(+), 2 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 4af7a08c9d33..4de1a2b6a3e6 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -10047,6 +10047,7 @@ struct VerticalTabsSidebar: View { // row sitting behind the open menu. See `SidebarShortcutHintFreezePolicy`. @State private var frozenShortcutHintsTabId: UUID? @State private var frozenShortcutHintsValue: Bool = false + @Environment(\.colorScheme) private var colorScheme @State private var pendingSelectedWorkspaceScrollId: UUID? @State private var collapsedExtensionSidebarSectionIds: Set = [] @State private var extensionSidebarWorktreeCreationInFlightSectionIds: Set = [] @@ -10442,6 +10443,10 @@ struct VerticalTabsSidebar: View { let workspaceGroupMenuSnapshot: WorkspaceGroupMenuSnapshot let workspaceRenderItems: [SidebarWorkspaceRenderItem] let visibleWorkspaceRowIds: [UUID] + /// Per-origin rail color, keyed by workspace id. Empty when the window + /// contains <= 1 distinct origin (so no origin rail is drawn). An origin + /// is "Local" (a non-mirror workspace) or a specific remote tmux host. + let originColorByWorkspaceId: [UUID: Color] var workspaceIds: [UUID] { tabIds } } @@ -10496,6 +10501,10 @@ struct VerticalTabsSidebar: View { visibleWorkspaceRowIds: visibleWorkspaceRowIds ) } ?? [] + let originColorByWorkspaceId = Self.originColorByWorkspaceId( + tabs: tabs, + colorScheme: colorScheme + ) let renderContext = WorkspaceListRenderContext( tabs: tabs, tabIds: tabIds, @@ -10516,7 +10525,8 @@ struct VerticalTabsSidebar: View { workspaceGroupById: workspaceGroupById, workspaceGroupMenuSnapshot: workspaceGroupMenuSnapshot, workspaceRenderItems: workspaceRenderItems, - visibleWorkspaceRowIds: visibleWorkspaceRowIds + visibleWorkspaceRowIds: visibleWorkspaceRowIds, + originColorByWorkspaceId: originColorByWorkspaceId ) ZStack(alignment: .bottomLeading) { @@ -12326,6 +12336,126 @@ struct VerticalTabsSidebar: View { ) } + /// Computes the per-origin rail color for every workspace in the window. + /// + /// Origin = "Local" (a workspace where `!isRemoteTmuxMirror`) OR a specific + /// remote tmux host. The rail is shown ONLY when the window contains MORE + /// THAN ONE distinct origin; otherwise this returns an empty map (no rail). + /// + /// Local origin renders a muted gray. Each remote host gets a palette color: + /// the distinct hosts are sorted lexicographically by `destination` + /// (`localizedStandardCompare`) and assigned `defaultPalette` entries in + /// order, skipping any palette entry whose color equals the app accent color + /// or any custom color a workspace in this window has explicitly chosen (the + /// per-workspace color used by the leftRail). + @MainActor + private static func originColorByWorkspaceId( + tabs: [Workspace], + colorScheme: ColorScheme + ) -> [UUID: Color] { + // Resolve each workspace's origin: nil destination → Local. + let controller = AppDelegate.shared?.remoteTmuxController + var hostByWorkspaceId: [UUID: RemoteTmuxHost] = [:] + var hasLocalOrigin = false + var distinctHostsByHash: [String: RemoteTmuxHost] = [:] + for tab in tabs { + if tab.isRemoteTmuxMirror, let host = controller?.remoteTmuxHost(forWorkspaceId: tab.id) { + hostByWorkspaceId[tab.id] = host + distinctHostsByHash[host.connectionHash] = host + } else { + hasLocalOrigin = true + } + } + + // Distinct origins = Local (if present) + each distinct remote host. + let distinctOriginCount = distinctHostsByHash.count + (hasLocalOrigin ? 1 : 0) + guard distinctOriginCount > 1 else { return [:] } + + // Colors already "claimed" so the host palette skips them: the app accent + // color plus every custom color a workspace in this window has chosen. + let accentColor = cmuxAccentNSColor() + var reservedRGB: Set<[Int]> = [] + if let rgb = rgbKey(accentColor) { + reservedRGB.insert(rgb) + } + for tab in tabs { + guard let hex = tab.customColor, + let nsColor = WorkspaceTabColorSettings.displayNSColor( + hex: hex, + colorScheme: colorScheme, + forceBright: true + ), + let rgb = rgbKey(nsColor) else { continue } + reservedRGB.insert(rgb) + } + + // Assign palette entries to hosts sorted by destination, skipping reserved. + let sortedHosts = distinctHostsByHash.values.sorted { + $0.destination.localizedStandardCompare($1.destination) == .orderedAscending + } + var colorByHash: [String: Color] = [:] + var paletteIndex = 0 + let palette = WorkspaceTabColorSettings.defaultPalette + for host in sortedHosts { + var assigned: Color? + while paletteIndex < palette.count { + let entry = palette[paletteIndex] + paletteIndex += 1 + guard let nsColor = WorkspaceTabColorSettings.displayNSColor( + hex: entry.hex, + colorScheme: colorScheme, + forceBright: true + ) else { continue } + if let rgb = rgbKey(nsColor), reservedRGB.contains(rgb) { continue } + assigned = Color(nsColor: pastel(nsColor)) + break + } + if let assigned { + colorByHash[host.connectionHash] = assigned + } + } + + // Muted gray for the Local origin, matching the subtle rail rendering. + let localColor = Color(nsColor: NSColor.secondaryLabelColor) + + var result: [UUID: Color] = [:] + for tab in tabs { + if let host = hostByWorkspaceId[tab.id] { + if let color = colorByHash[host.connectionHash] { + result[tab.id] = color + } + } else { + result[tab.id] = localColor + } + } + return result + } + + /// Softens a palette color toward a pastel tone by blending it part-way to + /// white, so the origin rails read as gentle accents rather than vivid bars. + private static func pastel(_ color: NSColor) -> NSColor { + guard let c = color.usingColorSpace(.sRGB) else { return color } + let blend: CGFloat = 0.4 + return NSColor( + srgbRed: c.redComponent + (1 - c.redComponent) * blend, + green: c.greenComponent + (1 - c.greenComponent) * blend, + blue: c.blueComponent + (1 - c.blueComponent) * blend, + alpha: 1 + ) + } + + /// A coarse RGB identity (0-255 per channel) for comparing two `NSColor`s by + /// appearance, used to skip palette entries that collide with the accent / + /// chosen colors. + private static func rgbKey(_ color: NSColor) -> [Int]? { + guard let rgb = color.usingColorSpace(.sRGB) else { return nil } + return [ + Int((rgb.redComponent * 255).rounded()), + Int((rgb.greenComponent * 255).rounded()), + Int((rgb.blueComponent * 255).rounded()), + ] + } + @ViewBuilder private func workspaceRow( _ tab: Workspace, @@ -12460,6 +12590,7 @@ struct VerticalTabsSidebar: View { contextMenuPinState: contextMenuPinState, workspaceGroupMenuSnapshot: renderContext.workspaceGroupMenuSnapshot, settings: renderContext.tabItemSettings, + originColor: renderContext.originColorByWorkspaceId[tab.id], onContextMenuAppear: onContextMenuAppear, onContextMenuDisappear: onContextMenuDisappear ) @@ -13294,6 +13425,7 @@ struct TabItemView: View, Equatable { lhs.topDropIndicatorVisible == rhs.topDropIndicatorVisible && lhs.bottomDropIndicatorVisible == rhs.bottomDropIndicatorVisible && lhs.isBonsplitWorkspaceDropActive == rhs.isBonsplitWorkspaceDropActive && + lhs.originColor == rhs.originColor && lhs.settings == rhs.settings } @@ -13347,6 +13479,10 @@ struct TabItemView: View, Equatable { let contextMenuPinState: WorkspaceActionDispatcher.PinState? let workspaceGroupMenuSnapshot: WorkspaceGroupMenuSnapshot let settings: SidebarTabItemSettingsSnapshot + /// Immutable per-origin rail color for this row (nil when the window has a + /// single origin so no rail is shown). Passed in as a value snapshot to keep + /// the row's snapshot-boundary contract; never read from a store in the body. + let originColor: Color? /// Called from this row's contextMenu.onAppear so the parent can freeze /// `showsModifierShortcutHints` to the value it last passed in. Prevents /// modifier-key transitions from flipping the badges on the row sitting @@ -14055,12 +14191,25 @@ struct TabItemView: View, Equatable { RoundedRectangle(cornerRadius: 6) .strokeBorder(activeBorderColor, lineWidth: activeBorderLineWidth) } + .overlay(alignment: .leading) { + // Origin rail sits at the very leading edge; when the custom + // per-workspace rail is also shown it is nudged inward so the + // two 3pt capsules sit side-by-side instead of overlapping. + if let originColor { + Capsule(style: .continuous) + .fill(originColor.opacity(0.85)) + .frame(width: 3) + .padding(.leading, 4) + .padding(.vertical, 5) + .offset(x: -1) + } + } .overlay(alignment: .leading) { if showsLeadingRail { Capsule(style: .continuous) .fill(railColor) .frame(width: 3) - .padding(.leading, 4) + .padding(.leading, originColor != nil ? 9 : 4) .padding(.vertical, 5) .offset(x: -1) } diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 418ecfa1f934..9ddf2c44a781 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -754,6 +754,13 @@ final class RemoteTmuxController { ?? linkedMirrors.values.first { $0.mirroredWorkspaceId == workspaceId } } + /// The remote host a mirrored workspace is mirroring, in either mode. Used by + /// the sidebar to color a per-origin rail by host; `nil` for a workspace that + /// is not a remote tmux mirror. + func remoteTmuxHost(forWorkspaceId workspaceId: UUID) -> RemoteTmuxHost? { + actionMirror(forWorkspaceId: workspaceId)?.host + } + /// All action mirrors across both modes, for surface-based lookups. private var actionMirrors: [RemoteTmuxSessionMirror] { Array(sessionMirrors.values) + Array(linkedMirrors.values) From b8f64421533951c04124e720a74eb225f0de0f43 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sun, 28 Jun 2026 10:29:23 -0700 Subject: [PATCH 31/38] remote-tmux: show the server name in the window title for mirror workspaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Sources/TabManager+WindowTitle.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Sources/TabManager+WindowTitle.swift b/Sources/TabManager+WindowTitle.swift index 90ae5665de07..88aed4a9c4d2 100644 --- a/Sources/TabManager+WindowTitle.swift +++ b/Sources/TabManager+WindowTitle.swift @@ -43,6 +43,22 @@ extension TabManager { } private func windowTitle(for tab: Workspace?) -> String { + withRemoteHostPrefix(baseWindowTitle(for: tab), tab: tab) + } + + /// Prefixes the window title with the remote tmux host name when the selected + /// workspace is a mirror, so windows that hold (or aggregate) remote sessions + /// are identifiable in the Window menu / Mission Control: "host — workspace". + private func withRemoteHostPrefix(_ title: String, tab: Workspace?) -> String { + guard let tab, tab.isRemoteTmuxMirror, + let host = AppDelegate.shared?.remoteTmuxController.remoteTmuxHost(forWorkspaceId: tab.id) + else { return title } + let destination = host.destination.trimmingCharacters(in: .whitespacesAndNewlines) + guard !destination.isEmpty else { return title } + return title.isEmpty ? destination : "\(destination) \u{2014} \(title)" + } + + private func baseWindowTitle(for tab: Workspace?) -> String { let defaultTitle = defaultWindowTitle(for: tab) guard let windowId, let template = WindowTitleTemplate.configured() else { return defaultTitle } From 042d9a3ab880b40a32994f5383ae8d928e602278 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sun, 28 Jun 2026 10:43:23 -0700 Subject: [PATCH 32/38] remote-tmux: harden browser-tab + aggregate-into-regular-window from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- Sources/RemoteTmuxController.swift | 9 ++++++++- Sources/RemoteTmuxSessionMirror.swift | 29 +++++++++++++++++++++------ Sources/Workspace.swift | 28 +++++++++++++++----------- 3 files changed, 47 insertions(+), 19 deletions(-) diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 9ddf2c44a781..46d20d77ab9a 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -716,7 +716,14 @@ final class RemoteTmuxController { manager.closeWorkspace(workspace, recordHistory: false) } } - if windowRegistry.hosts(forWindowId: windowId).isEmpty, + // Only discard a window that was purely this host's (a dedicated remote + // window whose last tab can't be closed). If the window also holds LOCAL + // workspaces (a host aggregated into the user's regular window), the close + // loop above already removed this host's tabs — discarding now would destroy + // the user's local work. + let hasLocalWorkspaces = manager?.tabs.contains { !$0.isRemoteTmuxMirror } ?? false + if !hasLocalWorkspaces, + windowRegistry.hosts(forWindowId: windowId).isEmpty, let appDelegate = AppDelegate.shared, appDelegate.windowForMainWindowId(windowId) != nil { appDelegate.discardMainWindowWithoutClosedHistory(windowId: windowId) diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index ba8fcb4ce7b0..568c85f18083 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -552,13 +552,30 @@ final class RemoteTmuxSessionMirror { /// - Parameters: /// - current: the workspace's current mirror-tab order (panel ids). /// - requested: the tmux window order mapped to panel ids. - /// - Returns: the new order to apply, or `nil` when the tabs already match - /// `requested` or when `requested` (restricted to currently-present tabs) is - /// not a permutation of `current` (sets diverge — leave the tabs untouched). + /// - Returns: the new order to apply, or `nil` when no reorder is needed. + /// Reorders ONLY the mirror tabs named in `requested` (in that order); any + /// other tab in `current` — a local browser tab the user opened, or a mirror + /// tab not yet reconciled — keeps its slot. This way a coexisting local tab + /// doesn't defeat tmux-driven reordering of the mirror tabs around it. nonisolated static func mirrorTabReorder(current: [UUID], requested: [UUID]) -> [UUID]? { let present = Set(current) - let desired = requested.filter { present.contains($0) } - guard desired.count == current.count, Set(desired) == present else { return nil } - return desired == current ? nil : desired + let mirrorOrder = requested.filter { present.contains($0) } + guard !mirrorOrder.isEmpty else { return nil } + let mirrorSet = Set(mirrorOrder) + var queue = mirrorOrder[...] + var result: [UUID] = [] + result.reserveCapacity(current.count) + for id in current { + if mirrorSet.contains(id) { + // Mirror slot: fill with the next tmux-ordered mirror tab. + if let next = queue.first { + result.append(next) + queue = queue.dropFirst() + } + } else { + result.append(id) // non-mirror (e.g. local browser) tab stays put + } + } + return result == current ? nil : result } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 8cb1396eb977..6886fd2be448 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -7783,8 +7783,10 @@ final class Workspace: Identifiable, ObservableObject { bypassRemoteProxy: Bool = false, initialDividerPosition: CGFloat? = nil ) -> BrowserPanel? { - // No local browser surfaces in a remote tmux mirror workspace (it is a - // 1:1 view of a tmux session). See ``newBrowserSurface(inPane:)``. + // A mirror workspace allows a local browser as a TAB (see + // ``newBrowserSurface(inPane:)``), but not as a SPLIT: the mirror's tabs must + // all live in one pane (the tmux-driven reorder can't span a user split), so + // a second local pane here would break that invariant. Refuse the split. if isRemoteTmuxMirror { return nil } let browserEnabled = BrowserAvailabilitySettings.isEnabled() guard browserEnabled || creationPolicy.permitsCreationWhenBrowserDisabled else { @@ -12216,16 +12218,18 @@ extension Workspace: BonsplitDelegate { } func splitTabBar(_ controller: BonsplitController, shouldSplitPane pane: PaneID, orientation: SplitOrientation) -> Bool { - // In a remote tmux mirror, split means tmux `split-window`; always veto - // local splits so the mirror never gains an orphan pane. - guard isRemoteTmuxMirror else { return true } - if let tabId = bonsplitController.selectedTab(inPane: pane)?.id, - let panelId = panelIdFromSurfaceId(tabId) { - _ = AppDelegate.shared?.remoteTmuxController.handleMirrorTabSplitRequested( - workspaceId: id, panelId: panelId, vertical: orientation == .vertical - ) - } - return false + // In a remote tmux mirror, splitting a MIRROR window tab means tmux + // `split-window` — veto the local split so the mirror never gains an orphan + // pane. But the workspace may also hold a local tab (e.g. a browser the user + // opened); splitting that should behave locally. So only veto when the split + // actually routed to tmux (the selected tab is a mirror-owned window tab). + guard isRemoteTmuxMirror, + let tabId = bonsplitController.selectedTab(inPane: pane)?.id, + let panelId = panelIdFromSurfaceId(tabId) else { return true } + let routedToTmux = AppDelegate.shared?.remoteTmuxController.handleMirrorTabSplitRequested( + workspaceId: id, panelId: panelId, vertical: orientation == .vertical + ) ?? false + return !routedToTmux } func splitTabBar(_ controller: BonsplitController, didReorderTabsInPane pane: PaneID, orderedTabIds: [TabID]) { From 2f77b0f615a27be2e559eae45c3cd235e217508b Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sun, 28 Jun 2026 10:46:33 -0700 Subject: [PATCH 33/38] remote-tmux: simplify pastel() to use NSColor.blended(withFraction:of:) (review) --- Sources/ContentView.swift | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 4de1a2b6a3e6..77b219b816f5 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -12434,14 +12434,7 @@ struct VerticalTabsSidebar: View { /// Softens a palette color toward a pastel tone by blending it part-way to /// white, so the origin rails read as gentle accents rather than vivid bars. private static func pastel(_ color: NSColor) -> NSColor { - guard let c = color.usingColorSpace(.sRGB) else { return color } - let blend: CGFloat = 0.4 - return NSColor( - srgbRed: c.redComponent + (1 - c.redComponent) * blend, - green: c.greenComponent + (1 - c.greenComponent) * blend, - blue: c.blueComponent + (1 - c.blueComponent) * blend, - alpha: 1 - ) + color.usingColorSpace(.sRGB)?.blended(withFraction: 0.4, of: .white) ?? color } /// A coarse RGB identity (0-255 per channel) for comparing two `NSColor`s by From 7c0bc25ca18e885f005276d120eca1bad6e72bc0 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sun, 28 Jun 2026 10:56:02 -0700 Subject: [PATCH 34/38] remote-tmux: subtle origin-rail palette + keep mirror workspaces single-pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- Sources/ContentView.swift | 35 +++++++++++++++++++++++++++-------- Sources/Workspace.swift | 26 ++++++++++++++------------ 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 77b219b816f5..bc10e5925f3e 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -12395,17 +12395,13 @@ struct VerticalTabsSidebar: View { } var colorByHash: [String: Color] = [:] var paletteIndex = 0 - let palette = WorkspaceTabColorSettings.defaultPalette + let palette = originRailPalette for host in sortedHosts { var assigned: Color? while paletteIndex < palette.count { - let entry = palette[paletteIndex] + let hex = palette[paletteIndex] paletteIndex += 1 - guard let nsColor = WorkspaceTabColorSettings.displayNSColor( - hex: entry.hex, - colorScheme: colorScheme, - forceBright: true - ) else { continue } + guard let nsColor = nsColorFromHex(hex) else { continue } if let rgb = rgbKey(nsColor), reservedRGB.contains(rgb) { continue } assigned = Color(nsColor: pastel(nsColor)) break @@ -12431,10 +12427,33 @@ struct VerticalTabsSidebar: View { return result } + /// A calm, muted palette for the origin rails — distinguishable hues with the + /// quietest first, so a multi-origin window reads gently. Assigned to hosts in + /// lexicographic order. Deliberately not the vivid workspace-tag palette (whose + /// first entry pastelizes to a hot pink). + private static let originRailPalette: [String] = [ + "#6E9BD2", // soft blue + "#5FB3A1", // teal + "#8FB36B", // sage green + "#D2A96B", // sand + "#A493D2", // lavender + "#CC8FA6", // dusty rose + "#6FB6C9", // sky + "#C2A36E", // tan + "#9DB36B", // olive + "#B98FC0", // mauve + ] + + private static func nsColorFromHex(_ hex: String) -> NSColor? { + guard let color = Color(hex: hex) else { return nil } + return NSColor(color).usingColorSpace(.sRGB) + } + /// Softens a palette color toward a pastel tone by blending it part-way to /// white, so the origin rails read as gentle accents rather than vivid bars. + /// Light blend — the palette is already muted. private static func pastel(_ color: NSColor) -> NSColor { - color.usingColorSpace(.sRGB)?.blended(withFraction: 0.4, of: .white) ?? color + color.usingColorSpace(.sRGB)?.blended(withFraction: 0.2, of: .white) ?? color } /// A coarse RGB identity (0-255 per channel) for comparing two `NSColor`s by diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 6886fd2be448..617a429a8e7c 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -12218,18 +12218,20 @@ extension Workspace: BonsplitDelegate { } func splitTabBar(_ controller: BonsplitController, shouldSplitPane pane: PaneID, orientation: SplitOrientation) -> Bool { - // In a remote tmux mirror, splitting a MIRROR window tab means tmux - // `split-window` — veto the local split so the mirror never gains an orphan - // pane. But the workspace may also hold a local tab (e.g. a browser the user - // opened); splitting that should behave locally. So only veto when the split - // actually routed to tmux (the selected tab is a mirror-owned window tab). - guard isRemoteTmuxMirror, - let tabId = bonsplitController.selectedTab(inPane: pane)?.id, - let panelId = panelIdFromSurfaceId(tabId) else { return true } - let routedToTmux = AppDelegate.shared?.remoteTmuxController.handleMirrorTabSplitRequested( - workspaceId: id, panelId: panelId, vertical: orientation == .vertical - ) ?? false - return !routedToTmux + // A mirror workspace stays SINGLE-PANE: all its mirror tabs must share one + // bonsplit pane (the tmux-driven window reorder can't span a user split, and + // a second pane could capture a newly-mirrored tmux window). So always veto a + // local split here. A split of a MIRROR window tab is instead routed to tmux + // `split-window` (rendered inside that tab). A local browser tab simply can't + // be split in a mirror workspace (open it in another window for a split). + guard isRemoteTmuxMirror else { return true } + if let tabId = bonsplitController.selectedTab(inPane: pane)?.id, + let panelId = panelIdFromSurfaceId(tabId) { + _ = AppDelegate.shared?.remoteTmuxController.handleMirrorTabSplitRequested( + workspaceId: id, panelId: panelId, vertical: orientation == .vertical + ) + } + return false } func splitTabBar(_ controller: BonsplitController, didReorderTabsInPane pane: PaneID, orderedTabIds: [TabID]) { From 3582b5a478db16806188906bb0be42284ff0c9c1 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sun, 28 Jun 2026 11:14:36 -0700 Subject: [PATCH 35/38] remote-tmux: show the host name in cmux's own title bar + command label too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Sources/ContentView.swift | 4 +++- Sources/TabManager+WindowTitle.swift | 9 +++++---- Sources/WindowToolbarController.swift | 3 ++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index bc10e5925f3e..c0a65e6c492d 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2250,8 +2250,10 @@ struct ContentView: View { } return } - let title = tabManager.resolvedWorkspaceDisplayTitle(for: tab) + let baseTitle = tabManager.resolvedWorkspaceDisplayTitle(for: tab) .trimmingCharacters(in: .whitespacesAndNewlines) + // Show the remote host name in the title bar for a mirror workspace. + let title = tabManager.withRemoteHostPrefix(baseTitle, tab: tab) if titlebarText != title { titlebarText = title } diff --git a/Sources/TabManager+WindowTitle.swift b/Sources/TabManager+WindowTitle.swift index 88aed4a9c4d2..6d4d1e13e3fc 100644 --- a/Sources/TabManager+WindowTitle.swift +++ b/Sources/TabManager+WindowTitle.swift @@ -46,10 +46,11 @@ extension TabManager { withRemoteHostPrefix(baseWindowTitle(for: tab), tab: tab) } - /// Prefixes the window title with the remote tmux host name when the selected - /// workspace is a mirror, so windows that hold (or aggregate) remote sessions - /// are identifiable in the Window menu / Mission Control: "host — workspace". - private func withRemoteHostPrefix(_ title: String, tab: Workspace?) -> String { + /// Prefixes a display title with the remote tmux host name when `tab` is a + /// mirror, so windows/chrome that hold (or aggregate) remote sessions are + /// identifiable: "host — workspace". Used by the NSWindow title (Window menu / + /// Mission Control) and cmux's own title bar + command label. + func withRemoteHostPrefix(_ title: String, tab: Workspace?) -> String { guard let tab, tab.isRemoteTmuxMirror, let host = AppDelegate.shared?.remoteTmuxController.remoteTmuxHost(forWorkspaceId: tab.id) else { return title } diff --git a/Sources/WindowToolbarController.swift b/Sources/WindowToolbarController.swift index 0e6aeb0ae57a..f8c9373e435c 100644 --- a/Sources/WindowToolbarController.swift +++ b/Sources/WindowToolbarController.swift @@ -191,8 +191,9 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate { let text: String if let selectedId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == selectedId }) { - let title = tabManager.resolvedWorkspaceDisplayTitle(for: tab) + let baseTitle = tabManager.resolvedWorkspaceDisplayTitle(for: tab) .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + let title = tabManager.withRemoteHostPrefix(baseTitle, tab: tab) text = title.isEmpty ? "Cmd: —" : "Cmd: \(title)" } else { text = "Cmd: —" From fe36c4f7d28f7d744cba74df5f886bcac0a78d73 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sun, 28 Jun 2026 11:51:44 -0700 Subject: [PATCH 36/38] remote-tmux: ship the toned-down origin-rail palette + add palette design doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- Sources/ContentView.swift | 36 +++--- docs/remote-tmux-origin-rail-palette.html | 142 ++++++++++++++++++++++ 2 files changed, 156 insertions(+), 22 deletions(-) create mode 100644 docs/remote-tmux-origin-rail-palette.html diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index c0a65e6c492d..96bcc1413ae2 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -12405,7 +12405,7 @@ struct VerticalTabsSidebar: View { paletteIndex += 1 guard let nsColor = nsColorFromHex(hex) else { continue } if let rgb = rgbKey(nsColor), reservedRGB.contains(rgb) { continue } - assigned = Color(nsColor: pastel(nsColor)) + assigned = Color(nsColor: nsColor) break } if let assigned { @@ -12429,21 +12429,20 @@ struct VerticalTabsSidebar: View { return result } - /// A calm, muted palette for the origin rails — distinguishable hues with the - /// quietest first, so a multi-origin window reads gently. Assigned to hosts in - /// lexicographic order. Deliberately not the vivid workspace-tag palette (whose - /// first entry pastelizes to a hot pink). + /// Muted, optimized origin-rail palette. The 6 hues were 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 + /// (it reads like the accent/selection) — min pairwise ΔE2000 ≈ 24.5. Calm but + /// clearly separable; the rose is dusty, not a bright pink. Assigned to hosts in + /// lexicographic order. (Distinguishing is best-effort — the host name in the + /// title bar / row label is the primary cue.) private static let originRailPalette: [String] = [ - "#6E9BD2", // soft blue - "#5FB3A1", // teal - "#8FB36B", // sage green - "#D2A96B", // sand - "#A493D2", // lavender - "#CC8FA6", // dusty rose - "#6FB6C9", // sky - "#C2A36E", // tan - "#9DB36B", // olive - "#B98FC0", // mauve + "#9E6652", // clay + "#C7A958", // gold + "#75C758", // green + "#469E92", // teal + "#63469E", // indigo + "#C75893", // rose ] private static func nsColorFromHex(_ hex: String) -> NSColor? { @@ -12451,13 +12450,6 @@ struct VerticalTabsSidebar: View { return NSColor(color).usingColorSpace(.sRGB) } - /// Softens a palette color toward a pastel tone by blending it part-way to - /// white, so the origin rails read as gentle accents rather than vivid bars. - /// Light blend — the palette is already muted. - private static func pastel(_ color: NSColor) -> NSColor { - color.usingColorSpace(.sRGB)?.blended(withFraction: 0.2, of: .white) ?? color - } - /// A coarse RGB identity (0-255 per channel) for comparing two `NSColor`s by /// appearance, used to skip palette entries that collide with the accent / /// chosen colors. diff --git a/docs/remote-tmux-origin-rail-palette.html b/docs/remote-tmux-origin-rail-palette.html new file mode 100644 index 000000000000..3429e6fd1c0f --- /dev/null +++ b/docs/remote-tmux-origin-rail-palette.html @@ -0,0 +1,142 @@ + + + + + +cmux origin-rail palette options + + + +

    cmux origin-rail palette options

    +

    Origin-rail palette exploration for the remote-tmux linked-view feature. Each option shows a mock workspace sidebar for a window that mixes origins — Local rows (gray rail) + remote hosts (each its own color) — on the actual cmux grey background and the dark/near-black background, with none selected and all selected (the blue is the #3B86F7 selection highlight). Shipped scheme: “Toned-down (recommended)”. Palettes were optimized to maximize the minimum CIEDE2000 distance between every pair and from the built-in colors (accent #0088FF/#0091FF, selection #3B86F7, backgrounds), excluding blue. Distinguishing is best-effort — the host name in the title bar / row label is the primary cue. Local uses an adaptive gray.

    +
    + + + + From 6663c9049f0b46261e7632d2bf52fae1837461e9 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Sat, 27 Jun 2026 22:09:00 -0700 Subject: [PATCH 37/38] remote-tmux: strip the screen/tmux ESC k window-title escape from mirror 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. --- .../Values/RemoteTmuxScreenTitleFilter.swift | 82 +++++++++++++++++++ .../RemoteTmuxScreenTitleFilterTests.swift | 78 ++++++++++++++++++ Sources/RemoteTmuxSessionMirror.swift | 25 +++++- 3 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Values/RemoteTmuxScreenTitleFilter.swift create mode 100644 Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteTmuxScreenTitleFilterTests.swift diff --git a/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Values/RemoteTmuxScreenTitleFilter.swift b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Values/RemoteTmuxScreenTitleFilter.swift new file mode 100644 index 000000000000..2dce6b9d968a --- /dev/null +++ b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Values/RemoteTmuxScreenTitleFilter.swift @@ -0,0 +1,82 @@ +public import Foundation + +/// Strips the GNU screen / tmux window-title escape (`ESC k ESC \`) from a +/// mirrored pane's output stream. +/// +/// A remote shell running *inside* tmux sees `TERM=screen*`/`tmux*`, so its prompt +/// (e.g. oh-my-zsh) sets the title with the screen sequence `\ek<cmd>\e\\` instead of +/// the xterm OSC. `%output` is the raw pty copy, so tmux forwards the `ESC k` bytes +/// verbatim and only interprets them for its OWN screen (window name) — its rendered +/// pane (`capture-pane`) has the title stripped. cmux's mirror surface is an +/// xterm-style emulator that doesn't recognize `ESC k`, so it would instead print the +/// title text onto the screen — e.g. `echo "ej"\r\n\ekecho\e\\ej` renders as `echoej`. +/// To match what the remote tmux actually shows, the mirror interprets/strips the +/// sequence here (the tab name already tracks tmux's `window_name`). +/// +/// Stateful across calls: a `%output` chunk can split the sequence at any byte. Like +/// tmux/screen, `ESC k` is terminated ONLY by ST (`ESC \`), so an unterminated title +/// consumes until ST — matching tmux's own screen exactly (verified empirically by +/// diffing cmux's render against `capture-pane`). +public struct RemoteTmuxScreenTitleFilter { + private enum State { + case text // normal passthrough + case esc // saw ESC, holding it until we know if it's `ESC k` + case title // inside `ESC k …`, dropping the title bytes + case titleEsc // inside the title, saw ESC — maybe the `ESC \` terminator + } + + private var state: State = .text + + public init() {} + + /// Returns `data` with any `ESC k … ESC \` title sequences removed. + public mutating func filter(_ data: Data) -> Data { + // Hot path: routeOutput calls this for every %output chunk. When we're not + // mid-sequence and the chunk has no ESC, there is nothing to strip — return it + // unchanged and skip the per-byte copy + allocation. + if state == .text, !data.contains(0x1b) { return data } + // Build into a `[UInt8]` buffer (cheaper than per-byte `Data.append`) and wrap + // it once at the end. + var out = [UInt8]() + out.reserveCapacity(data.count) + for byte in data { + switch state { + case .text: + if byte == 0x1b { + state = .esc // hold the ESC; emit it only if it isn't `ESC k` + } else { + out.append(byte) + } + case .esc: + if byte == UInt8(ascii: "k") { + state = .title // `ESC k` → start of title; drop both bytes + } else { + out.append(0x1b) // not a title: emit the held ESC … + if byte == 0x1b { + // another ESC: keep holding it (stay in .esc) + } else { + out.append(byte) // … followed by this byte + state = .text + } + } + case .title: + // tmux/screen terminate `ESC k` ONLY on ST (`ESC \`), never on BEL — + // so a BEL is part of the title and the title runs until ST (matching + // what the remote tmux renders). Drop everything until then. + if byte == 0x1b { + state = .titleEsc // maybe the `ESC \` terminator + } + // otherwise (incl. BEL): title text — drop it + case .titleEsc: + if byte == 0x5c { + state = .text // `ESC \` (ST) terminates the title + } else if byte == 0x1b { + state = .titleEsc // consecutive ESC — keep waiting + } else { + state = .title // ESC + other byte: still inside the title + } + } + } + return Data(out) + } +} diff --git a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteTmuxScreenTitleFilterTests.swift b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteTmuxScreenTitleFilterTests.swift new file mode 100644 index 000000000000..b68dd75c732f --- /dev/null +++ b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteTmuxScreenTitleFilterTests.swift @@ -0,0 +1,78 @@ +import Foundation +import Testing +@testable import CmuxRemoteSession + +/// Tests the screen/tmux window-title escape stripper used on mirrored `%output`. +/// A remote shell inside tmux (TERM=screen*/tmux*) sets its title with +/// `ESC k <title> ST`; cmux's xterm-style mirror surface would print the title text +/// otherwise (the `echoej` bug). The filter must drop the sequence, survive chunk +/// splits, and leave everything else byte-identical. +/// +/// Assertions compare raw `Data` (not UTF-8-decoded strings): the filter is a +/// byte-stream transform, and `String(decoding:as:)` silently replaces invalid +/// UTF-8 — which would mask a byte-corruption regression instead of failing. +@Suite struct RemoteTmuxScreenTitleFilterTests { + private func run(_ chunks: [String]) -> Data { + var f = RemoteTmuxScreenTitleFilter() + var out = Data() + for c in chunks { out.append(f.filter(Data(c.utf8))) } + return out + } + private func run(_ s: String) -> Data { run([s]) } + + private func bytes(_ s: String) -> Data { Data(s.utf8) } + + private let ESC = "\u{1b}" + + @Test func stripsStTerminatedTitleBetweenText() { + // The exact echoej repro: command output `ej` preceded by `ESC k echo ESC \`. + let input = "\(ESC)kecho\(ESC)\\ej" + #expect(run(input) == bytes("ej")) + } + + @Test func belDoesNotTerminateTitleMatchingTmux() { + // tmux/screen end `ESC k` only on ST (`ESC \`), never BEL. A BEL is swallowed + // as title text and the title runs until ST — matching the remote's rendering. + #expect(run("a\(ESC)kfoo\u{07}bar\(ESC)\\Z") == bytes("aZ")) // ST ends it; BEL consumed + #expect(run("a\(ESC)kfoo\u{07}bar") == bytes("a")) // no ST: rest consumed + } + + @Test func stripsMultipleTitlesAndKeepsSurroundingText() { + // Prompt sets title to `~`, command sets it to `echo`, output is `ej`. + let input = "\(ESC)k~\(ESC)\\prompt \(ESC)kecho\(ESC)\\ej\r\n" + #expect(run(input) == bytes("prompt ej\r\n")) + } + + @Test func survivesChunkSplitsAtEveryBoundary() { + let full = "X\(ESC)kabc\(ESC)\\Y" + let allBytes = Array(full.utf8) + // Split the stream after each byte and confirm the result is always "XY". + for cut in 1..<allBytes.count { + var f = RemoteTmuxScreenTitleFilter() + var out = Data() + out.append(f.filter(Data(allBytes[0..<cut]))) + out.append(f.filter(Data(allBytes[cut...]))) + #expect(out == bytes("XY"), "split at \(cut)") + } + } + + @Test func preservesCsiAndOtherEscapes() { + // Color SGR and cursor moves must pass through untouched. + let input = "\(ESC)[32mgreen\(ESC)[0m\(ESC)[2J\(ESC)[H" + #expect(run(input) == bytes(input)) + } + + @Test func preservesEscFollowedByNonK() { + // `ESC \` (ST) on its own, and an OSC title, are not `ESC k` and pass through. + #expect(run("\(ESC)\\done") == bytes("\(ESC)\\done")) + #expect(run("\(ESC)]0;title\u{07}x") == bytes("\(ESC)]0;title\u{07}x")) + } + + @Test func plainTextUnchanged() { + #expect(run("echo \"ej\"\r\nej\r\n") == bytes("echo \"ej\"\r\nej\r\n")) + } + + @Test func titleImmediatelyFollowedByMoreTitle() { + #expect(run("\(ESC)ka\(ESC)\\\(ESC)kb\(ESC)\\Z") == bytes("Z")) + } +} diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index 568c85f18083..48307a170fe5 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -1,4 +1,5 @@ import Foundation +import CmuxRemoteSession /// Mirrors one remote tmux session into a dedicated cmux sidebar workspace. /// @@ -50,6 +51,9 @@ final class RemoteTmuxSessionMirror { /// Last-known working directory per tmux pane, so switching the active pane of /// a multi-pane window can re-project that pane's directory onto the tab. private var cwdByPane: [Int: String] = [:] + /// Per-pane filter that strips the screen/tmux `ESC k <title> ST` window-title + /// escape from `%output` (stateful across chunk boundaries). + private var titleFilters: [Int: RemoteTmuxScreenTitleFilter] = [:] /// Per-window multi-pane renderers (present once a window has >1 pane). private var windowMirrorByWindowId: [Int: RemoteTmuxWindowMirror] = [:] private var observerToken: RemoteTmuxControlConnection.ObserverToken? @@ -136,6 +140,14 @@ final class RemoteTmuxSessionMirror { onExit: { [weak self] in guard let self, self.managesOwnLifecycle else { return } self.handleConnectionExited() + }, + onConnectionStateChanged: { [weak self] state in + // Drop any mid-`ESC k` title-filter state when the stream isn't live: + // a reconnect's `reseedAfterReconnect` re-emits clear/capture bytes, + // and a filter stuck mid-title from before the drop would swallow them. + // Resetting on the disconnect edge is ordering-independent (no output + // arrives while not connected). + if state != .connected { self?.titleFilters.removeAll() } } ) rebuild() @@ -295,6 +307,7 @@ final class RemoteTmuxSessionMirror { // stays bounded across window/pane churn (tmux pane ids never recur). let livePanes = Set(connection.windowsByID.values.flatMap { $0.paneIDsInOrder }) cwdByPane = cwdByPane.filter { livePanes.contains($0.key) } + titleFilters = titleFilters.filter { livePanes.contains($0.key) } closeDefaultTabsIfNeeded() // Follow out-of-band tmux window reorders (a second client, or a manual // move-window / a new-window inserted mid-list): the cmux tabs are created @@ -464,17 +477,25 @@ final class RemoteTmuxSessionMirror { } private func routeOutput(paneId: Int, data: Data) { + // Strip the screen/tmux `ESC k <title> ST` window-title escape that a remote + // shell (TERM=screen*/tmux*) emits — the mirror's xterm-style surface would + // otherwise print the title text onto the screen (see + // ``RemoteTmuxScreenTitleFilter``). Per-pane state survives chunk splits. + var filter = titleFilters[paneId] ?? RemoteTmuxScreenTitleFilter() + let cleaned = filter.filter(data) + titleFilters[paneId] = filter + // Multi-pane window: its in-tab renderer owns the pane's surface. if let windowId = windowIdContaining(pane: paneId), let mirror = windowMirrorByWindowId[windowId] { - mirror.routeOutput(paneId: paneId, data: data) + mirror.routeOutput(paneId: paneId, data: cleaned) return } // Single-pane window: route to the window-tab's panel surface. guard let workspace, let panelId = panelIdByPane[paneId], let panel = workspace.panels[panelId] as? TerminalPanel else { return } - panel.surface.processRemoteOutput(data) + panel.surface.processRemoteOutput(cleaned) } /// Applies a pane's reflow classification to its mirror surface (suppress From 6100b3355d9efd8292baf739338cf7fdd0bd4955 Mon Sep 17 00:00:00 2001 From: ejc3 <ejc3@users.noreply.github.com> Date: Mon, 29 Jun 2026 01:04:54 -0700 Subject: [PATCH 38/38] remote-tmux: ssh-tmux reuses the caller's window by default (multiple 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. --- CLI/cmux.swift | 54 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 858541ac2cf0..bca09f401c53 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -8480,8 +8480,9 @@ struct CMUXCLI { ) } - /// `cmux ssh-tmux <destination>` — open a dedicated cmux window mirroring a remote - /// host's tmux sessions over `tmux -CC` (the remote-tmux beta). + /// `cmux ssh-tmux <destination>` — mirror a remote host's tmux sessions over + /// `tmux -CC` (the remote-tmux beta). Aggregates into the caller's current window + /// by default; `--new-window`/`--into-window` override the target. /// /// Unlike `cmux ssh`, this carries no cmuxd-remote/relay bootstrap: it only /// drives the SSH ControlMaster the mirror multiplexes over. The app's mirror @@ -8500,6 +8501,7 @@ struct CMUXCLI { var identityFile: String? var noFocus = false var intoWindow: String? + var newWindow = false // Intentional subset of parseSSHCommandOptions: the mirror verb has a // different pipeline (no relay/cmuxd bootstrap, no `--` passthrough, no @@ -8533,6 +8535,9 @@ struct CMUXCLI { } intoWindow = commandArgs[index + 1] index += 2 + case "--new-window": + newWindow = true + index += 1 default: if arg.hasPrefix("-") { throw CLIError( @@ -8556,10 +8561,15 @@ struct CMUXCLI { if let port { params["port"] = port } if let identityFile, !identityFile.isEmpty { params["identity_file"] = identityFile } if noFocus { params["activate"] = false } - // Aggregate into an existing linked-view window ("multiple servers in one - // window"). `current` resolves the caller's window from CMUX_WORKSPACE_ID - // (surfaces export the workspace id, not the window id); otherwise pass a - // window id (e.g. from a prior `ssh-tmux` JSON result or `list-windows`). + // Window targeting. By DEFAULT, aggregate the host into the caller's current + // window ("multiple servers in one window") so `cmux ssh-tmux <host>` run from + // inside a cmux surface reuses that window instead of spawning a new dedicated + // one. `--into-window <id|current>` targets a window explicitly; `--new-window` + // forces a fresh dedicated window. Surfaces export CMUX_WORKSPACE_ID (the + // workspace id, not the window id), which the app maps to the host window. + if intoWindow != nil && newWindow { + throw CLIError(message: "ssh-tmux: pass only one of --into-window or --new-window") + } if let intoWindow { if intoWindow == "current" { guard let workspaceId = ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"], @@ -8570,6 +8580,12 @@ struct CMUXCLI { } else { params["into_window"] = intoWindow } + } else if !newWindow, + let workspaceId = ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"], + !workspaceId.isEmpty { + // Default: reuse the caller's window. Outside a cmux surface (no + // CMUX_WORKSPACE_ID) this falls through to a new window, as does --new-window. + params["into_workspace"] = workspaceId } // The first call runs a non-interactive (BatchMode) discovery in the app, @@ -14946,11 +14962,17 @@ struct CMUXCLI { case "ssh-tmux": return String(localized: "cli.help.ssh-tmux", defaultValue: """ Usage: cmux ssh-tmux <destination> [--port <n>] [--identity <path>] [--no-focus] + [--into-window <id|current>] [--new-window] + + Mirror a remote host's tmux sessions over tmux control mode (tmux -CC) via + SSH: each tmux session becomes a workspace, each window a tab, and a + multi-pane window a native split. Requires the "Remote tmux" beta to be + enabled in Settings. - Open a dedicated cmux window that mirrors a remote host's tmux sessions over - tmux control mode (tmux -CC) via SSH: each tmux session becomes a workspace, - each window a tab, and a multi-pane window a native split. Requires the - "Remote tmux" beta to be enabled in Settings. + Run from inside a cmux surface, this aggregates the host into your CURRENT + window by default (multiple servers, plus local, in one window). Pass + --new-window to open a fresh dedicated window instead, or --into-window to + target a specific window. Outside a cmux surface it opens a new window. If the host needs interactive authentication (password, host-key confirmation, MFA, or a security-key touch), cmux runs ssh inline in this terminal so you can @@ -14960,13 +14982,15 @@ struct CMUXCLI { settings are honored. Flags: - --port <n> SSH port - --identity <path> SSH identity file path - --no-focus Open the mirror window without activating it + --port <n> SSH port + --identity <path> SSH identity file path + --no-focus Mirror without activating the window + --into-window <id|current> Aggregate into a specific window (or the caller's) + --new-window Open a new dedicated window instead of reusing the current one Example: - cmux ssh-tmux dev@my-host - cmux ssh-tmux my-ssh-alias + cmux ssh-tmux dev@my-host # aggregate into the current window + cmux ssh-tmux my-ssh-alias --new-window # open a separate window cmux ssh-tmux dev@my-host --port 2222 --identity ~/.ssh/id_ed25519 """) case "ssh-session-list":