diff --git a/CLI/cmux.swift b/CLI/cmux.swift index eae2fe0e2124..43214283bb19 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1846,7 +1846,11 @@ final class ClaudeHookSessionStore { includeTerminalPromptTurnIds: Bool = true ) -> Bool { if max(record.activePromptDepth ?? 0, record.activePromptTurnIds?.count ?? 0) > 0 { - return true + // Prompt depth belongs to a process generation. An interrupted + // process (or a ledger-settled Stop) can leave depth behind; do not + // let it suppress the next confirmed process's resume binding. + // Same-process and unverifiable starts still cannot erase a turn. + return !authoritativeSessionStartProcessIsNewer(incomingPID, than: record) } let hasCompletedTurnState = normalizeOptional(record.lastPromptTurnId) != nil || (includeTerminalPromptTurnIds && !terminalPromptTurnSet(from: record).isEmpty) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchPositionalScanning.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchPositionalScanning.swift index 67a797dda77a..abfefa970714 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchPositionalScanning.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchPositionalScanning.swift @@ -180,7 +180,9 @@ extension AgentLaunchSanitizer { static func variadicValueCanContinue(_ value: String, policy: Policy) -> Bool { guard policy.scansOptionsPastPositionals else { return true } - return looksLikeGreedyOptionalValue(value) + // An explicit empty argv value can disable tools or clear a list. It is + // distinct from a missing value and must survive capture and replay. + return value.isEmpty || looksLikeGreedyOptionalValue(value) } static func looksLikeOptionalValue(_ value: String, following: String?) -> Bool { diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerGrokPolicy.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerGrokPolicy.swift index 754614e6eb6f..a0231b2bd661 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerGrokPolicy.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerGrokPolicy.swift @@ -19,6 +19,8 @@ extension AgentLaunchSanitizer { "-r", "--rules", "--sandbox", + "--session-id", + "-s", "--system-prompt-override", "--tools", "--worktree", @@ -27,6 +29,9 @@ extension AgentLaunchSanitizer { optionalValueOptions: [ "--resume", "-r", + // Older captures dropped the UUID but retained this selector. + "--session-id", + "-s", "--worktree", "-w" ], @@ -53,15 +58,20 @@ extension AgentLaunchSanitizer { droppedOptions: [ "--continue", "-c", + "--fork-session", "--restore-code", "--resume", "-r", + "--session-id", + "-s", "--worktree", "-w" ], droppedOptionPrefixes: [ "--resume=", "-r=", + "--session-id=", + "-s=", "--worktree=", "-w=" ], diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentResumeArgvTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentResumeArgvTests.swift index 68bb111db706..5765065f63e0 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentResumeArgvTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentResumeArgvTests.swift @@ -3,6 +3,40 @@ import Testing @Suite("AgentResumeArgv") struct AgentResumeArgvTests { + @Test("Claude preserves the explicit empty tools list through capture and resume") + func claudeResumeKeepsEmptyToolList() throws { + let captured = try #require(AgentLaunchSanitizer.sanitizedLaunchArguments( + ["claude", "--tools", "", "--model", "sonnet"], + launcher: "claude", + fallbackKind: "claude" + )) + #expect( + AgentResumeArgv().builtInKind( + kind: "claude", sessionId: "saved-session", executablePath: nil, arguments: captured + ) == ["claude", "--resume", "saved-session", "--tools", "", "--model", "sonnet"] + ) + } + + @Test("Grok resumes the saved conversation without new-session selectors", arguments: [ + ["--session-id", "new-session"], + ["-s", "new-session"], + ["--session-id=new-session"], + ["-s=new-session"], + ["--resume", "parent-session", "--fork-session", "--session-id", "new-session"], + // Older captures kept the flag but dropped its value as a positional prompt. + ["--session-id"], + ]) + func grokResumeDropsNewSessionSelectors(selectors: [String]) { + #expect( + AgentResumeArgv().builtInKind( + kind: "grok", + sessionId: "saved-session", + executablePath: "/opt/bin/grok", + arguments: ["/opt/bin/grok"] + selectors + ["--model", "grok-4", "--sandbox", "read-only"] + ) == ["/opt/bin/grok", "-r", "saved-session", "--model", "grok-4", "--sandbox", "read-only"] + ) + } + @Test("Built-in --option style kinds", arguments: [ ("claude", "claude", ["claude", "--resume", "SID"]), ("grok", "grok", ["grok", "-r", "SID"]), diff --git a/Sources/DockSplitStore+SessionRestore.swift b/Sources/DockSplitStore+SessionRestore.swift index 71f7c5b2bf02..866b596abb3d 100644 --- a/Sources/DockSplitStore+SessionRestore.swift +++ b/Sources/DockSplitStore+SessionRestore.swift @@ -235,7 +235,8 @@ extension DockSplitStore { snapshot, workspaceId: workspaceId ) - guard let terminalSnapshot = snapshot.terminal else { return nil } + guard let savedTerminalSnapshot = snapshot.terminal else { return nil } + let terminalSnapshot = savedTerminalSnapshot.reconcilingAgentBindingForRestore(surfaceID: snapshot.id) let policy = Workspace.makeSessionRestorePolicyService() let localTmuxStartCommand = policy.localTmuxStartCommand(terminalSnapshot.tmuxStartCommand) let restorableAgent = localTmuxStartCommand == nil @@ -286,17 +287,20 @@ extension DockSplitStore { recoveryObservationsByPanelId[snapshot.id] = recoveryObservation let recoveryPlan = terminalSnapshot.recoveryPlan( surfaceID: snapshot.id, - claimedOwnerSurfaceID: recoveryObservation.agent.flatMap { recoveryPlannedAgentOwners[$0] } + claimedOwnerSurfaceID: terminalSnapshot.boundAgentIdentityForRestore.flatMap { recoveryPlannedAgentOwners[$0] } ) let shouldAutoResumeAgent: Bool = { guard AgentSessionAutoResumeSettings.isEnabled(defaults: agentSessionAutoResumeDefaults), case .resumeAgent = recoveryPlan.action else { return false } return true }() - if shouldAutoResumeAgent, let agent = recoveryObservation.agent { + if shouldAutoResumeAgent, let agent = terminalSnapshot.boundAgentIdentityForRestore { recoveryPlannedAgentOwners[agent] = snapshot.id } - let shouldCheckAgentOwnership = shouldAutoResumeAgent && + let restoresBoundLocalAgent = shouldAutoResumeAgent && + terminalSnapshot.hasLocalBoundAgentBindingForRestore && + terminalSnapshot.isRemoteTerminal != true && hibernation == nil + let shouldCheckAgentOwnership = !restoresBoundLocalAgent && shouldAutoResumeAgent && (restorableAgentCanAutoResume || resumeBinding?.isAgentHookBinding == true) let restoreAgentIndex = shouldCheckAgentOwnership ? restorableAgentIndex : nil let restoreIndexUnavailable = shouldCheckAgentOwnership && restoreAgentIndex == nil @@ -361,7 +365,10 @@ extension DockSplitStore { stablePanelHasLiveProcess ? nil : approvedResumeBinding.flatMap { - policy.surfaceResumeStartupLaunch( + if restoresBoundLocalAgent { + return $0.boundAgentStartupLaunch() + } + return policy.surfaceResumeStartupLaunch( forApprovedBinding: $0 ) } diff --git a/Sources/DockSplitStore+SessionSnapshot.swift b/Sources/DockSplitStore+SessionSnapshot.swift index ec677c0fecdd..5277160704dc 100644 --- a/Sources/DockSplitStore+SessionSnapshot.swift +++ b/Sources/DockSplitStore+SessionSnapshot.swift @@ -520,11 +520,9 @@ extension DockSplitStore { currentProcessIdentity: Workspace.agentPIDProcessIdentity(pid:) ) } - let coordinated = restoredAgentLifecycle.continuationSnapshot( - panelId: panelId, - observation: observation, - currentProcessIdentity: Workspace.agentPIDProcessIdentity(pid:) - ) + // Completion ends the old process; the panel keeps its session binding + // until another command or an explicit mutation replaces it. + let coordinated = restoredAgentLifecycle.snapshotsByPanelId[panelId] let observed = restoredAgentLifecycle.resumeStatesByPanelId[panelId] == .completedAgentExit ? nil : observation?.snapshot diff --git a/Sources/MainWindowPersistenceRouteSnapshot.swift b/Sources/MainWindowPersistenceRouteSnapshot.swift index e5284286b2af..f323947683d2 100644 --- a/Sources/MainWindowPersistenceRouteSnapshot.swift +++ b/Sources/MainWindowPersistenceRouteSnapshot.swift @@ -51,15 +51,14 @@ enum MainWindowPersistenceRouteSnapshot { } } - /// Remote-tmux-only live windows require their active SSH control graph - /// and intentionally do not participate in persisted session topology. + /// Mirrors with a saved attachment target participate in session persistence. var isEligibleForSessionPersistence: Bool { switch self { case .live(let route): let workspaces = route.tabManager.tabs let omitsRemoteMirrorOnlyWindow = route.dock == nil && !workspaces.isEmpty - && workspaces.allSatisfy(\.isRemoteTmuxMirror) + && workspaces.allSatisfy { $0.isRemoteTmuxMirror && !$0.isRestorableInSessionSnapshot } return !omitsRemoteMirrorOnlyWindow case .frozen: return true diff --git a/Sources/RemoteTmuxControlConnection+Commands.swift b/Sources/RemoteTmuxControlConnection+Commands.swift index bcc5422e6111..0265f72809a8 100644 --- a/Sources/RemoteTmuxControlConnection+Commands.swift +++ b/Sources/RemoteTmuxControlConnection+Commands.swift @@ -413,6 +413,14 @@ extension RemoteTmuxControlConnection { // while a smaller peer legitimately clamped the window must not suppress // recovery after that peer leaves. windowClaimParityRearmsSpent.removeAll() + if !supportsPerWindowSize { + _ = send("set-option -g focus-events on") + _ = send("refresh-client -f ignore-size") + legacyWindowSizingConfigured.removeAll() + for windowId in lastWindowSizes.keys.sorted() { + configureLegacyWindowSizing(windowId: windowId) + } + } if let size = lastClientSize { if send("refresh-client -C \(size.columns)x\(size.rows)") { lastSentClientSize = size @@ -448,8 +456,9 @@ extension RemoteTmuxControlConnection { /// Sends literal key bytes to a pane via tmux `send-keys -H` (hex-encoded), /// which is binary-safe and needs no shell quoting. @discardableResult - func sendKeys(paneId: Int, data: Data) -> Bool { + func sendKeys(paneId: Int, data: Data, claimsSizing: Bool = true) -> Bool { guard !data.isEmpty else { return true } + if claimsSizing, !activatePaneSizing(paneId: paneId) { return false } let hex = Self.hexByteArguments(data) return sendInternal("send-keys -t %\(paneId) -H \(hex)", kind: .other) } @@ -457,8 +466,9 @@ extension RemoteTmuxControlConnection { /// Sends a physical named key and lets tmux encode it for the target pane's /// advertised terminal and live input modes. @discardableResult - func sendKey(paneId: Int, key: RemoteTmuxKeyName) -> Bool { - sendInternal("send-keys -t %\(paneId) \(key.value)", kind: .other) + func sendKey(paneId: Int, key: RemoteTmuxKeyName, claimsSizing: Bool = true) -> Bool { + if claimsSizing, !activatePaneSizing(paneId: paneId) { return false } + return sendInternal("send-keys -t %\(paneId) \(key.value)", kind: .other) } nonisolated static func hexByteArguments(_ data: Data) -> String { diff --git a/Sources/RemoteTmuxControlConnection+Sizing.swift b/Sources/RemoteTmuxControlConnection+Sizing.swift index 8a2b13166104..4007b68e963a 100644 --- a/Sources/RemoteTmuxControlConnection+Sizing.swift +++ b/Sources/RemoteTmuxControlConnection+Sizing.swift @@ -29,6 +29,11 @@ extension RemoteTmuxControlConnection { func removeWindowSizeClaim(windowId: Int) { windowClaimParityRearmsSpent.removeValue(forKey: windowId) + legacyWindowSizingConfigured.remove(windowId) + if interactiveSizingWindowId == windowId { + interactiveSizingWindowId = nil + interactiveSizingOwnerId = nil + } guard let removed = lastWindowSizes.removeValue(forKey: windowId) else { sentWindowSizes.removeValue(forKey: windowId) return @@ -46,6 +51,11 @@ extension RemoteTmuxControlConnection { func retainWindowSizeClaims(for liveWindowIDs: Set) { lastWindowSizes = lastWindowSizes.filter { liveWindowIDs.contains($0.key) } sentWindowSizes = sentWindowSizes.filter { liveWindowIDs.contains($0.key) } + legacyWindowSizingConfigured.formIntersection(liveWindowIDs) + if let id = interactiveSizingWindowId, !liveWindowIDs.contains(id) { + interactiveSizingWindowId = nil + interactiveSizingOwnerId = nil + } windowClaimParityRearmsSpent = windowClaimParityRearmsSpent.filter { liveWindowIDs.contains($0.key) } maximumWindowClaimColumns = lastWindowSizes.values.reduce(0) { max($0, $1.0) } maximumWindowClaimRows = lastWindowSizes.values.reduce(0) { max($0, $1.1) } @@ -138,10 +148,10 @@ extension RemoteTmuxControlConnection { /// ``reseedAfterReconnect()`` re-pins every window (a fresh ssh client /// otherwise reverts everything to 80×24). /// - /// If the server rejects the `@id:` form (`%error` — pre-3.x tmux), the - /// connection flips to the session-wide fallback for its lifetime and - /// surfaces the degraded mode in diagnostics; callers keep calling this - /// method either way. + /// If the server rejects the `@id:` form (including tmux 3.2a), the + /// connection uses a session-wide client size and configures mirrored + /// windows to follow the last interacting client. Geometry records dimensions; + /// explicit focus/input transfers ownership through `activateWindowSizing`. func setWindowSize(windowId: Int, columns: Int, rows: Int) { guard columns > 0, rows > 0 else { return } // Record desired state before dedup. If a different debounced size is @@ -150,7 +160,10 @@ extension RemoteTmuxControlConnection { _ = recordWindowSizeClaim(windowId: windowId, columns: columns, rows: rows) lastSizeRequestWindowId = windowId guard supportsPerWindowSize else { - setClientSize(columns: columns, rows: rows) + configureLegacyWindowSizing(windowId: windowId) + if interactiveSizingWindowId == nil || interactiveSizingWindowId == windowId { + setClientSize(columns: columns, rows: rows) + } return } // The CLIENT's own size must cover the largest window claim: tmux @@ -269,6 +282,7 @@ extension RemoteTmuxControlConnection { /// Sends the per-window form, tagging the command so an `%error` reply /// can flip the capability off and replay via the session-wide path. func sendPerWindowSize(windowId: Int, columns: Int, rows: Int) { + guard supportsPerWindowSize else { return } // Record AFTER the send reports success: a send attempted while the // transport is down returns false, and recording it anyway makes // the ledger claim the server has a size it never received — dedup @@ -289,7 +303,17 @@ extension RemoteTmuxControlConnection { func notePerWindowSizeRejected() { guard supportsPerWindowSize else { return } supportsPerWindowSize = false - record("remote.tmux.perWindowSize unsupported; falling back to session-wide client size") + for task in windowSizeDebounceTasks.values { task.cancel() } + windowSizeDebounceTasks.removeAll() + for task in perWindowRedrawKickTasks.values { task.cancel() } + perWindowRedrawKickTasks.removeAll() + sentWindowSizes.removeAll() + _ = send("set-option -g focus-events on") + _ = send("refresh-client -f ignore-size") + record("remote.tmux.perWindowSize unsupported; using latest interaction with session-wide client size") + for windowId in lastWindowSizes.keys.sorted() { + configureLegacyWindowSizing(windowId: windowId) + } // Replay the most recently requested window's size — deterministic, // and in practice the visible tab's. (`.values.first` on a Dictionary // could hand the session a hidden tab's stale claim.) @@ -297,8 +321,72 @@ extension RemoteTmuxControlConnection { if let replay { setClientSize(columns: replay.0, rows: replay.1) } + if let windowId = interactiveSizingWindowId { + _ = activateWindowSizing(windowId: windowId, ownerId: interactiveSizingOwnerId) + } + } + + /// Removes the previous compatibility policy. Latest keeps a background + /// cmux connection from permanently limiting a native terminal's grid. + func configureLegacyWindowSizing(windowId: Int) { + guard !supportsPerWindowSize, + connectionState == .connected, + !legacyWindowSizingConfigured.contains(windowId) else { return } + guard sendTracked( + "set-option -w -t @\(windowId) window-size latest", + completion: { [weak self] succeeded in + if !succeeded { self?.legacyWindowSizingConfigured.remove(windowId) } + } + ) else { return } + legacyWindowSizingConfigured.insert(windowId) + lastSentClientSize = nil + } + + /// Only explicit user focus/input calls this. In tmux 3.2a neither + /// refresh-client -C nor send-keys updates w->latest. select-window does, + /// then the final refresh commits its otherwise deferred resize immediately. + /// Native terminal input/resize can subsequently take ownership naturally. + @discardableResult + func activateWindowSizing(windowId: Int, ownerId: UUID? = nil) -> Bool { + interactiveSizingWindowId = windowId + interactiveSizingOwnerId = ownerId + guard !supportsPerWindowSize else { return true } + guard let size = lastWindowSizes[windowId] else { return true } + guard connectionState == .connected else { return false } + let sessionTarget = sessionId.map { "'$\($0)'" } + ?? RemoteTmuxHost.controlModeLineSafeName(sessionName) + .map { RemoteTmuxHost.shellSingleQuoted("=" + $0) } + guard let sessionTarget else { return false } + clientSizeDebounceTask?.cancel() + clientSizeDebounceTask = nil + let commands = [ + "refresh-client -f '!ignore-size'", + "set-option -w -t @\(windowId) window-size latest", + "select-window -t \(sessionTarget):@\(windowId)", + "refresh-client -C \(size.0)x\(size.1)" + ] + guard sendBatchInternal(commands, kinds: commands.map { _ in .other }) else { return false } + lastClientSize = (size.0, size.1) + lastSentClientSize = lastClientSize + scheduleAttachRedrawKickIfNeeded() + return true + } + + func deactivateWindowSizing(windowId: Int, ownerId: UUID? = nil) { + guard interactiveSizingWindowId == windowId, + ownerId == nil || ownerId == interactiveSizingOwnerId else { return } + interactiveSizingWindowId = nil + interactiveSizingOwnerId = nil + if !supportsPerWindowSize { _ = send("refresh-client -f ignore-size") } } + func activatePaneSizing(paneId: Int) -> Bool { + guard !supportsPerWindowSize else { return true } + guard let windowId = publishedWindowIdByPane[paneId] + ?? windowsByID.values.first(where: { $0.paneIDsInOrder.contains(paneId) })?.id + else { return false } + return activateWindowSizing(windowId: windowId) + } /// One-shot, attach-only: force a real SIGWINCH when the attach size push was a /// no-op, so a running TUI re-renders the current frame at the current width. @@ -354,8 +442,8 @@ extension RemoteTmuxControlConnection { return } else { // Only kick when some mirrored window ALREADY has the target size — i.e. the - // size apply above cannot produce a SIGWINCH for it. (window-size latest makes - // every window track the client, so one client-level kick redraws them all.) + // size apply above cannot produce a SIGWINCH for it. A client-level + // kick redraws the windows whose size is bounded by this client. let windowAlreadyAtTarget = windowsByID.values.contains { Self.windowMatchesClaim( windowColumns: $0.width, windowRows: $0.height, diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index 35371dda271a..bf872d71a639 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -195,6 +195,11 @@ final class RemoteTmuxControlConnection { var windowSizeDebounceTasks: [Int: Task] = [:] /// Whether the server accepts per-window `refresh-client -C` sizing. var supportsPerWindowSize = true + /// Mirrored legacy windows migrated from the old smallest policy to latest. + var legacyWindowSizingConfigured: Set = [] + /// Last window explicitly activated by the user, never by a layout echo. + var interactiveSizingWindowId: Int? + var interactiveSizingOwnerId: UUID? /// Instant of the most recent sizing write on this connection — kept for /// diagnostics (how stale is the last size request). var lastSizingSendAt: ContinuousClock.Instant? diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 6c56d2ea4eb8..8e247542261f 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -295,7 +295,8 @@ final class RemoteTmuxController { host: RemoteTmuxHost, sessionName: String, sessionId: Int? = nil, - into tabManager: TabManager + into tabManager: TabManager, + restoring restoredWorkspace: Workspace? = nil ) throws -> Bool { let key = Self.connectionKey(host: host, sessionName: sessionName) guard sessionMirrors[key] == nil else { return false } @@ -305,6 +306,19 @@ final class RemoteTmuxController { connection: RemoteTmuxControlConnection, workspace: Workspace )? in + let pendingWorkspace = restoredWorkspace ?? tabManager.tabs.first { + $0.isRemoteTmuxMirror && $0.remoteTmuxSessionMirror == nil && + $0.remoteTmuxRestoration?.host == host && + $0.remoteTmuxRestoration?.sessionName == sessionName + } + if let pendingWorkspace { + guard !pendingWorkspace.isRetiredFromOwningTabManager, + tabManager.tabs.contains(where: { $0 === pendingWorkspace }), + pendingWorkspace.remoteTmuxRestoration?.host == host, + pendingWorkspace.remoteTmuxRestoration?.sessionName == sessionName else { return nil } + let connection = try attach(host: host, sessionName: sessionName) + return (connection: connection, workspace: pendingWorkspace) + } let connection = try attach(host: host, sessionName: sessionName) guard let workspace = tabManager.addWorkspaceIfActive( title: sessionName, diff --git a/Sources/RemoteTmuxPreviewFetcher.swift b/Sources/RemoteTmuxPreviewFetcher.swift index dcdba63738a8..755495e9f362 100644 --- a/Sources/RemoteTmuxPreviewFetcher.swift +++ b/Sources/RemoteTmuxPreviewFetcher.swift @@ -1,12 +1,18 @@ import Foundation -/// Copies a remote regular file through the existing SSH master without text decoding. +/// Copies a remote regular file over SSH without text decoding. File reads may +/// use a separate connection when the terminal master has no free sessions. actor RemoteTmuxPreviewFetcher { private let maximumBytes: Int64 = 256 * 1_048_576 + private let sshExecutablePath: String + + init(sshExecutablePath: String = RemoteTmuxHost.defaultSSHExecutablePath()) { + self.sshExecutablePath = sshExecutablePath + } func remoteHome(host: RemoteTmuxHost) async throws -> String { let output = try await Task.detached(priority: .utility) { - try Self.runSSH(host: host, command: "printf '%s' \"$HOME\"") + try Self.runSSH(host: host, command: "printf '%s' \"$HOME\"", executable: self.sshExecutablePath) }.value guard let home = String(data: output, encoding: .utf8), home.hasPrefix("/") else { throw RemoteTmuxPreviewError.remoteHomeUnavailable @@ -16,19 +22,29 @@ actor RemoteTmuxPreviewFetcher { func fetch(path: String, cwd: String?, host: RemoteTmuxHost) async throws -> URL { try await Task.detached(priority: .utility) { - try Self.fetchBlocking(path: path, cwd: cwd, host: host, maximumBytes: self.maximumBytes) + try Self.fetchBlocking( + path: path, cwd: cwd, host: host, + maximumBytes: self.maximumBytes, executable: self.sshExecutablePath + ) }.value } private nonisolated static func fetchBlocking( - path: String, cwd: String?, host: RemoteTmuxHost, maximumBytes: Int64 + path: String, cwd: String?, host: RemoteTmuxHost, maximumBytes: Int64, executable: String ) throws -> URL { - let quotedPath = RemoteTmuxHost.shellSingleQuoted(path) - let prefix = cwd.map { "cd -- \(RemoteTmuxHost.shellSingleQuoted($0)) || exit 3; " } ?? "" - // python3 is on both the Linux ptmux hosts and macOS. GNU `realpath -m` - // is not, and a missing file must still resolve to a path we can reject. - let resolve = "\(prefix)p=$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' \(quotedPath)) || exit 4; [ -f \"$p\" ] || exit 5; " - let metadata = try runSSH(host: host, command: resolve + "wc -c < \"$p\"") + let remotePath: String + if path.hasPrefix("/") { + remotePath = path + } else if let cwd, cwd.hasPrefix("/") { + remotePath = cwd + "/" + path + } else { + throw RemoteTmuxPreviewError.remoteDirectoryUnavailable + } + // Resolve once against the remote pane. An absolute path must not + // depend on an unrelated cwd still existing, or on remote Python. + let quotedPath = RemoteTmuxHost.shellSingleQuoted(remotePath) + let resolve = "p=\(quotedPath); [ -f \"$p\" ] || exit 5; " + let metadata = try runSSH(host: host, command: resolve + "wc -c < \"$p\"", executable: executable) guard let size = Int64(String(decoding: metadata, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)) else { throw RemoteTmuxPreviewError.remoteFileUnavailable } @@ -50,12 +66,13 @@ actor RemoteTmuxPreviewFetcher { let output = try FileHandle(forWritingTo: destination) defer { try? output.close() } let process = Process() - process.executableURL = URL(fileURLWithPath: RemoteTmuxHost.defaultSSHExecutablePath()) - process.arguments = RemoteTmuxPreviewSSHOptions(host: host).arguments + ["--", host.destination, resolve + "cat -- \"$p\""] + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = RemoteTmuxPreviewSSHOptions(host: host).fileReadArguments + ["--", host.destination, resolve + "cat -- \"$p\""] let stdout = Pipe() let stderr = Pipe() process.standardOutput = stdout process.standardError = stderr + process.standardInput = FileHandle.nullDevice try process.run() stdout.fileHandleForWriting.closeFile() stderr.fileHandleForWriting.closeFile() @@ -84,14 +101,15 @@ actor RemoteTmuxPreviewFetcher { } } - private nonisolated static func runSSH(host: RemoteTmuxHost, command: String) throws -> Data { + private nonisolated static func runSSH(host: RemoteTmuxHost, command: String, executable: String) throws -> Data { let process = Process() - process.executableURL = URL(fileURLWithPath: RemoteTmuxHost.defaultSSHExecutablePath()) - process.arguments = RemoteTmuxPreviewSSHOptions(host: host).arguments + ["--", host.destination, command] + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = RemoteTmuxPreviewSSHOptions(host: host).fileReadArguments + ["--", host.destination, command] let stdout = Pipe() let stderr = Pipe() process.standardOutput = stdout process.standardError = stderr + process.standardInput = FileHandle.nullDevice try process.run() stdout.fileHandleForWriting.closeFile() stderr.fileHandleForWriting.closeFile() diff --git a/Sources/RemoteTmuxPreviewSSHOptions.swift b/Sources/RemoteTmuxPreviewSSHOptions.swift index 67845254450f..d5abbb9b895f 100644 --- a/Sources/RemoteTmuxPreviewSSHOptions.swift +++ b/Sources/RemoteTmuxPreviewSSHOptions.swift @@ -1,16 +1,29 @@ import Foundation -/// SSH options for one non-interactive channel on an already-running tmux master. +/// Non-interactive SSH options for file reads and browser forwarding. struct RemoteTmuxPreviewSSHOptions { let host: RemoteTmuxHost + /// Browser forwarding stays bound to the authenticated terminal master. var arguments: [String] { + baseArguments + ["-o", "ProxyCommand=/usr/bin/false"] + } + + /// Reuse the master when possible. OpenSSH can authenticate a separate + /// connection when tmux control clients exhaust its session channels. + /// Preserve the host's identity, proxy and host-key policies; never prompt. + var fileReadArguments: [String] { + baseArguments + ["-T", "-n", "-o", "ClearAllForwardings=yes"] + } + + private var baseArguments: [String] { let original = host.sshControlArguments(controlPersistSeconds: 180, batchMode: true) var filtered: [String] = [] var index = 0 while index < original.count { if original[index] == "-o", index + 1 < original.count, (original[index + 1].hasPrefix("ControlMaster=") + || original[index + 1].hasPrefix("ControlPath=") || original[index + 1].hasPrefix("ControlPersist=")) { index += 2 continue @@ -22,8 +35,6 @@ struct RemoteTmuxPreviewSSHOptions { "-o", "ControlMaster=no", "-o", "ControlPath=\(host.controlSocketPath)", "-o", "BatchMode=yes", - // A failed mux connection must never fall back to a fresh TCP login. - "-o", "ProxyCommand=/usr/bin/false", ] } } diff --git a/Sources/RemoteTmuxSessionMirror+ControlTopology.swift b/Sources/RemoteTmuxSessionMirror+ControlTopology.swift index b7980e062a6d..0d2079e0c87d 100644 --- a/Sources/RemoteTmuxSessionMirror+ControlTopology.swift +++ b/Sources/RemoteTmuxSessionMirror+ControlTopology.swift @@ -175,26 +175,23 @@ extension RemoteTmuxSessionMirror { let windowMirror = windowMirrorByWindowId[windowID] else { return false } - return windowMirror.requestControlFocus( - pane: tmuxPaneID, - sendTracked: { [connection] command, trackedCompletion in - connection.sendTracked(command, completion: trackedCompletion) - }, - completion: completion - ) + return windowMirror.controlFocus(pane: tmuxPaneID, completion: completion) } func sendInput(toPane tmuxPaneID: Int, text: String) -> Bool { - guard let data = text.data(using: .utf8) else { return false } - return sendInputBytes(data, toPane: tmuxPaneID) + guard let windowID = windowIdByPane[tmuxPaneID], + let windowMirror = windowMirrorByWindowId[windowID] else { return false } + return windowMirror.sendInput(toPane: tmuxPaneID, text: text) } func sendKey( toPane tmuxPaneID: Int, name: String ) -> RemoteTmuxControlKeySendResult { - guard let key = RemoteTmuxKeyName(rawName: name) else { return .unknownKey } - return sendNamedKey(key, toPane: tmuxPaneID) ? .sent : .rejected + guard RemoteTmuxKeyName(rawName: name) != nil else { return .unknownKey } + guard let windowID = windowIdByPane[tmuxPaneID], + let windowMirror = windowMirrorByWindowId[windowID] else { return .rejected } + return windowMirror.sendKey(toPane: tmuxPaneID, name: name) } func requestSplit( diff --git a/Sources/RemoteTmuxSessionMirror+Input.swift b/Sources/RemoteTmuxSessionMirror+Input.swift index f6b47a68bb04..7cfb1d3b5227 100644 --- a/Sources/RemoteTmuxSessionMirror+Input.swift +++ b/Sources/RemoteTmuxSessionMirror+Input.swift @@ -30,7 +30,7 @@ extension RemoteTmuxSessionMirror { #endif return false } - return connection.sendKeys(paneId: tmuxPaneID, data: data) + return connection.sendKeys(paneId: tmuxPaneID, data: data, claimsSizing: false) } /// Sends a validated key only while the pane remains part of this session mirror. @@ -41,6 +41,6 @@ extension RemoteTmuxSessionMirror { #endif return false } - return connection.sendKey(paneId: tmuxPaneID, key: key) + return connection.sendKey(paneId: tmuxPaneID, key: key, claimsSizing: false) } } diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index 218699a7be4e..be104455f845 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -188,7 +188,7 @@ final class RemoteTmuxSessionMirror: RemoteTmuxControlPaneMutationOwner { self.onControlSurfaceRemoved = onControlSurfaceRemoved self.tabManager = tabManager self.workspace = workspace - self.defaultPanelIds = Array(workspace.panels.keys) + self.defaultPanelIds = workspace.panels.values.compactMap { ($0 as? TerminalPanel)?.id } workspace.remoteTmuxSessionMirror = self self.paneInputForwarder = RemoteTmuxPaneInputForwarder( isActive: connection.connectionState == .connected, diff --git a/Sources/RemoteTmuxWindowMirror+ControlMutations.swift b/Sources/RemoteTmuxWindowMirror+ControlMutations.swift index 05a5fca3999f..ab6d5483962b 100644 --- a/Sources/RemoteTmuxWindowMirror+ControlMutations.swift +++ b/Sources/RemoteTmuxWindowMirror+ControlMutations.swift @@ -117,6 +117,7 @@ extension RemoteTmuxWindowMirror { completion: @escaping (Bool) -> Void ) -> Bool { guard let connection else { return false } + _ = connection.activateWindowSizing(windowId: windowId, ownerId: panelId) return requestControlFocus( pane: tmuxPaneID, sendTracked: { command, trackedCompletion in diff --git a/Sources/RemoteTmuxWindowMirror.swift b/Sources/RemoteTmuxWindowMirror.swift index 594e25c4533d..d021b8ed1f8e 100644 --- a/Sources/RemoteTmuxWindowMirror.swift +++ b/Sources/RemoteTmuxWindowMirror.swift @@ -150,6 +150,7 @@ final class RemoteTmuxWindowMirror: RemoteTmuxControlPaneMutationOwner { /// portal-hosted panels churn, and its superview chain is the real /// ancestor stack that produced the SwiftUI proposal. @ObservationIgnored weak var hostProbeView: NSView? + @ObservationIgnored var isOuterFocusedForSizing = false /// Set when the divider sync sends a resize-pane between a drag session's /// begin and end. Bonsplit delivers the final drag geometry notification /// just BEFORE drag-end (the delegate contract: settled geometry is @@ -454,6 +455,12 @@ final class RemoteTmuxWindowMirror: RemoteTmuxControlPaneMutationOwner { private func configurePanePanel(_ panel: TerminalPanel, paneId: Int, needsSeed: Bool) { let surface = panel.surface + let previousExplicitInput = surface.onExplicitInput + surface.onExplicitInput = { [weak self] in + previousExplicitInput?() + guard let self, !self.isTornDown else { return } + _ = self.connection?.activateWindowSizing(windowId: self.windowId, ownerId: self.panelId) + } surface.onManualSizeApplied = { [weak self] in self?.handleSizingSample($0, paneId: paneId) self?.onPaneSurfaceProgress?(paneId) @@ -495,6 +502,7 @@ final class RemoteTmuxWindowMirror: RemoteTmuxControlPaneMutationOwner { func setActivePane(_ paneId: Int, fromTmux: Bool) { guard layout.paneIDsInOrder.contains(paneId) else { return } if !fromTmux { + _ = connection?.activateWindowSizing(windowId: windowId, ownerId: panelId) if pendingControlPaneFocusRequest?.paneID == paneId { projectActivePane(paneId) return diff --git a/Sources/RemoteTmuxWindowMirrorSplitView.swift b/Sources/RemoteTmuxWindowMirrorSplitView.swift index 6402e1d2af55..8989e7f2312f 100644 --- a/Sources/RemoteTmuxWindowMirrorSplitView.swift +++ b/Sources/RemoteTmuxWindowMirrorSplitView.swift @@ -41,6 +41,7 @@ struct RemoteTmuxWindowMirrorSplitView: View { } .onAppear { mirror.isVisibleForSizing = isVisibleInUI + mirror.isOuterFocusedForSizing = isOuterFocused if !isVisibleInUI { mirror.cancelPendingControlPaneFocus() mirror.cancelPendingCreatedPaneFocus() @@ -59,12 +60,21 @@ struct RemoteTmuxWindowMirrorSplitView: View { .onChange(of: isVisibleInUI) { _, visible in mirror.isVisibleForSizing = visible if !visible { + mirror.connection?.deactivateWindowSizing(windowId: mirror.windowId, ownerId: mirror.panelId) mirror.cancelPendingControlPaneFocus() mirror.cancelPendingCreatedPaneFocus() } mirror.bonsplitController.isInteractive = visible if visible { becameVisible() } } + .onChange(of: isOuterFocused) { _, focused in + mirror.isOuterFocusedForSizing = focused + if focused { + mirror.activateSizingIfForeground() + } else { + mirror.connection?.deactivateWindowSizing(windowId: mirror.windowId, ownerId: mirror.panelId) + } + } .onChange(of: mirror.layoutStructureVersion) { _, _ in pushClientSize(pointSize: containerSize) } @@ -99,6 +109,7 @@ struct RemoteTmuxWindowMirrorSplitView: View { .onTapGesture { onOuterFocus() mirror.bonsplitController.focusPane(paneId) + _ = mirror.connection?.activateWindowSizing(windowId: mirror.windowId, ownerId: mirror.panelId) } } else { Color(nsColor: appearance.backgroundColor) @@ -134,6 +145,7 @@ struct RemoteTmuxWindowMirrorSplitView: View { private func becameVisible() { pushClientSize(pointSize: containerSize) mirror.setNeedsSizingPassIgnoringInputs() + mirror.activateSizingIfForeground() } } @@ -143,6 +155,14 @@ struct RemoteTmuxWindowMirrorSplitView: View { /// geometry diagnostics. final class MirrorHostProbeView: NSView { weak var mirror: RemoteTmuxWindowMirror? + private var keyWindowTasks: [Task] = [] + + deinit { keyWindowTasks.forEach { $0.cancel() } } + + override func viewWillStartLiveResize() { + super.viewWillStartLiveResize() + mirror?.activateSizingIfForeground() + } /// The probe backs the whole mirror region, including the sub-cell /// margin outside the split tree; it must never swallow a click there. @@ -162,6 +182,8 @@ final class MirrorHostProbeView: NSView { override func viewDidMoveToWindow() { super.viewDidMoveToWindow() + keyWindowTasks.forEach { $0.cancel() } + keyWindowTasks.removeAll() guard window != nil else { // A tab re-show can recreate the probe, and AppKit delivers the // DYING probe's move-to-nil-window after the replacement already @@ -169,10 +191,45 @@ final class MirrorHostProbeView: NSView { // window handle with a windowless view until the next SwiftUI // update. Only the currently registered probe may clear the // slot; a stale probe changes nothing. - if mirror?.hostProbeView === self { mirror?.hostProbeView = nil } + if let mirror, mirror.hostProbeView === self { + mirror.connection?.deactivateWindowSizing(windowId: mirror.windowId, ownerId: mirror.panelId) + mirror.hostProbeView = nil + } return } mirror?.hostProbeView = self + mirror?.activateSizingIfForeground() + for name in [NSWindow.didBecomeKeyNotification, NSApplication.didBecomeActiveNotification] { + keyWindowTasks.append(Task { @MainActor [weak self] in + for await notification in NotificationCenter.default.notifications(named: name) { + guard let self else { return } + guard self.mirror?.hostProbeView === self else { continue } + if name == NSWindow.didBecomeKeyNotification, + (notification.object as? NSWindow) !== self.window { continue } + self.mirror?.activateSizingIfForeground() + } + }) + } + for name in [NSWindow.didResignKeyNotification, NSApplication.didResignActiveNotification] { + keyWindowTasks.append(Task { @MainActor [weak self] in + for await notification in NotificationCenter.default.notifications(named: name) { + guard let self else { return } + guard let mirror = self.mirror, mirror.hostProbeView === self else { continue } + if name == NSWindow.didResignKeyNotification, + (notification.object as? NSWindow) !== self.window { continue } + mirror.connection?.deactivateWindowSizing(windowId: mirror.windowId, ownerId: mirror.panelId) + } + }) + } + } +} + +extension RemoteTmuxWindowMirror { + /// Visible background windows and remote layout echoes never take ownership. + func activateSizingIfForeground(applicationIsActive: Bool? = nil) { + guard (applicationIsActive ?? NSApp.isActive), isVisibleForSizing, isOuterFocusedForSizing, + visibleHostingContext()?.window?.isKeyWindow == true else { return } + _ = connection?.activateWindowSizing(windowId: windowId, ownerId: panelId) } } diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index e15fcb461051..c09c494fbcd3 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -875,7 +875,9 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable { struct RestorableAgentSessionIndex: Sendable { static let empty = RestorableAgentSessionIndex(entriesByPanel: [:], isComplete: true) - static let unavailable = RestorableAgentSessionIndex(entriesByPanel: [:], isComplete: false) + static let unavailable = RestorableAgentSessionIndex( + entriesByPanel: [:], isComplete: false, processCensusIsAvailable: false + ) struct PanelKey: Hashable, Sendable { let workspaceId: UUID @@ -990,6 +992,9 @@ struct RestorableAgentSessionIndex: Sendable { /// Missing files are complete (the agent kind may not be installed); a /// present but unreadable/invalid file is incomplete and unsafe for auto-resume. let isComplete: Bool + /// A failed or incomplete process census is transient, unlike an unreadable + /// durable hook store. Admission must wait for a complete census before launch. + let processCensusIsAvailable: Bool /// Agent kinds whose present hook-store file was unreadable or invalid. /// Owners are recorded per kind and process-detected owners come from no /// store, so a corrupt store for one kind cannot hide a live owner of @@ -1545,6 +1550,7 @@ struct RestorableAgentSessionIndex: Sendable { processIdentityProvider: processIdentityProvider ), isComplete: self.isComplete, + processCensusIsAvailable: self.processCensusIsAvailable, incompleteHookStoreKinds: self.incompleteHookStoreKinds, incompleteCodexPanelKeys: self.incompleteCodexPanelKeys, verifiedCodexPanelKeys: self.verifiedCodexPanelKeys, @@ -3456,6 +3462,7 @@ struct RestorableAgentSessionIndex: Sendable { entriesByPanel: [PanelKey: Entry], liveSessionOwners: LiveAgentSessionOwnerIndex = .empty, isComplete: Bool = true, + processCensusIsAvailable: Bool = true, incompleteHookStoreKinds: Set = [], incompleteCodexPanelKeys: Set = [], verifiedCodexPanelKeys: Set = [], @@ -3464,6 +3471,7 @@ struct RestorableAgentSessionIndex: Sendable { self.entriesByPanel = entriesByPanel self.liveSessionOwners = liveSessionOwners self.isComplete = isComplete + self.processCensusIsAvailable = processCensusIsAvailable self.incompleteHookStoreKinds = incompleteHookStoreKinds self.incompleteCodexPanelKeys = incompleteCodexPanelKeys self.incompleteCodexPanelIds = Set(incompleteCodexPanelKeys.map(\.panelId)) diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index e5fe6973f4f9..21974639b3b4 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -1730,6 +1730,7 @@ struct SessionWorkspaceSnapshot: Codable, Sendable { var progress: SessionProgressSnapshot? var gitBranch: SessionGitBranchSnapshot? var remote: SessionRemoteWorkspaceSnapshot? + var remoteTmux: SessionRemoteTmuxWorkspaceSnapshot? = nil /// cmux-tui cloud machine binding; absent in manifests written before the Cloud tree and for /// workspaces that are not cloud machines. var cloudVM: SessionCloudVMBindingSnapshot? = nil diff --git a/Sources/SessionRemoteTmuxWorkspaceSnapshot.swift b/Sources/SessionRemoteTmuxWorkspaceSnapshot.swift new file mode 100644 index 000000000000..71b7423400c0 --- /dev/null +++ b/Sources/SessionRemoteTmuxWorkspaceSnapshot.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Reconnects an existing remote session without persisting a local launch command. +struct SessionRemoteTmuxWorkspaceSnapshot: Codable, Sendable, Equatable { + var destination: String + var port: Int? + var identityFile: String? + var sessionName: String + var selectedWindowId: Int? = nil + var browserURLs: [String: String]? = nil + + var host: RemoteTmuxHost? { + guard !destination.isEmpty, !destination.hasPrefix("-"), + !destination.contains(where: { $0.isWhitespace || $0 == "\0" }), + port.map({ (1...65535).contains($0) }) ?? true, + !sessionName.isEmpty, + RemoteTmuxHost.controlModeLineSafeName(sessionName) != nil, + identityFile?.contains("\0") != true else { return nil } + return RemoteTmuxHost(destination: destination, port: port, identityFile: identityFile) + } +} diff --git a/Sources/SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift b/Sources/SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift index c1ba939f338c..c160c889ccc4 100644 --- a/Sources/SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift +++ b/Sources/SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift @@ -51,9 +51,10 @@ extension SurfaceResumeBindingSnapshot { } func restoreStartupInput( - repairPortableAgentExecutable: Bool + repairPortableAgentExecutable: Bool, + useLocalRestoreVerb: Bool = true ) -> String? { - if usesLocalRestoreVerb { + if useLocalRestoreVerb && usesLocalRestoreVerb { // Bare words (` cmux restore `): parses identically in // POSIX shells and nushell, no dialect handling needed. return localRestoreCLIInput diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 188ec7bba379..ed43c2c5ef4b 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -6808,6 +6808,11 @@ extension TabManager { userInfo: [GhosttyNotificationKey.tabId: selectedTabId] ) } + if let controller = AppDelegate.shared?.remoteTmuxController { + for workspace in newTabs { + workspace.reconnectRestoredRemoteTmux(using: controller) + } + } return restoredPanelIdsByWorkspaceIndex } diff --git a/Sources/TerminalController+AgentRestoreAdmission.swift b/Sources/TerminalController+AgentRestoreAdmission.swift index 4cd0ff28f62c..b8b4a5973041 100644 --- a/Sources/TerminalController+AgentRestoreAdmission.swift +++ b/Sources/TerminalController+AgentRestoreAdmission.swift @@ -31,9 +31,10 @@ private enum AgentRestoreAdmissionDecision: Sendable { /// Each reason carries its own explanation so the CLI shows the user what /// actually failed. A scan that ran out of time is worth retrying; an /// unreadable hook store for this agent kind is not. -private enum AgentRestoreAdmissionUnverifiableReason: String, Sendable { +enum AgentRestoreAdmissionUnverifiableReason: String, Sendable { case scanTimedOut = "scan_timed_out" case scanCancelled = "scan_cancelled" + case scanUnavailable = "scan_unavailable" case hookStoreUnreadable = "hook_store_unreadable" var isRetryable: Bool { @@ -47,7 +48,7 @@ private enum AgentRestoreAdmissionUnverifiableReason: String, Sendable { localized: "agentRestore.admission.unavailable.timedOut", defaultValue: "cmux could not finish checking whether this agent session is already running before the time limit. Retry 'cmux restore --surface'." ) - case .scanCancelled: + case .scanCancelled, .scanUnavailable: return String( localized: "agentRestore.admission.unavailable", defaultValue: "cmux could not verify whether this agent session is already running. Retry 'cmux restore --surface'." @@ -114,15 +115,16 @@ extension TerminalController { startedAt: admissionStart ) } - guard index.isComplete( - forWorkspaceId: inputs.workspaceID, - panelId: inputs.surfaceID, + if let reason = Self.agentRestoreIndexFailure( + index, + workspaceID: inputs.workspaceID, + surfaceID: inputs.surfaceID, kind: inputs.kind - ) else { + ) { return Self.agentRestoreAdmissionResponse( request: request, inputs: inputs, - decision: .unverifiable(.hookStoreUnreadable), + decision: .unverifiable(reason), startedAt: admissionStart ) } @@ -169,6 +171,18 @@ extension TerminalController { ) } + /// Classifies incomplete ownership evidence before any launch claim is made. + nonisolated static func agentRestoreIndexFailure( + _ index: RestorableAgentSessionIndex, + workspaceID: UUID, + surfaceID: UUID, + kind: String + ) -> AgentRestoreAdmissionUnverifiableReason? { + guard index.processCensusIsAvailable else { return .scanUnavailable } + return index.isComplete(forWorkspaceId: workspaceID, panelId: surfaceID, kind: kind) + ? nil : .hookStoreUnreadable + } + /// Releases a pre-exec claim only when the requesting CLI owns its token. #if compiler(>=6.2) @concurrent diff --git a/Sources/TerminalLinkOpenContainer.swift b/Sources/TerminalLinkOpenContainer.swift index be07320878a0..40a6654ba53d 100644 --- a/Sources/TerminalLinkOpenContainer.swift +++ b/Sources/TerminalLinkOpenContainer.swift @@ -35,7 +35,7 @@ extension TerminalLinkOpenContainer { struct RemoteTmuxPreviewContext: Sendable { let host: RemoteTmuxHost - let cwd: String? + let paneId: Int } extension TerminalLinkOpenContainer { diff --git a/Sources/TerminalLinkOpenCoordinator.swift b/Sources/TerminalLinkOpenCoordinator.swift index 5e9f2f21b29e..9814a0c9ecae 100644 --- a/Sources/TerminalLinkOpenCoordinator.swift +++ b/Sources/TerminalLinkOpenCoordinator.swift @@ -59,7 +59,7 @@ struct TerminalLinkOpenCoordinator { if let sourcePanelId = request.sourcePanelId, let context = container?.remoteTmuxPreviewContext(for: sourcePanelId) { - let target = RemoteTmuxPreviewTarget(raw: request.rawValue, cwd: context.cwd, home: nil) + let target = RemoteTmuxPreviewTarget(raw: request.rawValue, cwd: nil, home: nil) if case .publicWeb = target { // Public URLs keep the existing browser and cloud routing policy. } else { @@ -170,16 +170,31 @@ struct TerminalLinkOpenCoordinator { return true } Task { @MainActor in + var attemptedTarget = raw do { var target = initialTarget + if case .needsRemoteDirectory = target { + // The subscription cache can lag behind a remote cd. + // Query the clicked pane, independently of local focus/cwd. + let result = try await controller.transport(for: context.host).runTmux([ + "display-message", "-p", "-t", "%\(context.paneId)", "-F", "#{pane_current_path}" + ]) + let cwd = result.stdout.trimmingCharacters(in: .newlines) + guard result.succeeded, cwd.hasPrefix("/"), + !cwd.contains("\n"), !cwd.contains("\r"), !cwd.contains("\0") else { + throw RemoteTmuxPreviewError.remoteDirectoryUnavailable + } + target = RemoteTmuxPreviewTarget(raw: raw, cwd: cwd, home: nil) + } if case .needsRemoteHome = target { let home = try await controller.previewFetcher.remoteHome(host: context.host) - target = RemoteTmuxPreviewTarget(raw: raw, cwd: context.cwd, home: home) + target = RemoteTmuxPreviewTarget(raw: raw, cwd: nil, home: home) } switch target { case .remoteFile(let path): + attemptedTarget = path let localFile = try await controller.previewFetcher.fetch( - path: path, cwd: context.cwd, host: context.host + path: path, cwd: nil, host: context.host ) guard let container = containerResolver(request.sourceWorkspaceId, sourcePanelId) else { showRemoteTmuxLinkError(path, reason: .sourcePaneClosed) @@ -187,7 +202,7 @@ struct TerminalLinkOpenCoordinator { } if ["html", "htm"].contains(localFile.pathExtension.lowercased()) { guard container.openTerminalBrowserLink( - url: localFile, sourcePanelId: sourcePanelId, focus: true + url: localFile, sourcePanelId: sourcePanelId, focus: request.focus ) else { showRemoteTmuxLinkError(path, reason: .browserUnavailable) return @@ -197,14 +212,14 @@ struct TerminalLinkOpenCoordinator { if let rightPane = workspace.preferredRightSideTargetPane( fromPanelId: location.containerPanelID ), !workspace.openFileSurfaces( - inPane: rightPane, filePaths: [localFile.path], focus: true + inPane: rightPane, filePaths: [localFile.path], focus: request.focus ).isEmpty { return } if let sourcePane = workspace.paneId(forPanelId: location.containerPanelID), workspace.splitPaneWithFilePreview( targetPane: sourcePane, orientation: .horizontal, - insertFirst: false, filePath: localFile.path + insertFirst: false, filePath: localFile.path, focus: request.focus ) != nil { return } @@ -216,13 +231,14 @@ struct TerminalLinkOpenCoordinator { let localURL = try await controller.loopbackForwarder.forwardedURL( remoteURL, host: context.host ) - guard let container = containerResolver(request.sourceWorkspaceId, sourcePanelId), - container.openTerminalBrowserLink( - url: localURL, sourcePanelId: sourcePanelId, focus: true + guard let workspace = containerResolver(request.sourceWorkspaceId, sourcePanelId) as? Workspace, + let browser = workspace.openTerminalBrowserPanel( + url: localURL, sourcePanelId: sourcePanelId, focus: request.focus ) else { showRemoteTmuxLinkError(raw, reason: .browserUnavailable) return } + workspace.remoteTmuxPreviewURLsByPanelId[browser.id] = (remoteURL, localURL) case .needsRemoteDirectory: showRemoteTmuxLinkError(raw, reason: .remoteDirectoryUnavailable) case .needsRemoteHome: @@ -231,7 +247,7 @@ struct TerminalLinkOpenCoordinator { showRemoteTmuxLinkError(raw, reason: .invalidLink) } } catch { - showRemoteTmuxLinkError(raw, reason: .remoteDetail(error.localizedDescription)) + showRemoteTmuxLinkError(attemptedTarget, reason: .remoteDetail(error.localizedDescription)) } } return true diff --git a/Sources/TerminalRecovery.swift b/Sources/TerminalRecovery.swift index 0816b1b0c18d..10aa0f50d4e1 100644 --- a/Sources/TerminalRecovery.swift +++ b/Sources/TerminalRecovery.swift @@ -143,11 +143,55 @@ extension SessionTerminalPanelSnapshot { } else { snapshot.recovery = candidate } - return snapshot + return freshEvidence ? snapshot.reconcilingAgentBindingForRestore(surfaceID: surfaceID) : snapshot + } + + /// A bound session retains automatic restore intent after its process exits. + func reconcilingAgentBindingForRestore(surfaceID: UUID) -> Self { + guard case .resumeAgent(let boundAgent) = recoveryPlan(surfaceID: surfaceID).action else { + return self + } + var candidate = self + for keyPath in [\Self.resumeBinding, \Self.managedAgentResumeBinding] { + guard var binding = candidate[keyPath: keyPath], + binding.isAgentHookBinding, binding.autoResume == false, + binding.approvalPolicy == nil || binding.approvalPolicy == .auto, + TerminalRecoveryAgentIdentity(kind: binding.kind, sessionID: binding.checkpointId) == boundAgent + else { continue } + binding.autoResume = true + candidate[keyPath: keyPath] = binding + } + return candidate + } + + /// The session still attached to this panel, independent of process liveness. + var boundAgentIdentityForRestore: TerminalRecoveryAgentIdentity? { + let binding = managedAgentResumeBinding ?? resumeBinding + let savedAgent = TerminalRecoveryAgentIdentity(kind: agent?.kind.rawValue, sessionID: agent?.sessionId) + let savedBinding = binding.flatMap { + $0.isAgentHookBinding || $0.hasCompleteManagedSessionIdentity + ? TerminalRecoveryAgentIdentity(kind: $0.kind, sessionID: $0.checkpointId) + : nil + } + if let savedAgent, let savedBinding, savedAgent != savedBinding { + return nil + } + return savedBinding ?? savedAgent + } + + /// A saved local binding supplies its own exact launch target. Restoring + /// it does not require a process census of the previous execution. + var hasLocalBoundAgentBindingForRestore: Bool { + guard let boundAgent = boundAgentIdentityForRestore, + let binding = resumeBinding, + binding.isAgentHookBinding, binding.launchFlavor == .local else { + return false + } + return TerminalRecoveryAgentIdentity(kind: binding.kind, sessionID: binding.checkpointId) == boundAgent } func recoveryPlan(surfaceID: UUID? = nil, claimedOwnerSurfaceID: UUID? = nil) -> TerminalRecoveryPlan { - let observation = recovery ?? .migrated(from: self, surfaceID: surfaceID) + var observation = recovery ?? .migrated(from: self, surfaceID: surfaceID) let savedAgent = TerminalRecoveryAgentIdentity(kind: agent?.kind.rawValue, sessionID: agent?.sessionId) let savedBinding = TerminalRecoveryAgentIdentity( kind: (managedAgentResumeBinding ?? resumeBinding)?.kind, @@ -157,11 +201,26 @@ extension SessionTerminalPanelSnapshot { let hasLocalTmux = tmuxStartCommand?.contains("CMUX_LOCAL_TMUX=1") == true let hasAttachment = remotePTYSessionID?.isEmpty == false || hasLocalTmux || hibernation != nil + let binding = managedAgentResumeBinding ?? resumeBinding + if !hasAttachment, binding?.isAgentHookBinding == true, + binding?.autoResume != true, binding?.approvalPolicy == .manual { + return TerminalRecoveryPlan(action: .shell, reason: "agent binding requires manual resume") + } if !hasAttachment, observation.state == .agent, let observedAgent = observation.agent, (savedIdentities.isEmpty || savedIdentities.contains(where: { $0 != observedAgent })) { return TerminalRecoveryPlan(action: .shell, reason: "saved agent identity conflicts with recovery observation") } + // Process observations describe the old execution. The panel's saved + // binding decides what to launch when the application reopens. + if let boundAgent = boundAgentIdentityForRestore { + observation.state = .agent + observation.agent = boundAgent + observation.diagnosticReason = nil + } else { + observation.state = .shell + observation.agent = nil + } return TerminalRecoveryPlan.decide( observation: observation, hasRemotePTY: remotePTYSessionID?.isEmpty == false, @@ -172,3 +231,15 @@ extension SessionTerminalPanelSnapshot { ) } } + +extension SurfaceResumeBindingSnapshot { + /// Runs an approved saved binding through the managed wrapper, without + /// asking the CLI to infer process ownership again during app restore. + func boundAgentStartupLaunch() -> Workspace.SurfaceResumeStartupLaunch? { + guard isAgentHookBinding, launchFlavor == .local else { return nil } + return restoreStartupInput( + repairPortableAgentExecutable: true, + useLocalRestoreVerb: false + ).map(Workspace.SurfaceResumeStartupLaunch.input) + } +} diff --git a/Sources/Workspace+RemoteTmuxSessionRestore.swift b/Sources/Workspace+RemoteTmuxSessionRestore.swift new file mode 100644 index 000000000000..ef30bd1bbc95 --- /dev/null +++ b/Sources/Workspace+RemoteTmuxSessionRestore.swift @@ -0,0 +1,110 @@ +import Bonsplit +import CmuxWorkspaces +import Foundation +import os + +@MainActor +extension Workspace { + var remoteTmuxSessionSnapshot: SessionRemoteTmuxWorkspaceSnapshot? { + guard isRemoteTmuxMirror else { return nil } + var snapshot = remoteTmuxRestoration + if let mirror = remoteTmuxSessionMirror { + let selectedPanel = remoteTmuxWindowsPaneId() + .flatMap { bonsplitController.selectedTab(inPane: $0)?.id } + .flatMap { panelIdFromSurfaceId($0) } + snapshot = SessionRemoteTmuxWorkspaceSnapshot( + destination: mirror.host.destination, + port: mirror.host.port, + identityFile: mirror.host.identityFile, + sessionName: mirror.sessionName, + selectedWindowId: selectedPanel.flatMap { mirror.windowId(forPanel: $0) } + ) + } + let browserURLs = remoteTmuxPreviewURLsByPanelId.compactMap { panelID, source -> (String, String)? in + guard let browser = panels[panelID] as? BrowserPanel else { return nil } + let current = browser.preferredURLStringForSessionSnapshot().flatMap(URL.init(string:)) + if source.local.absoluteString == "about:blank", + current == nil || current?.absoluteString == "about:blank" { + return (panelID.uuidString, source.remote.absoluteString) + } + guard let current, current.scheme == source.local.scheme, + current.host == source.local.host, current.port == source.local.port, + var restored = URLComponents(url: current, resolvingAgainstBaseURL: false) else { return nil } + restored.host = source.remote.host + restored.port = source.remote.port + return restored.url.map { (panelID.uuidString, $0.absoluteString) } + } + snapshot?.browserURLs = browserURLs.isEmpty ? nil : Dictionary( + uniqueKeysWithValues: browserURLs + ) + return snapshot + } + + /// A placeholder uses manual I/O, so a remote snapshot cannot run local commands. + func restoreRemoteTmuxDisplayPanel(_ snapshot: SessionPanelSnapshot, in pane: PaneID) -> UUID? { + guard let panel = makeRemoteTmuxPanePanel(onInput: { _ in }), + let tab = bonsplitController.createTab( + title: snapshot.customTitle ?? snapshot.title ?? String(localized: "remoteTmux.tab.pane", defaultValue: "tmux pane"), + icon: "rectangle.connected.to.line.below", + kind: SurfaceKind.terminal.rawValue, + inPane: pane + ) else { return nil } + panels[panel.id] = panel + bindSurface(tab, toPanelId: panel.id) + applySessionPanelMetadata(snapshot, toPanelId: panel.id) + return panel.id + } + + func remoteTmuxBrowserURL(for savedPanelID: UUID) -> URL? { + guard let raw = remoteTmuxRestoration?.browserURLs?[savedPanelID.uuidString], + case .loopbackWeb(let url) = RemoteTmuxPreviewTarget(raw: raw, cwd: nil, home: nil) else { return nil } + return url + } + + func restoreRemoteTmuxPreviewIdentities(_ remapped: [UUID: UUID]) { + for (oldID, newID) in remapped { + guard let remote = remoteTmuxBrowserURL(for: oldID), panels[newID] is BrowserPanel else { continue } + remoteTmuxPreviewURLsByPanelId[newID] = (remote, URL(string: "about:blank")!) + } + } + + /// Starts after the manager has installed the complete restored workspace graph. + func reconnectRestoredRemoteTmux(using controller: RemoteTmuxController) { + guard isRemoteTmuxMirror, let saved = remoteTmuxRestoration, + let host = saved.host, let manager = owningTabManager, + RemoteTmuxController.isEnabled, + !managedDevicePolicy.isEnforced(.disableRemoteConnections), + !isRetiredFromOwningTabManager, remoteTmuxRestoreTask == nil else { return } + remoteTmuxRestoreTask = Task { @MainActor [weak self, weak manager, weak controller] in + guard let controller else { return } + defer { self?.remoteTmuxRestoreTask = nil } + do { + try await controller.ensureControlMasterReadyForBurst(host: host) + guard let self, let manager, !self.isRetiredFromOwningTabManager, + !self.managedDevicePolicy.isEnforced(.disableRemoteConnections), + manager.tabs.contains(where: { $0 === self }) else { return } + _ = try controller.mirrorSession( + host: host, sessionName: saved.sessionName, into: manager, + restoring: self + ) + if let selected = saved.selectedWindowId, + manager.selectedTabId == self.id, + self.focusedPanelId.flatMap({ self.panels[$0] }) is TerminalPanel { + self.remoteTmuxSessionMirror?.focusWindowWhenAvailable(selected) + } + for (panelID, source) in self.remoteTmuxPreviewURLsByPanelId { + let local = try await controller.loopbackForwarder.forwardedURL(source.remote, host: host) + try Task.checkCancellation() + guard !self.isRetiredFromOwningTabManager, + let browser = self.panels[panelID] as? BrowserPanel else { continue } + self.remoteTmuxPreviewURLsByPanelId[panelID] = (source.remote, local) + browser.navigate(to: local) + } + } catch { + if !Task.isCancelled { + RemoteTmuxController.logger.warning("remote-tmux session restore could not attach [\(host.connectionHash, privacy: .public)]") + } + } + } + } +} diff --git a/Sources/Workspace+RemoteTmuxTabOrder.swift b/Sources/Workspace+RemoteTmuxTabOrder.swift index 603d7879527b..51286f99f993 100644 --- a/Sources/Workspace+RemoteTmuxTabOrder.swift +++ b/Sources/Workspace+RemoteTmuxTabOrder.swift @@ -5,14 +5,14 @@ extension Workspace { @discardableResult func reorderSurface(panelId: UUID, toIndex index: Int, focus: Bool = true) -> Bool { guard let tabId = surfaceIdFromPanelId(panelId) else { return false } - let mirrorPaneId = isRemoteTmuxMirror ? paneId(forPanelId: panelId) : nil + let mirrorPaneId = isRemoteTmuxMirror && panels[panelId] is TerminalPanel + ? paneId(forPanelId: panelId) : nil let reordered: Bool if let mirrorPaneId { reordered = performRemoteTmuxMirrorOrderMutation(in: mirrorPaneId) { bonsplitController.reorderTab(tabId, toIndex: index) } } else { - guard !isRemoteTmuxMirror else { return false } reordered = bonsplitController.reorderTab(tabId, toIndex: index) } guard reordered else { return false } diff --git a/Sources/Workspace+TerminalLinkOpening.swift b/Sources/Workspace+TerminalLinkOpening.swift index a6f59b2741c3..7178e06c3bc5 100644 --- a/Sources/Workspace+TerminalLinkOpening.swift +++ b/Sources/Workspace+TerminalLinkOpening.swift @@ -1,3 +1,4 @@ +import Bonsplit import CmuxPanes import Foundation @@ -30,7 +31,7 @@ extension Workspace: TerminalLinkOpenContainer { } return RemoteTmuxPreviewContext( host: host, - cwd: effectivePanelDirectory(panelId: sourcePanelId) + paneId: location.pane.tmuxPaneID ) } @@ -60,15 +61,54 @@ extension Workspace: TerminalLinkOpenContainer { } func openTerminalBrowserLink(url: URL, sourcePanelId: UUID, focus: Bool = true) -> Bool { - guard let target = surfaceOwnershipTarget(for: sourcePanelId) else { return false } + openTerminalBrowserPanel(url: url, sourcePanelId: sourcePanelId, focus: focus) != nil + } + + func openTerminalBrowserPanel(url: URL, sourcePanelId: UUID, focus: Bool) -> BrowserPanel? { + guard let target = surfaceOwnershipTarget(for: sourcePanelId) else { return nil } if let targetPane = preferredRightSideTargetPane(fromPanelId: target.containerPanelID) { - return newBrowserSurface(inPane: targetPane, url: url, focus: focus) != nil + return newBrowserSurface( + inPane: targetPane, url: url, focus: focus, + allowsExternalBrowserFallback: !isRemoteTmuxMirror + ) } return newBrowserSplit( from: target.containerPanelID, orientation: .horizontal, url: url, - focus: focus - ) != nil + focus: focus, + allowsExternalBrowserFallback: !isRemoteTmuxMirror + ) + } +} + +extension Workspace { + /// Finds the tmux window strip independently of focus in a local preview. + func remoteTmuxWindowsPaneId() -> PaneID? { + bonsplitController.allPaneIds.first { pane in + bonsplitController.tabs(inPane: pane).contains { tab in + panelIdFromSurfaceId(tab.id).map { panels[$0] is TerminalPanel } == true + } + } ?? bonsplitController.allPaneIds.first { bonsplitController.tabs(inPane: $0).isEmpty } + } + + /// Reuses one outer pane for browsers and downloaded remote files. + func remoteTmuxPreviewPaneId() -> PaneID? { + guard isRemoteTmuxMirror else { return nil } + return bonsplitController.allPaneIds.first { pane in + let tabs = bonsplitController.tabs(inPane: pane) + return !tabs.isEmpty && tabs.allSatisfy { tab in + guard let panelId = panelIdFromSurfaceId(tab.id), let panel = panels[panelId] else { return false } + return panel is BrowserPanel || panel is FilePreviewPanel + } + } + } + + /// Grants a synchronous split only for the local preview being installed. + func performRemoteTmuxPreviewSplit(_ split: () -> T) -> T { + let previous = isCreatingRemoteTmuxPreviewSplit + isCreatingRemoteTmuxPreviewSplit = isRemoteTmuxMirror + defer { isCreatingRemoteTmuxPreviewSplit = previous } + return split() } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index dc2d53a5c8ca..173dad7ae6b9 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -199,6 +199,7 @@ extension Workspace { environment: workspaceEnvironment.isEmpty ? nil : workspaceEnvironment ) snapshot.captureTodoState(from: self) + snapshot.remoteTmux = remoteTmuxSessionSnapshot snapshot.dock = _dockSplit?.sessionSnapshot( includeScrollback: includeScrollback, restorableAgentIndex: restorableAgentIndex, @@ -219,6 +220,10 @@ extension Workspace { deferBrowserPanels: Bool = false ) -> [UUID: UUID] { guard acceptsRestoredSession(snapshot) else { return [:] } + remoteTmuxRestoreTask?.cancel() + remoteTmuxRestoreTask = nil + remoteTmuxRestoration = snapshot.remoteTmux + remoteTmuxPreviewURLsByPanelId.removeAll() let finishWork = beginTerminalGeometryTransition(.restore) defer { finishWork() } sessionRestoreLayoutSuppressionDepth += 1 @@ -320,6 +325,7 @@ extension Workspace { defer { suppressRemoteTerminalStartupForSessionRestoreScaffold = previousValue } return restoreSessionLayout(snapshot.layout) }() + isRemoteTmuxMirror = snapshot.remoteTmux != nil var oldToNewPanelIds: [UUID: UUID] = [:] let deviceProjectionPanelIDs = Set((snapshot.surfaceProjections ?? []) .filter { $0.resource.machine.isDevice }.map(\.panelID)) @@ -422,6 +428,7 @@ extension Workspace { terminalStartupRestoreCoordinator.commitPendingRestores() } restoreSurfaceProjections(snapshot.surfaceProjections, oldToNewPanelIds: oldToNewPanelIds) + restoreRemoteTmuxPreviewIdentities(oldToNewPanelIds) return oldToNewPanelIds } @@ -497,8 +504,7 @@ extension Workspace { ) == nil ? nil : state } : nil - let restoredAgentCompleted = restoredAgentResumeStatesByPanelId[panelId] == .completedAgentExit - let effectiveRestorableAgent = localTmuxStartCommand == nil && !restoredAgentCompleted + let effectiveRestorableAgent = localTmuxStartCommand == nil ? Self.restorableAgentForSessionRestore( restoredAgentLifecycle.reconcileSnapshotWithQueuedRestoreIntent( panelId: panelId, @@ -1603,6 +1609,10 @@ extension Workspace { restoresLegacyRemoteDirectoryWithoutProvenance(snapshot))) switch snapshot.type { case .terminal: + if isRemoteTmuxMirror { + return restoreRemoteTmuxDisplayPanel(snapshot, in: paneId) + } + snapshot.terminal = snapshot.terminal?.reconcilingAgentBindingForRestore(surfaceID: snapshot.id) if restoresDeviceProjection { return restoreDeviceDisplayPanel(snapshot, in: paneId) } @@ -1619,7 +1629,8 @@ extension Workspace { let recoveryObservation = snapshot.terminal?.recovery ?? snapshot.terminal.map { TerminalRecoveryObservation.migrated(from: $0, surfaceID: snapshot.id) } recoveryObservationsByPanelId[snapshot.id] = recoveryObservation - let claimedRecoveryOwner = recoveryObservation?.agent.flatMap { recoveryPlannedAgentOwners[$0] } + let boundAgent = snapshot.terminal?.boundAgentIdentityForRestore + let claimedRecoveryOwner = boundAgent.flatMap { recoveryPlannedAgentOwners[$0] } let recoveryPlan = snapshot.terminal?.recoveryPlan( surfaceID: snapshot.id, claimedOwnerSurfaceID: claimedRecoveryOwner ) @@ -1628,7 +1639,7 @@ extension Workspace { case .resumeAgent = recoveryPlan?.action else { return false } return true }() - if shouldAutoResumeAgent, let agent = recoveryObservation?.agent { + if shouldAutoResumeAgent, let agent = boundAgent { recoveryPlannedAgentOwners[agent] = snapshot.id } let remoteStartupCommand = remoteTerminalStartupCommand() @@ -1672,7 +1683,10 @@ extension Workspace { // top of that binding. let restorableAgentCanAutoResume = restorableAgent != nil && (resumeBinding == nil || resumeBinding?.isAgentHookBinding == true) - let shouldCheckAgentOwnership = shouldAutoResumeAgent && + let restoresBoundLocalAgent = shouldAutoResumeAgent && + snapshot.terminal?.hasLocalBoundAgentBindingForRestore == true && + !restoresRemoteWorkspaceTerminalSnapshot && restoredHibernation == nil + let shouldCheckAgentOwnership = !restoresBoundLocalAgent && shouldAutoResumeAgent && (restorableAgentCanAutoResume || resumeBinding?.isAgentHookBinding == true) let restoreAgentIndex = shouldCheckAgentOwnership ? restorableAgentIndex : nil let restoreIndexUnavailable = shouldCheckAgentOwnership && restoreAgentIndex == nil @@ -1772,9 +1786,13 @@ extension Workspace { !stablePanelHasLiveProcess, canAttemptLocalBindingResume, let effectiveResumeBindingForStartup { - sessionRestorePolicy.surfaceResumeStartupLaunch( - forApprovedBinding: effectiveResumeBindingForStartup - ) + if restoresBoundLocalAgent { + effectiveResumeBindingForStartup.boundAgentStartupLaunch() + } else { + sessionRestorePolicy.surfaceResumeStartupLaunch( + forApprovedBinding: effectiveResumeBindingForStartup + ) + } } else { nil } @@ -2267,6 +2285,12 @@ extension Workspace { } return terminalPanel.id case .browser: + if remoteTmuxBrowserURL(for: snapshot.id) != nil { + // A forwarded localhost port belongs to the previous SSH connection. + snapshot.browser?.urlString = "about:blank" + snapshot.browser?.backHistoryURLStrings = [] + snapshot.browser?.forwardHistoryURLStrings = [] + } if deferBrowserPanelsDuringSessionRestore, snapshot.browser != nil, !isRemoteTmuxMirror, @@ -5496,7 +5520,8 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos } let desiredOrder = pinnedTabs + unpinnedTabs - if isRemoteTmuxMirror, desiredOrder.map(\.id) != tabs.map(\.id) { + if isRemoteTmuxMirror, paneId == remoteTmuxWindowsPaneId(), + desiredOrder.map(\.id) != tabs.map(\.id) { let desiredPanelOrder = desiredOrder.compactMap { panelIdFromSurfaceId($0.id) } guard desiredPanelOrder.count == desiredOrder.count else { return false } return performRemoteTmuxMirrorOrderMutation( @@ -6710,8 +6735,18 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos remoteConfiguration != nil } - /// Ephemeral remote tmux mirror; excluded from cmux session restore. - var isRemoteTmuxMirror: Bool = false + /// Remote topology is authoritative; session snapshots retain its attachment target. + var isRemoteTmuxMirror: Bool = false { + didSet { + // Remote window tabs and local previews have different owners. + bonsplitController.configuration.allowCrossPaneTabMove = !isRemoteTmuxMirror + } + } + /// Only browser/file creation may split the outer mirror workspace locally. + var isCreatingRemoteTmuxPreviewSplit = false + var remoteTmuxRestoration: SessionRemoteTmuxWorkspaceSnapshot? + var remoteTmuxRestoreTask: Task? + var remoteTmuxPreviewURLsByPanelId: [UUID: (remote: URL, local: URL)] = [:] weak var remoteTmuxSessionMirror: RemoteTmuxSessionMirror? /// Bound action for this mirror's outbound window-order mutation boundary. var remoteTmuxWindowOrderSync: (([UUID], ((Bool) -> Void)?) -> Bool)? @@ -6758,7 +6793,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos } var isRestorableInSessionSnapshot: Bool { - if isRemoteTmuxMirror { return false } + if isRemoteTmuxMirror { return remoteTmuxSessionSnapshot != nil } if panels.values.contains(where: { switch $0.panelType { case .cloudVMLoading, .mobilePairing, .accountSignIn: @@ -9462,7 +9497,9 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos ) -> TerminalPanel? { guard !isRetiredFromOwningTabManager else { return nil } let newPanel = performRemoteTmuxMirrorMutation { () -> TerminalPanel? in - guard let paneId = bonsplitController.focusedPaneId ?? bonsplitController.allPaneIds.first + guard let paneId = isRemoteTmuxMirror + ? remoteTmuxWindowsPaneId() + : (bonsplitController.focusedPaneId ?? bonsplitController.allPaneIds.first) else { return nil } let title = customTitle ?? String(localized: "remoteTmux.tab.pane", defaultValue: "tmux pane") @@ -9668,12 +9705,24 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos transparentBackground: Bool = false, bypassRemoteProxy: Bool = false, initialDividerPosition: CGFloat? = nil, - websiteDataStore: WKWebsiteDataStore? = nil + websiteDataStore: WKWebsiteDataStore? = nil, + bypassInsecureHTTPHostOnce: String? = nil ) -> BrowserPanel? { guard !isRetiredFromOwningTabManager, acceptsUnownedBrowserURL(initialRequest?.url ?? url) else { return nil } - // No local browser surfaces in a remote tmux mirror workspace (it is a - // 1:1 view of a tmux session). See ``newBrowserSurface(inPane:)``. - if isRemoteTmuxMirror { return nil } + if isRemoteTmuxMirror { + guard paneId(forPanelId: panelId) != nil else { return nil } + if let previewPane = remoteTmuxPreviewPaneId() { + return newBrowserSurface( + inPane: previewPane, url: url, initialRequest: initialRequest, + focus: focus, preferredProfileID: preferredProfileID, + bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce, + creationPolicy: creationPolicy, + allowsExternalBrowserFallback: allowsExternalBrowserFallback, + chromeVisibility: chromeVisibility, transparentBackground: transparentBackground, + bypassRemoteProxy: bypassRemoteProxy, websiteDataStore: websiteDataStore + ) + } + } let browserEnabled = BrowserAvailabilitySettings.isEnabled() // Under an MDM-managed disable no path may create a browser panel, // including session restore (mirrors the Dock restore behavior). @@ -9714,6 +9763,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos initialRequest: initialRequest, renderInitialNavigation: browserEnabled || creationPolicy != .restoration, preloadInitialNavigationInBackground: creationPolicy.preloadsInitialNavigationInBackground, + bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce, chromeVisibility: chromeVisibility, transparentBackground: transparentBackground, proxyEndpoint: remoteProxyEndpoint, @@ -9744,7 +9794,9 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos // Mark this split as programmatic so didSplitPane doesn't auto-create a terminal. isProgrammaticSplit = true defer { isProgrammaticSplit = false } - guard let newPaneId = bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) else { + guard let newPaneId = performRemoteTmuxPreviewSplit({ + bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) + }) else { removeSurfaceMapping(forSurfaceId: newTab.id) panels.removeValue(forKey: browserPanel.id) panelTitles.removeValue(forKey: browserPanel.id) @@ -9798,11 +9850,33 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos websiteDataStore: WKWebsiteDataStore? = nil ) -> BrowserPanel? { guard !isRetiredFromOwningTabManager, acceptsUnownedBrowserURL(initialRequest?.url ?? url) else { return nil } - // 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 } + guard bonsplitController.allPaneIds.contains(paneId) else { return nil } + if isRemoteTmuxMirror, sessionRestoreLayoutSuppressionDepth == 0, + paneId != remoteTmuxPreviewPaneId() { + if let previewPane = remoteTmuxPreviewPaneId() { + return newBrowserSurface( + inPane: previewPane, url: url, initialRequest: initialRequest, + focus: focus, selectWhenNotFocused: selectWhenNotFocused, insertAtEnd: insertAtEnd, + preferredProfileID: preferredProfileID, + bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce, + creationPolicy: creationPolicy, + allowsExternalBrowserFallback: allowsExternalBrowserFallback, + chromeVisibility: chromeVisibility, transparentBackground: transparentBackground, + bypassRemoteProxy: bypassRemoteProxy, websiteDataStore: websiteDataStore + ) + } + guard let sourcePanelId = effectiveSelectedPanelId(inPane: paneId) else { return nil } + return newBrowserSplit( + from: sourcePanelId, orientation: .horizontal, url: url, initialRequest: initialRequest, + preferredProfileID: preferredProfileID, + focus: focus ?? (bonsplitController.focusedPaneId == paneId), + creationPolicy: creationPolicy, + allowsExternalBrowserFallback: allowsExternalBrowserFallback, + chromeVisibility: chromeVisibility, transparentBackground: transparentBackground, + bypassRemoteProxy: bypassRemoteProxy, websiteDataStore: websiteDataStore, + bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce + ) + } let browserEnabled = BrowserAvailabilitySettings.isEnabled() // Under an MDM-managed disable no path may create a browser panel, // including session restore (mirrors the Dock restore behavior). @@ -10590,7 +10664,8 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos targetPane paneId: PaneID, orientation: SplitOrientation, insertFirst: Bool, - filePath: String + filePath: String, + focus: Bool = true ) -> FilePreviewPanel? { guard !isRetiredFromOwningTabManager else { return nil } let filePreviewPanel = FilePreviewPanel( @@ -10611,19 +10686,30 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos ) bindSurface(newTab.id, toPanelId: filePreviewPanel.id) + let previousFocusedPanelId = focusedPanelId isProgrammaticSplit = true defer { isProgrammaticSplit = false } - guard let newPaneId = bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) else { + guard let newPaneId = performRemoteTmuxPreviewSplit({ + bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) + }) else { filePreviewPanel.close() panels.removeValue(forKey: filePreviewPanel.id) panelTitles.removeValue(forKey: filePreviewPanel.id) removeSurfaceMapping(forSurfaceId: newTab.id) return nil } - publishCmuxSplitCreated(newPaneId, sourcePaneId: paneId, orientation: orientation, surfaceId: filePreviewPanel.id, kind: "file_preview", origin: "file_preview_split", focused: true) + publishCmuxSplitCreated(newPaneId, sourcePaneId: paneId, orientation: orientation, surfaceId: filePreviewPanel.id, kind: "file_preview", origin: "file_preview_split", focused: focus) - bonsplitController.selectTab(newTab.id) - filePreviewPanel.focus() + if focus { + bonsplitController.selectTab(newTab.id) + filePreviewPanel.focus() + } else { + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: filePreviewPanel.id, + previousHostedView: focusedTerminalInputTarget()?.panel.hostedView + ) + } filePreviewPanel.bindTabMetadata(to: self) return filePreviewPanel } @@ -10631,6 +10717,8 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos /// Permanently retires this workspace before releasing its runtime resources. func retireFromOwningTabManager() { guard !isRetiredFromOwningTabManager else { return } + remoteTmuxRestoreTask?.cancel() + remoteTmuxRestoreTask = nil isRetiredFromOwningTabManager = true // Workspace retirement is the shared ownership boundary used by // window-close, stale-registration, and rejected-window cleanup. A @@ -14259,9 +14347,10 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, shouldSplitPane pane: PaneID, orientation: SplitOrientation) -> Bool { guard !isRetiredFromOwningTabManager else { return false } - // In a remote tmux mirror, split means tmux `split-window`; always veto - // local splits so the mirror never gains an orphan pane. + // Terminal splits belong to tmux. Browser/file previews live in the + // outer workspace beside the mirrored window containers. guard isRemoteTmuxMirror else { return true } + if isCreatingRemoteTmuxPreviewSplit { 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, focusIntent: .focusCreatedPane) @@ -14274,6 +14363,7 @@ extension Workspace: BonsplitDelegate { // Mirror transactions send their desired order explicitly. Their local // mutations, including rollback and remote updates, must not echo it. guard isRemoteTmuxMirror, + pane == remoteTmuxWindowsPaneId(), !remoteTmuxMirrorMutations.suppressesFocusActivation else { return } let orderedPanelIds = orderedTabIds.compactMap { panelIdFromSurfaceId($0) } guard !orderedPanelIds.isEmpty else { return } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 7eec1faf01ed..c6625d33a99f 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2730,6 +2730,7 @@ F1936170A623DA26173CDB01 /* RemoteTmuxInputCommandCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1936170A623DA26173CDB02 /* RemoteTmuxInputCommandCapture.swift */; }; F1936170A623DA26173CDB03 /* RemoteTmuxInputCommandCaptureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1936170A623DA26173CDB04 /* RemoteTmuxInputCommandCaptureTests.swift */; }; 9251A0019251A0019251A002 /* RemoteTmuxKeyName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9251A0019251A0019251A001 /* RemoteTmuxKeyName.swift */; }; + A5C149469073D87DAE0AAF71 /* RemoteTmuxLegacySizingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E392ABDB4640D6D6B233FA0C /* RemoteTmuxLegacySizingTests.swift */; }; AEEF00000000000000000005 /* RemoteTmuxLoopbackForwarder.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEEF00000000000000000006 /* RemoteTmuxLoopbackForwarder.swift */; }; 6732BEEF6732BEEF6732B002 /* RemoteTmuxMasterReadinessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6732BEEF6732BEEF6732B001 /* RemoteTmuxMasterReadinessTests.swift */; }; 7738A0027738A0027738A002 /* RemoteTmuxMirrorCLIFailClosedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7738B0027738B0027738B002 /* RemoteTmuxMirrorCLIFailClosedTests.swift */; }; @@ -2771,6 +2772,9 @@ 761639EDDD6C40883902D781 /* RemoteTmuxPostAttachAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE24906539C19AB10E4C1C71 /* RemoteTmuxPostAttachAction.swift */; }; AEEF00000000000000000007 /* RemoteTmuxPreviewError.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEEF00000000000000000008 /* RemoteTmuxPreviewError.swift */; }; AEEF00000000000000000003 /* RemoteTmuxPreviewFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEEF00000000000000000004 /* RemoteTmuxPreviewFetcher.swift */; }; + 1CBF39BE5766FE37AA31BC5E /* RemoteTmuxPreviewFetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D0480BA13AE6EC34FAE99D3 /* RemoteTmuxPreviewFetcherTests.swift */; }; + 6AC03D7638CBE5A52E74C939 /* RemoteTmuxPreviewIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E2944CE86EFED2DD99707AC /* RemoteTmuxPreviewIntegrationTests.swift */; }; + A0B973CA6223B3684E4D84DC /* RemoteTmuxPreviewRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 776C6A7112B96BA3811C7EF5 /* RemoteTmuxPreviewRoutingTests.swift */; }; AEEF00000000000000000009 /* RemoteTmuxPreviewSSHOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEEF0000000000000000000A /* RemoteTmuxPreviewSSHOptions.swift */; }; AEEF00000000000000000001 /* RemoteTmuxPreviewTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEEF00000000000000000002 /* RemoteTmuxPreviewTarget.swift */; }; 94871B514A31D99604D99603 /* RemoteTmuxPreviewTargetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78B7D1E5F15612C48E564C91 /* RemoteTmuxPreviewTargetTests.swift */; }; @@ -2953,6 +2957,7 @@ C65930020000000000000002 /* SessionPersistencePolicy+CrashStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C65930020000000000000001 /* SessionPersistencePolicy+CrashStorage.swift */; }; F6572002A1B2C3D4E5F60718 /* SessionPersistenceResumeBindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6572003A1B2C3D4E5F60718 /* SessionPersistenceResumeBindingTests.swift */; }; F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; + BF794D70F3D54A5AB7065FA8 /* SessionRemoteTmuxWorkspaceSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44684C2B15A5443AA0F8E89C /* SessionRemoteTmuxWorkspaceSnapshot.swift */; }; 812600000000000000000003 /* SessionRemoteWorkspaceMoshRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 812600000000000000000004 /* SessionRemoteWorkspaceMoshRestoreTests.swift */; }; E30780000000000000000014 /* SessionRemoteWorkspaceSnapshot+Restore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30780000000000000000013 /* SessionRemoteWorkspaceSnapshot+Restore.swift */; }; 0A1107110000000000000022 /* SessionRestorableAgentSnapshot+Commands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A1107110000000000000023 /* SessionRestorableAgentSnapshot+Commands.swift */; }; @@ -3988,6 +3993,7 @@ BDA7F8504F7F487C8596D4A5 /* Workspace+RemoteTerminalRetirement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DABDC3327C24142A1EF2518 /* Workspace+RemoteTerminalRetirement.swift */; }; D7AB12048000000000000006 /* Workspace+RemoteTerminalWorkingDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB12048000000000000007 /* Workspace+RemoteTerminalWorkingDirectory.swift */; }; 7738C0037738C0037738C003 /* Workspace+RemoteTmuxControlTopology.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7738D0037738D0037738D003 /* Workspace+RemoteTmuxControlTopology.swift */; }; + A26FBBFA5C8D4A46B8FE484A /* Workspace+RemoteTmuxSessionRestore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A773BEA962C4770B78A6D73 /* Workspace+RemoteTmuxSessionRestore.swift */; }; D77340010000000000000002 /* Workspace+RemoteTmuxTabOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77340010000000000000001 /* Workspace+RemoteTmuxTabOrder.swift */; }; 961900000000000000000001 /* Workspace+RestoredPanelTitleBoundary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 961900000000000000000002 /* Workspace+RestoredPanelTitleBoundary.swift */; }; A6AC73020000000000000003 /* Workspace+RestoredStartupInputOwnership.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AC73020000000000000004 /* Workspace+RestoredStartupInputOwnership.swift */; }; @@ -6861,6 +6867,7 @@ F1936170A623DA26173CDB02 /* RemoteTmuxInputCommandCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxInputCommandCapture.swift; sourceTree = ""; }; F1936170A623DA26173CDB04 /* RemoteTmuxInputCommandCaptureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxInputCommandCaptureTests.swift; sourceTree = ""; }; 9251A0019251A0019251A001 /* RemoteTmuxKeyName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxKeyName.swift; sourceTree = ""; }; + E392ABDB4640D6D6B233FA0C /* RemoteTmuxLegacySizingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTmuxLegacySizingTests.swift"; sourceTree = ""; }; AEEF00000000000000000006 /* RemoteTmuxLoopbackForwarder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxLoopbackForwarder.swift; sourceTree = ""; }; 6732BEEF6732BEEF6732B001 /* RemoteTmuxMasterReadinessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMasterReadinessTests.swift; sourceTree = ""; }; 7738B0027738B0027738B002 /* RemoteTmuxMirrorCLIFailClosedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorCLIFailClosedTests.swift; sourceTree = ""; }; @@ -6902,6 +6909,9 @@ CE24906539C19AB10E4C1C71 /* RemoteTmuxPostAttachAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxPostAttachAction.swift; sourceTree = ""; }; AEEF00000000000000000008 /* RemoteTmuxPreviewError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxPreviewError.swift; sourceTree = ""; }; AEEF00000000000000000004 /* RemoteTmuxPreviewFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxPreviewFetcher.swift; sourceTree = ""; }; + 9D0480BA13AE6EC34FAE99D3 /* RemoteTmuxPreviewFetcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTmuxPreviewFetcherTests.swift"; sourceTree = ""; }; + 7E2944CE86EFED2DD99707AC /* RemoteTmuxPreviewIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTmuxPreviewIntegrationTests.swift"; sourceTree = ""; }; + 776C6A7112B96BA3811C7EF5 /* RemoteTmuxPreviewRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTmuxPreviewRoutingTests.swift"; sourceTree = ""; }; AEEF0000000000000000000A /* RemoteTmuxPreviewSSHOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxPreviewSSHOptions.swift; sourceTree = ""; }; AEEF00000000000000000002 /* RemoteTmuxPreviewTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxPreviewTarget.swift; sourceTree = ""; }; 78B7D1E5F15612C48E564C91 /* RemoteTmuxPreviewTargetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTmuxPreviewTargetTests.swift"; sourceTree = ""; }; @@ -7083,6 +7093,7 @@ C65930020000000000000001 /* SessionPersistencePolicy+CrashStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionPersistencePolicy+CrashStorage.swift"; sourceTree = ""; }; F6572003A1B2C3D4E5F60718 /* SessionPersistenceResumeBindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceResumeBindingTests.swift; sourceTree = ""; }; F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; + 44684C2B15A5443AA0F8E89C /* SessionRemoteTmuxWorkspaceSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionRemoteTmuxWorkspaceSnapshot.swift"; sourceTree = ""; }; 812600000000000000000004 /* SessionRemoteWorkspaceMoshRestoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionRemoteWorkspaceMoshRestoreTests.swift; sourceTree = ""; }; E30780000000000000000013 /* SessionRemoteWorkspaceSnapshot+Restore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionRemoteWorkspaceSnapshot+Restore.swift"; sourceTree = ""; }; 0A1107110000000000000023 /* SessionRestorableAgentSnapshot+Commands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionRestorableAgentSnapshot+Commands.swift"; sourceTree = ""; }; @@ -8106,6 +8117,7 @@ 2DABDC3327C24142A1EF2518 /* Workspace+RemoteTerminalRetirement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RemoteTerminalRetirement.swift"; sourceTree = ""; }; D7AB12048000000000000007 /* Workspace+RemoteTerminalWorkingDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RemoteTerminalWorkingDirectory.swift"; sourceTree = ""; }; 7738D0037738D0037738D003 /* Workspace+RemoteTmuxControlTopology.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RemoteTmuxControlTopology.swift"; sourceTree = ""; }; + 9A773BEA962C4770B78A6D73 /* Workspace+RemoteTmuxSessionRestore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RemoteTmuxSessionRestore.swift"; sourceTree = ""; }; D77340010000000000000001 /* Workspace+RemoteTmuxTabOrder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RemoteTmuxTabOrder.swift"; sourceTree = ""; }; 961900000000000000000002 /* Workspace+RestoredPanelTitleBoundary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RestoredPanelTitleBoundary.swift"; sourceTree = ""; }; A6AC73020000000000000004 /* Workspace+RestoredStartupInputOwnership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RestoredStartupInputOwnership.swift"; sourceTree = ""; }; @@ -9940,6 +9952,7 @@ 85911000000000000000000A /* TerminalLinkOpenRequest.swift */, D86862000000000000000002 /* TerminalWorkingDirectoryResolver.swift */, 85911000000000000000000C /* Workspace+TerminalLinkOpening.swift */, + 9A773BEA962C4770B78A6D73 /* Workspace+RemoteTmuxSessionRestore.swift */, C40410010000000000000020 /* FileExplorerDoubleClickActionSettings.swift */, C0DEF828300000000000002 /* GhosttyTerminalImmediateHostedStateAction.swift */, E89950010000000000000002 /* GhosttyFlashOverlayView.swift */, @@ -10845,6 +10858,7 @@ A5001611 /* SessionPersistence.swift */, A5C0A0010000000000000001 /* TerminalRecovery.swift */, 19FE2238F1F44DE98D6501DB /* SessionBrowserPanelSnapshot.swift */, + 44684C2B15A5443AA0F8E89C /* SessionRemoteTmuxWorkspaceSnapshot.swift */, A12980000000000000000002 /* SessionPanelSnapshot+TerminalTitle.swift */, 87590000000000000000000A /* MainThreadHangCaptureStore.swift */, 87590000000000000000000C /* MainThreadHangSampleRunner.swift */, @@ -12514,6 +12528,10 @@ AFCD25AD22A1CB4925E8CCD5 /* WorkspaceFocusCircuitBreakerTests.swift */, 58D6BFF0D1E9A9DD1DE18993 /* TerminalRecoveryTests.swift */, 78B7D1E5F15612C48E564C91 /* RemoteTmuxPreviewTargetTests.swift */, + 776C6A7112B96BA3811C7EF5 /* RemoteTmuxPreviewRoutingTests.swift */, + 7E2944CE86EFED2DD99707AC /* RemoteTmuxPreviewIntegrationTests.swift */, + 9D0480BA13AE6EC34FAE99D3 /* RemoteTmuxPreviewFetcherTests.swift */, + E392ABDB4640D6D6B233FA0C /* RemoteTmuxLegacySizingTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -15013,6 +15031,7 @@ A5001610 /* SessionPersistence.swift in Sources */, A74770010000000000000009 /* SessionPersistencePolicy+ConfigFrames.swift in Sources */, C65930020000000000000002 /* SessionPersistencePolicy+CrashStorage.swift in Sources */, + BF794D70F3D54A5AB7065FA8 /* SessionRemoteTmuxWorkspaceSnapshot.swift in Sources */, E30780000000000000000014 /* SessionRemoteWorkspaceSnapshot+Restore.swift in Sources */, 0A1107110000000000000022 /* SessionRestorableAgentSnapshot+Commands.swift in Sources */, 112950000000000000000004 /* SessionRestorableAgentSnapshot+ForkVerb.swift in Sources */, @@ -15788,6 +15807,7 @@ BDA7F8504F7F487C8596D4A5 /* Workspace+RemoteTerminalRetirement.swift in Sources */, D7AB12048000000000000006 /* Workspace+RemoteTerminalWorkingDirectory.swift in Sources */, 7738C0037738C0037738C003 /* Workspace+RemoteTmuxControlTopology.swift in Sources */, + A26FBBFA5C8D4A46B8FE484A /* Workspace+RemoteTmuxSessionRestore.swift in Sources */, D77340010000000000000002 /* Workspace+RemoteTmuxTabOrder.swift in Sources */, 961900000000000000000001 /* Workspace+RestoredPanelTitleBoundary.swift in Sources */, A6AC73020000000000000003 /* Workspace+RestoredStartupInputOwnership.swift in Sources */, @@ -16939,6 +16959,7 @@ F6724602A1B2C3D4E5F67246 /* RemoteTmuxHostRemoteCommandOverrideTests.swift in Sources */, F1936170A623DA26173CDB01 /* RemoteTmuxInputCommandCapture.swift in Sources */, F1936170A623DA26173CDB03 /* RemoteTmuxInputCommandCaptureTests.swift in Sources */, + A5C149469073D87DAE0AAF71 /* RemoteTmuxLegacySizingTests.swift in Sources */, 6732BEEF6732BEEF6732B002 /* RemoteTmuxMasterReadinessTests.swift in Sources */, 7738A0027738A0027738A002 /* RemoteTmuxMirrorCLIFailClosedTests.swift in Sources */, 7738A0017738A0017738A001 /* RemoteTmuxMirrorCLIObservabilityTests.swift in Sources */, @@ -16964,6 +16985,9 @@ A90070000000000000009007 /* RemoteTmuxNotificationLifecycleTests.swift in Sources */, 3ABD5AD438AF5EDB33109C3A /* RemoteTmuxNotificationOSCFilterTests.swift in Sources */, 7990A0017990A0017990A001 /* RemoteTmuxPaneSeedTransportTests.swift in Sources */, + 1CBF39BE5766FE37AA31BC5E /* RemoteTmuxPreviewFetcherTests.swift in Sources */, + 6AC03D7638CBE5A52E74C939 /* RemoteTmuxPreviewIntegrationTests.swift in Sources */, + A0B973CA6223B3684E4D84DC /* RemoteTmuxPreviewRoutingTests.swift in Sources */, 94871B514A31D99604D99603 /* RemoteTmuxPreviewTargetTests.swift in Sources */, 9007F0C19007F0C1907F0001 /* RemoteTmuxProjectedFocusInteractionTests.swift in Sources */, 7020C0DE7020C0DE7020A002 /* RemoteTmuxProxyTransportRetryTests.swift in Sources */, diff --git a/cmuxTests/AgentRestoreLiveOwnerAdmissionTests.swift b/cmuxTests/AgentRestoreLiveOwnerAdmissionTests.swift index 482eefccbc1f..446b05a0ad54 100644 --- a/cmuxTests/AgentRestoreLiveOwnerAdmissionTests.swift +++ b/cmuxTests/AgentRestoreLiveOwnerAdmissionTests.swift @@ -21,6 +21,18 @@ import Testing @MainActor @Suite("Agent restore live-owner admission", .serialized) struct AgentRestoreLiveOwnerAdmissionTests { + @Test("An unavailable process census permits bounded retry without admitting a launch") + func unavailableProcessCensusIsRetryable() throws { + let reason = try #require(TerminalController.agentRestoreIndexFailure( + .unavailable, + workspaceID: UUID(), + surfaceID: UUID(), + kind: "claude" + )) + #expect(reason.rawValue == "scan_unavailable") + #expect(reason.isRetryable) + } + enum OwnerState: Equatable, Sendable { case absent case dead @@ -140,6 +152,21 @@ struct AgentRestoreLiveOwnerAdmissionTests { defer { fixture.cleanup() } let index = fixture.index + let failure = TerminalController.agentRestoreIndexFailure( + index, + workspaceID: fixture.ownerWorkspaceID, + surfaceID: fixture.ownerSurfaceID, + kind: "claude" + ) + #expect(failure?.rawValue == "hook_store_unreadable") + #expect(failure?.isRetryable == false) + #expect(TerminalController.agentRestoreIndexFailure( + index, + workspaceID: fixture.ownerWorkspaceID, + surfaceID: fixture.ownerSurfaceID, + kind: "amp" + ) == nil) + #expect(!index.isComplete) #expect(!index.isComplete( forWorkspaceId: fixture.ownerWorkspaceID, @@ -742,6 +769,11 @@ struct AgentRestoreLiveOwnerAdmissionTests { let panelIndex = try #require(snapshot.panels.firstIndex { $0.id == sourcePanelID }) snapshot.panels[panelIndex].terminal?.agent = fixture.agent snapshot.panels[panelIndex].terminal?.wasAgentRunning = true + // The fixture replaces a shell snapshot with a running agent. Update + // its observation too so the test reaches live-owner admission. + snapshot.panels[panelIndex].terminal = snapshot.panels[panelIndex].terminal?.recordingRecovery( + surfaceID: sourcePanelID, previous: nil, freshEvidence: true + ) let index = fixture.index let restored = Workspace( diff --git a/cmuxTests/AgentSessionAutoResumeSettingsTests.swift b/cmuxTests/AgentSessionAutoResumeSettingsTests.swift index c0d1e28f7bc7..0a40869eded2 100644 --- a/cmuxTests/AgentSessionAutoResumeSettingsTests.swift +++ b/cmuxTests/AgentSessionAutoResumeSettingsTests.swift @@ -117,11 +117,9 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { XCTAssertNil(restoredWithoutAutoResume.sessionSnapshot(includeScrollback: false).panels.first?.terminal?.agent) } - /// When autoResumeAgentSessions is enabled but the agent was already exited at snapshot time - /// (wasAgentRunning == false), cmux must NOT inject the resume command on restore. - /// The session ID must still be preserved for manual resume. + /// A bound session restores even when its old process exited before the snapshot. @MainActor - func testAgentExitedBeforeSnapshotDoesNotAutoResumeEvenWhenSettingEnabled() throws { + func testAgentExitedBeforeSnapshotResumesWhenSettingEnabled() throws { try withRestoredDefaults(key: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) { let defaults = UserDefaults.standard defaults.removeObject(forKey: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) // autoResumeAgentSessions = true (default) @@ -148,8 +146,8 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { let restoredPanel = try XCTUnwrap(restored.terminalPanel(for: restoredPanelId)) let restoredInput = restoredPanel.surface.debugInitialInputMetadata() - XCTAssertFalse(restoredInput.hasInitialInput, - "must not auto-resume when agent was already exited at snapshot time") + XCTAssertTrue(restoredInput.hasInitialInput, + "the saved session binding must resume after its process exits") XCTAssertNil(restoredPanel.surface.debugInitialCommand()) XCTAssertEqual( restored.sessionSnapshot(includeScrollback: false).panels.first?.terminal?.agent?.sessionId, @@ -205,7 +203,10 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { ) restored.updatePanelShellActivityState(panelId: restoredPanelId, state: .promptIdle) - XCTAssertNil(restored.sessionSnapshot(includeScrollback: false).panels.first?.terminal?.agent) + XCTAssertEqual( + restored.sessionSnapshot(includeScrollback: false).panels.first?.terminal?.agent?.sessionId, + "codex-running-at-snapshot-session" + ) } } @@ -346,7 +347,7 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { } @MainActor - func testUnknownAgentShellStateDoesNotAutoResume() throws { + func testUnknownAgentShellStateRetainsBoundSessionRestore() throws { try withRestoredDefaults(key: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) { let defaults = UserDefaults.standard defaults.removeObject(forKey: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) // autoResumeAgentSessions = true (default) @@ -370,7 +371,7 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { let restoredPanel = try XCTUnwrap(restored.terminalPanel(for: restoredPanelId)) let restoredInput = restoredPanel.surface.debugInitialInputMetadata() - XCTAssertFalse(restoredInput.hasInitialInput) + XCTAssertTrue(restoredInput.hasInitialInput) XCTAssertEqual(snapshot.panels.first?.terminal?.recovery?.state, .unknown) XCTAssertEqual( snapshot.panels.first?.terminal?.recovery?.lastKnownAgent?.sessionID, @@ -380,7 +381,7 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { } @MainActor - func testAgentHookBindingExitedBeforeSnapshotDoesNotAutoResumeEvenWhenTrusted() throws { + func testAgentHookBindingExitedBeforeSnapshotResumesWhenTrusted() throws { try withRestoredDefaults(key: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) { let defaults = UserDefaults.standard defaults.removeObject(forKey: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) // autoResumeAgentSessions = true (default) @@ -419,8 +420,8 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { let restoredPanel = try XCTUnwrap(restored.terminalPanel(for: restoredPanelId)) let input = restoredPanel.surface.debugInitialInputMetadata() - XCTAssertFalse(input.hasInitialInput) - XCTAssertEqual(input.byteCount, 0) + XCTAssertTrue(input.hasInitialInput) + XCTAssertGreaterThan(input.byteCount, 0) XCTAssertNil(restoredPanel.surface.debugInitialCommand()) XCTAssertEqual( restored.sessionSnapshot(includeScrollback: false).panels.first?.terminal?.agent?.sessionId, @@ -548,7 +549,7 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { } @MainActor - func testAgentHookResumeBindingBecomesManualAfterStartupCommandCompletes() throws { + func testAgentHookSessionBindingSurvivesStartupCommandCompletion() throws { let defaults = UserDefaults.standard let key = AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey let previous = defaults.object(forKey: key) @@ -594,10 +595,9 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { let input = restoredPanel.surface.debugInitialInputMetadata() XCTAssertTrue(input.hasInitialInput) XCTAssertGreaterThan(input.byteCount, 0) - try assertAgentAutoResumeUsesRestoreVerb( - restoredPanel, - sessionID: "codex-binding-auto-resume-session" - ) + let restoredPanelInput = try XCTUnwrap(restoredPanel.surface.initialInput) + XCTAssertTrue(restoredPanelInput.contains("codex resume codex-binding-auto-resume-session")) + XCTAssertFalse(restoredPanelInput.contains(" cmux restore ")) XCTAssertEqual( restored.restoredAgentResumeStatesByPanelId[restoredPanelId], .awaitingAutoResumeCommand @@ -611,10 +611,23 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { restored.updatePanelShellActivityState(panelId: restoredPanelId, state: .promptIdle) let completedSnapshot = restored.sessionSnapshot(includeScrollback: false) - XCTAssertNil(completedSnapshot.panels.first?.terminal?.agent) + XCTAssertEqual( + completedSnapshot.panels.first?.terminal?.agent?.sessionId, + "codex-binding-auto-resume-session" + ) let retainedBinding = try XCTUnwrap(completedSnapshot.panels.first?.terminal?.resumeBinding) XCTAssertEqual(retainedBinding.checkpointId, "codex-binding-auto-resume-session") XCTAssertFalse(retainedBinding.allowsAutomaticResume) + + let reopened = Workspace(restorableAgentIndexProvider: { .empty }) + defer { reopened.teardownAllPanels() } + reopened.restoreSessionSnapshot(completedSnapshot) + let reopenedPanelID = try XCTUnwrap(reopened.focusedPanelId) + let reopenedPanel = try XCTUnwrap(reopened.terminalPanel(for: reopenedPanelID)) + XCTAssertTrue(reopenedPanel.surface.debugInitialInputMetadata().hasInitialInput) + let reopenedPanelInput = try XCTUnwrap(reopenedPanel.surface.initialInput) + XCTAssertTrue(reopenedPanelInput.contains("codex resume codex-binding-auto-resume-session")) + XCTAssertFalse(reopenedPanelInput.contains(" cmux restore ")) } @MainActor diff --git a/cmuxTests/AgentSessionAutoResumeSwiftTests.swift b/cmuxTests/AgentSessionAutoResumeSwiftTests.swift index 26243792cabd..f9c4dec8ac7d 100644 --- a/cmuxTests/AgentSessionAutoResumeSwiftTests.swift +++ b/cmuxTests/AgentSessionAutoResumeSwiftTests.swift @@ -57,8 +57,9 @@ struct AgentSessionAutoResumeSwiftTests { let restoredPanelID = try #require(restoredPanelIDs[sourcePanelID]) let restoredPanel = try #require(restored.terminalPanel(for: restoredPanelID)) let restoredTabID = try #require(restored.surfaceIdFromPanelId(restoredPanelID)) - let bootstrapInput = - " \(AgentRestoreLaunch.cliStartupExecutableToken) restore \(kind) \(checkpointID)\n" + let bootstrapInput = try #require(restoredPanel.surface.debugInitialInputForTesting()) + #expect(bootstrapInput.contains("CMUX_AGENT_RESTORE_LAUNCH=\(kind):\(checkpointID)")) + #expect(!bootstrapInput.contains(" cmux restore ")) let bootstrapTitle = bootstrapInput.trimmingCharacters(in: .whitespacesAndNewlines) #expect(restoredPanel.surface.debugInitialInputForTesting() == bootstrapInput) @@ -1853,7 +1854,16 @@ struct AgentSessionAutoResumeSwiftTests { ) throws { #expect(panel.surface.debugInitialCommand() == nil) let input = try #require(panel.surface.debugInitialInputForTesting()) - #expect(input == " \(AgentRestoreLaunch.cliStartupExecutableToken) restore \(kind) \(sessionID)\n") + if let binding = workspace.surfaceResumeBindingsByPanelId[panel.id], + binding.isAgentHookBinding, binding.launchFlavor == .local { + #expect(input == binding.restoreStartupInput( + repairPortableAgentExecutable: true, useLocalRestoreVerb: false + )) + #expect(input.contains(sessionID)) + #expect(!input.contains(" cmux restore ")) + } else { + #expect(input == " \(AgentRestoreLaunch.cliStartupExecutableToken) restore \(kind) \(sessionID)\n") + } let command: String if let agent = workspace.restoredAgentSnapshotForTesting(panelId: panel.id) { #expect(agent.kind.rawValue == kind) diff --git a/cmuxTests/CLICodexHookTimeoutRegressionTests.swift b/cmuxTests/CLICodexHookTimeoutRegressionTests.swift index 0b572b3f9f2d..56c8fa2e1316 100644 --- a/cmuxTests/CLICodexHookTimeoutRegressionTests.swift +++ b/cmuxTests/CLICodexHookTimeoutRegressionTests.swift @@ -911,7 +911,8 @@ struct CLICodexHookTimeoutRegressionTests { #expect(session["activePromptTurnIds"] as? [String] == ["turn-active"]) } - @Test func codexSessionStartRefreshesCompletedPriorTurn() throws { + @Test(arguments: ["completed", "idle", "running"]) + func codexSessionStartRefreshesPriorProcessTurn(priorState: String) throws { let cliPath = try bundledCLIPath() let root = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-codex-fresh-start-\(UUID().uuidString)", isDirectory: true) @@ -950,6 +951,16 @@ struct CLICodexHookTimeoutRegressionTests { try? FileManager.default.removeItem(at: root) } + // Reopening replaces the process even if the old hook record still + // contains prompt depth (including a Stop already recorded as idle). + let previousProcess = Process() + previousProcess.executableURL = URL(fileURLWithPath: "/usr/bin/true") + try previousProcess.run() + previousProcess.waitUntilExit() + let previousPID = Int(previousProcess.processIdentifier) + let resumedPID = Int(ProcessInfo.processInfo.processIdentifier) + let hasStalePromptDepth = priorState != "completed" + let priorRuntimeStatus = priorState == "running" ? "running" : "idle" let now = Date().timeIntervalSince1970 let store: [String: Any] = [ "version": 1, @@ -959,9 +970,11 @@ struct CLICodexHookTimeoutRegressionTests { "workspaceId": workspaceId, "surfaceId": surfaceId, "cwd": root.path, - "pid": 1, - "agentLifecycle": "idle", - "runtimeStatus": "idle", + "pid": previousPID, + "agentLifecycle": priorRuntimeStatus, + "runtimeStatus": priorRuntimeStatus, + "activePromptDepth": hasStalePromptDepth ? 1 : 0, + "activePromptTurnIds": hasStalePromptDepth ? ["turn-done"] : [], "lastPromptTurnId": "turn-done", "terminalPromptTurnIds": ["turn-done"], "startedAt": now, @@ -977,7 +990,7 @@ struct CLICodexHookTimeoutRegressionTests { surfaceId: surfaceId, connectionLimit: 8, processBinding: CodexHookMockProcessBinding( - processID: 4242, + processID: resumedPID, workspaceID: workspaceId, surfaceID: surfaceId ) @@ -996,7 +1009,7 @@ struct CLICodexHookTimeoutRegressionTests { "CMUX_AGENT_HOOK_STATE_DIR": root.path, "CMUX_CLI_SENTRY_DISABLED": "1", "CODEX_HOME": codexHome.path, - "CMUX_CODEX_PID": "4242", + "CMUX_CODEX_PID": String(resumedPID), ], standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"SessionStart"}"#, timeout: 5 @@ -1018,6 +1031,9 @@ struct CLICodexHookTimeoutRegressionTests { let session = try #require(sessions[sessionId] as? [String: Any]) #expect(session["agentLifecycle"] as? String == "unknown") #expect(session["runtimeStatus"] as? String == "running") + #expect(session["pid"] as? Int == resumedPID) + #expect(session["activePromptDepth"] == nil) + #expect(session["activePromptTurnIds"] == nil) #expect(session["lastPromptTurnId"] == nil) #expect(session["terminalPromptTurnIds"] as? [String] == ["turn-done"]) @@ -1034,7 +1050,7 @@ struct CLICodexHookTimeoutRegressionTests { "CMUX_SURFACE_ID": surfaceId, "CMUX_AGENT_HOOK_STATE_DIR": root.path, "CMUX_CLI_SENTRY_DISABLED": "1", - "CMUX_CODEX_PID": "1", + "CMUX_CODEX_PID": String(previousPID), ], standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-done","cwd":"\#(root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"UserPromptSubmit","prompt":"late"}"#, timeout: 5 diff --git a/cmuxTests/CompletedRestoredAgentGenerationTests.swift b/cmuxTests/CompletedRestoredAgentGenerationTests.swift index 092d617901bf..5c8abcd8b612 100644 --- a/cmuxTests/CompletedRestoredAgentGenerationTests.swift +++ b/cmuxTests/CompletedRestoredAgentGenerationTests.swift @@ -90,7 +90,7 @@ struct CompletedRestoredAgentGenerationTests { restorableAgentIndex: fixture.index ) - #expect(snapshot.panels.first?.terminal?.agent == nil) + #expect(snapshot.panels.first?.terminal?.agent?.sessionId == sessionId) #expect(workspace.restoredAgentResumeStatesByPanelId[panelId] == .completedAgentExit) #expect(workspace.restoredAgentSnapshotsByPanelId[panelId]?.sessionId == sessionId) #expect(!workspace.allowsAgentContinuation(forPanelId: panelId)) @@ -263,7 +263,7 @@ struct CompletedRestoredAgentGenerationTests { restorableAgentIndex: fixture.index ) - #expect(snapshot.panels.first?.terminal?.agent == nil) + #expect(snapshot.panels.first?.terminal?.agent?.sessionId == completedSessionId) #expect(workspace.restoredAgentResumeStatesByPanelId[panelId] == .completedAgentExit) #expect(workspace.restoredAgentSnapshotsByPanelId[panelId]?.sessionId == completedSessionId) #expect(!workspace.allowsAgentContinuation(forPanelId: panelId)) diff --git a/cmuxTests/RemoteTmuxLegacySizingTests.swift b/cmuxTests/RemoteTmuxLegacySizingTests.swift new file mode 100644 index 000000000000..f1d8c39c99f2 --- /dev/null +++ b/cmuxTests/RemoteTmuxLegacySizingTests.swift @@ -0,0 +1,304 @@ +import CmuxRemoteSession +import AppKit +import CmuxTerminal +import GhosttyKit +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Runs the production sizing commands against an isolated real tmux server. +/// CMUX_LEGACY_SIZING_TMUX may point at a tmux executable or an SSH adapter +/// forwarding its argv to an older server binary. Every process uses a unique +/// socket, so even the remote variant cannot resize or stop a user's sessions. +@MainActor +@Suite(.serialized) +struct RemoteTmuxLegacySizingTests { + nonisolated private static var executable: String? { + ProcessInfo.processInfo.environment["CMUX_LEGACY_SIZING_TMUX"] + ?? ["/opt/homebrew/bin/tmux", "/usr/local/bin/tmux", "/usr/bin/tmux"] + .first { FileManager.default.isExecutableFile(atPath: $0) } + } + + @Test(.enabled(if: executable != nil), .timeLimit(.minutes(1))) + func latestInteractionOwnsSizeWithoutBackgroundReplayStealingIt() async throws { + let executable = try #require(Self.executable) + let prefix = ["-L", "cmux-sizing-\(UUID().uuidString.lowercased())", "-f", "/dev/null"] + func tmux(_ arguments: [String]) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = prefix + arguments + process.standardInput = FileHandle.nullDevice + let output = Pipe() + process.standardOutput = output + process.standardError = output + try process.run() + output.fileHandleForWriting.closeFile() + let result = String(decoding: output.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + process.waitUntilExit() + try #require(process.terminationStatus == 0, "\(arguments): \(result)") + return result.trimmingCharacters(in: .whitespacesAndNewlines) + } + func width(_ window: Int) throws -> String { + try tmux(["display-message", "-p", "-t", "@\(window)", "#{window_width}"]) + } + + print("Latest interaction sizing fixture: \(try tmux(["-V"]))") + _ = try tmux(["new-session", "-d", "-s", "sizing", "-x", "188", "-y", "50", "sleep 90"]) + defer { _ = try? tmux(["kill-server"]) } + _ = try tmux(["set-option", "-g", "window-size", "latest"]) + let first = try #require(Int(try tmux(["display-message", "-p", "-t", "sizing", "#{window_id}"]).dropFirst())) + let second = try #require(Int(try tmux(["new-window", "-d", "-P", "-F", "#{window_id}", "-t", "sizing", "sleep 90"]).dropFirst())) + let client = try Client(executable: executable, arguments: prefix) + defer { client.stop() } + try await client.ready() + let peer = try Client(executable: executable, arguments: prefix) + defer { peer.stop() } + try await peer.ready() + peer.connection.setClientSize(columns: 188, rows: 50) + await peer.connection.clientSizeDebounceTask?.value + try await peer.command("select-window -t @\(first)") + try await peer.command("refresh-client -C 188x50") + try #require(try width(first) == "188") + + client.connection.setWindowSize(windowId: second, columns: 99, rows: 36) + client.connection.setWindowSize(windowId: first, columns: 139, rows: 36) + client.connection.notePerWindowSizeRejected() + await client.connection.clientSizeDebounceTask?.value + try await client.fence() + #expect(client.connection.windowSizeDebounceTasks.isEmpty) + // Attach, hidden geometry and capability fallback are not user actions. + #expect(try width(first) == "188") + #expect(try tmux(["show-options", "-w", "-v", "-t", "@\(first)", "window-size"]) == "latest") + + let firstPane = try #require(Int(try tmux(["display-message", "-p", "-t", "@\(first)", "#{pane_id}"]).dropFirst())) + let secondPane = try #require(Int(try tmux(["display-message", "-p", "-t", "@\(second)", "#{pane_id}"]).dropFirst())) + for (pane, window, columns) in [(firstPane, first, 139), (secondPane, second, 99), (firstPane, first, 139)] { + #expect(client.connection.sendKeys(paneId: pane, data: Data("x".utf8))) + try await client.fence() + #expect(try width(window) == String(columns)) + #expect(try tmux(["display-message", "-p", "-t", "sizing", "#{window_id}"]) == "@\(window)") + } + + // A wide peer must reclaim the full grid even while cmux stays attached. + try await peer.command("select-window -t @\(first)") + try await peer.command("refresh-client -C 188x50") + try await client.fence() + #expect(try width(first) == "188") + client.connection.replayRecordedSizeClaims() + client.connection.setWindowSize(windowId: first, columns: 120, rows: 36) + await client.connection.clientSizeDebounceTask?.value + try await client.fence() + #expect(try width(first) == "188", "Background replay/geometry must not reclaim ownership") + #expect(client.connection.sendKey(paneId: firstPane, key: RemoteTmuxKeyName(rawName: "Enter")!)) + try await client.fence() + #expect(try width(first) == "120") + + // The production foreground gate transfers sizing without typing. + // A visible but unfocused mirror, or a hidden tab, cannot take it. + let layout = try #require(client.connection.windowsByID[first]?.layout) + var explicitInputCount = 0 + let mirror = RemoteTmuxWindowMirror( + windowId: first, panelId: UUID(), connection: client.connection, + layout: layout, makePanel: { _ in + let surface = TerminalSurface(tabId: UUID(), context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: nil, ioMode: .manualMirror) + surface.onExplicitInput = { explicitInputCount += 1 } + return TerminalPanel(workspaceId: UUID(), surface: surface) + } + ) + defer { mirror.teardown() } + let window = KeyStatusTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled], backing: .buffered, defer: false + ) + window.orderFront(nil) + defer { window.orderOut(nil) } + let probe = MirrorHostProbeView(frame: window.contentLayoutRect) + probe.mirror = mirror + window.contentView?.addSubview(probe) + mirror.hostProbeView = probe + mirror.isVisibleForSizing = true + mirror.isOuterFocusedForSizing = false + try await peer.command("select-window -t @\(first)") + try await peer.command("refresh-client -C 188x50") + mirror.activateSizingIfForeground(applicationIsActive: true) + try await client.fence() + #expect(try width(first) == "188") + // Cursor-position replies travel through manual I/O without activation. + #expect(client.connection.sendKeys(paneId: firstPane, data: Data("\u{1B}[1;1R".utf8), claimsSizing: false)) + try await client.fence() + #expect(try width(first) == "188") + let panel = try #require(mirror.panel(forPane: firstPane)) + panel.surface.didAcceptExplicitInput() + try await client.fence() + #expect(try width(first) == "120") + #expect(explicitInputCount == 1) + try await peer.command("select-window -t @\(first)") + try await peer.command("refresh-client -C 188x50") + mirror.isOuterFocusedForSizing = true + mirror.activateSizingIfForeground(applicationIsActive: true) + try await client.fence() + #expect(try width(first) == "120") + let otherOwner = UUID() + _ = client.connection.activateWindowSizing(windowId: first, ownerId: otherOwner) + client.connection.deactivateWindowSizing(windowId: first, ownerId: mirror.panelId) + try await client.fence() + #expect(try width(first) == "120", "An old view cannot release a new view's ownership") + client.connection.deactivateWindowSizing(windowId: first) + try await peer.command("select-window -t @\(first)") + try await peer.command("refresh-client -C 188x50") + mirror.isVisibleForSizing = false + mirror.activateSizingIfForeground(applicationIsActive: true) + try await client.fence() + #expect(try width(first) == "188") + + // The workspace resolves projected panes through its SESSION owner. + // Focus/text/key control APIs must claim sizing on that production route. + let manager = TabManager() + let workspace = manager.addWorkspace(select: false, autoWelcomeIfNeeded: false) + workspace.isRemoteTmuxMirror = true + let sessionMirror = RemoteTmuxSessionMirror( + host: client.connection.host, sessionName: "sizing", + connection: client.connection, tabManager: manager, workspace: workspace + ) + client.connection.setWindowSize(windowId: first, columns: 120, rows: 36) + client.connection.setWindowSize(windowId: second, columns: 99, rows: 36) + try await peer.command("select-window -t @\(first)") + try await peer.command("refresh-client -C 188x50") + #expect(sessionMirror.controlFocus(pane: secondPane, completion: { _ in })) + try await client.fence() + #expect(try width(second) == "99", "Session-owned focus must select and resize the target window") + #expect(try tmux(["display-message", "-p", "-t", "sizing", "#{window_id}"]) == "@\(second)") + #expect(sessionMirror.sendInput(toPane: firstPane, text: "x")) + try await client.fence() + #expect(try width(first) == "120", "Explicit control API text must claim sizing") + #expect(sessionMirror.sendKey(toPane: secondPane, name: "Enter") == .sent) + try await client.fence() + #expect(try width(second) == "99", "Explicit control API keys must claim sizing") + sessionMirror.detachObserver() + client.connection.setWindowSize(windowId: first, columns: 120, rows: 36) + client.connection.setWindowSize(windowId: second, columns: 99, rows: 36) + try await peer.command("select-window -t @\(first)") + try await peer.command("refresh-client -C 188x50") + + // Reconnect creates a new control client; replay must remain passive. + let replacement = try Client(executable: executable, arguments: prefix) + defer { replacement.stop() } + try await replacement.ready() + replacement.connection.setWindowSize(windowId: first, columns: 110, rows: 36) + replacement.connection.notePerWindowSizeRejected() + replacement.connection.replayRecordedSizeClaims() + await replacement.connection.clientSizeDebounceTask?.value + try await replacement.fence() + #expect(try width(first) == "188") + #expect(replacement.connection.sendKeys(paneId: firstPane, data: Data("x".utf8))) + try await replacement.fence() + #expect(try width(first) == "110") + try await peer.command("select-window -t @\(first)") + try await peer.command("refresh-client -C 188x50") + try await replacement.command("detach-client") + try await client.fence() + #expect(try width(first) == "188") + + // A window can close between a focus event and server execution. Each + // command keeps its own reply, so the next fence still completes. + _ = try tmux(["kill-window", "-t", "@\(second)"]) + _ = client.connection.recordWindowSizeClaim(windowId: second, columns: 99, rows: 36) + #expect(client.connection.activateWindowSizing(windowId: second)) + try await client.fence() + #expect(try width(first) == "188") + + // This fixture has no transport respawn after close/seed recovery. + // Judge the closed-target batch by its own writer's fence above; + // peer handoff and detach were already checked before closing it. + #expect(try tmux(["show-options", "-g", "-v", "window-size"]) == "latest") + } + + @MainActor + private final class Client { + let connection = RemoteTmuxControlConnection( + host: RemoteTmuxHost(destination: "sizing-fixture"), sessionName: "sizing" + ) + private let process = Process() + private let writer: RemoteTmuxControlPipeWriter + private let reader: RemoteTmuxProcessOutputReader + private var readTask: Task? + private let topology: AsyncStream + private let topologyContinuation: AsyncStream.Continuation + + init(executable: String, arguments: [String]) throws { + let stream = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + topology = stream.stream + topologyContinuation = stream.continuation + let input = Pipe(), output = Pipe() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + ["-C", "attach-session", "-t", "sizing"] + process.standardInput = input + process.standardOutput = output + process.standardError = FileHandle.nullDevice + writer = RemoteTmuxControlPipeWriter( + handle: input.fileHandleForWriting, label: "legacy-sizing-test-input", + maxPendingBytes: 1 << 20, onFailure: {} + ) + reader = RemoteTmuxProcessOutputReader( + label: "legacy-sizing-test-output", maxPendingChunks: 128, + maxPendingBytes: 1 << 20, onOverflow: {} + ) + connection.installStdinWriterForTesting(writer) + connection.handleMessageForTesting(.enter) + connection.pendingAttachRedrawKick = false + _ = connection.addObserver(onTopologyChanged: { stream.continuation.yield(()) }) + try process.run() + output.fileHandleForWriting.closeFile() + input.fileHandleForReading.closeFile() + reader.attach(to: output.fileHandleForReading) + readTask = Task { [connection, reader] in + var parser = RemoteTmuxControlStreamParser() + for await data in reader.stream { + for message in parser.feed(data) { + if case let .commandResult(number, lines, true) = message { + print("Sizing fixture error #\(number): \(lines); pending=\(String(describing: connection.pendingCommands.first))") + } + connection.handleMessageForTesting(message) + } + reader.release(data) + } + } + } + + func ready() async throws { + if connection.windowsByID.isEmpty { + for await _ in topology where !connection.windowsByID.isEmpty { break } + } + try #require(!connection.windowsByID.isEmpty) + try await fence() + } + + func command(_ text: String) async throws { + print("Sizing fixture command: \(text)") + let succeeded = await withCheckedContinuation { continuation in + if !connection.sendTracked(text, completion: { continuation.resume(returning: $0) }) { + continuation.resume(returning: false) + } + } + try #require(succeeded, "tmux command failed: \(text); state=\(connection.connectionState)") + } + + func fence() async throws { + try await command("display-message -p cmux-sizing-fence") + } + + func stop() { + connection.stop() + writer.close() + reader.close() + readTask?.cancel() + topologyContinuation.finish() + if process.isRunning { process.terminate() } + } + } +} diff --git a/cmuxTests/RemoteTmuxPreviewFetcherTests.swift b/cmuxTests/RemoteTmuxPreviewFetcherTests.swift new file mode 100644 index 000000000000..9983b65d9ffc --- /dev/null +++ b/cmuxTests/RemoteTmuxPreviewFetcherTests.swift @@ -0,0 +1,68 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite struct RemoteTmuxPreviewFetcherTests { + @Test(arguments: ["available", "full", "missing", "full-during-download"]) + func fileReadsSurviveUnavailableMuxChannels(master: String) async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appendingPathComponent("训练 ' curves.png") + let bytes = Data([0x89, 0x50, 0x4e, 0x47, 0, 0xff, 0x0a, 0x0d]) + Data(repeating: 0xab, count: 140_000) + try bytes.write(to: source) + let host = RemoteTmuxHost(destination: "preview-\(UUID().uuidString).invalid", port: 2207, identityFile: "/test/key") + let shim = directory.appendingPathComponent("ssh") + // Model OpenSSH's mux refusal and normal connection fallback, then + // execute the actual generated remote command in an isolated directory. + // Real OpenSSH saturation is also covered by the opt-in live check. + let script = """ + #!/bin/sh + proxy= normal_master= port= identity= batch= + while [ "$#" -gt 0 ]; do + case "$1" in + -o) + case "$2" in + ProxyCommand=*) proxy=${2#ProxyCommand=} ;; + ControlPath=\(host.controlSocketPath)) normal_master=yes ;; + BatchMode=yes) batch=yes ;; + esac + shift 2 ;; + -p) port=$2; shift 2 ;; + -i) identity=$2; shift 2 ;; + --) shift; break ;; + *) shift ;; + esac + done + [ "$1" = '\(host.destination)' ] && [ "$port" = 2207 ] && [ "$identity" = /test/key ] && [ "$batch" = yes ] || exit 97 + shift + mode=\(master) + if [ "$mode" = full-during-download ]; then + case "$1" in *'cat --'*) mode=full ;; *) mode=available ;; esac + fi + if [ "$mode" = available ]; then + # An authenticated master must still work without fresh-login access. + [ "$normal_master" = yes ] || { echo 'Permission denied (publickey).' >&2; exit 255; } + elif [ "$proxy" = /usr/bin/false ]; then + echo 'mux_client_request_session: session request failed: Session open refused by peer' >&2 + echo 'Connection closed by UNKNOWN port 65535' >&2 + exit 255 + fi + exec /bin/sh -c "$1" + """ + try script.write(to: shim, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: shim.path) + let fetcher = RemoteTmuxPreviewFetcher(sshExecutablePath: shim.path) + for (path, cwd) in [(source.path, "/absent-directory"), (source.lastPathComponent, directory.path)] { + let downloaded = try await fetcher.fetch(path: path, cwd: cwd, host: host) + defer { try? FileManager.default.removeItem(at: downloaded) } + #expect(try Data(contentsOf: downloaded) == bytes) + } + #expect(try await fetcher.remoteHome(host: host) == FileManager.default.homeDirectoryForCurrentUser.path) + } +} diff --git a/cmuxTests/RemoteTmuxPreviewIntegrationTests.swift b/cmuxTests/RemoteTmuxPreviewIntegrationTests.swift new file mode 100644 index 000000000000..25ead76b5ac3 --- /dev/null +++ b/cmuxTests/RemoteTmuxPreviewIntegrationTests.swift @@ -0,0 +1,194 @@ +import CryptoKit +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Opt-in live SSH verification. The fixture contains connection coordinates and +/// expected artifact metadata, never credentials. Session-creating checks own +/// their fixtures; the busy-master probe only reads an existing connection. +@MainActor +@Suite(.serialized) +struct RemoteTmuxPreviewIntegrationTests { + private struct Fixture: Decodable { + var destination: String + var port: Int? + var identityFile: String? + var imagePath: String + var imageSHA256: String + var webURL: String + var pageMarker: String + } + + @Test( + .enabled(if: ProcessInfo.processInfo.environment["CMUX_LIVE_PTMUX_BUSY_FIXTURE"] != nil), + .timeLimit(.minutes(1)) + ) + func saturatedExistingMasterStillReadsTheRealImage() async throws { + let path = try #require(ProcessInfo.processInfo.environment["CMUX_LIVE_PTMUX_BUSY_FIXTURE"]) + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: URL(fileURLWithPath: path))) + let host = RemoteTmuxHost(destination: fixture.destination, port: fixture.port, identityFile: fixture.identityFile) + // Read-only opt-in probe of an already busy master. Do not attach, + // detach, or close it: the live terminal sessions belong to the user. + let probe = Process() + probe.executableURL = URL(fileURLWithPath: RemoteTmuxHost.defaultSSHExecutablePath()) + probe.arguments = RemoteTmuxPreviewSSHOptions(host: host).arguments + ["--", host.destination, "true"] + let stderr = Pipe() + probe.standardInput = FileHandle.nullDevice + probe.standardOutput = FileHandle.nullDevice + probe.standardError = stderr + try probe.run() + stderr.fileHandleForWriting.closeFile() + let detail = String(decoding: stderr.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + probe.waitUntilExit() + try #require(probe.terminationStatus == 255) + try #require(detail.contains("Session open refused by peer")) + + let fetcher = RemoteTmuxPreviewFetcher() + let directory = (fixture.imagePath as NSString).deletingLastPathComponent + let filename = (fixture.imagePath as NSString).lastPathComponent + for (path, cwd) in [(fixture.imagePath, "/absent-directory"), (filename, directory)] { + let downloaded = try await fetcher.fetch(path: path, cwd: cwd, host: host) + defer { try? FileManager.default.removeItem(at: downloaded) } + #expect(SHA256.hash(data: try Data(contentsOf: downloaded)) + .map { String(format: "%02x", $0) }.joined() == fixture.imageSHA256) + } + #expect(try await fetcher.remoteHome(host: host).hasPrefix("/")) + } + + @Test( + .enabled(if: ProcessInfo.processInfo.environment["CMUX_LIVE_PTMUX_FIXTURE"] != nil), + .timeLimit(.minutes(1)) + ) + func terminalLinkRequestsFetchTheRealImageAndForwardTheRealWebService() async throws { + let path = try #require(ProcessInfo.processInfo.environment["CMUX_LIVE_PTMUX_FIXTURE"]) + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: URL(fileURLWithPath: path))) + let host = RemoteTmuxHost(destination: fixture.destination, port: fixture.port, identityFile: fixture.identityFile) + let session = "cmux-preview-check-\(UUID().uuidString.lowercased())" + let previous = AppDelegate.shared + let delegate = AppDelegate() + AppDelegate.shared = delegate + let manager = TabManager(autoWelcomeIfNeeded: false) + let window = delegate.registerMainWindowContextForTesting(tabManager: manager) + let controller = delegate.remoteTmuxController + let transport = controller.transport(for: host) + defer { + controller.detachAll() + delegate.unregisterMainWindowContextForTesting(windowId: window) + AppDelegate.shared = previous + } + + let imageDirectory = (fixture.imagePath as NSString).deletingLastPathComponent + let created = try await transport.runTmux([ + "new-session", "-d", "-s", session, "-c", imageDirectory, "sleep 90" + ]) + try #require(created.exitCode == 0) + do { + try await verifyLinks(fixture: fixture, host: host, session: session, manager: manager, controller: controller) + } catch { + _ = try? await transport.runTmux(["kill-session", "-t", "=\(session)"]) + throw error + } + let cleaned = try await transport.runTmux(["kill-session", "-t", "=\(session)"]) + #expect(cleaned.exitCode == 0) + } + + private func verifyLinks( + fixture: Fixture, host: RemoteTmuxHost, session: String, + manager: TabManager, controller: RemoteTmuxController + ) async throws { + try await controller.ensureControlMasterReadyForBurst(host: host) + try #require(try controller.mirrorSession(host: host, sessionName: session, into: manager)) + let mirror = try #require(controller.sessionMirror(host: host, sessionName: session)) + let workspace = try #require(mirror.mirroredWorkspace) + let connection = mirror.connection + let (changes, continuation) = AsyncStream.makeStream() + let observer = connection.addObserver( + onTopologyChanged: { continuation.yield(()) }, + onExit: { continuation.finish() } + ) + defer { connection.removeObserver(observer); continuation.finish() } + try #require(await connection.waitUntilConnected()) + if mirror.paneSurfaceEntries().isEmpty { + for await _ in changes where !mirror.paneSurfaceEntries().isEmpty { break } + } + let entry = try #require(mirror.paneSurfaceEntries().first) + let source = try #require((entry["surface_id"] as? String).flatMap(UUID.init(uuidString:))) + // An absolute path must still download when a pane's cached cwd no + // longer exists. Exercise the real SSH command, before opening a UI. + let missingDirectory = "/__cmux_preview_missing_\(UUID().uuidString)" + let downloaded = try await controller.previewFetcher.fetch( + path: fixture.imagePath, cwd: missingDirectory, host: host + ) + defer { try? FileManager.default.removeItem(at: downloaded) } + #expect(SHA256.hash(data: try Data(contentsOf: downloaded)) + .map { String(format: "%02x", $0) }.joined() == fixture.imageSHA256) + + let location = try #require(workspace.remoteTmuxControlPane(surfaceID: source)) + let windowMirror = try #require(location.windowMirror) + windowMirror.updatePaneCwd(paneId: location.pane.tmuxPaneID, path: missingDirectory) + let terminalPane = try #require(workspace.remoteTmuxWindowsPaneId()) + let initialWindows = connection.windowOrder + let subscription = CmuxEventBus.shared.subscribe(afterSequence: nil, names: ["surface.created"], categories: []).subscription + defer { CmuxEventBus.shared.unsubscribe(subscription) } + + #expect(TerminalLinkOpenCoordinator().open(TerminalLinkOpenRequest( + rawValue: fixture.imagePath, sourceWorkspaceId: workspace.id, + sourcePanelId: source, workingDirectory: nil, focus: false + ))) + let imageID = try await nextCreatedSurface(in: workspace, subscription: subscription, kind: "file_preview") + let image = try #require(workspace.panels[imageID] as? FilePreviewPanel) + let data = try Data(contentsOf: URL(fileURLWithPath: image.filePath)) + let digest = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + #expect(digest == fixture.imageSHA256) + #expect(workspace.paneId(forPanelId: imageID) != terminalPane) + + // Click immediately after a remote cd, before the directory cache has + // caught up, and also before the first directory report has arrived. + let filename = (fixture.imagePath as NSString).lastPathComponent + for cachedDirectory in [missingDirectory, nil] as [String?] { + windowMirror.cwdByPaneId[location.pane.tmuxPaneID] = cachedDirectory + #expect(TerminalLinkOpenCoordinator().open(TerminalLinkOpenRequest( + rawValue: "./" + filename, sourceWorkspaceId: workspace.id, + sourcePanelId: source, workingDirectory: "/unrelated/local/directory", focus: false + ))) + let relativeID = try await nextCreatedSurface(in: workspace, subscription: subscription, kind: "file_preview") + let relativeImage = try #require(workspace.panels[relativeID] as? FilePreviewPanel) + #expect(SHA256.hash(data: try Data(contentsOf: URL(fileURLWithPath: relativeImage.filePath))) + .map { String(format: "%02x", $0) }.joined() == fixture.imageSHA256) + #expect(workspace.paneId(forPanelId: relativeID) == workspace.paneId(forPanelId: imageID)) + } + + #expect(TerminalLinkOpenCoordinator().open(TerminalLinkOpenRequest( + rawValue: fixture.webURL, sourceWorkspaceId: workspace.id, + sourcePanelId: source, workingDirectory: nil, focus: false + ))) + let browserID = try await nextCreatedSurface(in: workspace, subscription: subscription, kind: "browser") + let mapping = try #require(workspace.remoteTmuxPreviewURLsByPanelId[browserID]) + #expect(mapping.remote.absoluteString == fixture.webURL) + let (html, response) = try await URLSession.shared.data(from: mapping.local) + #expect((response as? HTTPURLResponse)?.statusCode == 200) + #expect(String(decoding: html, as: UTF8.self).contains(fixture.pageMarker)) + #expect(workspace.paneId(forPanelId: imageID) == workspace.paneId(forPanelId: browserID)) + #expect(workspace.bonsplitController.allPaneIds.count == 2) + #expect(connection.windowOrder == initialWindows) + let snapshot = workspace.sessionSnapshot(includeScrollback: false) + #expect(snapshot.remoteTmux?.browserURLs?[browserID.uuidString] == fixture.webURL) + } + + private func nextCreatedSurface( + in workspace: Workspace, subscription: CmuxEventSubscription, kind: String + ) async throws -> UUID { + while let event = await subscription.nextAsync() { + guard event["workspace_id"] as? String == workspace.id.uuidString, + let payload = event["payload"] as? [String: Any], payload["kind"] as? String == kind, + let id = (event["surface_id"] as? String).flatMap(UUID.init(uuidString:)) else { continue } + return id + } + throw NSError(domain: "RemoteTmuxPreviewIntegrationTests", code: 1) + } +} diff --git a/cmuxTests/RemoteTmuxPreviewRoutingTests.swift b/cmuxTests/RemoteTmuxPreviewRoutingTests.swift new file mode 100644 index 000000000000..0e589b13d0d4 --- /dev/null +++ b/cmuxTests/RemoteTmuxPreviewRoutingTests.swift @@ -0,0 +1,111 @@ +import AppKit +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite("Remote tmux preview routing", .serialized) +struct RemoteTmuxPreviewRoutingTests { + @Test func terminalLinksShareAPreviewPaneOutsideTheTmuxWindows() throws { + let harness = try RemoteTmuxMirrorCLIObservabilityTests.Harness(connectedTransport: true) + defer { harness.tearDown() } + let workspace = harness.workspace + let source = try #require(harness.mirror.panel(forPane: 11)) + let terminalPane = try #require(workspace.paneId(forPanelId: harness.outerPanelID)) + let originalTabs = workspace.bonsplitController.tabs(inPane: terminalPane).map(\.id) + let originalFocus = workspace.focusedPanelId + let originalMirrorPanes = harness.mirror.paneIDsInOrder + + #expect(workspace.openTerminalBrowserLink( + url: URL(string: "http://127.0.0.1:6013/")!, sourcePanelId: source.id, focus: false + )) + #expect(workspace.openTerminalBrowserLink( + url: URL(string: "http://127.0.0.1:6013/#scalars")!, sourcePanelId: source.id, focus: false + )) + + let browsers = workspace.panels.values.compactMap { $0 as? BrowserPanel } + #expect(browsers.count == 2) + #expect(workspace.bonsplitController.allPaneIds.count == 2) + #expect(workspace.bonsplitController.tabs(inPane: terminalPane).map(\.id) == originalTabs) + #expect(workspace.focusedPanelId == originalFocus) + #expect(harness.mirror.paneIDsInOrder == originalMirrorPanes) + let previewPane = try #require(browsers.first.flatMap { workspace.paneId(forPanelId: $0.id) }) + #expect(previewPane != terminalPane) + #expect(browsers.allSatisfy { workspace.paneId(forPanelId: $0.id) == previewPane }) + + // A remote window arriving while a preview is focused must stay in the + // tmux tab strip. Preview tabs also reorder locally, without tmux RPCs. + workspace.bonsplitController.focusPane(previewPane) + let nextWindow = try #require(workspace.addRemoteTmuxDisplayPane( + remotePaneId: 99, title: "next window", onInput: { _ in } + )) + #expect(workspace.paneId(forPanelId: nextWindow.id) == terminalPane) + #expect(workspace.reorderSurface(panelId: browsers[0].id, toIndex: 1, focus: false)) + workspace.setPanelPinned(panelId: browsers[0].id, pinned: true) + #expect(workspace.isPanelPinned(browsers[0].id)) + let remoteTab = try #require(workspace.surfaceIdFromPanelId(nextWindow.id)) + #expect(!workspace.bonsplitController.moveTab(remoteTab, toPane: previewPane)) + #expect(!workspace.splitTabBar( + workspace.bonsplitController, shouldSplitPane: terminalPane, orientation: .horizontal + )) + } + + @Test func browserCreationEntryPointsKeepTheTmuxStripAndCanReopenAfterClose() throws { + let harness = try RemoteTmuxMirrorCLIObservabilityTests.Harness() + defer { harness.tearDown() } + let workspace = harness.workspace + let terminalPane = try #require(workspace.paneId(forPanelId: harness.outerPanelID)) + let first = try #require(workspace.newBrowserSurface( + inPane: terminalPane, url: URL(string: "about:blank"), focus: false + )) + #expect(workspace.paneId(forPanelId: first.id) != terminalPane) + #expect(workspace.closePanel(first.id, force: true)) + #expect(workspace.bonsplitController.allPaneIds.count == 1) + let second = try #require(workspace.newBrowserSplit( + from: harness.outerPanelID, orientation: .horizontal, + url: URL(string: "about:blank"), focus: false + )) + #expect(workspace.paneId(forPanelId: second.id) != terminalPane) + #expect(workspace.bonsplitController.allPaneIds.count == 2) + } + + @Test func downloadedImagePreviewDoesNotRequestATmuxSplit() throws { + let harness = try RemoteTmuxMirrorCLIObservabilityTests.Harness(connectedTransport: true) + defer { harness.tearDown() } + let workspace = harness.workspace + let terminalPane = try #require(workspace.paneId(forPanelId: harness.outerPanelID)) + let file = FileManager.default.temporaryDirectory.appendingPathComponent("tmux-preview-\(UUID()).png") + try Data(base64Encoded: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a2ioAAAAASUVORK5CYII=")!.write(to: file) + defer { try? FileManager.default.removeItem(at: file) } + + let preview = workspace.splitPaneWithFilePreview( + targetPane: terminalPane, orientation: .horizontal, insertFirst: false, filePath: file.path + ) + #expect(preview != nil) + #expect(workspace.bonsplitController.allPaneIds.count == 2) + #expect(workspace.paneId(forPanelId: harness.outerPanelID) == terminalPane) + if let preview { #expect(workspace.paneId(forPanelId: preview.id) != terminalPane) } + let writer = try #require(harness.controlWriter) + writer.close() + let pipe = try #require(harness.controlPipe) + let commands = String(decoding: try pipe.fileHandleForReading.readToEnd() ?? Data(), as: UTF8.self) + #expect(!commands.split(separator: "\n").contains { $0.hasPrefix("split-window ") }) + } + + @Test func fileContextIdentifiesTheClickedRemotePane() throws { + let harness = try RemoteTmuxMirrorCLIObservabilityTests.Harness() + defer { harness.tearDown() } + harness.mirror.updatePaneCwd(paneId: 11, path: "/srv/artifacts/run") + harness.mirror.updatePaneCwd(paneId: 22, path: "/srv/other") + let source = try #require(harness.mirror.panel(forPane: 11)) + let context = try #require(harness.workspace.remoteTmuxPreviewContext(for: source.id)) + #expect(context.paneId == 11) + let other = try #require(harness.mirror.panel(forPane: 22)) + #expect(harness.workspace.remoteTmuxPreviewContext(for: other.id)?.paneId == 22) + } +} diff --git a/cmuxTests/RemoteTmuxPreviewTargetTests.swift b/cmuxTests/RemoteTmuxPreviewTargetTests.swift index ce704ab878fc..b727d811f7fd 100644 --- a/cmuxTests/RemoteTmuxPreviewTargetTests.swift +++ b/cmuxTests/RemoteTmuxPreviewTargetTests.swift @@ -24,6 +24,10 @@ struct RemoteTmuxPreviewTargetTests { } @Test func missingContext() { + #expect(RemoteTmuxPreviewTarget(raw: "/srv/图像 outputs/a.png", cwd: nil, home: nil) + == .remoteFile(absolutePOSIXPath: "/srv/图像 outputs/a.png")) + #expect(RemoteTmuxPreviewTarget(raw: "图像 outputs/a.png", cwd: "/srv/project", home: nil) + == .remoteFile(absolutePOSIXPath: "/srv/project/图像 outputs/a.png")) #expect(RemoteTmuxPreviewTarget(raw: "fig.png", cwd: nil, home: nil) == .needsRemoteDirectory) #expect(RemoteTmuxPreviewTarget(raw: "~/fig.png", cwd: nil, home: nil) == .needsRemoteHome) #expect(RemoteTmuxPreviewTarget(raw: "~other/fig.png", cwd: "/srv", home: "/home/user") == .invalid) diff --git a/cmuxTests/RemoteTmuxSessionSnapshotTests.swift b/cmuxTests/RemoteTmuxSessionSnapshotTests.swift index 58c839528463..1315f8791634 100644 --- a/cmuxTests/RemoteTmuxSessionSnapshotTests.swift +++ b/cmuxTests/RemoteTmuxSessionSnapshotTests.swift @@ -9,6 +9,102 @@ import Testing @MainActor @Suite(.serialized) struct RemoteTmuxSessionSnapshotTests { + @Test func connectedMirrorSurvivesTheAppSnapshotRoundTrip() throws { + let previous = AppDelegate.shared + let delegate = AppDelegate() + AppDelegate.shared = delegate + let manager = TabManager(autoWelcomeIfNeeded: false) + let host = RemoteTmuxHost(destination: "cmux-restore-test.invalid", port: 2222, identityFile: "/tmp/cmux-restore-test-key") + let connection = RemoteTmuxControlConnection(host: host, sessionName: "agents") + delegate.remoteTmuxController.cacheConnection(connection) + _ = try delegate.remoteTmuxController.mirrorSession(host: host, sessionName: "agents", into: manager) + let remote = try #require(manager.tabs.first { $0.isRemoteTmuxMirror }) + manager.selectWorkspace(remote) + let window = delegate.registerMainWindowContextForTesting(tabManager: manager) + defer { + delegate.remoteTmuxController.detachAll() + delegate.unregisterMainWindowContextForTesting(windowId: window) + AppDelegate.shared = previous + } + + let saved = try #require(delegate.sessionSnapshotForTesting()) + let decoded = try JSONDecoder().decode(AppSessionSnapshot.self, from: JSONEncoder().encode(saved)) + let restoredManager = try #require(decoded.windows.first?.tabManager) + #expect(restoredManager.workspaces.contains { $0.workspaceId == remote.id }) + #expect(restoredManager.selectedWorkspaceIndex == 1) + let remoteSnapshot = try #require(restoredManager.workspaces.first { $0.workspaceId == remote.id }) + #expect(remoteSnapshot.remoteTmux?.host == host) + #expect(remoteSnapshot.remoteTmux?.sessionName == "agents") + if let local = manager.tabs.first(where: { !$0.isRemoteTmuxMirror }) { + manager.closeWorkspace(local, recordHistory: false) + } + let remoteOnly = try #require(delegate.sessionSnapshotForTesting()) + #expect(remoteOnly.windows[0].tabManager.workspaces.map(\.workspaceId) == [remote.id]) + } + + @Test func savedRemoteTerminalsRestoreAsProcessFreeDisplays() throws { + let source = Workspace(title: "agents", portOrdinal: 0) + var snapshot = source.sessionSnapshot(includeScrollback: false) + snapshot.panels[0].terminal?.tmuxStartCommand = "printf LOCAL_RESTORE_FORBIDDEN" + var encoded = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(snapshot)) as? [String: Any]) + encoded["remoteTmux"] = [ + "destination": "cmux-restore-test.invalid", "port": 2222, + "identityFile": "/tmp/cmux-restore-test-key", "sessionName": "agents" + ] + snapshot = try JSONDecoder().decode(SessionWorkspaceSnapshot.self, from: JSONSerialization.data(withJSONObject: encoded)) + let restored = Workspace(title: "restore", portOrdinal: 1) + _ = restored.restoreSessionSnapshot(snapshot) + + #expect(restored.isRemoteTmuxMirror) + let terminals = restored.panels.values.compactMap { $0 as? TerminalPanel } + #expect(!terminals.isEmpty) + #expect(terminals.allSatisfy { $0.surface.ioMode == .manualMirror }) + #expect(terminals.allSatisfy { $0.surface.debugTmuxStartCommand() == nil }) + } + + @Test func reattachReusesSavedWorkspaceAndRetainsPreviewPane() throws { + let manager = TabManager(autoWelcomeIfNeeded: false) + let workspace = try #require(manager.selectedWorkspace) + let source = Workspace(title: "agents", portOrdinal: 1) + let terminal = try #require(source.focusedPanelId) + let browser = try #require(source.newBrowserSplit( + from: terminal, orientation: .horizontal, url: URL(string: "about:blank"), focus: false + )) + var snapshot = source.sessionSnapshot(includeScrollback: false) + let host = RemoteTmuxHost(destination: "cmux-restore-test.invalid") + snapshot.remoteTmux = SessionRemoteTmuxWorkspaceSnapshot( + destination: host.destination, sessionName: "agents", + browserURLs: [browser.id.uuidString: "http://127.0.0.1:6013/#scalars"] + ) + let remapped = workspace.restoreSessionSnapshot(snapshot) + let restoredBrowser = try #require(remapped[browser.id].flatMap { workspace.panels[$0] as? BrowserPanel }) + #expect(workspace.bonsplitController.allPaneIds.count == 2) + #expect(workspace.remoteTmuxPreviewURLsByPanelId[restoredBrowser.id]?.remote.absoluteString == "http://127.0.0.1:6013/#scalars") + + let controller = RemoteTmuxController() + let connection = RemoteTmuxControlConnection(host: host, sessionName: "agents") + controller.cacheConnection(connection) + defer { controller.detachAll() } + #expect(try controller.mirrorSession(host: host, sessionName: "agents", into: manager, restoring: workspace)) + #expect(manager.tabs.count == 1) + #expect(controller.sessionMirror(host: host, sessionName: "agents")?.mirroredWorkspace === workspace) + #expect(workspace.panels[restoredBrowser.id] === restoredBrowser) + #expect(!controller.mirrorDiscoveredSessions( + host: host, sessions: [], into: manager + ).isEmpty) + } + + @Test func invalidRemoteAttachmentNeverBecomesALocalLaunch() throws { + let workspace = Workspace(title: "invalid remote", portOrdinal: 0) + var snapshot = workspace.sessionSnapshot(includeScrollback: false) + snapshot.remoteTmux = SessionRemoteTmuxWorkspaceSnapshot(destination: "-oProxyCommand=invalid", sessionName: "agents") + snapshot.panels[0].terminal?.tmuxStartCommand = "printf LOCAL_RESTORE_FORBIDDEN" + _ = workspace.restoreSessionSnapshot(snapshot) + #expect(snapshot.remoteTmux?.host == nil) + #expect(workspace.isRemoteTmuxMirror) + #expect(workspace.panels.values.compactMap { $0 as? TerminalPanel }.allSatisfy { $0.surface.ioMode == .manualMirror }) + } + @Test func sessionSnapshotSkipsWindowWithOnlyRemoteTmuxMirrorWorkspaces() throws { let originalAppDelegate = AppDelegate.shared let appDelegate = AppDelegate() diff --git a/cmuxTests/SurfaceResumeExitedAgentLivenessTests.swift b/cmuxTests/SurfaceResumeExitedAgentLivenessTests.swift index e1074befc0d2..8e4d38d88bdc 100644 --- a/cmuxTests/SurfaceResumeExitedAgentLivenessTests.swift +++ b/cmuxTests/SurfaceResumeExitedAgentLivenessTests.swift @@ -10,9 +10,9 @@ import Testing @Suite(.serialized) struct SurfaceResumeExitedAgentLivenessTests { - @Test("Exited hook process does not auto-resume from an unknown shell state") + @Test("Bound session restores after its hook process exits") @MainActor - func exitedHookProcessDoesNotAutoResumeFromUnknownShellState() throws { + func exitedHookProcessRetainsBoundSessionRestore() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory .appendingPathComponent("cmux-exited-agent-resume-\(UUID().uuidString)", isDirectory: true) @@ -71,16 +71,16 @@ struct SurfaceResumeExitedAgentLivenessTests { let restoredPanel = try #require(restored.terminalPanel(for: restoredPanelID)) #expect(restoredPanel.surface.debugInitialCommand() == nil) - #expect(!restoredPanel.surface.debugInitialInputMetadata().hasInitialInput) + #expect(restoredPanel.surface.debugInitialInputMetadata().hasInitialInput) #expect( restored.sessionSnapshot(includeScrollback: false) .panels.first?.terminal?.agent?.sessionId == sessionID ) } - @Test("Newer agent binding and shell activity do not override cached exited process") + @Test("Bound session restores regardless of cached exited process") @MainActor - func newerAgentBindingAndShellActivityDoNotOverrideCachedExitedProcess() throws { + func boundSessionRestoresRegardlessOfCachedExitedProcess() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory .appendingPathComponent("cmux-newer-agent-binding-resume-\(UUID().uuidString)", isDirectory: true) @@ -138,7 +138,7 @@ struct SurfaceResumeExitedAgentLivenessTests { let restoredPanel = try #require(restored.terminalPanel(for: restoredPanelID)) #expect(restoredPanel.surface.debugInitialCommand() == nil) - #expect(!restoredPanel.surface.debugInitialInputMetadata().hasInitialInput) + #expect(restoredPanel.surface.debugInitialInputMetadata().hasInitialInput) } @Test("Exact live runtime PID overrides cached exited process") @@ -322,7 +322,7 @@ struct SurfaceResumeExitedAgentLivenessTests { let restoredPanel = try #require(restored.terminalPanel(for: restoredPanelID)) #expect(restoredPanel.surface.debugInitialCommand() == nil) - #expect(!restoredPanel.surface.debugInitialInputMetadata().hasInitialInput) + #expect(restoredPanel.surface.debugInitialInputMetadata().hasInitialInput) } @Test("Autosave fingerprint includes agent process liveness") diff --git a/cmuxTests/TerminalRecoveryTests.swift b/cmuxTests/TerminalRecoveryTests.swift index 90859e017706..ea0e286b42e3 100644 --- a/cmuxTests/TerminalRecoveryTests.swift +++ b/cmuxTests/TerminalRecoveryTests.swift @@ -12,12 +12,14 @@ import Testing SessionRestorableAgentSnapshot(kind: .codex, sessionId: id, workingDirectory: nil, launchCommand: nil) } - @Test func legacyUnknownKeepsManualIdentityWithoutAutoResume() { + @Test func legacyUnknownResumesBoundAgent() { let snapshot = SessionTerminalPanelSnapshot(agent: agent("manual-id")) let observation = TerminalRecoveryObservation.migrated(from: snapshot) #expect(observation.state == .unknown) #expect(observation.lastKnownAgent?.sessionID == "manual-id") - #expect(snapshot.recoveryPlan().action == .shell) + #expect(snapshot.recoveryPlan().action == .resumeAgent( + TerminalRecoveryAgentIdentity(kind: "codex", sessionID: "manual-id")! + )) } @Test func missingSessionIDCannotConfirmAnAgent() { @@ -37,12 +39,14 @@ import Testing #expect(plan.action == .resumeAgent(TerminalRecoveryAgentIdentity(kind: "codex", sessionID: "exact-id")!)) } - @Test func exitedAgentBecomesShellAndRetainsManualIdentity() { + @Test func exitedAgentRetainsAutomaticRestoreIntent() { let snapshot = SessionTerminalPanelSnapshot(agent: agent("old-id"), wasAgentRunning: false) let observation = TerminalRecoveryObservation.migrated(from: snapshot) #expect(observation.state == .shell) #expect(observation.lastKnownAgent?.sessionID == "old-id") - #expect(snapshot.recoveryPlan().action == .shell) + #expect(snapshot.recoveryPlan().action == .resumeAgent( + TerminalRecoveryAgentIdentity(kind: "codex", sessionID: "old-id")! + )) } @Test func foregroundOtherProcessRestoresAsShell() { @@ -71,10 +75,11 @@ import Testing )) } - @Test func duplicateSessionDoesNotResumeOnSecondSurface() { + @Test(arguments: [true, false, nil] as [Bool?]) + func duplicateSessionDoesNotResumeOnSecondSurface(wasRunning: Bool?) { let first = UUID() let second = UUID() - let snapshot = SessionTerminalPanelSnapshot(agent: agent("shared-id"), wasAgentRunning: true) + let snapshot = SessionTerminalPanelSnapshot(agent: agent("shared-id"), wasAgentRunning: wasRunning) let owned = snapshot.recordingRecovery(surfaceID: first, previous: nil) #expect(owned.recoveryPlan(surfaceID: first).action == .resumeAgent( TerminalRecoveryAgentIdentity(kind: "codex", sessionID: "shared-id")! @@ -108,4 +113,165 @@ import Testing #expect(unavailable.recovery?.generation == previous.generation) #expect(unavailable.recovery?.diagnosticReason == "scan timed out") } + + @MainActor + @Test(arguments: [RestorableAgentKind.codex, .claude, .grok], [true, false, nil] as [Bool?]) + func boundAgentRestoresRegardlessOfProcessState(kind: RestorableAgentKind, wasRunning: Bool?) throws { + let suite = "cmux-recovery-\(UUID())" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.set(true, forKey: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) + defer { defaults.removePersistentDomain(forName: suite) } + let source = Workspace(agentSessionAutoResumeDefaults: defaults) + defer { source.teardownAllPanels() } + var snapshot = source.sessionSnapshot(includeScrollback: false) + let panelID = try #require(snapshot.panels.first?.id) + let identity = "recovery-\(UUID().uuidString)" + snapshot.panels[0].terminal = SessionTerminalPanelSnapshot( + agent: SessionRestorableAgentSnapshot( + kind: kind, sessionId: identity, workingDirectory: "/tmp", launchCommand: nil + ), + resumeBinding: SurfaceResumeBindingSnapshot( + kind: kind.rawValue, command: "/usr/bin/true", cwd: "/tmp", + checkpointId: identity, source: "agent-hook", autoResume: false, + approvalPolicy: .auto + ), + wasAgentRunning: wasRunning + ).recordingRecovery(surfaceID: panelID, previous: nil, freshEvidence: true) + // Reproduce the contradictory snapshot written by the previous build. + snapshot.panels[0].terminal?.resumeBinding?.autoResume = false + + let restored = Workspace(agentSessionAutoResumeDefaults: defaults) + defer { restored.teardownAllPanels() } + let mapping = restored.restoreSessionSnapshot(snapshot, startupRestoreCommitOwner: .tabManagerTopology) + let restoredID = try #require(mapping[panelID]) + #expect(restored.deferredAgentResumeRestoresByPanelId[restoredID] == nil) + let terminal = try #require(restored.panels[restoredID] as? TerminalPanel) + #expect(terminal.surface.initialInput?.contains("/usr/bin/true") == true) + #expect(terminal.surface.initialInput?.contains("cmux restore") == false) + restored.terminalStartupRestoreCoordinator.commitPendingRestores() + #expect(restored.restoredAgentResumeStatesByPanelId[restoredID] == .awaitingAutoResumeCommand) + #expect(restored.restoredAgentLifecycle.startupInput(panelId: restoredID)?.contains("/usr/bin/true") == true) + #expect(restored.surfaceResumeBindingsByPanelId[restoredID]?.autoResume == true) + + let dock = DockSplitStore( + workspaceId: UUID(), baseDirectoryProvider: { "/tmp" }, + agentSessionAutoResumeDefaults: defaults, restorableAgentIndexProvider: { nil } + ) + defer { dock.closeAllPanels() } + let dockMapping = dock.restoreSessionSnapshot(SessionSplitContainerSnapshot( + focusedPanelId: panelID, layout: snapshot.layout, panels: snapshot.panels + )) + let dockID = try #require(dockMapping[panelID]) + #expect(dock.deferredAgentResumeRestoresByPanelId[dockID] == nil) + let dockTerminal = try #require(dock.panels[dockID] as? TerminalPanel) + #expect(dockTerminal.surface.initialInput?.contains("/usr/bin/true") == true) + #expect(dockTerminal.surface.initialInput?.contains("cmux restore") == false) + #expect(dock.restoredAgentLifecycle.resumeStatesByPanelId[dockID] == .awaitingAutoResumeCommand) + #expect(dock.surfaceResumeBindingsByPanelId[dockID]?.checkpointId == identity) + } + + @Test func explicitManualApprovalDoesNotAutoResume() { + let surfaceID = UUID() + let snapshot = SessionTerminalPanelSnapshot( + agent: agent("manual-id"), + resumeBinding: SurfaceResumeBindingSnapshot( + kind: "codex", command: "/usr/bin/true", checkpointId: "manual-id", + source: "agent-hook", autoResume: false, approvalPolicy: .manual + ), + wasAgentRunning: true + ).recordingRecovery(surfaceID: surfaceID, previous: nil, freshEvidence: true) + #expect(snapshot.recoveryPlan(surfaceID: surfaceID).action == .shell) + #expect(snapshot.resumeBinding?.autoResume == false) + } + + @Test func processStateDoesNotRetireBindingButOwnershipAndApprovalStillApply() { + let surfaceID = UUID() + let running = SessionTerminalPanelSnapshot( + agent: agent("exact-id"), + resumeBinding: SurfaceResumeBindingSnapshot( + kind: "codex", command: "/usr/bin/true", checkpointId: "exact-id", + source: "agent-hook", autoResume: false, approvalPolicy: .auto + ), + wasAgentRunning: true + ).recordingRecovery(surfaceID: surfaceID, previous: nil) + #expect(running.reconcilingAgentBindingForRestore(surfaceID: UUID()).resumeBinding?.autoResume == false) + var exited = running + exited.wasAgentRunning = false + #expect(exited.reconcilingAgentBindingForRestore(surfaceID: surfaceID).resumeBinding?.autoResume == true) + var legacy = exited + legacy.resumeBinding?.approvalPolicy = nil + #expect(legacy.reconcilingAgentBindingForRestore(surfaceID: surfaceID).resumeBinding?.autoResume == true) + var unknown = running + unknown.recovery?.state = .unknown + #expect(unknown.reconcilingAgentBindingForRestore(surfaceID: surfaceID).resumeBinding?.autoResume == true) + var conflicting = running + conflicting.agent = agent("different-id") + #expect(conflicting.reconcilingAgentBindingForRestore(surfaceID: surfaceID).resumeBinding?.autoResume == false) + var prompt = running + prompt.resumeBinding?.approvalPolicy = .prompt + #expect(prompt.reconcilingAgentBindingForRestore(surfaceID: surfaceID).resumeBinding?.autoResume == false) + var persistent = running + persistent.remotePTYSessionID = "remote-pty" + #expect(persistent.reconcilingAgentBindingForRestore(surfaceID: surfaceID).resumeBinding?.autoResume == false) + } + + @Test func bindingOnlyExitedAgentResumesButUnboundHistoryDoesNot() { + let surfaceID = UUID() + let snapshot = SessionTerminalPanelSnapshot( + resumeBinding: SurfaceResumeBindingSnapshot( + kind: "codex", command: "/usr/bin/true", checkpointId: "bound-id", + source: "agent-hook", autoResume: false, approvalPolicy: .auto + ), + wasAgentRunning: false + ).recordingRecovery(surfaceID: surfaceID, previous: nil) + #expect(snapshot.recoveryPlan(surfaceID: surfaceID).action == .resumeAgent( + TerminalRecoveryAgentIdentity(kind: "codex", sessionID: "bound-id")! + )) + var unbound = snapshot + unbound.resumeBinding = nil + #expect(unbound.recoveryPlan(surfaceID: surfaceID).action == .shell) + } + + @MainActor + @Test func completedAgentBindingSurvivesWorkspaceSnapshot() throws { + let workspace = Workspace() + defer { workspace.teardownAllPanels() } + let panelID = try #require(workspace.focusedPanelId) + workspace.setRestoredAgentSnapshotForTesting(agent("completed-id"), panelId: panelID) + workspace.restoredAgentLifecycle.setResumeState(.completedAgentExit, panelId: panelID) + let snapshot = workspace.sessionSnapshot(includeScrollback: false) + let terminal = try #require(snapshot.panels.first?.terminal) + #expect(terminal.agent?.sessionId == "completed-id") + #expect(terminal.recoveryPlan(surfaceID: panelID).action == .resumeAgent( + TerminalRecoveryAgentIdentity(kind: "codex", sessionID: "completed-id")! + )) + + let dock = DockSplitStore( + workspaceId: UUID(), baseDirectoryProvider: { "/tmp" }, + restorableAgentIndexProvider: { .empty } + ) + defer { dock.closeAllPanels() } + let mapping = dock.restoreSessionSnapshot(SessionSplitContainerSnapshot( + focusedPanelId: panelID, layout: snapshot.layout, panels: snapshot.panels + )) + let dockID = try #require(mapping[panelID]) + dock.restoredAgentLifecycle.setResumeState(.completedAgentExit, panelId: dockID) + let dockSnapshot = dock.sessionSnapshot( + includeScrollback: false, + restorableAgentIndex: .empty, + surfaceResumeBindingIndex: SurfaceResumeBindingIndex(bindingsByPanel: [:]) + ) + #expect(dockSnapshot.panels.first?.terminal?.agent?.sessionId == "completed-id") + } + @Test func approvedPromptBindingStartsWithoutAutomaticResumeFlag() throws { + // The restore policy already accepted the user's prompt response. + let approvedBinding = SurfaceResumeBindingSnapshot( + kind: "codex", command: "/usr/bin/true", checkpointId: "approved-session", + source: "agent-hook", autoResume: false, approvalPolicy: .prompt + ) + let launch = try #require(approvedBinding.boundAgentStartupLaunch()) + #expect(launch.initialInput.contains("/usr/bin/true") == true) + #expect(launch.initialInput.contains("cmux restore") == false) + } + } diff --git a/docs/ptmux-preview.md b/docs/ptmux-preview.md new file mode 100644 index 000000000000..262b12940d59 --- /dev/null +++ b/docs/ptmux-preview.md @@ -0,0 +1,226 @@ +# Remote tmux link previews in this fork + +This checkout adds ptmux session discovery and remote link previews to the +0.64.25 based `gema/latest-ptmux` branch. ptmux manages the remote tmux sessions; +the macOS app renders them through tmux control mode over SSH. + +## Project map + +| Responsibility | Files | +| --- | --- | +| Discover and restore ptmux sessions | `Sources/RemoteTmuxSSHTransport.swift` | +| Own SSH connections and preview services | `Sources/RemoteTmuxController.swift` | +| Mirror remote windows and panes | `Sources/RemoteTmuxSessionMirror*.swift`, `Sources/RemoteTmuxWindowMirror*.swift` | +| Synchronize remote window dimensions | `Sources/RemoteTmuxControlConnection+Sizing.swift` | +| Classify a clicked URL or file path | `Sources/RemoteTmuxPreviewTarget.swift` | +| Resolve the clicked terminal's host and directory | `Sources/Workspace+TerminalLinkOpening.swift` | +| Fetch a remote file through SSH | `Sources/RemoteTmuxPreviewFetcher.swift` | +| Forward remote localhost HTTP traffic | `Sources/RemoteTmuxLoopbackForwarder.swift` | +| Route a click into a browser or file preview | `Sources/TerminalLinkOpenCoordinator.swift` | +| Create and place local preview panels | `Sources/Workspace.swift` | + +Absolute file paths are read directly on the remote host, without changing to +the terminal directory. They also work when that directory is unknown or has +been removed. Relative paths query the clicked tmux pane's current directory at +click time and resolve against it, including immediately after a remote `cd`. +The local terminal directory and cached sidebar directory do not affect this +lookup. A directory used inside an agent's tool +call can differ from the tmux shell directory, so agent output should use an +absolute remote path when linking to an artifact outside the shell directory. +If the resulting remote path does not exist, report that path; do not search +other directories for a matching filename. + +File reads first reuse the terminal's SSH master. When its session channels are +full or its socket is gone, OpenSSH can use a separate non-interactive connection +with the same destination, port, identity and configured proxy. Host-key checks +and authentication policy still apply; no password prompt is opened. This also +applies to file-size and home-directory queries. Configured port forwards are +cleared on file-read connections so a download cannot collide with an existing +listener. No SSH server configuration change is needed. + +`Session open refused by peer` can occur after many tmux sessions are restored: +each control client occupies an SSH session channel. Web forwarding uses a +different channel type and can still work. See OpenSSH's +[MaxSessions documentation](https://man.openbsd.org/sshd_config#MaxSessions). + +For example, if the shell is in `/srv/project` but the image is +`/srv/project/artifacts/run/visuals/frames.png`, use that absolute path or +`artifacts/run/visuals/frames.png`. The link `visuals/frames.png` alone refers +to `/srv/project/visuals/frames.png`. + +Remote `http://127.0.0.1:PORT/` and `http://localhost:PORT/` URLs refer to the +SSH host. The preview service allocates a free local port and carries traffic +through the existing SSH master. The resulting local port can differ from +the remote port. A disconnected SSH master must be reconnected first. + +## Cropped remote terminal width + +On tmux 3.2a, the last interacting client determines the shared window size. +Focus or input in cmux applies the visible panel's grid; ordinary terminal +input, resize, or reported focus-in applies that terminal's grid. Background +cmux clients use `ignore-size`, and automatic terminal replies or reconnect +replay do not take ownership. The running server enables `focus-events` so +supported native terminals can hand sizing back on focus without typing. + +This replaces the old persistent `smallest` compatibility policy on mirrored +windows. It keeps the global `window-size` default and configuration file +unchanged. Switching cmux tabs also selects the remote session's current +window. Simultaneous viewers share one grid; a background viewer can show +margins or a clipped viewport until it becomes active. Newer tmux servers +retain their existing per-window claims. +See [the sizing design](remote-tmux-sizing-design.md#older-tmux-servers-without-per-window-claims) +for the command order, regression harness, and compatibility limits. + +## Initialize and build + +Read `AGENTS.md` and the relevant `skills/` instructions before editing. +For a new standalone checkout, run `./scripts/setup.sh`. An existing checkout +can reuse its initialized submodules, git hooks, GhosttyKit, and tagged +DerivedData. Avoid a cold Ghostty build when the verified prebuilt framework +is already available. + +Standalone builds use `./scripts/reload.sh --tag ptmux-preview`. Team fleet +builds use `cmux-ci` as documented in `AGENTS.md`. Use +`CMUX_TAG=ptmux-preview scripts/cmux-debug-cli.sh ...` for the matching app's +CLI. Do not use the unscoped `/tmp/cmux-cli` helper. + +## Focused verification + +`RemoteTmuxPreviewTargetTests` covers path and localhost URL classification. +`RemoteTmuxPreviewFetcherTests` executes file reads with available, saturated, +missing, and newly saturated mux channels, including binary data and quoted paths. +`RemoteTmuxPreviewRoutingTests` exercises real workspace browser/file creation, +pane placement, focus preservation, and the absence of unintended tmux splits. +Run these through the `cmux-unit` scheme with a tagged DerivedData path and +bundle identifier; `reload.sh` alone does not execute them. + +For a live check, click an absolute remote PNG path and a remote localhost URL. +Both should open beside the mirrored terminals. Open a second preview, focus +it, and create a remote tmux window: the new window must remain in the tmux +tab strip. Closing the preview must leave the remote tmux panes intact. + +If a file is unavailable, inspect the resolved absolute path in the error. +Check that exact path on the SSH host before changing browser or SSH settings. +For a web URL, first check the service on the host with +`curl -I --max-time 5 http://127.0.0.1:PORT/`. + +An SSH session that can run tmux and read files can still forbid TCP forwarding. +In particular, `no-port-forwarding` on the selected `authorized_keys` entry +rejects `ssh -W` channels. The local listener can start successfully while every +HTTP request receives an empty response. Check the actual forwarded response, +not only whether a local port is listening. Changes to SSH authorization require +the owner's approval and a new SSH connection to take effect. + +For a single approved preview port, `permitopen="127.0.0.1:6013"` limits +the key's TCP destinations. If replacing `no-port-forwarding`, also constrain +`permitlisten` so reverse listeners do not become unrestricted. Preserve the +forced command and the key's other restrictions, and back up the file first. +Verify both the allowed port and a denied port with a fresh authentication; +an already connected ControlMaster retains its previous authorization. + +## Remote workspace reopen + +Remote tmux workspaces now participate in the normal per-window session snapshot. +The saved target contains the SSH destination, port, identity-file path, and tmux +session name. Restore reserves process-free terminal displays in the same workspace, +then reconnects after the restored workspace graph is installed. Existing tmux panes +and their agent processes remain on the host; restore never replays their saved +commands locally or creates a replacement remote session. + +The local browser/file preview pane is retained alongside the tmux window strip. +For localhost links opened from a remote terminal, the snapshot saves the original +remote URL. A fresh SSH connection allocates a new local forwarding port before +loading that browser. It does not reuse the previous connection's transient port. +A host that cannot be reached retains its attachment target; an explicit `ssh-tmux` +retry reuses that workspace. Closing a workspace cancels an in-flight restore. + +`RemoteTmuxSessionSnapshotTests` covers snapshot round trips, remote-only windows, +process-free placeholders, exact workspace reuse, preview placement, and invalid +attachment targets. A live reopen check should compare tmux pane/process identities +before and after closing the app, then verify the browser uses a working new forward. + +`RemoteTmuxPreviewIntegrationTests` is an opt-in SSH check of the terminal-link +coordinator against a real image and web service. It creates one uniquely named +temporary tmux session, verifies the downloaded SHA-256 and forwarded HTTP response, +and removes its session. Normal test runs skip it. Supply a private JSON fixture +with `destination`, optional `port` and `identityFile`, `imagePath`, `imageSHA256`, +`webURL`, and `pageMarker`; it references an existing SSH identity without copying +key material. Pass its path through `TEST_RUNNER_CMUX_LIVE_PTMUX_FIXTURE` when +running the targeted native suite with the tag's DerivedData and test bundle ID. +The test exercises the same coordinator invoked by terminal link clicks; it does +not synthesize a physical Cmd-click gesture. + +To reproduce channel exhaustion against an already saturated terminal master, +pass a fixture for that exact connection identity through +`TEST_RUNNER_CMUX_LIVE_PTMUX_BUSY_FIXTURE`. The additional live test first requires +the real mux refusal, then verifies absolute and relative binary downloads by +SHA-256. It only reads from that master and never closes or replaces it. + +## Agent restore diagnosis + +Each build tag owns a separate session snapshot in +`~/Library/Application Support/cmux/`. The `-previous.json` file holds the +startup backup. Back up both files before another restart when investigating +a missed restore. Do not copy one tag's snapshot over a running app's file. + +`terminal.autoResumeAgentSessions` defaults to `true` in this fork. A saved +session ID alone does not trigger automatic resume: the terminal must have +been confirmed to contain a running agent, and its binding must permit the +launch. A saved shell keeps its last agent available for manual continuation. +An agent still running in another cmux instance also prevents a duplicate +launch. Remote tmux sessions are reattached rather than relaunched locally. + +The 2026-09-27 investigation found both `fix-ptmux` and `ptmux-preview` running. +The old instance still contained two Codex processes and a Grok process. The +new instance's startup backup included shell records and a Codex hook binding +with `autoResume=false`; its deferred restore was cancelled. Those are +different cases from a missing session ID. Preserve the old instance until its +active work can be stopped intentionally; migrating tags is not a live PTY +transfer. `cmux restore ` runs in the invoking terminal and checks +that terminal's saved identity. It must not be sent to an unrelated agent pane. + +The recovery reconciliation repairs that contradictory automatic binding when +the versioned observation confirms the same agent on the same surface, the +saved running state is true, and the binding still has automatic approval. +Both workspace and Dock restores apply it before deferred admission. Fresh +snapshot evidence also repairs the persisted copy. Manual and prompted +approval, unknown/exited states, identity conflicts, other surfaces, and +persistent-session attachment do not gain automatic launch permission. The +existing live-owner check still prevents duplicate agent processes. Records +already saved as shells keep their exact ID for explicit manual continuation. + +Resume arguments also preserve the existing conversation's identity and options. +Grok's `--session-id` / `-s` and `--fork-session` selectors are removed before +adding the saved resume ID; older captures with a dangling `--session-id` are +handled as well. Claude's explicit empty variadic values, such as `--tools ''`, +remain empty arguments through capture and resume. Dropping that argument leaves +an invalid command and loses the user's tool restriction. `AgentResumeArgvTests` +checks both paths through the launch sanitizer and resume command builder. + +Startup can briefly lack a complete process census while restored terminals and +hooks start together. Restore admission now distinguishes that transient failure +from an unreadable durable hook store. The CLI's existing bounded retry waits for +a complete census before launching; corrupt records and live owners still block +automatic launch. + +Codex prompt-depth records belong to the process that produced them. A stopped +or interrupted process can leave depth behind even after its turn ledger settled. +A verified newer process may refresh that session's hook identity and resume +binding; same-process or unverifiable startup callbacks cannot clear an active +turn. This lets a resumed, idle Codex session remain restorable on the next app +reopen without requiring another prompt. `CLICodexHookTimeoutRegressionTests` +exercises the packaged CLI against completed, idle-with-depth, and interrupted +records, while retaining the stale same-process callback checks. + +The embedded CLI also needs its SwiftPM localization bundle beside the binary. +The build resource phase exposes the app's `CmuxFoundation_CmuxFoundation.bundle` +under `Resources/bin/`. Without that link, CLI help and config validation crash +even though socket commands such as `ping` work. Verify the packaged CLI, with +no `PACKAGE_RESOURCE_BUNDLE_PATH` override, rather than the top-level build +product. `tests/test_build_app_bundled_resources.sh` exercises cold and cached +resource installation and repair of a missing CLI resource link. + +The separation between tmux windows and a local browser is also proposed in +[upstream PR #9861](https://github.com/manaflow-ai/cmux/pull/9861). This fork's +preview pane additionally hosts downloaded files. Keep fixes scoped to this +fork while that upstream work is open. diff --git a/docs/remote-tmux-sizing-design.md b/docs/remote-tmux-sizing-design.md index d6320e4cfe10..1de9115be204 100644 --- a/docs/remote-tmux-sizing-design.md +++ b/docs/remote-tmux-sizing-design.md @@ -38,9 +38,61 @@ Invariants: 2. Every view is space-filling (answers a proposal with the proposal; hosting controllers publish no intrinsic size) so content exerts no outward pressure. 3. tmux is the only grid authority. -4. A size is sent only when it differs from what the server was last sent. +4. Geometry sends are deduplicated; legacy user activation must also commit an unchanged size. 5. Across a transport gap, trust a refetched snapshot, not the event stream. +## Older tmux servers without per-window claims + +tmux 3.2a accepts the session-wide `refresh-client -C WxH` but rejects the +`@id:WxH` form. Under its default `window-size latest` policy, a wider attached +terminal can continue to own the window's dimensions even after our client size +was accepted. A 139-column mirror can then receive a 188-column pane and clip +its right side. Comparing the rendered grid only with tmux's assigned grid +misses this case: both are 188 columns, wider than the viewport. + +The compatibility path uses `window-size latest` and explicit user activation. +The previous `smallest` fallback prevented local crop but also held wider native +terminals at a narrow grid. On capability rejection, migrate mirrored windows +to `latest`, enable the server's `focus-events`, and mark background control +clients `ignore-size`. The global `window-size` default and `tmux.conf` remain +unchanged; focus reporting is enabled for the running server. + +Focus, tab selection, accepted user input, and a foreground window resize hand +sizing to cmux. The ordered command queue clears `ignore-size`, sets the target +window to `latest`, selects that window on the attached session, then sends +`refresh-client -C WxH`. Selection updates tmux 3.2a's latest-client pointer; +the final refresh immediately commits the resize, including unchanged client +dimensions. Ordinary terminal input, resize, or reported focus-in can then +hand sizing back to a native client while cmux remains connected. + +Session-owned projected panes delegate explicit control focus, text and key +commands to the same window-mirror mutation owner. This covers socket/CLI +actions as well as the visible terminal, without treating manual protocol +replies as user input. + +Background geometry, protocol replies, and reconnect or peer-detach replay +never select a window. Manual I/O carries terminal replies as well as keyboard +bytes, so activation uses the surface's accepted-explicit-input callback; +forwarded bytes alone do not count as user activity. Losing foreground focus +or hiding the selected mirror marks its control client `ignore-size`. + +Legacy clients share one size and one selected window across the session. +Selecting a cmux tab therefore selects the same window in a native client of +that session. A viewer that is not the last active client can temporarily show +margins or a viewport smaller than the shared grid; returning to it transfers +sizing. Native focus-only transfer requires a terminal that reports focus +changes; keyboard input remains available when it does not. Newer servers keep +the per-window claim behavior described below. + +`RemoteTmuxLegacySizingTests` runs the production connection against an isolated +real tmux socket. It checks explicit input handoff, two window sizes, wide-peer +recovery while cmux remains attached, background replay, and clean detach. +`TEST_RUNNER_CMUX_LEGACY_SIZING_TMUX` can point to an SSH adapter for an older +binary; preserve the generated `-L` argument so no user's server is shared. +The separate `scripts/remote-tmux-latest-sizing-native-probe.py` uses a real +PTY to check native key and focus-in handoff, background replay, and unchanged +cmux reclaim. Pipe it to `python3 -` over SSH to probe an older remote binary. + ## Correctness is a settled property, not a per-frame one Exactness is required only at rest. While the user is dragging a divider, or a @@ -117,7 +169,7 @@ size again and no further callback ever comes. Two things close that hole: liveness edges (a pass on the probe's own move-to-window, same-instant frame sampling) were tried and proved unnecessary on this architecture. -## One tmux window shown in more than one place: clamp to the smallest +## Newer tmux per-window claims: clamp to the smallest A tmux window can be displayed at two different sizes at once — most obviously when the same window is mirrored in two macOS windows (drag a tab across @@ -359,10 +411,12 @@ painted, so the panes never quite fit however cleanly the rest settled. already does well. We keep bonsplit as the renderer and instead make the claim agree with it (shared chrome config) and impose the tmux-assigned extents on it. -- **One client size + `window-size latest` (all windows follow).** Simple, but +- **One client size + `window-size latest` on newer servers (all windows follow).** Simple, but O(N): one change resizes every window in the session, fanning out layout events under churn. Rejected in favor of per-window `refresh-client -C` pins on the - visible window only (O(1)); same visual result without the fan-out. + visible window only (O(1)); same visual result without the fan-out. Older + servers lack those pins and use the interaction-based compatibility path + described above. - **Let NSSplitView's built-in minimum pane sizes stand.** Its minimums caused the wrong behavior (a 1-column tmux pane rendered wider than assigned); tmux is diff --git a/scripts/build-app-bundled-resources.sh b/scripts/build-app-bundled-resources.sh index a873ced38004..f9453ed82791 100755 --- a/scripts/build-app-bundled-resources.sh +++ b/scripts/build-app-bundled-resources.sh @@ -25,6 +25,13 @@ CMUX_CUA_HELPER_OWNER_MARKER="${CMUX_CUA_HELPER_APP}/Contents/Resources/.cmux-cu CMUX_CUA_HELPER_EXEC="${CMUX_CUA_HELPER_APP}/Contents/MacOS/cmux-cua" INFO_PLIST="${TARGET_BUILD_DIR}/${INFOPLIST_PATH}" +# SwiftPM looks beside a command-line executable for its package resources. +# Xcode embeds this bundle in the app's Resources directory; expose the same +# bundle beside Resources/bin/cmux without maintaining a second copy. Do this +# before the helper-build cache check so warm builds repair a missing link. +mkdir -p "$BIN_DEST" +ln -sfn ../CmuxFoundation_CmuxFoundation.bundle "$BIN_DEST/CmuxFoundation_CmuxFoundation.bundle" + run_git() ( unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE unset GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_PREFIX diff --git a/scripts/remote-tmux-latest-sizing-native-probe.py b/scripts/remote-tmux-latest-sizing-native-probe.py new file mode 100644 index 000000000000..80f99ebcf4fc --- /dev/null +++ b/scripts/remote-tmux-latest-sizing-native-probe.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Probe latest-client handoff using a native PTY and an isolated tmux server. + +Run locally, or pipe this file to python3 over SSH. This checks native tmux +focus/key behavior; RemoteTmuxLegacySizingTests checks the production app path. +CMUX_TMUX_BINARY may select a specific server binary. Every command retains the +unique -L socket; cleanup never targets the user's default server. +""" +import os, subprocess, fcntl, termios, struct, time, select, uuid, threading, shutil +prefix=[os.environ.get('CMUX_TMUX_BINARY') or shutil.which('tmux') or '/usr/bin/tmux','-L','cmux-native-sizing-'+uuid.uuid4().hex,'-f','/dev/null'] +def tmux(*args): + p=subprocess.run(prefix+list(args), text=True, capture_output=True, timeout=10) + if p.returncode: raise RuntimeError((args,p.stderr)) + return p.stdout.strip() +control=None; native=None; master=None +try: + print(tmux('-V'), flush=True) + tmux('new-session','-d','-s','sizing','-x','188','-y','50','sleep 90') + tmux('set-option','-g','window-size','latest') + tmux('set-option','-g','focus-events','on') + tmux('set-option','-g','status-left','CMUX_NATIVE_READY') + tmux('set-option','-g','status-left-length','30') + tmux('bind-key','-n','F12','wait-for','-S','cmux-native-key') + w=tmux('display-message','-p','-t','sizing','#{window_id}') + control=subprocess.Popen(prefix+['-C','attach-session','-t','sizing'],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,bufsize=0) + buf=bytearray() + def read_block(): + deadline=time.monotonic()+10 + while time.monotonic() "$SRCROOT/Resources/shell-integration/cmux.zsh" printf 'alternate\n' > "$SRCROOT/Resources/shell-integration/alternate.zsh" ln -s cmux.zsh "$SRCROOT/Resources/shell-integration/current.zsh" printf 'plist\n' > "$BUILD_DIR/Products/Info.plist" +PACKAGE_BUNDLE="CmuxFoundation_CmuxFoundation.bundle" +mkdir -p "$BUILD_DIR/Resources/$PACKAGE_BUNDLE/en.lproj" +printf '"fixture" = "Package localization";\n' > "$BUILD_DIR/Resources/$PACKAGE_BUNDLE/en.lproj/Localizable.strings" # A tracked source file outside every directory the phase copies, so only the # Ghostty worktree part of the stamp can notice it changing. printf 'helper-source-v1\n' > "$SRCROOT/ghostty/src/main.zig" @@ -94,6 +97,15 @@ run_phase > "$TMP_DIR/first.log" run_phase > "$TMP_DIR/second.log" grep -q 'skipping helper rebuilds' "$TMP_DIR/second.log" +# SwiftPM's executable resource lookup starts beside the embedded CLI. The +# package resources must also be readable there after both cold and warm builds. +cmp "$BUILD_DIR/Resources/$PACKAGE_BUNDLE/en.lproj/Localizable.strings" \ + "$BUILD_DIR/Resources/bin/$PACKAGE_BUNDLE/en.lproj/Localizable.strings" +rm "$BUILD_DIR/Resources/bin/$PACKAGE_BUNDLE" +run_phase > "$TMP_DIR/missing-cli-package-resources.log" +cmp "$BUILD_DIR/Resources/$PACKAGE_BUNDLE/en.lproj/Localizable.strings" \ + "$BUILD_DIR/Resources/bin/$PACKAGE_BUNDLE/en.lproj/Localizable.strings" + chmod -x "$BUILD_DIR/Resources/bin/ghostty" run_phase > "$TMP_DIR/non-executable-helper.log" if grep -q 'skipping helper rebuilds' "$TMP_DIR/non-executable-helper.log"; then