From 12cf75818db12d08003506f014d5a8c2c396f4dd Mon Sep 17 00:00:00 2001 From: Joshua Van Deren Date: Sat, 26 Sep 2026 00:53:08 -0600 Subject: [PATCH] Show a banner when mux auto-start binary is missing When command -v fails, auto-start prints a stable marker and execs $SHELL. Only those sessions scan output, then drop the optimistic mux binding and reuse the detach banner. Custom commands and herdr control mode stay as they are. --- .../Multiplexer/MuxAutoStartFallback.swift | 44 ++++++++++++++ rootshell/Features/SSH/Config/SSHConfig.swift | 27 ++++++++- .../UI/Shell/MainView+Notifications.swift | 25 ++++++++ .../UI/Terminal/TerminalSplitTreeView.swift | 1 + .../UI/Terminal/TerminalView+Session.swift | 20 +++++++ .../Terminal/TerminalView+SessionHost.swift | 16 ++++- rootshell/UI/Terminal/TerminalView.swift | 3 + .../MuxAutoStartFallbackTests.swift | 60 +++++++++++++++++++ 8 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 rootshell/Features/Multiplexer/MuxAutoStartFallback.swift create mode 100644 rootshellTests/MuxAutoStartFallbackTests.swift diff --git a/rootshell/Features/Multiplexer/MuxAutoStartFallback.swift b/rootshell/Features/Multiplexer/MuxAutoStartFallback.swift new file mode 100644 index 00000000..252948c9 --- /dev/null +++ b/rootshell/Features/Multiplexer/MuxAutoStartFallback.swift @@ -0,0 +1,44 @@ +// +// MuxAutoStartFallback.swift +// rootshell +// +// Detects the missing-binary line auto-start prints before it execs $SHELL. +// + +import Foundation + +struct MuxAutoStartFallbackScanner: Sendable { + private var tail = Data() + private var fired = false + + /// Returns `tmux`, `herdr`, or `zmx` once, including when the marker is + /// split across reads. Other output is ignored. + mutating func consume(_ data: Data) -> String? { + guard !fired else { return nil } + let marker = Data(SSHConfig.muxAutoStartFallbackMarkerPrefix.utf8) + var window = tail + window.append(data) + let keep = marker.count + 8 + let cap = keep + marker.count + if window.count > cap { + window.removeFirst(window.count - cap) + } + tail = window.count > keep ? Data(window.suffix(keep)) : window + + var search = window.startIndex.. String { + "{ printf \"\\r\\n\(muxAutoStartFallbackMarkerPrefix)\(wanted)\\r\\n\"; exec \"${SHELL:-/bin/sh}\"; }" + } + + /// Which auto-start command will emit `muxAutoStartFallbackMarkerPrefix`, + /// or nil when this connection will not (custom command, herdr control + /// mode, or a reconnect that attaches directly). + var muxAutoStartFallbackName: String? { + if muxResumeTarget != nil { return nil } + if tmuxAutoEnable, Self.tmuxGlobalCustomCommand == nil { return "tmux" } + if herdrAutoEnable, !herdrControlModeEnabled, Self.herdrGlobalCustomCommand == nil { return "herdr" } + if zmxAutoEnable, Self.zmxGlobalCustomCommand == nil { return "zmx" } + return nil + } + /// Builds the `sh -c '...'` line that attaches to (or creates) a tmux /// session, optionally in control mode (`-CC`), falling back to `$SHELL` /// when tmux is missing. The session name must already be validated as /// embeddable in the single-quoted command (see TmuxGatewaySessionStore). static func tmuxExecCommandLine(sessionName: String, controlMode: Bool) -> String { let cc = controlMode ? "-CC " : "" - return "sh -c '\(remoteExecPathPrefix)command -v tmux >/dev/null && exec tmux \(cc)new-session -A -s \(sessionName) || exec $SHELL'" + return "sh -c '\(remoteExecPathPrefix)command -v tmux >/dev/null && exec tmux \(cc)new-session -A -s \(sessionName) || \(muxAutoStartFallbackShellFragment(wanted: "tmux"))'" } /// Session name to attach to for this connection. The profile's explicit @@ -782,7 +803,7 @@ struct SSHConfig: Codable, Hashable { /// launch is attach-or-create, so no `-A` analogue is needed. static func herdrExecCommandLine(sessionName: String) -> String { let arg = isEmbeddableHerdrSessionName(sessionName) ? " --session \(sessionName)" : "" - return "sh -c '\(remoteExecPathPrefix)command -v herdr >/dev/null && exec herdr\(arg) || exec $SHELL'" + return "sh -c '\(remoteExecPathPrefix)command -v herdr >/dev/null && exec herdr\(arg) || \(muxAutoStartFallbackShellFragment(wanted: "herdr"))'" } /// Builds the `sh -c '...'` line a control-mode exec channel runs: the @@ -893,7 +914,7 @@ struct SSHConfig: Codable, Hashable { // Listed names already contain any configured session prefix. return "sh -c '\(remoteExecPathPrefix)command -v zmx >/dev/null" + " && ZMX_SESSION_PREFIX= exec zmx attach \(name)" - + " || exec $SHELL'" + + " || \(muxAutoStartFallbackShellFragment(wanted: "zmx"))'" } /// Shared zmx exec command used by all session types. diff --git a/rootshell/UI/Shell/MainView+Notifications.swift b/rootshell/UI/Shell/MainView+Notifications.swift index cc89ad3e..a84ec775 100644 --- a/rootshell/UI/Shell/MainView+Notifications.swift +++ b/rootshell/UI/Shell/MainView+Notifications.swift @@ -395,6 +395,20 @@ extension MainView { self.scheduleMuxDetachBannerDismiss() } + observerBag.observeOnMainActor(.muxAutoStartDidFallback) { [self] notification in + if let targetWindow = notification.userInfo?["windowId"] as? String { + guard targetWindow == self.windowId else { return } + } else if !self.shouldHandleNotification(notification) { + return + } + let wanted = notification.userInfo?["wanted"] as? String + self.muxDetachBanner = MuxDetachBannerState( + message: Self.muxMissingBinaryBannerMessage(wanted: wanted), + offer: nil + ) + self.scheduleMuxDetachBannerDismiss() + } + observerBag.observeOnMainActor(.increaseFontSize) { [self] notification in guard self.shouldHandleNotification(notification) else { return } guard terminals.indices.contains(selectedTabIndex), @@ -811,6 +825,17 @@ extension MainView { performWindowCleanup(reason: "sceneDisconnect") } #endif + + private static func muxMissingBinaryBannerMessage(wanted: String?) -> String { + switch wanted { + case "herdr": + return String(localized: "herdr was not found on the remote host. Started a normal shell.", comment: "Banner when herdr auto-start falls back") + case "zmx": + return String(localized: "zmx was not found on the remote host. Started a normal shell.", comment: "Banner when zmx auto-start falls back") + default: + return String(localized: "tmux was not found on the remote host. Started a normal shell.", comment: "Banner when tmux auto-start falls back") + } + } } // MARK: - Window Filtering and Title Observation diff --git a/rootshell/UI/Terminal/TerminalSplitTreeView.swift b/rootshell/UI/Terminal/TerminalSplitTreeView.swift index 17086f1f..e1a76f24 100644 --- a/rootshell/UI/Terminal/TerminalSplitTreeView.swift +++ b/rootshell/UI/Terminal/TerminalSplitTreeView.swift @@ -1720,6 +1720,7 @@ extension Notification.Name { static let detachSession = Notification.Name("com.rootshell.detachSession") static let detachOtherClients = Notification.Name("com.rootshell.detachOtherClients") static let muxSessionDidDetach = Notification.Name("com.rootshell.muxSessionDidDetach") + static let muxAutoStartDidFallback = Notification.Name("com.rootshell.muxAutoStartDidFallback") static let showToolbarSettings = Notification.Name("com.rootshell.showToolbarSettings") static let forceASCIIKeyboardChanged = Notification.Name("com.rootshell.forceASCIIKeyboardChanged") static let ghosttySessionDiscoveryChanged = Notification.Name("com.rootshell.sessionDiscoveryChanged") diff --git a/rootshell/UI/Terminal/TerminalView+Session.swift b/rootshell/UI/Terminal/TerminalView+Session.swift index 84646728..88f28fb8 100644 --- a/rootshell/UI/Terminal/TerminalView+Session.swift +++ b/rootshell/UI/Terminal/TerminalView+Session.swift @@ -532,7 +532,27 @@ extension Ghostty.TerminalView { /// which bails early for resumed and exec sessions — a resumed session's /// multiplexer is still running, so the binding must survive those paths. /// (id=agent-attention-raw-mux) + /// Auto-start's remote binary was missing. Drop the optimistic mux binding + /// so this pane is a normal shell, and ask the window for the banner. + func noteMuxAutoStartFallback(wanted: String) { + guard !multiplexerAutoStartFellBack else { return } + guard connectionConfig.sshConfigForHistory?.muxAutoStartFallbackName == wanted else { return } + multiplexerAutoStartFellBack = true + rawMultiplexer = nil + passthroughMultiplexer = nil + AgentAttentionCenter.shared.topologyDidChange() + NotificationCenter.default.post( + name: .muxAutoStartDidFallback, + object: self, + userInfo: [ + "wanted": wanted, + "windowId": windowId + ] + ) + } + func applyConfiguredMultiplexerBinding() { + guard !multiplexerAutoStartFellBack else { return } guard let sshConfig = connectionConfig.sshConfigForHistory else { return } if let target = sshConfig.muxResumeTarget { diff --git a/rootshell/UI/Terminal/TerminalView+SessionHost.swift b/rootshell/UI/Terminal/TerminalView+SessionHost.swift index 82b8a43d..e4e0d813 100644 --- a/rootshell/UI/Terminal/TerminalView+SessionHost.swift +++ b/rootshell/UI/Terminal/TerminalView+SessionHost.swift @@ -28,7 +28,7 @@ extension Ghostty.TerminalView: TerminalSessionControllerHost { /// `outputHandler` did. Runs on the session's background queue — no main /// actor hop on the hot output path. func makeSessionOutputSink() -> @Sendable (Data) -> Void { - outputPipeline.makeSessionOutputSink( + let base = outputPipeline.makeSessionOutputSink( useOutputCoalescer: shouldUseOutputCoalescer, terminalUUID: uuid, noteGatewayInboundBytes: { [weak self] byteCount in @@ -39,6 +39,20 @@ extension Ghostty.TerminalView: TerminalSessionControllerHost { } } ) + // Normal shells never look at the byte stream. Only an auto-start that + // can print the missing-binary marker pays for a scan. + guard let expected = connectionConfig.sshConfigForHistory?.muxAutoStartFallbackName else { + return base + } + let scanner = OSAllocatedUnfairLock(initialState: MuxAutoStartFallbackScanner()) + return { [weak self] data in + let wanted = scanner.withLock { $0.consume(data) } + base(data) + guard let wanted, wanted == expected else { return } + Task { @MainActor [weak self] in + self?.noteMuxAutoStartFallback(wanted: wanted) + } + } } func sessionDidChangeTitle(_ title: String) { diff --git a/rootshell/UI/Terminal/TerminalView.swift b/rootshell/UI/Terminal/TerminalView.swift index 7fdc745b..3f94c1f4 100644 --- a/rootshell/UI/Terminal/TerminalView.swift +++ b/rootshell/UI/Terminal/TerminalView.swift @@ -500,6 +500,9 @@ extension Ghostty { /// attention or depend on alternate-screen ownership. var passthroughMultiplexer: RawMultiplexerBinding? + /// Auto-start printed `muxAutoStartFallbackMarkerPrefix` and exec'd `$SHELL`. + var multiplexerAutoStartFellBack: Bool = false + nonisolated(unsafe) var tmuxDetachInProgressAtomic: Bool = false var isTmuxDetachInProgress: Bool { diff --git a/rootshellTests/MuxAutoStartFallbackTests.swift b/rootshellTests/MuxAutoStartFallbackTests.swift new file mode 100644 index 00000000..0ae6fa6b --- /dev/null +++ b/rootshellTests/MuxAutoStartFallbackTests.swift @@ -0,0 +1,60 @@ +import Foundation +import XCTest + +final class MuxAutoStartFallbackTests: XCTestCase { + + func testExecLinesFallBackWithMarkerInsteadOfBareShell() { + let tmux = SSHConfig.tmuxExecCommandLine(sessionName: "main", controlMode: false) + let herdr = SSHConfig.herdrExecCommandLine(sessionName: "dev") + let zmx = SSHConfig.zmxExecCommandLine(sessionName: "main") + for (line, name) in [(tmux, "tmux"), (herdr, "herdr"), (zmx, "zmx")] { + XCTAssertTrue(line.contains("command -v \(name)")) + XCTAssertTrue(line.contains("rootshell: mux-fallback \(name)")) + XCTAssertTrue(line.contains("exec \"${SHELL:-/bin/sh}\"")) + XCTAssertFalse(line.contains("|| exec $SHELL")) + XCTAssertFalse( + SSHConfig.muxAutoStartFallbackShellFragment(wanted: name).contains("'"), + "fallback fragment must stay inside the single-quoted sh -c" + ) + } + XCTAssertTrue(tmux.contains("new-session -A -s main")) + XCTAssertTrue(SSHConfig.tmuxExecCommandLine(sessionName: "main", controlMode: true).contains("-CC ")) + } + + func testMissingBinaryBranchPrintsMarkerThenReplacesTheProcess() throws { + let fragment = SSHConfig.muxAutoStartFallbackShellFragment(wanted: "zmx") + let script = "command -v rootshell-no-such-mux >/dev/null && exec rootshell-no-such-mux || \(fragment)" + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", script] + var environment = ProcessInfo.processInfo.environment + environment["SHELL"] = "/usr/bin/true" + process.environment = environment + let output = Pipe() + process.standardOutput = output + try process.run() + process.waitUntilExit() + XCTAssertEqual(process.terminationStatus, 0) + let text = String(decoding: output.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + XCTAssertTrue(text.contains("rootshell: mux-fallback zmx")) + } + + func testScannerFindsNameSplitAcrossReadsAndFiresOnce() { + var scanner = MuxAutoStartFallbackScanner() + let marker = Data(SSHConfig.muxAutoStartFallbackMarkerPrefix.utf8) + let split = marker.count - 4 + XCTAssertNil(scanner.consume(Data(marker.prefix(split)))) + XCTAssertEqual( + scanner.consume(Data(marker.dropFirst(split)) + Data("tmux\r\n".utf8)), + "tmux" + ) + XCTAssertNil(scanner.consume(Data("rootshell: mux-fallback herdr\n".utf8))) + } + + func testScannerIgnoresOrdinaryOutput() { + var scanner = MuxAutoStartFallbackScanner() + XCTAssertNil(scanner.consume(Data("tmux: command not found\r\n$ ".utf8))) + XCTAssertNil(scanner.consume(Data("rootshell: mux-fallback tmux".utf8))) + XCTAssertEqual(scanner.consume(Data("\n".utf8)), "tmux") + } +}