diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 51d544e74089..7d64dfdf3b26 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,15 +1,15 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -32955 CLI/cmux.swift -22444 Sources/TerminalController.swift -19955 Sources/Workspace.swift +32997 CLI/cmux.swift +22505 Sources/TerminalController.swift +20141 Sources/Workspace.swift 19248 Sources/ContentView.swift -18057 Sources/AppDelegate.swift -16539 Sources/GhosttyTerminalView.swift +18060 Sources/AppDelegate.swift +16810 Sources/GhosttyTerminalView.swift 13608 Sources/Panels/BrowserPanel.swift 11916 cmuxTests/AppDelegateShortcutRoutingTests.swift -9992 Sources/TabManager.swift +10012 Sources/TabManager.swift 8494 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 7737 Sources/Panels/BrowserPanelView.swift 7198 cmuxTests/WorkspaceUnitTests.swift @@ -44,10 +44,10 @@ 2290 Sources/FileExplorerView.swift 2260 Sources/TerminalWindowPortal.swift 2208 Sources/Mobile/MobileHostService.swift -2138 Sources/SessionPersistence.swift +2196 Sources/SessionPersistence.swift 2123 cmuxTests/ShortcutAndCommandPaletteTests.swift 2117 cmuxTests/CmuxConfigTests.swift -1996 Sources/KeyboardShortcutSettingsFileStore.swift +2039 Sources/KeyboardShortcutSettingsFileStore.swift 1949 Sources/Panels/BrowserWebAuthnSupport.swift 1860 cmuxTests/NotificationAndMenuBarTests.swift 1793 Sources/SessionIndexStore.swift @@ -72,17 +72,19 @@ 1285 cmuxUITests/SidebarHelpMenuUITests.swift 1239 Sources/Feed/FeedCoordinator.swift 1197 cmuxTests/CodexAppServerSessionTests.swift +1163 cmuxTests/SurfaceHibernationPolicyTests.swift 1156 cmuxTests/SidebarOrderingTests.swift 1144 Sources/VaultAgentProcessScanner.swift 1139 cmuxTests/PiVaultAgentPersistenceTests.swift 1107 Sources/AppDelegate+CmuxSSHURL.swift 1096 Sources/GhosttyConfig.swift -1084 cmuxTests/AgentHibernationTests.swift +1096 cmuxTests/AgentHibernationTests.swift 1068 cmuxTests/FileExplorerStoreTests.swift 1058 cmuxUITests/BonsplitTabDragUITests.swift 1021 cmuxUITests/TerminalCmdClickUITests.swift 1006 cmuxTests/CmuxSSHURLRequestTests.swift 1000 cmuxTests/CmuxTopSnapshotScopeTests.swift +988 Sources/Panels/TerminalPanel.swift 949 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift 947 Sources/TerminalNotificationPolicy.swift 945 Sources/SessionIndexRegisteredAgents.swift @@ -93,10 +95,10 @@ 924 Sources/DockPanelView.swift 913 cmuxTests/WorkspaceGroupTests.swift 905 Sources/CmuxSSHURLRequest.swift +894 Sources/CommandPalette/CommandPaletteSettingsToggle.swift 878 Sources/WorkspaceContentView.swift 868 Sources/Panels/BrowserScreenshotSnapshotter.swift -866 Sources/CommandPalette/CommandPaletteSettingsToggle.swift -864 Sources/Panels/TerminalPanel.swift +863 Sources/App/AgentHibernationController.swift 856 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift 846 cmuxTests/AgentSessionAutoResumeSettingsTests.swift 845 cmuxTests/SSHStartupSignalLifecycleTests.swift @@ -131,8 +133,8 @@ 668 cmuxTests/FeedCoordinatorTests.swift 668 cmuxTests/KeyboardShortcutContextTests.swift 654 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift +653 Sources/CmuxTopSnapshot.swift 650 Sources/Panels/MarkdownRemoteImageLoader.swift -649 Sources/CmuxTopSnapshot.swift 640 cmuxTests/CommandPaletteNucleoFFITests.swift 638 Packages/CmuxSwiftRender/Sources/CmuxSwiftRender/ExpressionEvaluator.swift 630 Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift @@ -145,8 +147,8 @@ 599 Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift 596 cmuxTests/CmuxEventBusTests.swift 594 Sources/SessionIndexModels.swift +594 Sources/SettingsNavigation.swift 594 cmuxTests/PortalTabDragRoutingTests.swift -589 Sources/SettingsNavigation.swift 588 cmuxTests/CommandPaletteShortcutCustomizationTests.swift 586 Sources/JSONCParser.swift 585 Sources/Cloud/VMClient.swift @@ -157,6 +159,7 @@ 574 Sources/Feed/FeedTextEditorDebugWindowController.swift 568 Packages/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGrid.swift 566 Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift +562 Sources/App/WorkspaceRuntimeSettings.swift 562 cmuxTests/AgentExecutableResolverTests.swift 560 cmuxTests/GhosttyConfigPathResolverTests.swift 558 Packages/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+Config.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c93fc9db448..c6ffbfbc9f82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -331,6 +331,23 @@ jobs: -only-testing:cmuxTests/AppearanceSettingsTests/testSplitGhosttyThemeUsesSystemDarkWhenAppAppearanceIsStaleLight \ test + # The full unit-test pass below tolerates app-host crashes by classifying + # the surviving summary lines, so suites that sort after an early crash + # can be skipped without failing the job. Surface hibernation policy + # tests gate memory reclamation (issue 5731) and must always execute. + - name: Run surface hibernation policy regression + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + xcodebuild -project cmux.xcodeproj -scheme cmux-unit -configuration Debug \ + -derivedDataPath "$CMUX_DERIVED_DATA_PATH" \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -destination "platform=macOS" \ + CMUX_SKIP_ZIG_BUILD=1 \ + -only-testing:cmuxTests/SurfaceHibernationPolicyTests \ + test + - name: Run unit tests run: | set -euo pipefail diff --git a/CLI/cmux.swift b/CLI/cmux.swift index b14eec9a0cee..ef0da3c3fc21 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -3359,6 +3359,9 @@ struct CMUXCLI { case "agent-hibernation": try runAgentHibernation(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput) + case "surface-hibernation": + try runSurfaceHibernation(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput) + case "auth", "login", "logout": let authArgs = command == "auth" ? commandArgs : [command] + commandArgs let sub = authArgs.first?.lowercased() ?? "status" @@ -5156,6 +5159,7 @@ struct CMUXCLI { "ssh-session-list", "surface", "surface-health", + "surface-hibernation", "surface-resume", "swap-pane", "tab-action", @@ -13345,6 +13349,13 @@ struct CMUXCLI { Enable or disable Agent Hibernation. Configure idle and live-terminal limits from Settings or cmux settings JSON. """ + case "surface-hibernation": + return """ + Usage: cmux surface-hibernation [--json] + + Enable or disable Surface Hibernation for idle background terminals. + Configure idle and live-surface limits from Settings or cmux settings JSON. + """ case "restore-session": return """ Usage: cmux restore-session @@ -22665,6 +22676,36 @@ struct CMUXCLI { } } + private func runSurfaceHibernation( + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool + ) throws { + guard let subcommand = commandArgs.first?.lowercased() else { + throw CLIError(message: "Usage: cmux surface-hibernation [--json]") + } + let response: String + switch subcommand { + case "on", "enable": + response = try sendV1Command("surface_hibernation on", client: client) + case "off", "disable": + response = try sendV1Command("surface_hibernation off", client: client) + default: + throw CLIError(message: "Usage: cmux surface-hibernation [--json]") + } + + if jsonOutput { + let ok = response == "OK" + var fallback: [String: Any] = ["ok": ok] + if !ok { + fallback["message"] = response + } + print(jsonString(fallback)) + } else { + print(response) + } + } + private func shouldApplyClaudeHookVisibleMutation( sessionStore: ClaudeHookSessionStore, parsedInput: ClaudeHookParsedInput, @@ -32724,6 +32765,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { shortcuts disable-browser | enable-browser | browser-status agent-hibernation + surface-hibernation restore-session open ... [--workspace ] [--surface ] [--pane ] [--window ] [--focus ] [--no-focus] diff [patch-file|-] [--source ] [--unstaged|--staged|--branch|--last-turn] [--workspace ] [--surface ] [--window ] [--cwd ] [--base ] [--focus ] [--no-focus] [--title ] [--layout ] [--font-size ] diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Server/SocketControlServer+Startup.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Server/SocketControlServer+Startup.swift index bbcbb3ea7222..e403ea82b7c9 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Server/SocketControlServer+Startup.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Server/SocketControlServer+Startup.swift @@ -168,9 +168,9 @@ extension SocketControlServer { } // Create socket - let newServerSocket = socket(AF_UNIX, SOCK_STREAM, 0) + let (newServerSocket, createSocketErrno) = transport.makeListenerSocket() guard newServerSocket >= 0 else { - let errnoCode = errno + let errnoCode = createSocketErrno ?? EIO print("SocketControlServer: Failed to create socket") reportSocketListenerFailure( message: "socket.listener.start.failed", diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Transport/SocketTransport+ClientSocket.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Transport/SocketTransport+ClientSocket.swift index 400aa96e4a0d..22e48aa970f3 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Transport/SocketTransport+ClientSocket.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Transport/SocketTransport+ClientSocket.swift @@ -2,6 +2,23 @@ public import Foundation internal import Darwin extension SocketTransport { + /// Creates the listener socket (`AF_UNIX`/`SOCK_STREAM`) with `FD_CLOEXEC` + /// set so it is not inherited by PTY-child forks. + /// + /// - Returns: The descriptor and a nil errno on success, or `-1` and the + /// failing `errno`. + public func makeListenerSocket() -> (fd: Int32, errnoCode: Int32?) { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + return (-1, errno) + } + if let errnoCode = configureCloseOnExec(fd) { + close(fd) + return (-1, errnoCode) + } + return (fd, nil) + } + /// Sets `O_NONBLOCK` on `fd`. /// /// - Parameter fd: The socket descriptor to configure. @@ -17,6 +34,30 @@ extension SocketTransport { return nil } + /// Sets `FD_CLOEXEC` on `fd` so it is not inherited across `fork`/`exec`. + /// + /// Read-modify-write via `F_GETFD` to preserve any existing descriptor + /// flags; a no-op when the flag is already set. macOS has no `accept4`/ + /// `SOCK_CLOEXEC`, so control-socket descriptors must be marked explicitly + /// to keep them out of PTY-child forks (see the path-lock fd, which uses + /// the same pattern). + /// + /// - Parameter fd: The descriptor to mark close-on-exec. + /// - Returns: Nil on success, the failing `errno` otherwise. + public func configureCloseOnExec(_ fd: Int32) -> Int32? { + let flags = fcntl(fd, F_GETFD, 0) + guard flags >= 0 else { + return errno + } + if flags & FD_CLOEXEC != 0 { + return nil + } + guard fcntl(fd, F_SETFD, flags | FD_CLOEXEC) >= 0 else { + return errno + } + return nil + } + /// Clears `O_NONBLOCK` on `fd`; returns the failing `errno` otherwise. func configureBlocking(_ fd: Int32) -> Int32? { let flags = fcntl(fd, F_GETFL, 0) @@ -109,6 +150,12 @@ extension SocketTransport { /// - Parameter fd: The freshly accepted client socket descriptor. /// - Returns: Nil on success, or the failing ``SocketStageFailure``. public func configureAcceptedClientSocket(_ fd: Int32) -> SocketStageFailure? { + // Set close-on-exec first, before any later configuration step can + // fail and before a concurrent PTY fork can inherit the descriptor + // (macOS has no `accept4`/`SOCK_CLOEXEC`). + if let errnoCode = configureCloseOnExec(fd) { + return SocketStageFailure(stage: "accept_client_configure_cloexec", errnoCode: errnoCode) + } if let errnoCode = configureBlocking(fd) { return SocketStageFailure(stage: "accept_client_configure_blocking", errnoCode: errnoCode) } diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Transport/SocketTransport+IO.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Transport/SocketTransport+IO.swift index 73e6bfa295f8..495a671904f5 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Transport/SocketTransport+IO.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Transport/SocketTransport+IO.swift @@ -54,6 +54,7 @@ extension SocketTransport { let fd = socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { return nil } defer { close(fd) } + _ = configureCloseOnExec(fd) configureSocketTimeouts(fd, timeout: timeout) _ = configureNoSigPipe(fd) diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Transport/SocketTransport+PathIdentity.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Transport/SocketTransport+PathIdentity.swift index 0ff00ed26a02..dafc47f8c513 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Transport/SocketTransport+PathIdentity.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Transport/SocketTransport+PathIdentity.swift @@ -65,6 +65,7 @@ extension SocketTransport { let fd = socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { return .occupiedOrIndeterminate } defer { close(fd) } + _ = configureCloseOnExec(fd) let originalFlags = fcntl(fd, F_GETFL, 0) guard originalFlags >= 0 else { return .occupiedOrIndeterminate } diff --git a/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/SocketTransportHardeningTests.swift b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/SocketTransportHardeningTests.swift index 75a4ea802122..5b7ea8310a2d 100644 --- a/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/SocketTransportHardeningTests.swift +++ b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/SocketTransportHardeningTests.swift @@ -39,4 +39,43 @@ import Testing #expect(flags >= 0) #expect(flags & FD_CLOEXEC != 0, "lock fd must not leak across fork/exec") } + + @Test func listenerSocketIsCloseOnExec() { + let (fd, errnoCode) = transport.makeListenerSocket() + #expect(errnoCode == nil) + guard fd >= 0 else { + Issue.record("expected listener socket creation to succeed") + return + } + defer { Darwin.close(fd) } + + let flags = fcntl(fd, F_GETFD) + #expect(flags >= 0) + #expect(flags & FD_CLOEXEC != 0, "listener fd must not leak into PTY-child forks") + } + + @Test func acceptedClientSocketIsCloseOnExec() throws { + let path = UnixSocketFixture.makeTempSocketPath() + let listenerFD = try UnixSocketFixture.bindListeningSocket(at: path) + defer { + Darwin.close(listenerFD) + unlink(path) + } + + let clientFD = try UnixSocketFixture.connectClient(to: path) + defer { Darwin.close(clientFD) } + + let acceptedFD = accept(listenerFD, nil, nil) + guard acceptedFD >= 0 else { + Issue.record("expected accept to return a client descriptor") + return + } + defer { Darwin.close(acceptedFD) } + + #expect(transport.configureAcceptedClientSocket(acceptedFD) == nil) + + let flags = fcntl(acceptedFD, F_GETFD) + #expect(flags >= 0) + #expect(flags & FD_CLOEXEC != 0, "accepted client fd must not leak into PTY-child forks") + } } diff --git a/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/UnixSocketFixture.swift b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/UnixSocketFixture.swift index 648ae1e2b302..68704d532165 100644 --- a/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/UnixSocketFixture.swift +++ b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/UnixSocketFixture.swift @@ -53,6 +53,44 @@ enum UnixSocketFixture { return fd } + /// Connects a blocking client to the Unix socket at `path`. + static func connectClient(to path: String) throws -> Int32 { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw posixError("socket(client)") + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let maxLength = MemoryLayout.size(ofValue: addr.sun_path) + let didFit = path.withCString { ptr -> Bool in + guard strlen(ptr) < maxLength else { return false } + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + let pathBuf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) + memset(pathBuf, 0, maxLength) + strncpy(pathBuf, ptr, maxLength - 1) + } + return true + } + guard didFit else { + Darwin.close(fd) + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENAMETOOLONG)) + } + + let connectResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.connect(fd, sockaddrPtr, socklen_t(MemoryLayout.size)) + } + } + guard connectResult == 0 else { + let error = posixError("connect(\(path))") + Darwin.close(fd) + throw error + } + + return fd + } + /// Accepts a single client on a background thread and runs `handler` with /// the client fd. Returns after the handler finishes via the continuation /// stored in the returned closure-waitable. diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift index 76c5bcdc4688..d9769c7e903f 100644 --- a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift @@ -38,6 +38,39 @@ public struct TerminalCatalogSection: SettingCatalogSection { userDefaultsKey: "terminal.agentHibernation.maxLiveTerminals" ) + /// Whether Surface Hibernation reclaims idle plain-shell terminals in + /// hidden workspaces (scrollback and working directory are restored on + /// the next visit). + public let surfaceHibernationEnabled = DefaultsKey( + id: "terminal.surfaceHibernation.enabled", + defaultValue: true, + userDefaultsKey: "terminal.surfaceHibernation.enabled" + ) + + /// Minimum quiet seconds before the live-surface cap may reclaim a + /// background shell terminal. + public let surfaceHibernationIdleSeconds = DefaultsKey( + id: "terminal.surfaceHibernation.idleSeconds", + defaultValue: 300, + userDefaultsKey: "terminal.surfaceHibernation.idleSeconds" + ) + + /// Seconds a workspace must stay hidden — with its terminal quiet — + /// before its idle shell surfaces hibernate even under the cap. + public let surfaceHibernationUnmountedIdleSeconds = DefaultsKey( + id: "terminal.surfaceHibernation.unmountedIdleSeconds", + defaultValue: 1800, + userDefaultsKey: "terminal.surfaceHibernation.unmountedIdleSeconds" + ) + + /// Maximum simultaneously live terminal surfaces before the oldest + /// eligible background terminals hibernate. + public let surfaceHibernationMaxLiveSurfaces = DefaultsKey( + id: "terminal.surfaceHibernation.maxLiveSurfaces", + defaultValue: 12, + userDefaultsKey: "terminal.surfaceHibernation.maxLiveSurfaces" + ) + public let showTextBoxOnNewTerminals = DefaultsKey( id: "terminal.showTextBoxOnNewTerminals", defaultValue: false, diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift index c9615a9ea8f7..104bca53d6d0 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift @@ -20,6 +20,10 @@ public struct TerminalSection: View { @State private var hibernation: DefaultsValueModel @State private var idleSeconds: DefaultsValueModel @State private var maxLive: DefaultsValueModel + @State private var surfaceHibernation: DefaultsValueModel + @State private var surfaceIdleSeconds: DefaultsValueModel + @State private var surfaceUnmountedIdleSeconds: DefaultsValueModel + @State private var surfaceMaxLive: DefaultsValueModel public init( defaultsStore: UserDefaultsSettingsStore, @@ -37,6 +41,10 @@ public struct TerminalSection: View { _hibernation = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.agentHibernationEnabled)) _idleSeconds = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.agentHibernationIdleSeconds)) _maxLive = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.agentHibernationMaxLiveTerminals)) + _surfaceHibernation = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.surfaceHibernationEnabled)) + _surfaceIdleSeconds = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.surfaceHibernationIdleSeconds)) + _surfaceUnmountedIdleSeconds = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.surfaceHibernationUnmountedIdleSeconds)) + _surfaceMaxLive = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.surfaceHibernationMaxLiveSurfaces)) } public var body: some View { @@ -210,6 +218,64 @@ public struct TerminalSection: View { ) .accessibilityIdentifier("SettingsTerminalAgentHibernationMaxLiveStepper") } + SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("terminal.surfaceHibernation.enabled"), + String(localized: "settings.terminal.surfaceHibernation", defaultValue: "Surface Hibernation"), + subtitle: surfaceHibernation.current + ? String(localized: "settings.terminal.surfaceHibernation.subtitleOn", defaultValue: "Idle shell terminals in hidden workspaces release their renderer and restart with scrollback and directory restored on the next visit.") + : String(localized: "settings.terminal.surfaceHibernation.subtitleOff", defaultValue: "Terminal surfaces stay live until you close them or quit cmux.") + ) { + Toggle("", isOn: Binding(get: { surfaceHibernation.current }, set: { surfaceHibernation.set($0) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsTerminalSurfaceHibernationToggle") + } + SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("terminal.surfaceHibernation.idleSeconds"), + String(localized: "settings.terminal.surfaceHibernation.idleSeconds", defaultValue: "Cap Reclaim After Idle Seconds"), + subtitle: String(localized: "settings.terminal.surfaceHibernation.idleSeconds.subtitle", defaultValue: "A background shell terminal must be quiet for this long before the live-surface cap may reclaim it."), + controlWidth: 140 + ) { + Stepper( + "\(Int(surfaceIdleSeconds.current))", + value: Binding(get: { surfaceIdleSeconds.current }, set: { surfaceIdleSeconds.set($0) }), + in: 30...604_800, + step: 60 + ) + .accessibilityIdentifier("SettingsTerminalSurfaceHibernationIdleSecondsStepper") + } + SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("terminal.surfaceHibernation.unmountedIdleSeconds"), + String(localized: "settings.terminal.surfaceHibernation.unmountedIdleSeconds", defaultValue: "Hibernate Hidden Workspaces After Seconds"), + subtitle: String(localized: "settings.terminal.surfaceHibernation.unmountedIdleSeconds.subtitle", defaultValue: "Shell terminals in workspaces hidden and quiet for this long hibernate even under the live-surface cap."), + controlWidth: 140 + ) { + Stepper( + "\(Int(surfaceUnmountedIdleSeconds.current))", + value: Binding(get: { surfaceUnmountedIdleSeconds.current }, set: { surfaceUnmountedIdleSeconds.set($0) }), + in: 60...2_592_000, + step: 300 + ) + .accessibilityIdentifier("SettingsTerminalSurfaceHibernationUnmountedIdleSecondsStepper") + } + SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("terminal.surfaceHibernation.maxLiveSurfaces"), + String(localized: "settings.terminal.surfaceHibernation.maxLiveSurfaces", defaultValue: "Max Live Terminal Surfaces"), + subtitle: String(localized: "settings.terminal.surfaceHibernation.maxLiveSurfaces.subtitle", defaultValue: "Every live surface counts toward the cap; only idle, non-busy, hidden ones are reclaimed, oldest first."), + controlWidth: 120 + ) { + Stepper( + "\(surfaceMaxLive.current)", + value: Binding(get: { surfaceMaxLive.current }, set: { surfaceMaxLive.set($0) }), + in: 1...256, + step: 1 + ) + .accessibilityIdentifier("SettingsTerminalSurfaceHibernationMaxLiveStepper") + } } } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index d65fe6d6d087..47b325428e19 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -151256,6 +151256,159 @@ } } }, + "settings.terminal.surfaceHibernation": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Surface Hibernation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サーフェス休止" + } + } + } + }, + "settings.terminal.surfaceHibernation.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Idle shell terminals in hidden workspaces release their renderer and restart with scrollback and directory restored on the next visit." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "非表示ワークスペースのアイドルなシェル端末はレンダラーを解放し、次に表示したときにスクロールバックと作業ディレクトリを復元して再起動します。" + } + } + } + }, + "settings.terminal.surfaceHibernation.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal surfaces stay live until you close them or quit cmux." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルサーフェスは、閉じるか cmux を終了するまでライブのままです。" + } + } + } + }, + "settings.terminal.surfaceHibernation.idleSeconds": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cap Reclaim After Idle Seconds" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "上限による回収までのアイドル秒数" + } + } + } + }, + "settings.terminal.surfaceHibernation.idleSeconds.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A background shell terminal must be quiet for this long before the live-surface cap may reclaim it." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ライブサーフェス数の上限がバックグラウンドのシェル端末を回収できるようになるまで、この秒数のあいだ静止している必要があります。" + } + } + } + }, + "settings.terminal.surfaceHibernation.unmountedIdleSeconds": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hibernate Hidden Workspaces After Seconds" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "非表示ワークスペースを休止するまでの秒数" + } + } + } + }, + "settings.terminal.surfaceHibernation.unmountedIdleSeconds.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shell terminals in workspaces hidden and quiet for this long hibernate even under the live-surface cap." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この秒数のあいだ非表示かつ静止しているワークスペースのシェル端末は、ライブサーフェス数が上限未満でも休止します。" + } + } + } + }, + "settings.terminal.surfaceHibernation.maxLiveSurfaces": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Max Live Terminal Surfaces" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ライブターミナルサーフェスの最大数" + } + } + } + }, + "settings.terminal.surfaceHibernation.maxLiveSurfaces.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Every live surface counts toward the cap; only idle, non-busy, hidden ones are reclaimed, oldest first." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべてのライブサーフェスが上限のカウント対象です。回収されるのはアイドルでビジーでない非表示のものだけで、古い順に休止します。" + } + } + } + }, "settings.terminal.agentHibernation.subtitleOff": { "extractionState": "manual", "localizations": { diff --git a/Resources/shell-integration/fish/config.fish b/Resources/shell-integration/fish/config.fish index feaedd1a56c5..893597fd2431 100644 --- a/Resources/shell-integration/fish/config.fish +++ b/Resources/shell-integration/fish/config.fish @@ -29,6 +29,21 @@ if test "$_cmux_integration_enabled" != 0 end end + # Replay restored scrollback (session restore and surface hibernation) + # exactly once, then remove the one-shot file. Mirrors the zsh/bash + # integrations. + function _cmux_restore_scrollback_once + set -l restore_path "$CMUX_RESTORE_SCROLLBACK_FILE" + set -e CMUX_RESTORE_SCROLLBACK_FILE + test -n "$restore_path"; or return 0 + if test -r "$restore_path" + command /bin/cat -- "$restore_path" 2>/dev/null + command /bin/rm -f -- "$restore_path" >/dev/null 2>&1 + end + return 0 + end + _cmux_restore_scrollback_once + function _cmux_socket_is_unix test -n "$CMUX_SOCKET_PATH"; and test -S "$CMUX_SOCKET_PATH" end diff --git a/Sources/App/AgentHibernationController.swift b/Sources/App/AgentHibernationController.swift index 2234fe2c2d45..bd4fe740fb68 100644 --- a/Sources/App/AgentHibernationController.swift +++ b/Sources/App/AgentHibernationController.swift @@ -7,57 +7,39 @@ struct AgentHibernationPanelKey: Hashable, Sendable { let panelId: UUID } -struct AgentHibernationPlannerInput: Sendable { - let key: AgentHibernationPanelKey - let hasRestorableAgent: Bool - let isLive: Bool - let isProtected: Bool - let lifecycle: AgentHibernationLifecycleState - let hasUnconfirmedTerminalInput: Bool - let lastActivityAt: TimeInterval -} - -enum AgentHibernationPlanner { - static func selectedPanelKeys( - inputs: [AgentHibernationPlannerInput], - settings: AgentHibernationSettings.Values, - now: TimeInterval - ) -> Set { - guard settings.enabled else { return [] } - let liveRestorable = inputs.filter { $0.hasRestorableAgent && $0.isLive } - let excess = liveRestorable.count - settings.maxLiveTerminals - guard excess > 0 else { return [] } - - let eligible = liveRestorable - .filter { input in - !input.isProtected && - input.lifecycle.allowsHibernation && - !input.hasUnconfirmedTerminalInput && - now - input.lastActivityAt >= settings.idleSeconds - } - .sorted { lhs, rhs in - if lhs.lastActivityAt == rhs.lastActivityAt { - return lhs.key.panelId.uuidString < rhs.key.panelId.uuidString - } - return lhs.lastActivityAt < rhs.lastActivityAt - } - - return Set(eligible.prefix(excess).map(\.key)) - } -} - @MainActor struct AgentHibernationRecord { let key: AgentHibernationPanelKey let workspace: Workspace let terminalPanel: TerminalPanel - let agent: SessionRestorableAgentSnapshot + /// Present when the panel runs a restorable coding agent; nil for plain + /// shells, which hibernate through the shell-restart mechanism instead. + let agent: SessionRestorableAgentSnapshot? let lifecycle: AgentHibernationLifecycleState let hasUnconfirmedTerminalInput: Bool let lastActivityAt: TimeInterval let isProtected: Bool + /// Mutable so the bounded foreground-verification pass can mark + /// candidates after the census (see agentHibernationRecords). + var isBusy: Bool + let canRestartShell: Bool + let workspaceUnmountedAt: TimeInterval? + let runtimeSurfaceCreatedAt: TimeInterval let hasLiveProcess: Bool let processIDs: Set + + var mechanism: SurfaceHibernationMechanism? { + if agent != nil { return .agentResume } + return canRestartShell ? .shellRestart : nil + } +} + +/// The per-tick panel census plus the shell-restart candidates whose +/// foreground process still needs (bounded, cached) verification. +@MainActor +struct AgentHibernationRecordCensus { + var records: [AgentHibernationRecord] + let foregroundVerificationCandidates: [(recordIndex: Int, lastActivityAt: TimeInterval)] } @MainActor @@ -78,8 +60,23 @@ final class AgentHibernationController { private let timerQueue = DispatchQueue(label: "com.cmux.agent-hibernation", qos: .utility) private var timer: DispatchSourceTimer? private var settingsObserver: NSObjectProtocol? + private var surfaceSettingsObserver: NSObjectProtocol? + private var defaultsObserver: NSObjectProtocol? private var activityByPanel: [AgentHibernationPanelKey: TimeInterval] = [:] private var terminalInputByPanel: [AgentHibernationPanelKey: TimeInterval] = [:] + private var pendingCommandLineByPanel: [AgentHibernationPanelKey: TimeInterval] = [:] + /// Pending entries that came from pre-tracking seeding rather than + /// observed input; only these may clear on a prompt redraw without a + /// command (empty Enter, ^C). + private var seededPendingPanelKeys: Set = [] + /// Shell-restart candidates whose foreground process was verified busy, + /// by verification time; fresh entries skip re-verification (and the + /// per-tick budget) so the bounded window advances past a busy prefix. + private var foregroundBusyVerifiedAt: [AgentHibernationPanelKey: TimeInterval] = [:] + private var pendingPromptSurvivalsByPanel: [AgentHibernationPanelKey: Int] = [:] + private var lastCommandStartByPanel: [AgentHibernationPanelKey: TimeInterval] = [:] + private var seenLivePanelKeys: Set = [] + private var trackingEnabledAt: TimeInterval? private var lifecycleChangeByPanel: [AgentHibernationPanelKey: TimeInterval] = [:] private var confirmations: [AgentHibernationPanelKey: Confirmation] = [:] private var tailFingerprintSamples: [AgentHibernationPanelKey: TailFingerprintSample] = [:] @@ -100,6 +97,28 @@ final class AgentHibernationController { AgentHibernationController.shared.updateTimerForCurrentSettings() } } + surfaceSettingsObserver = NotificationCenter.default.addObserver( + forName: SurfaceHibernationSettings.didChangeNotification, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in + AgentHibernationController.shared.updateTimerForCurrentSettings() + } + } + // The Settings window writes the enabled keys straight to UserDefaults + // without posting the dedicated change notifications, so reconcile the + // timer on any defaults change as well; the reconcile is a cheap + // idempotent read of two booleans. + defaultsObserver = NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in + AgentHibernationController.shared.updateTimerForCurrentSettings() + } + } updateTimerForCurrentSettings() } @@ -112,13 +131,113 @@ final class AgentHibernationController { NotificationCenter.default.removeObserver(settingsObserver) self.settingsObserver = nil } + if let surfaceSettingsObserver { + NotificationCenter.default.removeObserver(surfaceSettingsObserver) + self.surfaceSettingsObserver = nil + } + if let defaultsObserver { + NotificationCenter.default.removeObserver(defaultsObserver) + self.defaultsObserver = nil + } } - func recordTerminalInput(workspaceId: UUID, panelId: UUID, recordedAt: Date? = nil) { + func recordTerminalInput( + workspaceId: UUID, + panelId: UUID, + recordedAt: Date? = nil, + armsPendingCommandLine: Bool = true, + pendingPromptSurvivals: Int = 0, + clearsSeededPendingCommandLine: Bool = false + ) { guard AgentHibernationTrackingGate.isEnabled() else { return } let recordedAt = recordedAt ?? Date() let key = recordActivity(workspaceId: workspaceId, panelId: panelId, recordedAt: recordedAt) terminalInputByPanel[key] = recordedAt.timeIntervalSince1970 + // Text-bearing input may leave editable text at the prompt — a return + // after text can open a PS2 continuation (unclosed quote, heredoc), + // so no keystroke proves the line settled; only the shell's prompt + // transitions clear this (see recordShellActivityTransition). Bare + // settling/navigation input types nothing and often produces no + // shell transition at all (Enter on an empty prompt, ^C), so it must + // not arm a fresh guard — but it also must not clear one, since ^C + // outcomes are unobservable here. + if armsPendingCommandLine { + pendingCommandLineByPanel[key] = recordedAt.timeIntervalSince1970 + // Observed input replaces a pre-tracking seed: from here on only + // a command consuming the line may clear the guard, because text + // typed at (or ahead of) a prompt reappears editable. + seededPendingPanelKeys.remove(key) + } else if clearsSeededPendingCommandLine, seededPendingPanelKeys.contains(key) { + // A bare Enter settles whatever pre-tracking text the seed + // guarded: the line either submits (its text runs as a command), + // opens a PS2 continuation (needsConfirmClose keeps the panel + // busy), or was already empty. ^C deliberately does not clear — + // it can resurface input typed ahead of a still-running + // pre-tracking command. Input-backed pending keeps the strict + // command-cycle rule. This is the requalification path for + // shells that were already idle when hibernation was re-enabled, + // because the shell integrations dedupe repeat promptIdle + // reports at the source (_CMUX_SHELL_ACTIVITY_LAST), so an + // empty Enter never reaches us as a prompt transition. + seededPendingPanelKeys.remove(key) + pendingCommandLineByPanel.removeValue(forKey: key) + } + // A batched payload such as "cmd1\ncmd2\npartial" runs one command + // per settling character and only then leaves text editable, so the + // pending state must outlive that many prompt transitions. Counts + // accumulate across payloads and survive interleaved plain text: + // typing "x" while a batch's commands are still settling appends to + // the eventual editable line, so dropping the count here would let + // the batch's own promptIdle clear pending while "…x" sits at the + // prompt. Only shell transitions consume survivals (an overcount + // after ^C just delays eviction by a few prompts). + if pendingPromptSurvivals > 0 { + pendingPromptSurvivalsByPanel[key] = + (pendingPromptSurvivalsByPanel[key] ?? 0) + pendingPromptSurvivals + } + } + + /// Shell-integration prompt transitions are the only trustworthy signal + /// that the editable command line settled: preexec marks the moment input + /// was consumed into a command, and the following precmd (prompt idle) + /// clears pending input recorded before that consumption. Input typed + /// while a command runs stays pending — it reappears editable at the next + /// prompt — and PS2 continuations never produce these transitions at all. + func recordShellActivityTransition( + workspaceId: UUID, + panelId: UUID, + state: Workspace.PanelShellActivityState, + recordedAt: Date? = nil + ) { + guard AgentHibernationTrackingGate.isEnabled() else { return } + let recordedAt = recordedAt ?? Date() + let now = recordedAt.timeIntervalSince1970 + switch state { + case .commandRunning: + // Transitions are activity: the idle window must restart when a + // hidden command starts or finishes, not stay anchored at the + // input or unmount time. + let key = recordActivity(workspaceId: workspaceId, panelId: panelId, recordedAt: recordedAt) + lastCommandStartByPanel[key] = now + case .promptIdle: + let key = recordActivity(workspaceId: workspaceId, panelId: panelId, recordedAt: recordedAt) + if let survivals = pendingPromptSurvivalsByPanel[key], survivals > 0 { + // One of the batched commands returned to a prompt; the + // trailing text is only editable after the last of them. + if survivals == 1 { + pendingPromptSurvivalsByPanel.removeValue(forKey: key) + } else { + pendingPromptSurvivalsByPanel[key] = survivals - 1 + } + } else if let pendingAt = pendingCommandLineByPanel[key], + let commandStartAt = lastCommandStartByPanel[key], + pendingAt <= commandStartAt { + pendingCommandLineByPanel.removeValue(forKey: key) + seededPendingPanelKeys.remove(key) + } + case .unknown: + break + } } func recordTerminalFocus(workspaceId: UUID, panelId: UUID, recordedAt: Date? = nil) { @@ -127,6 +246,28 @@ final class AgentHibernationController { recordActivity(workspaceId: workspaceId, panelId: panelId, recordedAt: recordedAt) } +#if DEBUG + func debugPendingCommandLineStateForTesting( + workspaceId: UUID, + panelId: UUID + ) -> (pendingAt: TimeInterval?, survivals: Int?) { + let key = AgentHibernationPanelKey(workspaceId: workspaceId, panelId: panelId) + return (pendingCommandLineByPanel[key], pendingPromptSurvivalsByPanel[key]) + } + + /// Mirrors the pre-tracking seeding evaluate() performs for surfaces + /// created while tracking was off. + func debugSeedPendingCommandLineForTesting( + workspaceId: UUID, + panelId: UUID, + recordedAt: Date + ) { + let key = AgentHibernationPanelKey(workspaceId: workspaceId, panelId: panelId) + pendingCommandLineByPanel[key] = recordedAt.timeIntervalSince1970 + seededPendingPanelKeys.insert(key) + } +#endif + func recordAgentLifecycleChange(workspaceId: UUID, panelId: UUID, recordedAt: Date? = nil) { guard AgentHibernationTrackingGate.isEnabled() else { return } let recordedAt = recordedAt ?? Date() @@ -143,7 +284,13 @@ final class AgentHibernationController { } private func updateTimerForCurrentSettings() { - let enabled = AgentHibernationSettings.isEnabled() + let enabled = AgentHibernationSettings.isEnabled() || SurfaceHibernationSettings.isEnabled() + if enabled, !AgentHibernationTrackingGate.isEnabled() { + // Input typed while tracking was off was never recorded, so panels + // that already existed are conservatively seeded as having pending + // command-line input when they are first evaluated. + trackingEnabledAt = Date().timeIntervalSince1970 + } AgentHibernationTrackingGate.setEnabled(enabled) guard enabled else { timer?.cancel() @@ -157,11 +304,30 @@ final class AgentHibernationController { timer.setEventHandler { let now = Date() Task.detached(priority: .utility) { - let index = await RestorableAgentSessionIndex.loadIncludingProcessDetectedSnapshots() + guard AgentHibernationSettings.isEnabled() || SurfaceHibernationSettings.isEnabled() else { + return + } + // The full index load is an expensive disk/process scan that + // only the opt-in agent mechanism needs. Surface-only ticks + // still recognize restored agent panels through the in-memory + // workspace snapshots and protect running agents via the busy + // gates instead. + let index = AgentHibernationSettings.isEnabled() + ? await RestorableAgentSessionIndex.loadIncludingProcessDetectedSnapshots() + : .empty await MainActor.run { - let settings = AgentHibernationSettings.values() - guard settings.enabled else { return } - AgentHibernationController.shared.evaluate(index: index, settings: settings, now: now) + // Re-read on the main actor: the user may have disabled + // hibernation while the index load was in flight, and a + // stale enabled snapshot must not reach evaluate. + let agentSettings = AgentHibernationSettings.values() + let surfaceSettings = SurfaceHibernationSettings.values() + guard agentSettings.enabled || surfaceSettings.enabled else { return } + AgentHibernationController.shared.evaluate( + index: index, + agentSettings: agentSettings, + surfaceSettings: surfaceSettings, + now: now + ) } } } @@ -171,91 +337,123 @@ final class AgentHibernationController { private func evaluate( index: RestorableAgentSessionIndex, - settings: AgentHibernationSettings.Values, + agentSettings: AgentHibernationSettings.Values, + surfaceSettings: SurfaceHibernationSettings.Values, now: Date ) { - guard settings.enabled else { + guard agentSettings.enabled || surfaceSettings.enabled else { AgentHibernationTrackingGate.setEnabled(false) clearTrackingState() return } guard let appDelegate = AppDelegate.shared else { return } - let records = appDelegate.agentHibernationRecords( + var census = appDelegate.agentHibernationRecords( index: index, activityByPanel: activityByPanel, terminalInputByPanel: terminalInputByPanel, - lifecycleChangeByPanel: lifecycleChangeByPanel + lifecycleChangeByPanel: lifecycleChangeByPanel, + surfaceSettings: surfaceSettings, + now: now.timeIntervalSince1970 ) + applyBoundedForegroundVerification(census: &census, nowTime: now.timeIntervalSince1970) + let records = census.records let nowTime = now.timeIntervalSince1970 let isLiveByKey = Dictionary(uniqueKeysWithValues: records.map { record in - ( - record.key, - (record.terminalPanel.surface.hasLiveSurface || record.hasLiveProcess) && - !record.terminalPanel.isAgentHibernated - ) + (record.key, Self.isRecordLive(record)) }) - let liveRestorableCount = isLiveByKey.values.filter { $0 }.count - let shouldMaintainTailSamples = liveRestorableCount >= settings.maxLiveTerminals - var effectiveActivityByKey: [AgentHibernationPanelKey: TimeInterval] = [:] - let plannerInputs = records.map { record in - let isLive = isLiveByKey[record.key] ?? false - var effectiveLastActivityAt = record.lastActivityAt - if shouldMaintainTailSamples, - isLive, - !record.isProtected, - record.lifecycle.allowsHibernation, - !record.hasUnconfirmedTerminalInput, - let tailActivityAt = updateTailFingerprintSample(record: record, now: nowTime) { - effectiveLastActivityAt = max(record.lastActivityAt, tailActivityAt) + for record in records where record.agent == nil && (isLiveByKey[record.key] ?? false) { + guard seenLivePanelKeys.insert(record.key).inserted else { continue } + if let trackingEnabledAt, record.runtimeSurfaceCreatedAt < trackingEnabledAt { + // The surface predates tracking: its prompt may hold typed + // text we never saw. Treat it as pending until a prompt + // transition proves the command line settled. The seed marker + // lets a later prompt redraw with no command (empty Enter, + // ^C) clear this, which real typed-ahead input must never do. + pendingCommandLineByPanel[record.key] = nowTime + seededPendingPanelKeys.insert(record.key) } - effectiveActivityByKey[record.key] = effectiveLastActivityAt - return AgentHibernationPlannerInput( + } + let liveRestorableCount = records.filter { record in + record.agent != nil && (isLiveByKey[record.key] ?? false) + }.count + // Tail fingerprints detect output-only activity (a hidden build still + // streaming) and cost a terminal text read per candidate per tick, so + // they stay scoped to the pre-existing opt-in agent cap. The surface + // rules select at most maxSelectionsPerEvaluation panels and the + // confirmation fingerprint re-reads exactly those tails, which keeps + // streaming panels safe without fanning reads across every hidden + // terminal. + let shouldMaintainTailSamples = agentSettings.enabled && + liveRestorableCount >= agentSettings.maxLiveTerminals + var inputsByKey: [AgentHibernationPanelKey: SurfaceHibernationPlannerInput] = [:] + let plannerInputs = records.map { record -> SurfaceHibernationPlannerInput in + var input = SurfaceHibernationPlannerInput( key: record.key, - hasRestorableAgent: true, - isLive: isLive, + mechanism: record.mechanism, + isLive: isLiveByKey[record.key] ?? false, isProtected: record.isProtected, + isBusy: record.isBusy, lifecycle: record.lifecycle, - hasUnconfirmedTerminalInput: record.hasUnconfirmedTerminalInput, - lastActivityAt: effectiveLastActivityAt + hasUnconfirmedTerminalInput: record.agent != nil + ? record.hasUnconfirmedTerminalInput + : pendingCommandLineByPanel[record.key] != nil, + lastActivityAt: record.lastActivityAt, + workspaceUnmountedAt: record.workspaceUnmountedAt ) + if shouldMaintainTailSamples, + record.agent != nil, + input.isLive, + SurfaceHibernationPlanner.isEvictable(input, agentSettings: agentSettings), + let tailActivityAt = updateTailFingerprintSample(record: record, now: nowTime) { + input.lastActivityAt = max(input.lastActivityAt, tailActivityAt) + } + inputsByKey[record.key] = input + return input } - let selectedKeys = AgentHibernationPlanner.selectedPanelKeys( + let selectedKeys = SurfaceHibernationPlanner.selectedPanelKeys( inputs: plannerInputs, - settings: settings, + agentSettings: agentSettings, + surfaceSettings: surfaceSettings, now: nowTime ) let currentKeys = Set(records.map(\.key)) pruneTrackingState(currentKeys: currentKeys, selectedKeys: selectedKeys) for record in records where selectedKeys.contains(record.key) { + guard let input = inputsByKey[record.key] else { continue } evaluateConfirmation( record: record, - effectiveLastActivityAt: effectiveActivityByKey[record.key] ?? record.lastActivityAt, - settings: settings, + input: input, + agentSettings: agentSettings, + surfaceSettings: surfaceSettings, now: nowTime ) } } + private static func isRecordLive(_ record: AgentHibernationRecord) -> Bool { + (record.terminalPanel.surface.hasLiveSurface || (record.agent != nil && record.hasLiveProcess)) && + !record.terminalPanel.isAgentHibernated && + !record.terminalPanel.isSurfaceHibernated + } + private func evaluateConfirmation( record: AgentHibernationRecord, - effectiveLastActivityAt: TimeInterval, - settings: AgentHibernationSettings.Values, + input: SurfaceHibernationPlannerInput, + agentSettings: AgentHibernationSettings.Values, + surfaceSettings: SurfaceHibernationSettings.Values, now: TimeInterval ) { - guard record.lifecycle.allowsHibernation, - !record.hasUnconfirmedTerminalInput, - !record.isProtected, - record.terminalPanel.surface.hasLiveSurface || record.hasLiveProcess, - !record.terminalPanel.isAgentHibernated else { + guard input.isLive, + SurfaceHibernationPlanner.isEvictable(input, agentSettings: agentSettings) else { confirmations.removeValue(forKey: record.key) return } if let confirmation = confirmations[record.key] { guard now >= confirmation.dueAt else { return } - guard effectiveLastActivityAt <= confirmation.sampledAt else { + guard input.lastActivityAt <= confirmation.sampledAt else { confirmations.removeValue(forKey: record.key) return } @@ -265,23 +463,74 @@ final class AgentHibernationController { return } confirmations.removeValue(forKey: record.key) - terminateScopedProcessesForHibernation(record: record) - record.workspace.enterAgentHibernation( - panelId: record.key.panelId, - agent: record.agent, - lastActivityAt: Date(timeIntervalSince1970: effectiveLastActivityAt) - ) + hibernate(record: record, effectiveLastActivityAt: input.lastActivityAt) return } guard let fingerprint = hibernationFingerprint(for: record) else { return } + let confirmationSeconds = record.agent != nil + ? agentSettings.confirmationSeconds + : surfaceSettings.confirmationSeconds confirmations[record.key] = Confirmation( fingerprint: fingerprint, sampledAt: now, - dueAt: now + settings.confirmationSeconds + dueAt: now + confirmationSeconds ) } + private func hibernate(record: AgentHibernationRecord, effectiveLastActivityAt: TimeInterval) { + let lastActivityAt = Date(timeIntervalSince1970: effectiveLastActivityAt) + if let agent = record.agent { + terminateScopedProcessesForHibernation(record: record) + record.workspace.enterAgentHibernation( + panelId: record.key.panelId, + agent: agent, + lastActivityAt: lastActivityAt + ) + } else { + hibernateSurfaceWithOffActorReplayWrite(record: record, lastActivityAt: lastActivityAt) + } + } + + /// Shell-restart hibernation from the timer: capture on the main actor, + /// write the replay file on a utility task (an atomic write of a large + /// scrollback would stall the main thread), then re-validate and commit. + /// Any activity or input observed during the write hop aborts the + /// transition and discards the file — the panel simply stays live. + private func hibernateSurfaceWithOffActorReplayWrite( + record: AgentHibernationRecord, + lastActivityAt: Date + ) { + let key = record.key + guard let capture = record.workspace.captureSurfaceHibernation(panelId: key.panelId) else { + return + } + let activityAtCapture = activityByPanel[key] + let inputAtCapture = terminalInputByPanel[key] + let scrollback = capture.scrollback + Task { @MainActor in + let replayFilePath = await Task.detached(priority: .utility) { + SessionScrollbackReplayStore.replayFilePath(for: scrollback) + }.value + let stillQuiet = self.activityByPanel[key] == activityAtCapture && + self.terminalInputByPanel[key] == inputAtCapture + if !stillQuiet || !record.workspace.commitSurfaceHibernation( + panelId: key.panelId, + capture: capture, + replayFilePath: replayFilePath, + lastActivityAt: lastActivityAt + ) { + if !stillQuiet, let replayFilePath { + // commit deletes the file itself when it aborts; the + // quiet-check abort must clean up its own copy. + Task.detached(priority: .utility) { + try? FileManager.default.removeItem(atPath: replayFilePath) + } + } + } + } + } + private func updateTailFingerprintSample( record: AgentHibernationRecord, now: TimeInterval @@ -305,7 +554,8 @@ final class AgentHibernationController { previousStableSince: previousSample?.stableSince, currentFingerprint: fingerprint, lastActivityAt: record.lastActivityAt, - now: now + now: now, + firstSampleFallback: record.workspaceUnmountedAt.map { max(record.lastActivityAt, $0) } ) tailFingerprintSamples[record.key] = TailFingerprintSample( fingerprint: fingerprint, @@ -319,11 +569,12 @@ final class AgentHibernationController { if let tail = tailFingerprint(for: record.terminalPanel) { return Self.scrollbackFingerprint(tail: tail, processIDs: record.processIDs) } - guard record.hasLiveProcess, + guard let agent = record.agent, + record.hasLiveProcess, !record.terminalPanel.surface.hasLiveSurface else { return nil } return Self.processFallbackFingerprint( - kind: record.agent.kind, - sessionId: record.agent.sessionId, + kind: agent.kind, + sessionId: agent.sessionId, processIDs: record.processIDs ) } @@ -345,11 +596,20 @@ final class AgentHibernationController { previousStableSince: TimeInterval?, currentFingerprint: String, lastActivityAt: TimeInterval, - now: TimeInterval + now: TimeInterval, + firstSampleFallback: TimeInterval? = nil ) -> TimeInterval { if previousFingerprint == currentFingerprint { return previousStableSince ?? lastActivityAt } + // First sample: stability was never observed. Cap-rule candidates + // conservatively start the window at `now`, but unmounted-workspace + // candidates may pass the wall-clock floor instead — otherwise the + // documented hidden-workspace window would double, since sampling only + // begins once the rule already has pressure. + if previousFingerprint == nil, let firstSampleFallback { + return firstSampleFallback + } return now } @@ -388,9 +648,59 @@ final class AgentHibernationController { } } + /// Foreground verification (child scan + argv read) costs syscalls per + /// candidate, so each tick verifies at most a fixed budget, oldest first — + /// the planner's eviction order. Candidates verified busy are cached for + /// `foregroundBusyRecheckSeconds` and marked busy without consuming + /// budget, so a long busy prefix (e.g. many attached tmux clients) cannot + /// starve reclaimable shells behind it: the budget flows to unverified + /// candidates and the window provably advances each tick. Candidates past + /// the budget stay conservatively busy until a later tick. + private func applyBoundedForegroundVerification( + census: inout AgentHibernationRecordCensus, + nowTime: TimeInterval + ) { + guard !census.foregroundVerificationCandidates.isEmpty else { return } + var budget = SurfaceHibernationPlanner.maxForegroundVerificationsPerEvaluation + let ordered = census.foregroundVerificationCandidates.sorted { lhs, rhs in + lhs.lastActivityAt < rhs.lastActivityAt + } + for candidate in ordered { + let key = census.records[candidate.recordIndex].key + // Pending command-line input already makes the panel ineligible + // (isEvictable), so verifying it would burn budget every tick + // without ever caching busy — 32 old pending panels could starve + // everything behind them. + if pendingCommandLineByPanel[key] != nil { continue } + if let verifiedAt = foregroundBusyVerifiedAt[key], + nowTime - verifiedAt < SurfaceHibernationPlanner.foregroundBusyRecheckSeconds { + census.records[candidate.recordIndex].isBusy = true + continue + } + guard budget > 0 else { + census.records[candidate.recordIndex].isBusy = true + continue + } + budget -= 1 + if census.records[candidate.recordIndex].terminalPanel.surface + .foregroundProcessAllowsShellRestart() { + foregroundBusyVerifiedAt.removeValue(forKey: key) + } else { + census.records[candidate.recordIndex].isBusy = true + foregroundBusyVerifiedAt[key] = nowTime + } + } + } + private func clearTrackingState() { activityByPanel.removeAll(keepingCapacity: false) terminalInputByPanel.removeAll(keepingCapacity: false) + pendingCommandLineByPanel.removeAll(keepingCapacity: false) + seededPendingPanelKeys.removeAll(keepingCapacity: false) + foregroundBusyVerifiedAt.removeAll(keepingCapacity: false) + pendingPromptSurvivalsByPanel.removeAll(keepingCapacity: false) + lastCommandStartByPanel.removeAll(keepingCapacity: false) + seenLivePanelKeys.removeAll(keepingCapacity: false) lifecycleChangeByPanel.removeAll(keepingCapacity: false) confirmations.removeAll(keepingCapacity: false) tailFingerprintSamples.removeAll(keepingCapacity: false) @@ -402,6 +712,12 @@ final class AgentHibernationController { ) { activityByPanel = activityByPanel.filter { currentKeys.contains($0.key) } terminalInputByPanel = terminalInputByPanel.filter { currentKeys.contains($0.key) } + pendingCommandLineByPanel = pendingCommandLineByPanel.filter { currentKeys.contains($0.key) } + seededPendingPanelKeys = seededPendingPanelKeys.filter { currentKeys.contains($0) } + foregroundBusyVerifiedAt = foregroundBusyVerifiedAt.filter { currentKeys.contains($0.key) } + pendingPromptSurvivalsByPanel = pendingPromptSurvivalsByPanel.filter { currentKeys.contains($0.key) } + lastCommandStartByPanel = lastCommandStartByPanel.filter { currentKeys.contains($0.key) } + seenLivePanelKeys = seenLivePanelKeys.filter { currentKeys.contains($0) } lifecycleChangeByPanel = lifecycleChangeByPanel.filter { currentKeys.contains($0.key) } confirmations = confirmations.filter { key, _ in currentKeys.contains(key) && selectedKeys.contains(key) @@ -416,24 +732,44 @@ extension AppDelegate { index: RestorableAgentSessionIndex, activityByPanel: [AgentHibernationPanelKey: TimeInterval], terminalInputByPanel: [AgentHibernationPanelKey: TimeInterval], - lifecycleChangeByPanel: [AgentHibernationPanelKey: TimeInterval] - ) -> [AgentHibernationRecord] { + lifecycleChangeByPanel: [AgentHibernationPanelKey: TimeInterval], + surfaceSettings: SurfaceHibernationSettings.Values, + now: TimeInterval + ) -> AgentHibernationRecordCensus { var records: [AgentHibernationRecord] = [] var seenManagers: Set = [] + // Replay capability can be revoked mid-session: the integration + // setting applies to the NEXT surface launch, so a shell hibernated + // after disabling it would restore without its scrollback. + let shellIntegrationCurrentlyEnabled = + UserDefaults.standard.object(forKey: "sidebarShellIntegration") as? Bool ?? true + // Every surface rule requires at least this much quiet before it can + // select a shell, so panels under the gate skip the per-process + // syscalls below. Tail-fingerprint folding only moves activity + // forward, so a panel that fails this unfolded check can never pass + // the planner's folded one. + let shellRestartCandidateIdleGate = + min(surfaceSettings.idleSeconds, surfaceSettings.unmountedIdleSeconds) + + var foregroundVerificationCandidates: [(recordIndex: Int, lastActivityAt: TimeInterval)] = [] + func visit(tabManager manager: TabManager, visibleWorkspaceId: UUID?) { let managerId = ObjectIdentifier(manager) guard seenManagers.insert(managerId).inserted else { return } for workspace in manager.tabs { let workspaceIsVisible = visibleWorkspaceId == workspace.id let visiblePanelIds = workspaceIsVisible - ? workspace.agentHibernationVisiblePanelIdsForCurrentLayout() + ? workspace.surfaceHibernationProtectedPanelIdsForCurrentLayout() : [] + let workspaceUnmountedAt = workspaceIsVisible + ? nil + : workspace.portalRenderingDisabledAt?.timeIntervalSince1970 for (panelId, panel) in workspace.panels { - guard let terminalPanel = panel as? TerminalPanel, - let agent = workspace.restorableAgentForHibernation(panelId: panelId, index: index) else { + guard let terminalPanel = panel as? TerminalPanel else { continue } + let agent = workspace.restorableAgentForHibernation(panelId: panelId, index: index) let key = AgentHibernationPanelKey(workspaceId: workspace.id, panelId: panelId) let indexActivity = index.updatedAt(workspaceId: workspace.id, panelId: panelId) ?? 0 let localActivity = activityByPanel[key] ?? 0 @@ -445,6 +781,47 @@ extension AppDelegate { panelId: panelId, fallback: index.lifecycle(workspaceId: workspace.id, panelId: panelId) ) + let isRemoteTerminal = workspace.isRemoteWorkspace || + workspace.isRemoteTerminalSurface(panelId) + // Recreating these surfaces would rerun startup commands, + // reattach remote PTYs, or drop queued input, so they are + // never shell-restarted. + let canRestartShell = agent == nil && + !isRemoteTerminal && + !terminalPanel.surface.hasDeferredStartupWorkForBackgroundStart() && + terminalPanel.surface.runtimeSupportsScrollbackReplay && + shellIntegrationCurrentlyEnabled + let lastActivityAt = max(indexActivity, localActivity, createdAt) + let isProtected = workspaceIsVisible && visiblePanelIds.contains(panelId) + // The planner can only select this shell once it is + // unprotected and past the smallest idle gate, so the + // per-process syscalls (child scan, argv read) run for + // actual candidates instead of every panel every tick. + let isShellRestartCandidate = canRestartShell && + surfaceSettings.enabled && + !isProtected && + terminalPanel.surface.hasLiveSurface && + now - lastActivityAt >= shellRestartCandidateIdleGate + // Busy means freeing the PTY could kill live work: the + // shell-integration state reports a running command (or + // Ghostty's prompt heuristic says we are not at one), the + // terminal serves a listening port, or — for shell-restart + // candidates — the foreground process is not a bare, + // childless shell (background jobs and attached tmux + // clients produce no prompt or output signal of their own). + // The foreground verification syscalls are deferred to a + // bounded LRU pass after the census below. + let isCheaplyBusy = workspace.panelNeedsConfirmClose( + panelId: panelId, + fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose() + ) || + (isShellRestartCandidate && + !(workspace.surfaceListeningPorts[panelId] ?? []).isEmpty) + if isShellRestartCandidate, !isCheaplyBusy { + foregroundVerificationCandidates.append( + (recordIndex: records.count, lastActivityAt: lastActivityAt) + ) + } records.append( AgentHibernationRecord( key: key, @@ -452,9 +829,16 @@ extension AppDelegate { terminalPanel: terminalPanel, agent: agent, lifecycle: lifecycle, - hasUnconfirmedTerminalInput: terminalInputAt > lifecycleChangeAt, - lastActivityAt: max(indexActivity, localActivity, createdAt), - isProtected: workspaceIsVisible && visiblePanelIds.contains(panelId), + // Agent-only here; plain-shell pending input is + // resolved at evaluation time, after pre-tracking + // surfaces are seeded. + hasUnconfirmedTerminalInput: agent != nil && terminalInputAt > lifecycleChangeAt, + lastActivityAt: lastActivityAt, + isProtected: isProtected, + isBusy: isCheaplyBusy, + canRestartShell: canRestartShell, + workspaceUnmountedAt: workspaceUnmountedAt, + runtimeSurfaceCreatedAt: createdAt, hasLiveProcess: index.hasLiveProcess(workspaceId: workspace.id, panelId: panelId), processIDs: index.processIDs(workspaceId: workspace.id, panelId: panelId) ) @@ -471,6 +855,9 @@ extension AppDelegate { visit(tabManager: tabManager, visibleWorkspaceId: nil) } - return records + return AgentHibernationRecordCensus( + records: records, + foregroundVerificationCandidates: foregroundVerificationCandidates + ) } } diff --git a/Sources/App/SurfaceHibernationPolicy.swift b/Sources/App/SurfaceHibernationPolicy.swift new file mode 100644 index 000000000000..188a52042826 --- /dev/null +++ b/Sources/App/SurfaceHibernationPolicy.swift @@ -0,0 +1,241 @@ +import Foundation + +/// How a live Ghostty runtime surface can be reclaimed by hibernation. +enum SurfaceHibernationMechanism: Equatable, Sendable { + /// The panel runs a restorable coding agent. Hibernation terminates the + /// scoped agent processes and frees the runtime surface; the agent resumes + /// with its saved session when the panel is visited again. + case agentResume + /// The panel is a plain shell whose recreation has no side effects. + /// Hibernation captures scrollback, frees the runtime surface (which ends + /// the shell), and starts a fresh shell in the captured working directory + /// with the scrollback replayed when the panel is visited again. + case shellRestart +} + +/// One terminal panel as seen by the surface-lifecycle planner. +struct SurfaceHibernationPlannerInput: Sendable { + let key: AgentHibernationPanelKey + /// nil when the panel must never be hibernated (deferred startup work, + /// remote PTY attach, tmux-bound, or queued input that suspension would drop). + let mechanism: SurfaceHibernationMechanism? + /// Whether the panel currently owns a live Ghostty runtime surface (or, for + /// agent panels, a live scoped process). Every live input counts toward the + /// global cap census even when it is exempt from eviction. + let isLive: Bool + /// Visible in the selected workspace's rendered layout. + let isProtected: Bool + /// Freeing the PTY could kill live work: the surface is not safely at a + /// shell prompt (`needsConfirmClose`), the foreground process is not a + /// bare shell (e.g. an attached tmux client) or has background child + /// processes, or the terminal owns listening ports. + let isBusy: Bool + /// Agent lifecycle reported by hooks; `.unknown` for plain shells. + let lifecycle: AgentHibernationLifecycleState + /// Input the user could lose: typed after the last agent lifecycle change + /// (agent panels) or left pending on the editable command line without a + /// settling return/interrupt (plain shells). + let hasUnconfirmedTerminalInput: Bool + /// Mutable so the controller can fold in tail-fingerprint (output-only) + /// activity after constructing the input. + var lastActivityAt: TimeInterval + /// When the owning workspace last stopped rendering (unmounted), or nil + /// while it is mounted. + let workspaceUnmountedAt: TimeInterval? + + init( + key: AgentHibernationPanelKey, + mechanism: SurfaceHibernationMechanism?, + isLive: Bool, + isProtected: Bool, + isBusy: Bool = false, + lifecycle: AgentHibernationLifecycleState = .unknown, + hasUnconfirmedTerminalInput: Bool = false, + lastActivityAt: TimeInterval, + workspaceUnmountedAt: TimeInterval? = nil + ) { + self.key = key + self.mechanism = mechanism + self.isLive = isLive + self.isProtected = isProtected + self.isBusy = isBusy + self.lifecycle = lifecycle + self.hasUnconfirmedTerminalInput = hasUnconfirmedTerminalInput + self.lastActivityAt = lastActivityAt + self.workspaceUnmountedAt = workspaceUnmountedAt + } +} + +/// Pure selection policy for which live terminal surfaces to hibernate. +/// +/// Three rules compose, and the result is their union: +/// - Agent cap: live restorable-agent terminals above +/// `AgentHibernationSettings.maxLiveTerminals`, oldest first. +/// - Global LRU cap: live surfaces of any kind above +/// `SurfaceHibernationSettings.maxLiveSurfaces`, oldest first. The census +/// counts every live surface — including ones materialized by background +/// priming or queued socket input — while only eligible ones are evicted. +/// - Unmounted idle: surfaces whose workspace has been unmounted longer than +/// `SurfaceHibernationSettings.unmountedIdleSeconds`, regardless of cap +/// pressure. +enum SurfaceHibernationPlanner { + static func selectedPanelKeys( + inputs: [SurfaceHibernationPlannerInput], + agentSettings: AgentHibernationSettings.Values, + surfaceSettings: SurfaceHibernationSettings.Values, + now: TimeInterval + ) -> Set { + var selected = agentCapSelection(inputs: inputs, agentSettings: agentSettings, now: now) + selected.formUnion( + unmountedIdleSelection( + inputs: inputs, + agentSettings: agentSettings, + surfaceSettings: surfaceSettings, + now: now + ) + ) + // The global cap runs last and discounts surfaces the other rules + // already selected — they are about to stop being live, so reclaiming + // further panels for the same overflow would overshoot the cap. + selected.formUnion( + globalCapSelection( + inputs: inputs, + agentSettings: agentSettings, + surfaceSettings: surfaceSettings, + now: now, + alreadySelected: selected + ) + ) + return selected + } + + /// Live restorable-agent terminals above `maxLiveTerminals`, oldest first. + /// Matches the pre-existing agent hibernation cap exactly. + private static func agentCapSelection( + inputs: [SurfaceHibernationPlannerInput], + agentSettings: AgentHibernationSettings.Values, + now: TimeInterval + ) -> Set { + guard agentSettings.enabled else { return [] } + let liveRestorable = inputs.filter { $0.mechanism == .agentResume && $0.isLive } + let excess = liveRestorable.count - agentSettings.maxLiveTerminals + guard excess > 0 else { return [] } + + let eligible = liveRestorable + .filter { input in + isEvictable(input, agentSettings: agentSettings) && + now - input.lastActivityAt >= agentSettings.idleSeconds + } + .sorted(by: leastRecentlyUsedFirst) + + return Set(eligible.prefix(excess).map(\.key)) + } + + /// Live surfaces of any kind above `maxLiveSurfaces`, oldest first. Every + /// live surface counts toward the census — including exempt ones — so + /// surfaces materialized by bypass paths still create eviction pressure. + private static func globalCapSelection( + inputs: [SurfaceHibernationPlannerInput], + agentSettings: AgentHibernationSettings.Values, + surfaceSettings: SurfaceHibernationSettings.Values, + now: TimeInterval, + alreadySelected: Set + ) -> Set { + guard surfaceSettings.enabled else { return [] } + let live = inputs.filter(\.isLive) + let alreadySelectedLiveCount = live.count { alreadySelected.contains($0.key) } + let excess = live.count - alreadySelectedLiveCount - surfaceSettings.maxLiveSurfaces + guard excess > 0 else { return [] } + + let eligible = live + .filter { input in + guard !alreadySelected.contains(input.key), + isEvictable(input, agentSettings: agentSettings) else { return false } + let idleGate = input.mechanism == .agentResume + ? agentSettings.idleSeconds + : surfaceSettings.idleSeconds + return now - input.lastActivityAt >= idleGate + } + .sorted(by: leastRecentlyUsedFirst) + + return Set(eligible.prefix(min(excess, maxSelectionsPerEvaluation)).map(\.key)) + } + + /// Hibernating a panel synchronously captures scrollback and frees the + /// runtime surface on the main actor, so when many hidden terminals become + /// reclaimable together — cap overflow or a cohort crossing the idle + /// window — each rule drains a few per evaluation instead of one unbounded + /// batch. Successive 30s ticks converge on the target. + static let maxSelectionsPerEvaluation = 4 + + /// Per-tick cap on foreground-process verifications (child scan + argv + /// read syscalls). Verification runs oldest-first — the planner's + /// eviction order — so the bound caps main-actor syscall fan-out at any + /// panel count while still covering several times more candidates than + /// one evaluation can drain. Verified-busy candidates are cached (see + /// `foregroundBusyRecheckSeconds`) and stop consuming this budget, so a + /// busy prefix cannot starve reclaimable shells behind it. + static let maxForegroundVerificationsPerEvaluation = maxSelectionsPerEvaluation * 8 + + /// How long a verified-busy foreground result is trusted before the + /// candidate pays the verification syscalls again. Until it expires the + /// panel simply is not reclaimed — conservative in the safe direction — + /// and the per-tick budget flows to candidates not yet verified. + static let foregroundBusyRecheckSeconds: TimeInterval = 600 + + /// Surfaces whose workspace has been unmounted — and which have been quiet + /// — for at least `unmountedIdleSeconds`, regardless of cap pressure. + /// Oldest first, bounded per evaluation. + private static func unmountedIdleSelection( + inputs: [SurfaceHibernationPlannerInput], + agentSettings: AgentHibernationSettings.Values, + surfaceSettings: SurfaceHibernationSettings.Values, + now: TimeInterval + ) -> Set { + guard surfaceSettings.enabled else { return [] } + let eligible = inputs + .filter { input in + guard input.isLive, + isEvictable(input, agentSettings: agentSettings), + let workspaceUnmountedAt = input.workspaceUnmountedAt else { + return false + } + let quietSince = max(input.lastActivityAt, workspaceUnmountedAt) + return now - quietSince >= surfaceSettings.unmountedIdleSeconds + } + .sorted(by: leastRecentlyUsedFirst) + return Set(eligible.prefix(maxSelectionsPerEvaluation).map(\.key)) + } + + static func isEvictable( + _ input: SurfaceHibernationPlannerInput, + agentSettings: AgentHibernationSettings.Values + ) -> Bool { + guard !input.isProtected, + !input.hasUnconfirmedTerminalInput, + let mechanism = input.mechanism else { + return false + } + switch mechanism { + case .agentResume: + // Agent terminals are only reclaimed through their resume path, + // which the user opts into separately, and only while the agent + // reports an idle lifecycle. + return agentSettings.enabled && input.lifecycle.allowsHibernation + case .shellRestart: + // Freeing the surface ends the shell, so never reclaim one that is + // not safely at a prompt. + return !input.isBusy + } + } + + static func leastRecentlyUsedFirst( + _ lhs: SurfaceHibernationPlannerInput, + _ rhs: SurfaceHibernationPlannerInput + ) -> Bool { + if lhs.lastActivityAt == rhs.lastActivityAt { + return lhs.key.panelId.uuidString < rhs.key.panelId.uuidString + } + return lhs.lastActivityAt < rhs.lastActivityAt + } +} diff --git a/Sources/App/WorkspaceRuntimeSettings.swift b/Sources/App/WorkspaceRuntimeSettings.swift index 830d30fcbbe2..1cd41f9519bb 100644 --- a/Sources/App/WorkspaceRuntimeSettings.swift +++ b/Sources/App/WorkspaceRuntimeSettings.swift @@ -360,9 +360,138 @@ enum AgentHibernationSettings { } } +enum SurfaceHibernationSettings { + struct Values: Equatable, Sendable { + var enabled: Bool + var idleSeconds: TimeInterval + var unmountedIdleSeconds: TimeInterval + var maxLiveSurfaces: Int + var confirmationSeconds: TimeInterval + } + + static let enabledKey = "terminal.surfaceHibernation.enabled" + static let idleSecondsKey = "terminal.surfaceHibernation.idleSeconds" + static let unmountedIdleSecondsKey = "terminal.surfaceHibernation.unmountedIdleSeconds" + static let maxLiveSurfacesKey = "terminal.surfaceHibernation.maxLiveSurfaces" + static let confirmationSecondsKey = "terminal.surfaceHibernation.confirmationSeconds" + + // Surface hibernation is on by default: it only reclaims surfaces that are + // safely at a shell prompt, restores scrollback and working directory on + // the next visit, and is the P0 backstop against unbounded per-workspace + // surface accumulation (https://github.com/manaflow-ai/cmux/issues/5731). + static let defaultEnabled = true + static let defaultIdleSeconds: TimeInterval = 300 + static let defaultUnmountedIdleSeconds: TimeInterval = 1800 + static let defaultMaxLiveSurfaces = 12 + static let defaultConfirmationSeconds: TimeInterval = 60 + static let didChangeNotification = Notification.Name("cmux.surfaceHibernationSettingsDidChange") + + static func values(defaults: UserDefaults = .standard) -> Values { + Values( + enabled: isEnabled(defaults: defaults), + idleSeconds: idleSeconds(defaults: defaults), + unmountedIdleSeconds: unmountedIdleSeconds(defaults: defaults), + maxLiveSurfaces: maxLiveSurfaces(defaults: defaults), + confirmationSeconds: confirmationSeconds(defaults: defaults) + ) + } + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + guard defaults.object(forKey: enabledKey) != nil else { return defaultEnabled } + return defaults.bool(forKey: enabledKey) + } + + static func idleSeconds(defaults: UserDefaults = .standard) -> TimeInterval { + guard defaults.object(forKey: idleSecondsKey) != nil else { return defaultIdleSeconds } + return sanitizedIdleSeconds(defaults.double(forKey: idleSecondsKey)) + } + + static func unmountedIdleSeconds(defaults: UserDefaults = .standard) -> TimeInterval { + guard defaults.object(forKey: unmountedIdleSecondsKey) != nil else { return defaultUnmountedIdleSeconds } + return sanitizedUnmountedIdleSeconds(defaults.double(forKey: unmountedIdleSecondsKey)) + } + + static func maxLiveSurfaces(defaults: UserDefaults = .standard) -> Int { + guard defaults.object(forKey: maxLiveSurfacesKey) != nil else { return defaultMaxLiveSurfaces } + return sanitizedMaxLiveSurfaces(defaults.integer(forKey: maxLiveSurfacesKey)) + } + + static func confirmationSeconds(defaults: UserDefaults = .standard) -> TimeInterval { + guard defaults.object(forKey: confirmationSecondsKey) != nil else { return defaultConfirmationSeconds } + return sanitizedConfirmationSeconds(defaults.double(forKey: confirmationSecondsKey)) + } + + static func sanitizedIdleSeconds(_ value: TimeInterval) -> TimeInterval { + guard value.isFinite else { return defaultIdleSeconds } + return min(max(value.rounded(), 30), 7 * 24 * 60 * 60) + } + + static func sanitizedUnmountedIdleSeconds(_ value: TimeInterval) -> TimeInterval { + guard value.isFinite else { return defaultUnmountedIdleSeconds } + return min(max(value.rounded(), 60), 30 * 24 * 60 * 60) + } + + static func sanitizedMaxLiveSurfaces(_ value: Int) -> Int { + min(max(value, 1), 256) + } + + static func sanitizedConfirmationSeconds(_ value: TimeInterval) -> TimeInterval { + guard value.isFinite else { return defaultConfirmationSeconds } + return min(max(value.rounded(), 5), 60 * 60) + } + + static func setValues( + enabled: Bool? = nil, + idleSeconds: TimeInterval? = nil, + unmountedIdleSeconds: TimeInterval? = nil, + maxLiveSurfaces: Int? = nil, + defaults: UserDefaults = .standard, + notificationCenter: NotificationCenter = .default + ) { + let oldValues = values(defaults: defaults) + if let enabled { + defaults.set(enabled, forKey: enabledKey) + } + if let idleSeconds { + defaults.set(sanitizedIdleSeconds(idleSeconds), forKey: idleSecondsKey) + } + if let unmountedIdleSeconds { + defaults.set(sanitizedUnmountedIdleSeconds(unmountedIdleSeconds), forKey: unmountedIdleSecondsKey) + } + if let maxLiveSurfaces { + defaults.set(sanitizedMaxLiveSurfaces(maxLiveSurfaces), forKey: maxLiveSurfacesKey) + } + if oldValues != values(defaults: defaults) { + notifyDidChange(notificationCenter: notificationCenter) + } + } + + @discardableResult + static func reset( + defaults: UserDefaults = .standard, + notificationCenter: NotificationCenter = .default + ) -> Bool { + let oldValues = values(defaults: defaults) + defaults.removeObject(forKey: enabledKey) + defaults.removeObject(forKey: idleSecondsKey) + defaults.removeObject(forKey: unmountedIdleSecondsKey) + defaults.removeObject(forKey: maxLiveSurfacesKey) + defaults.removeObject(forKey: confirmationSecondsKey) + let didChange = oldValues != values(defaults: defaults) + if didChange { + notifyDidChange(notificationCenter: notificationCenter) + } + return didChange + } + + static func notifyDidChange(notificationCenter: NotificationCenter = .default) { + notificationCenter.post(name: didChangeNotification, object: nil) + } +} + enum AgentHibernationTrackingGate { private static let lock = NSLock() - private static var enabled = AgentHibernationSettings.isEnabled() + private static var enabled = AgentHibernationSettings.isEnabled() || SurfaceHibernationSettings.isEnabled() static func isEnabled() -> Bool { lock.lock() diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index aad9176e2311..bf01a1246c9e 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -8946,7 +8946,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent in: tab, preferredPanelId: preferredPanelId ), - terminalPanel.isAgentHibernated { + terminalPanel.isAgentHibernated || terminalPanel.isSurfaceHibernated { + // Hibernated panels have no runtime surface and never will until + // they are restored; sending through the panel restores first and + // queues the input instead of waiting on surface readiness. beforeSend?() if !terminalPanel.sendText(text) { onFailure?() diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index b073897914b2..ce6763d99a4d 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -96,6 +96,10 @@ extension CmuxSettingsFileStore { "terminal.agentHibernation.enabled", "terminal.agentHibernation.idleSeconds", "terminal.agentHibernation.maxLiveTerminals", + "terminal.surfaceHibernation.enabled", + "terminal.surfaceHibernation.idleSeconds", + "terminal.surfaceHibernation.unmountedIdleSeconds", + "terminal.surfaceHibernation.maxLiveSurfaces", "terminal.textBoxMaxLines", "terminal.resumeCommands", "notifications.dockBadge", diff --git a/Sources/CmuxTopSnapshot.swift b/Sources/CmuxTopSnapshot.swift index ca3cf86d16fe..ecb3b130f675 100644 --- a/Sources/CmuxTopSnapshot.swift +++ b/Sources/CmuxTopSnapshot.swift @@ -270,6 +270,10 @@ nonisolated final class CmuxTopProcessSnapshot: @unchecked Sendable { return result } + nonisolated static func hasChildProcesses(parentPID: Int) -> Bool { + !listedChildPIDs(parentPID: parentPID).isEmpty + } + private static func listedChildPIDs(parentPID: Int) -> [Int] { guard parentPID > 0 else { return [] } diff --git a/Sources/CommandPalette/CommandPaletteSettingsToggle.swift b/Sources/CommandPalette/CommandPaletteSettingsToggle.swift index 8ac340850a7d..b0604dc1ae3c 100644 --- a/Sources/CommandPalette/CommandPaletteSettingsToggle.swift +++ b/Sources/CommandPalette/CommandPaletteSettingsToggle.swift @@ -461,6 +461,34 @@ enum CommandPaletteSettingsToggleCommands { ) } ), + CommandPaletteSettingToggleDescriptor( + commandId: commandIdPrefix + "surfaceHibernation", + settingsKey: "terminal.surfaceHibernation.enabled", + title: { + String(localized: "settings.terminal.surfaceHibernation", defaultValue: "Surface Hibernation") + }, + sectionTitle: terminal, + keywords: [ + "terminal.surfaceHibernation.enabled", + "terminal", + "surface", + "hibernation", + "hibernate", + "suspend", + "shell", + "memory", + "idle", + "background", + ], + isOn: { defaults in SurfaceHibernationSettings.isEnabled(defaults: defaults) }, + setOn: { newValue, defaults, notificationCenter in + SurfaceHibernationSettings.setValues( + enabled: newValue, + defaults: defaults, + notificationCenter: notificationCenter + ) + } + ), CommandPaletteSettingToggleDescriptor( commandId: commandIdPrefix + "hideAllSidebarDetails", settingsKey: "sidebar.hideAllDetails", diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 7200df58178e..a4033fc34f16 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5305,18 +5305,84 @@ enum TerminalSurfaceFocusPlacement: Equatable { case rightSidebarDock } -private func recordAgentHibernationTerminalInput(workspaceId: UUID, panelId: UUID) { +// Runs on the typing hot path. Every caller is already on the main actor +// (TerminalSurface methods and AppKit event handlers), so record directly — +// two dictionary writes — instead of spawning a per-keystroke Task. Not +// throttled: the unconfirmed-input safety check compares the latest input +// timestamp against the latest agent lifecycle change, so suppressing a +// "repeat" keystroke could hide input typed right after an idle lifecycle +// report and let hibernation kill an active agent. +@MainActor +private func recordAgentHibernationTerminalInput( + workspaceId: UUID, + panelId: UUID, + armsPendingCommandLine: Bool = true, + pendingPromptSurvivals: Int = 0, + clearsSeededPendingCommandLine: Bool = false +) { guard AgentHibernationTrackingGate.isEnabled() else { return } - let recordedAt = Date() - Task { @MainActor in - AgentHibernationController.shared.recordTerminalInput( - workspaceId: workspaceId, - panelId: panelId, - recordedAt: recordedAt - ) + AgentHibernationController.shared.recordTerminalInput( + workspaceId: workspaceId, + panelId: panelId, + armsPendingCommandLine: armsPendingCommandLine, + pendingPromptSurvivals: pendingPromptSurvivals, + clearsSeededPendingCommandLine: clearsSeededPendingCommandLine + ) +} + +/// Whether input can leave new editable text at the prompt. Bare settling +/// characters (Enter on an empty line, ^C, ^D, ^U), non-inserting editing +/// and navigation controls (^L, ^A, ^E, ^K, ^W, ^R, …), and function keys +/// type nothing, and most produce no shell preexec/precmd transition at +/// all — arming the pending guard for them would exempt the panel from +/// hibernation until a real command cycle. Among the C0 controls only tab +/// (completion inserts text), ^V (quoted-insert) and ^Y (yank) can put new +/// text on the line. +private func terminalInputCanLeavePromptText(_ text: String) -> Bool { + // Scalar-level on purpose: "\r\n" is a single CRLF grapheme Character + // that would otherwise match neither settling byte. + !text.unicodeScalars.allSatisfy { scalar in + if scalar.value < 0x20 || scalar.value == 0x7F { + let inserting: Set = [0x09, 0x16, 0x19] // tab, ^V, ^Y + return !inserting.contains(scalar.value) + } + // NSEvent function-key range (arrows, F-keys, page up/down…). + return (0xF700...0xF8FF).contains(scalar.value) } } +/// Whether a non-arming payload is a bare Enter — the input that settles a +/// pre-tracking seeded pending guard (the line submits, opens a busy-gated +/// PS2 continuation, or was empty). See recordTerminalInput. +func terminalInputClearsSeededPending(_ text: String) -> Bool { + // Scalar-level: "\r\n" is one CRLF grapheme Character. + !text.isEmpty && text.unicodeScalars.allSatisfy { $0 == "\r" || $0 == "\n" } +} + +/// How many prompt transitions the pending-input hibernation guard must +/// survive for a batched payload that leaves text editable at the prompt. +/// "cmd1\ncmd2\npartial" runs two commands — two prompt returns — before +/// "partial" sits on the editable line, so the guard must outlive both. +/// Returns 0 when nothing trails the last settling character (the normal +/// prompt-transition clear applies) or when no settling character exists +/// (pending until a real command cycle). Keyboard input arrives per +/// keystroke and never produces a positive count. +private func terminalInputPendingPromptSurvivals(_ text: String) -> Int { + // CRLF collapses to one submission before scalar-level counting; a + // CRLF grapheme Character would otherwise evade settling checks. + let normalized = text.replacingOccurrences(of: "\r\n", with: "\n") + let isSettling: (Unicode.Scalar) -> Bool = { scalar in + scalar == "\r" || scalar == "\n" || + scalar == "\u{03}" || scalar == "\u{04}" || scalar == "\u{15}" + } + let scalars = Array(normalized.unicodeScalars) + guard let lastSettlingIndex = scalars.lastIndex(where: isSettling), + lastSettlingIndex < scalars.count - 1 else { + return 0 + } + return scalars.lazy.filter(isSettling).count +} + final class TerminalSurface: Identifiable, ObservableObject { final class SearchState: ObservableObject { @Published var needle: String @@ -5455,6 +5521,11 @@ final class TerminalSurface: Identifiable, ObservableObject { let tmuxStartCommand: String? let initialInput: String? private var nextRuntimeInitialInput: String? + private var nextRuntimeWorkingDirectory: String? + /// Whether the most recent runtime surface launched with a managed shell + /// integration that replays CMUX_RESTORE_SCROLLBACK_FILE. Surface + /// hibernation must not restart shells that cannot restore their history. + private(set) var runtimeSupportsScrollbackReplay = false private let initialEnvironmentOverrides: [String: String] var requestedWorkingDirectory: String? { workingDirectory } let focusPlacement: TerminalSurfaceFocusPlacement @@ -5487,7 +5558,7 @@ final class TerminalSurface: Identifiable, ObservableObject { private var pendingSocketInputBytes: Int = 0 private let maxPendingSocketInputBytes = 1_048_576 private var backgroundSurfaceStartQueued = false - private var runtimeSurfaceSuspendedForAgentHibernation = false + private var runtimeSurfaceSuspendedForHibernation = false private var headlessStartupWindow: NSWindow? private var surfaceCallbackContext: Unmanaged? private var claudeCommandShim: ClaudeCommandShim? @@ -5513,6 +5584,7 @@ final class TerminalSurface: Identifiable, ObservableObject { private(set) var clipboardReadGeneration = 0 #if DEBUG private var needsConfirmCloseOverrideForTesting: Bool? + private var foregroundProcessHasChildrenOverrideForTesting: Bool? private var runtimeSurfaceFreedOutOfBandForTesting = false private var runtimeSurfaceCreateAttemptCountForTesting = 0 private let debugForceRefreshCountLock = NSLock() @@ -6084,7 +6156,7 @@ final class TerminalSurface: Identifiable, ObservableObject { } private func allowsRuntimeSurfaceCreation() -> Bool { - portalLifecycleState == .live && !runtimeSurfaceSuspendedForAgentHibernation + portalLifecycleState == .live && !runtimeSurfaceSuspendedForHibernation } private var hasDeferredStartupWork: Bool { @@ -6192,8 +6264,8 @@ final class TerminalSurface: Identifiable, ObservableObject { } @MainActor - func suspendRuntimeSurfaceForAgentHibernation(reason: String) { - runtimeSurfaceSuspendedForAgentHibernation = true + func suspendRuntimeSurfaceForHibernation(reason: String) { + runtimeSurfaceSuspendedForHibernation = true backgroundSurfaceStartQueued = false closeHeadlessStartupWindowIfNeeded() let callbackContext = surfaceCallbackContext @@ -6266,6 +6338,11 @@ final class TerminalSurface: Identifiable, ObservableObject { additionalEnvironment } + @MainActor + func debugNextRuntimeWorkingDirectoryForTesting() -> String? { + nextRuntimeWorkingDirectory + } + func debugForceRefreshCount() -> Int { debugForceRefreshCountLock.lock() defer { debugForceRefreshCountLock.unlock() } @@ -6630,7 +6707,9 @@ final class TerminalSurface: Identifiable, ObservableObject { } // Shell integration: inject startup wrappers for supported shells; skipped when the bundled dir is missing (deleted app bundle), see shellIntegrationDirectoryExists. - if UserDefaults.standard.object(forKey: "sidebarShellIntegration") as? Bool ?? true, + let shellIntegrationEnabled = UserDefaults.standard.object(forKey: "sidebarShellIntegration") as? Bool ?? true + runtimeSupportsScrollbackReplay = false + if shellIntegrationEnabled, let integrationDir = Bundle.main.resourceURL?.appendingPathComponent("shell-integration").path, Self.shellIntegrationDirectoryExists(integrationDir) { setManagedEnvironmentValue("CMUX_SHELL_INTEGRATION", "1") @@ -6646,15 +6725,34 @@ final class TerminalSurface: Identifiable, ObservableObject { ?? getenv("SHELL").map { String(cString: $0) } ?? ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh" - if let command = Self.applyManagedShellSpecificStartupEnvironment( + let shellName = URL(fileURLWithPath: shell).lastPathComponent + let protectedKeysBeforeShellSpecific = protectedStartupEnvironmentKeys + let shellSpecificCommand = Self.applyManagedShellSpecificStartupEnvironment( shell: shell, integrationDir: integrationDir, userGhosttyShellIntegrationMode: GhosttyApp.shared.userGhosttyShellIntegrationMode, to: &env, protectedKeys: &protectedStartupEnvironmentKeys - ) { - if baseConfig.command?.isEmpty != false { baseConfig.command = command } - } + ) + var shellSpecificCommandApplied = false + if let shellSpecificCommand, baseConfig.command?.isEmpty != false { + baseConfig.command = shellSpecificCommand + shellSpecificCommandApplied = true + } + // The managed integrations are the consumers of + // CMUX_RESTORE_SCROLLBACK_FILE; without one, a restored shell + // would never replay captured scrollback. The helper skips + // installation when its bundled bootstrap is unreadable (and a + // custom startup command displaces the fish wrapper), so the + // flag requires evidence this launch installed a hook: the zsh + // ZDOTDIR redirection, the bash PROMPT_COMMAND bootstrap, or the + // applied fish wrapper command. + runtimeSupportsScrollbackReplay = Self.shellIntegrationInstalledReplayHook( + shellName: shellName, + managedKeysAdded: protectedStartupEnvironmentKeys + .subtracting(protectedKeysBeforeShellSpecific), + shellSpecificCommandApplied: shellSpecificCommandApplied + ) } env = Self.mergedStartupEnvironment( base: env, @@ -6687,7 +6785,15 @@ final class TerminalSurface: Identifiable, ObservableObject { } } + // Consume the override before attempting creation: if the captured + // directory turned invalid and creation fails, a retry must not loop + // on the same value. + let runtimeWorkingDirectory = nextRuntimeWorkingDirectory + nextRuntimeWorkingDirectory = nil let resolvedWorkingDirectory: String? = { + if let runtimeWorkingDirectory, !runtimeWorkingDirectory.isEmpty { + return runtimeWorkingDirectory + } if let workingDirectory, !workingDirectory.isEmpty { return workingDirectory } @@ -6776,7 +6882,14 @@ final class TerminalSurface: Identifiable, ObservableObject { // Session scrollback replay must be one-shot. Reusing it on a later runtime // surface recreation would inject stale restored output into a live shell. - additionalEnvironment.removeValue(forKey: SessionScrollbackReplayStore.environmentKey) + // When this launch installed no replay hook (integration toggled off + // between staging and creation), nothing will consume the file — delete + // it instead of leaving it for the next-launch purge. + if let stagedReplayPath = additionalEnvironment.removeValue( + forKey: SessionScrollbackReplayStore.environmentKey + ), !runtimeSupportsScrollbackReplay, !stagedReplayPath.isEmpty { + try? FileManager.default.removeItem(atPath: stagedReplayPath) + } // For vsync-driven rendering, Ghostty needs to know which display we're on so it can // start a CVDisplayLink with the right refresh rate. If we don't set this early, the @@ -7175,11 +7288,44 @@ final class TerminalSurface: Identifiable, ObservableObject { } @MainActor - func prepareAgentHibernationResume(initialInput: String?) { - runtimeSurfaceSuspendedForAgentHibernation = false + func prepareHibernationResume(initialInput: String?) { + runtimeSurfaceSuspendedForHibernation = false prepareNextRuntimeInitialInput(initialInput) } + /// Drop a staged-but-unconsumed scrollback replay file. Ownership of the + /// path moves here when a restore begins, so a panel closed before the + /// relaunched shell consumes the file must clean it up through this. + @MainActor + func discardStagedHibernationReplayFile() { + guard let path = additionalEnvironment.removeValue(forKey: SessionScrollbackReplayStore.environmentKey), + !path.isEmpty else { return } + try? FileManager.default.removeItem(atPath: path) + } + + /// Stage scrollback replay and a working-directory override for the next + /// runtime surface created after hibernation. Both are one-shot: the + /// replay file environment is consumed by the next `createSurface` and the + /// directory override is cleared once applied. The replay file itself was + /// written at hibernation time — this path runs on focus/selection and + /// must not perform disk I/O. + @MainActor + func stageHibernationRestore(replayFilePath: String?, workingDirectory: String?) { + if let replayFilePath, !replayFilePath.isEmpty { + additionalEnvironment[SessionScrollbackReplayStore.environmentKey] = replayFilePath + } + // No disk I/O here: this runs on interactive selection/focus paths and + // a stat on a dead network mount can hang the main thread. A stale + // directory is harmless — Ghostty ignores an unspawnable cwd at spawn + // time and falls back (termio/Exec.zig logs "cannot spawn command at + // cwd, ignoring"), and the override is consumed even when creation + // fails, so retries cannot loop on a bad value. + let trimmedDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmedDirectory, !trimmedDirectory.isEmpty { + nextRuntimeWorkingDirectory = trimmedDirectory + } + } + func setOcclusion(_ visible: Bool) { guard let surface = surface else { return } ghostty_surface_set_occlusion(surface, visible) @@ -7195,6 +7341,43 @@ final class TerminalSurface: Identifiable, ObservableObject { return ghostty_surface_needs_confirm_quit(surface) } + private static let shellRestartForegroundShellNames: Set = [ + "zsh", "bash", "fish", "sh", "dash", "login", "-zsh", "-bash", "-fish", "-sh" + ] + + /// Whether the surface's foreground process is a shell sitting at a + /// prompt with no live children. Background jobs started with `&` + /// produce no prompt-state or output signal, and an attached `tmux` + /// client passes the prompt heuristics while its server is not a child — + /// both must keep surface hibernation from SIGHUPing the PTY. Freeing + /// the PTY is irreversible, so every unclassifiable state fails closed: + /// no live surface handle, no foreground PID (Ghostty reports 0 when + /// tcgetpgrp fails), or an unreadable argv all report "not allowed". + @MainActor + func foregroundProcessAllowsShellRestart() -> Bool { +#if DEBUG + if let foregroundProcessHasChildrenOverrideForTesting { + return !foregroundProcessHasChildrenOverrideForTesting + } +#endif + guard let surface = liveSurfaceForGhosttyAccess(reason: "foregroundProcessAllowsShellRestart") else { + return false + } + let rawPid = ghostty_surface_foreground_pid(surface) + guard rawPid > 0, rawPid <= UInt64(Int32.max) else { return false } + let pid = Int(rawPid) + if CmuxTopProcessSnapshot.hasChildProcesses(parentPID: pid) { + return false + } + guard let executable = CmuxTopProcessSnapshot.processArgumentsAndEnvironment(for: pid)? + .arguments.first else { + // Unknown foreground process: be conservative. + return false + } + let name = URL(fileURLWithPath: executable).lastPathComponent + return Self.shellRestartForegroundShellNames.contains(name) + } + func noteClipboardReadCompleted() { clipboardReadGeneration += 1 NotificationCenter.default.post( @@ -7207,11 +7390,20 @@ final class TerminalSurface: Identifiable, ObservableObject { @discardableResult func sendText(_ text: String) -> Bool { guard let data = text.data(using: .utf8), !data.isEmpty else { return true } + let armsPendingCommandLine = terminalInputCanLeavePromptText(text) + let pendingPromptSurvivals = terminalInputPendingPromptSurvivals(text) + let clearsSeededPendingCommandLine = terminalInputClearsSeededPending(text) guard surface != nil else { guard allowsRuntimeSurfaceCreation() else { return false } let queued = enqueuePendingSocketInput(.pasteText(data)) if queued { - recordAgentHibernationTerminalInput(workspaceId: tabId, panelId: id) + recordAgentHibernationTerminalInput( + workspaceId: tabId, + panelId: id, + armsPendingCommandLine: armsPendingCommandLine, + pendingPromptSurvivals: pendingPromptSurvivals, + clearsSeededPendingCommandLine: clearsSeededPendingCommandLine + ) requestBackgroundSurfaceStartIfNeeded() } return queued @@ -7220,7 +7412,13 @@ final class TerminalSurface: Identifiable, ObservableObject { return false } guard !ghostty_surface_process_exited(liveSurface) else { return false } - recordAgentHibernationTerminalInput(workspaceId: tabId, panelId: id) + recordAgentHibernationTerminalInput( + workspaceId: tabId, + panelId: id, + armsPendingCommandLine: armsPendingCommandLine, + pendingPromptSurvivals: pendingPromptSurvivals, + clearsSeededPendingCommandLine: clearsSeededPendingCommandLine + ) writeTextData(data, to: liveSurface) return true } @@ -7251,10 +7449,23 @@ final class TerminalSurface: Identifiable, ObservableObject { @discardableResult func sendNamedKey(_ keyName: String) -> NamedKeySendResult { guard let event = pendingKeyEvent(for: keyName) else { return .unknownKey } + // Single-character key names type editable prompt text ("a"), and so + // do space and tab (completion); other named special keys (enter, + // escape, arrows) do not. + let normalizedKeyName = keyName.lowercased() + let armsPendingCommandLine = keyName.count == 1 || + normalizedKeyName == "space" || normalizedKeyName == "tab" + let clearsSeededPendingCommandLine = normalizedKeyName == "enter" || + normalizedKeyName == "return" guard surface != nil else { guard allowsRuntimeSurfaceCreation() else { return .surfaceUnavailable } guard enqueuePendingSocketInput(.key(event)) else { return .inputQueueFull } - recordAgentHibernationTerminalInput(workspaceId: tabId, panelId: id) + recordAgentHibernationTerminalInput( + workspaceId: tabId, + panelId: id, + armsPendingCommandLine: armsPendingCommandLine, + clearsSeededPendingCommandLine: clearsSeededPendingCommandLine + ) requestBackgroundSurfaceStartIfNeeded() return .queued } @@ -7262,7 +7473,12 @@ final class TerminalSurface: Identifiable, ObservableObject { return .surfaceUnavailable } guard !ghostty_surface_process_exited(liveSurface) else { return .processExited } - recordAgentHibernationTerminalInput(workspaceId: tabId, panelId: id) + recordAgentHibernationTerminalInput( + workspaceId: tabId, + panelId: id, + armsPendingCommandLine: armsPendingCommandLine, + clearsSeededPendingCommandLine: clearsSeededPendingCommandLine + ) sendKeyEvent(surface: liveSurface, keycode: event.keycode, mods: event.mods) return .sent } @@ -7325,11 +7541,20 @@ final class TerminalSurface: Identifiable, ObservableObject { @discardableResult func sendInputResult(_ text: String) -> InputSendResult { guard !text.isEmpty else { return .sent } + let armsPendingCommandLine = terminalInputCanLeavePromptText(text) + let pendingPromptSurvivals = terminalInputPendingPromptSurvivals(text) + let clearsSeededPendingCommandLine = terminalInputClearsSeededPending(text) guard surface != nil else { guard allowsRuntimeSurfaceCreation() else { return .surfaceUnavailable } let queued = enqueuePendingSocketInput(text) if queued { - recordAgentHibernationTerminalInput(workspaceId: tabId, panelId: id) + recordAgentHibernationTerminalInput( + workspaceId: tabId, + panelId: id, + armsPendingCommandLine: armsPendingCommandLine, + pendingPromptSurvivals: pendingPromptSurvivals, + clearsSeededPendingCommandLine: clearsSeededPendingCommandLine + ) requestBackgroundSurfaceStartIfNeeded() } return queued ? .queued : .inputQueueFull @@ -7338,7 +7563,13 @@ final class TerminalSurface: Identifiable, ObservableObject { return .surfaceUnavailable } guard !ghostty_surface_process_exited(liveSurface) else { return .processExited } - recordAgentHibernationTerminalInput(workspaceId: tabId, panelId: id) + recordAgentHibernationTerminalInput( + workspaceId: tabId, + panelId: id, + armsPendingCommandLine: armsPendingCommandLine, + pendingPromptSurvivals: pendingPromptSurvivals, + clearsSeededPendingCommandLine: clearsSeededPendingCommandLine + ) sendInput(text, to: liveSurface) return .sent } @@ -7996,6 +8227,11 @@ final class TerminalSurface: Identifiable, ObservableObject { needsConfirmCloseOverrideForTesting = value } + @MainActor + func setForegroundProcessHasChildrenOverrideForTesting(_ value: Bool?) { + foregroundProcessHasChildrenOverrideForTesting = value + } + @MainActor func debugRuntimeSurfaceCreateAttemptCountForTesting() -> Int { runtimeSurfaceCreateAttemptCountForTesting @@ -9531,26 +9767,49 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ) } - private func recordDirectAgentHibernationTerminalInput() { + private func recordDirectAgentHibernationTerminalInput( + armsPendingCommandLine: Bool = true, + pendingPromptSurvivals: Int = 0, + clearsSeededPendingCommandLine: Bool = false + ) { guard let terminalSurface else { return } recordAgentHibernationTerminalInput( workspaceId: terminalSurface.tabId, - panelId: terminalSurface.id + panelId: terminalSurface.id, + armsPendingCommandLine: armsPendingCommandLine, + pendingPromptSurvivals: pendingPromptSurvivals, + clearsSeededPendingCommandLine: clearsSeededPendingCommandLine ) } + private func clipboardPendingPromptSurvivals() -> Int { + guard let pasted = NSPasteboard.general.string(forType: .string) else { return 0 } + return terminalInputPendingPromptSurvivals(pasted) + } + + private func clipboardCanLeavePromptText() -> Bool { + guard let pasted = NSPasteboard.general.string(forType: .string) else { return false } + return terminalInputCanLeavePromptText(pasted) + } + // MARK: - Clipboard paste @IBAction func paste(_ sender: Any?) { guard prepareSurfaceForPaste(reason: "paste.missingSurface") else { return } - recordDirectAgentHibernationTerminalInput() + recordDirectAgentHibernationTerminalInput( + armsPendingCommandLine: clipboardCanLeavePromptText(), + pendingPromptSurvivals: clipboardPendingPromptSurvivals() + ) _ = performBindingAction("paste_from_clipboard") } /// Pastes clipboard text as plain text, stripping any rich formatting. @IBAction func pasteAsPlainText(_ sender: Any?) { guard prepareSurfaceForPaste(reason: "pasteAsPlainText.missingSurface") else { return } - recordDirectAgentHibernationTerminalInput() + recordDirectAgentHibernationTerminalInput( + armsPendingCommandLine: clipboardCanLeavePromptText(), + pendingPromptSurvivals: clipboardPendingPromptSurvivals() + ) _ = performBindingAction("paste_from_clipboard") } @@ -10051,7 +10310,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { super.keyDown(with: event) return } - recordDirectAgentHibernationTerminalInput() #if DEBUG ensureSurfaceMs = (ProcessInfo.processInfo.systemUptime - ensureSurfaceStart) * 1000.0 #endif @@ -10094,6 +10352,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #if DEBUG keyboardCopyModeMs = (ProcessInfo.processInfo.systemUptime - keyboardCopyModeStart) * 1000.0 #endif + // Past the consumption branches (sidebar shortcut, find escape, copy + // mode) every remaining path delivers this event to the terminal, so + // only now does it count as terminal input: a consumed key never + // reaches the shell, and recording it would arm a pending + // command-line guard no shell transition can ever clear (and reset + // the panel's idle clock for input the shell never saw). + recordDirectAgentHibernationTerminalInput( + armsPendingCommandLine: event.characters.map(terminalInputCanLeavePromptText) ?? false, + clearsSeededPendingCommandLine: event.characters.map(terminalInputClearsSeededPending) ?? false + ) #if DEBUG recordKeyLatency(path: "keyDown", event: event) #endif @@ -16026,7 +16294,10 @@ extension GhosttyNSView: NSTextInputClient { guard !sanitizedChars.isEmpty else { return } // Otherwise send directly to the terminal - recordDirectAgentHibernationTerminalInput() + recordDirectAgentHibernationTerminalInput( + armsPendingCommandLine: terminalInputCanLeavePromptText(sanitizedChars), + pendingPromptSurvivals: terminalInputPendingPromptSurvivals(sanitizedChars) + ) sendTextToSurface( sanitizedChars, preserveLiteralEscape: !isExternalCommittedText diff --git a/Sources/KeyboardShortcutSettingsFileStore+Template.swift b/Sources/KeyboardShortcutSettingsFileStore+Template.swift index 908708d8cc44..ca2b8cfd49b7 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -98,6 +98,12 @@ extension CmuxSettingsFileStore { "idleSeconds": Int(AgentHibernationSettings.defaultIdleSeconds), "maxLiveTerminals": AgentHibernationSettings.defaultMaxLiveTerminals, ], + "surfaceHibernation": [ + "enabled": SurfaceHibernationSettings.defaultEnabled, + "idleSeconds": Int(SurfaceHibernationSettings.defaultIdleSeconds), + "unmountedIdleSeconds": Int(SurfaceHibernationSettings.defaultUnmountedIdleSeconds), + "maxLiveSurfaces": SurfaceHibernationSettings.defaultMaxLiveSurfaces, + ], "textBoxMaxLines": TerminalTextBoxInputSettings.defaultMaxLines, "resumeCommands": [], ], diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 7a1784c8d28d..1f13bde1e0f5 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -598,6 +598,38 @@ final class CmuxSettingsFileStore { logInvalid("terminal.agentHibernation", sourcePath: sourcePath) } + if let rawSurfaceHibernation = section["surfaceHibernation"], + let surfaceHibernation = rawSurfaceHibernation as? [String: Any] { + if let value = jsonBool(surfaceHibernation["enabled"]) { + snapshot.managedUserDefaults[SurfaceHibernationSettings.enabledKey] = .bool(value) + } else if surfaceHibernation.keys.contains("enabled") { + logInvalid("terminal.surfaceHibernation.enabled", sourcePath: sourcePath) + } + if let value = jsonInt(surfaceHibernation["idleSeconds"]) { + snapshot.managedUserDefaults[SurfaceHibernationSettings.idleSecondsKey] = .double( + SurfaceHibernationSettings.sanitizedIdleSeconds(TimeInterval(value)) + ) + } else if surfaceHibernation.keys.contains("idleSeconds") { + logInvalid("terminal.surfaceHibernation.idleSeconds", sourcePath: sourcePath) + } + if let value = jsonInt(surfaceHibernation["unmountedIdleSeconds"]) { + snapshot.managedUserDefaults[SurfaceHibernationSettings.unmountedIdleSecondsKey] = .double( + SurfaceHibernationSettings.sanitizedUnmountedIdleSeconds(TimeInterval(value)) + ) + } else if surfaceHibernation.keys.contains("unmountedIdleSeconds") { + logInvalid("terminal.surfaceHibernation.unmountedIdleSeconds", sourcePath: sourcePath) + } + if let value = jsonInt(surfaceHibernation["maxLiveSurfaces"]) { + snapshot.managedUserDefaults[SurfaceHibernationSettings.maxLiveSurfacesKey] = .int( + SurfaceHibernationSettings.sanitizedMaxLiveSurfaces(value) + ) + } else if surfaceHibernation.keys.contains("maxLiveSurfaces") { + logInvalid("terminal.surfaceHibernation.maxLiveSurfaces", sourcePath: sourcePath) + } + } else if section.keys.contains("surfaceHibernation") { + logInvalid("terminal.surfaceHibernation", sourcePath: sourcePath) + } + if let value = jsonInt(section["textBoxMaxLines"]) { if value >= TerminalTextBoxInputSettings.minimumMaxLines, value <= TerminalTextBoxInputSettings.maximumMaxLines { @@ -1664,6 +1696,7 @@ final class CmuxSettingsFileStore { let apply = { var agentSessionAutoResumeDidChange = false var agentHibernationDidChange = false + var surfaceHibernationDidChange = false for change in changes { if change.defaultsKey == TerminalScrollBarSettings.showScrollBarKey { TerminalScrollBarSettings.notifyDidChange(notificationCenter: notificationCenter) @@ -1682,6 +1715,13 @@ final class CmuxSettingsFileStore { change.defaultsKey == AgentHibernationSettings.confirmationSecondsKey { agentHibernationDidChange = true } + if change.defaultsKey == SurfaceHibernationSettings.enabledKey || + change.defaultsKey == SurfaceHibernationSettings.idleSecondsKey || + change.defaultsKey == SurfaceHibernationSettings.unmountedIdleSecondsKey || + change.defaultsKey == SurfaceHibernationSettings.maxLiveSurfacesKey || + change.defaultsKey == SurfaceHibernationSettings.confirmationSecondsKey { + surfaceHibernationDidChange = true + } if change.defaultsKey == LanguageSettings.languageKey { let rawValue = UserDefaults.standard.string(forKey: change.defaultsKey) ?? "" @@ -1705,6 +1745,9 @@ final class CmuxSettingsFileStore { if agentHibernationDidChange { AgentHibernationSettings.notifyDidChange(notificationCenter: notificationCenter) } + if surfaceHibernationDidChange { + SurfaceHibernationSettings.notifyDidChange(notificationCenter: notificationCenter) + } } if Thread.isMainThread { apply() diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index 6e77213f3b5d..13087357a600 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -13,6 +13,19 @@ struct AgentHibernationPanelState { } } +/// State for a panel whose Ghostty runtime surface was freed while the panel +/// stayed open (plain-shell surface hibernation). Restoring starts a fresh +/// shell in `workingDirectory` and replays `scrollback` into it. +struct SurfaceHibernationPanelState { + let hibernatedAt: Date + let lastActivityAt: Date + let scrollback: String? + /// One-shot replay file written at hibernation time so restore stages an + /// environment value without doing disk I/O on the focus path. + let replayFilePath: String? + let workingDirectory: String? +} + enum AgentHibernationResumePreparation: Equatable { case unavailable case resumed(queuedStartupInput: Bool) @@ -99,6 +112,7 @@ final class TerminalPanel: Panel, ObservableObject { @Published var viewReattachToken: UInt64 = 0 @Published private(set) var agentHibernationState: AgentHibernationPanelState? + @Published private(set) var surfaceHibernationState: SurfaceHibernationPanelState? var onRequestWorkspacePaneFlash: ((WorkspaceAttentionFlashReason) -> Void)? var onRequestAgentHibernationResume: ((Bool) -> Bool)? @@ -129,6 +143,10 @@ final class TerminalPanel: Panel, ObservableObject { agentHibernationState != nil } + var isSurfaceHibernated: Bool { + surfaceHibernationState != nil + } + /// The hosted NSView for embedding in SwiftUI var hostedView: GhosttySurfaceScrollView { surface.hostedView @@ -535,6 +553,9 @@ final class TerminalPanel: Panel, ObservableObject { _ = requestAgentHibernationResume(focus: true) return } + if isSurfaceHibernated { + prepareSurfaceHibernationRestore() + } focusTerminalSurface(respectForeignFirstResponder: true) } @@ -599,6 +620,17 @@ final class TerminalPanel: Panel, ObservableObject { func close() { isClosingPanel = true + // The shell integration deletes the replay file after a restore; a + // panel closed while hibernated never restores, so clean up the + // one-shot artifact here to keep abandoned hibernations from + // accumulating temp files. Once a restore begins, ownership of the + // path moves from the hibernation state into the surface's staged + // environment until the relaunched shell consumes it, so a close in + // that window must clean up the staged copy too. + if let replayFilePath = surfaceHibernationState?.replayFilePath { + try? FileManager.default.removeItem(atPath: replayFilePath) + } + surface.discardStagedHibernationReplayFile() discardTextBoxContentForClose() // The surface will be cleaned up by its deinit // Detach from the window portal on real close so stale hosted views @@ -637,11 +669,95 @@ final class TerminalPanel: Panel, ObservableObject { hibernatedAt: hibernatedAt, lastActivityAt: lastActivityAt ) + suspendSurfaceForHibernation(reason: "agentHibernation") + } + + /// Free the runtime surface of a plain-shell panel while keeping the panel + /// in the layout. The captured scrollback and working directory are + /// replayed into a fresh shell when the panel is restored. Writes the + /// replay file synchronously; the hibernation timer path pre-writes it + /// off-actor and uses the `replayFilePath:` variant instead. + @discardableResult + func enterSurfaceHibernation( + scrollback: String?, + workingDirectory: String?, + lastActivityAt: Date, + hibernatedAt: Date = Date() + ) -> Bool { + guard !isAgentHibernated, !isSurfaceHibernated else { return false } + let replayEnvironment = SessionScrollbackReplayStore.replayEnvironment(for: scrollback) + return enterSurfaceHibernation( + scrollback: scrollback, + workingDirectory: workingDirectory, + lastActivityAt: lastActivityAt, + hibernatedAt: hibernatedAt, + replayFilePath: replayEnvironment[SessionScrollbackReplayStore.environmentKey] + ) + } + + /// Core surface-hibernation transition with a pre-written replay file. + @discardableResult + func enterSurfaceHibernation( + scrollback: String?, + workingDirectory: String?, + lastActivityAt: Date, + hibernatedAt: Date = Date(), + replayFilePath: String? + ) -> Bool { + guard !isAgentHibernated, !isSurfaceHibernated else { return false } + if let scrollback, !scrollback.isEmpty, replayFilePath == nil { + // The replay file is the only restore artifact for the captured + // history; if it could not be written, keep the surface alive and + // let a later evaluation retry. + return false + } + surfaceHibernationState = SurfaceHibernationPanelState( + hibernatedAt: hibernatedAt, + lastActivityAt: lastActivityAt, + scrollback: scrollback, + replayFilePath: replayFilePath, + workingDirectory: workingDirectory + ) + suspendSurfaceForHibernation(reason: "surfaceHibernation") + return true + } + + /// Bring a surface-hibernated panel back: stage scrollback replay and the + /// captured working directory, then let the surface start on the next view + /// attach (or immediately when already visible). + @discardableResult + func prepareSurfaceHibernationRestore() -> Bool { + guard let state = surfaceHibernationState else { return false } + surfaceHibernationState = nil + // The managed shell integration is the replay file's consumer. When + // the user disabled integration while this panel was hibernated, the + // relaunched shell has no replay hook, so staging the file would only + // orphan it in the child environment. Drop it instead: the captured + // text still reaches the session snapshot through the fallback seeded + // at hibernation time, matching how app-relaunch session restore + // behaves when integration is disabled. + let shellIntegrationEnabled = UserDefaults.standard + .object(forKey: "sidebarShellIntegration") as? Bool ?? true + if !shellIntegrationEnabled, let orphanedReplayPath = state.replayFilePath { + try? FileManager.default.removeItem(atPath: orphanedReplayPath) + } + surface.stageHibernationRestore( + replayFilePath: shellIntegrationEnabled ? state.replayFilePath : nil, + workingDirectory: state.workingDirectory + ) + surface.prepareHibernationResume(initialInput: nil) + requestViewReattach() + surface.requestBackgroundSurfaceStartIfNeeded() + AgentHibernationController.shared.recordTerminalFocus(workspaceId: workspaceId, panelId: id) + return true + } + + private func suspendSurfaceForHibernation(reason: String) { unfocus() searchState = nil hostedView.setVisibleInUI(false) TerminalWindowPortalRegistry.detach(hostedView: hostedView) - surface.suspendRuntimeSurfaceForAgentHibernation(reason: "agentHibernation") + surface.suspendRuntimeSurfaceForHibernation(reason: reason) requestViewReattach() } @@ -652,7 +768,7 @@ final class TerminalPanel: Panel, ObservableObject { } let resumeStartupInput = state.agent.resumeStartupInput() agentHibernationState = nil - surface.prepareAgentHibernationResume(initialInput: resumeStartupInput) + surface.prepareHibernationResume(initialInput: resumeStartupInput) requestViewReattach() surface.requestBackgroundSurfaceStartIfNeeded() return .resumed(queuedStartupInput: resumeStartupInput != nil) @@ -697,13 +813,16 @@ final class TerminalPanel: Panel, ObservableObject { } func performBindingAction(_ action: String) -> Bool { - guard !isAgentHibernated else { return false } + guard !isAgentHibernated, !isSurfaceHibernated else { return false } return surface.performBindingAction(action) } private func resumeForExplicitInputIfNeeded() { - guard isAgentHibernated else { return } - _ = requestAgentHibernationResume(focus: false) + if isAgentHibernated { + _ = requestAgentHibernationResume(focus: false) + return + } + prepareSurfaceHibernationRestore() } @discardableResult @@ -753,7 +872,7 @@ final class TerminalPanel: Panel, ObservableObject { } func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent { - guard !isAgentHibernated else { return .panel } + guard !isAgentHibernated, !isSurfaceHibernated else { return .panel } if textBoxOwnsResponder(window?.firstResponder) { return .terminal(.textBoxInput) } @@ -761,7 +880,7 @@ final class TerminalPanel: Panel, ObservableObject { } func preferredFocusIntentForActivation() -> PanelFocusIntent { - guard !isAgentHibernated else { return .panel } + guard !isAgentHibernated, !isSurfaceHibernated else { return .panel } if isTextBoxActive, textBoxInputFocusIntent == .textBox { return .terminal(.textBoxInput) } @@ -769,7 +888,7 @@ final class TerminalPanel: Panel, ObservableObject { } func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) { - guard !isAgentHibernated else { return } + guard !isAgentHibernated, !isSurfaceHibernated else { return } guard case .terminal(let target) = intent else { return } switch target { case .surface, .findField: @@ -790,6 +909,11 @@ final class TerminalPanel: Panel, ObservableObject { if isAgentHibernated { return requestAgentHibernationResume(focus: true) } + if isSurfaceHibernated { + prepareSurfaceHibernationRestore() + focus() + return true + } switch intent { case .panel: focus() @@ -809,7 +933,7 @@ final class TerminalPanel: Panel, ObservableObject { } func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? { - guard !isAgentHibernated else { return nil } + guard !isAgentHibernated, !isSurfaceHibernated else { return nil } _ = window if textBoxOwnsResponder(responder) { return .terminal(.textBoxInput) @@ -820,7 +944,7 @@ final class TerminalPanel: Panel, ObservableObject { @discardableResult func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool { - guard !isAgentHibernated else { return false } + guard !isAgentHibernated, !isSurfaceHibernated else { return false } guard case .terminal(let target) = intent else { return false } if target == .textBoxInput { guard let firstResponder = window.firstResponder, diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 6b8ffa0ef161..ff5be4cf9e31 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -1981,21 +1981,79 @@ enum SessionPersistenceStore { enum SessionScrollbackReplayStore { static let environmentKey = "CMUX_RESTORE_SCROLLBACK_FILE" private static let directoryName = "cmux-session-scrollback" + /// Tagged debug instances share the per-user temp directory, so replay + /// files are written under a bundle-scoped subdirectory and the startup + /// purge only ever touches this instance's own files. + private static var bundleScopedDirectoryComponent: String { + let bundleId = Bundle.main.bundleIdentifier ?? "com.cmuxterm.app" + return bundleId.replacingOccurrences(of: "/", with: "-") + } + + private static func replayDirectory(tempDirectory: URL) -> URL { + tempDirectory + .appendingPathComponent(directoryName, isDirectory: true) + .appendingPathComponent(bundleScopedDirectoryComponent, isDirectory: true) + } private static let ansiEscape = "\u{001B}" private static let ansiReset = "\u{001B}[0m" + /// Replay files from a previous process are dead weight: restores write + /// fresh ones and the shell integration deletes them after replaying, but + /// quitting with hibernated panels leaves theirs behind. Purge leftovers + /// once per process before the first write of this session. The first + /// caller can be on the main actor (session restore, the hibernation + /// timer), so the calling thread only pays a single O(1) rename that + /// moves the stale tree aside; the recursive delete — unbounded with + /// many leftover files — runs detached. Renaming before the first write + /// also keeps the delete from racing freshly written replay files. + private static let purgeStaleReplayFilesOnce: Void = { + let fileManager = FileManager.default + let staleDirectory = replayDirectory(tempDirectory: fileManager.temporaryDirectory) + let discardPrefix = "\(bundleScopedDirectoryComponent)-stale-" + let discardDirectory = staleDirectory + .deletingLastPathComponent() + .appendingPathComponent( + "\(discardPrefix)\(ProcessInfo.processInfo.processIdentifier)", + isDirectory: true + ) + try? fileManager.moveItem(at: staleDirectory, to: discardDirectory) + Task.detached(priority: .utility) { + let fileManager = FileManager.default + // Sweep discard directories from earlier crashes too; the prefix + // is bundle-scoped, so tagged debug instances never cross paths. + let parent = discardDirectory.deletingLastPathComponent() + let leftovers = (try? fileManager.contentsOfDirectory( + at: parent, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + )) ?? [] + for leftover in leftovers where leftover.lastPathComponent.hasPrefix(discardPrefix) { + try? fileManager.removeItem(at: leftover) + } + } + }() + static func replayEnvironment( for scrollback: String?, tempDirectory: URL = FileManager.default.temporaryDirectory ) -> [String: String] { - guard let replayText = normalizedScrollback(scrollback) else { return [:] } - guard let replayFileURL = writeReplayFile( - contents: replayText, - tempDirectory: tempDirectory - ) else { + guard let path = replayFilePath(for: scrollback, tempDirectory: tempDirectory) else { return [:] } - return [environmentKey: replayFileURL.path] + return [environmentKey: path] + } + + /// Write a one-shot replay file for `scrollback` and return its path. + /// Safe to call off the main actor; the hibernation timer pre-writes + /// replay files on a utility task so the atomic write of a large capture + /// never blocks the main thread. + static func replayFilePath( + for scrollback: String?, + tempDirectory: URL = FileManager.default.temporaryDirectory + ) -> String? { + _ = purgeStaleReplayFilesOnce + guard let replayText = normalizedScrollback(scrollback) else { return nil } + return writeReplayFile(contents: replayText, tempDirectory: tempDirectory)?.path } private static func normalizedScrollback(_ scrollback: String?) -> String? { @@ -2118,7 +2176,7 @@ enum SessionScrollbackReplayStore { private static func writeReplayFile(contents: String, tempDirectory: URL) -> URL? { guard let data = contents.data(using: .utf8) else { return nil } - let directory = tempDirectory.appendingPathComponent(directoryName, isDirectory: true) + let directory = replayDirectory(tempDirectory: tempDirectory) do { try FileManager.default.createDirectory( diff --git a/Sources/SettingsNavigation.swift b/Sources/SettingsNavigation.swift index 97fcf516bedf..32593a378d7b 100644 --- a/Sources/SettingsNavigation.swift +++ b/Sources/SettingsNavigation.swift @@ -355,6 +355,7 @@ enum SettingsSearchIndex { setting(.terminal, "tab-bar-font-size", String(localized: "settings.terminal.tabBarFontSize", defaultValue: "Tab Bar Font Size"), "font size text scale terminal browser pane tab title surface-tab-bar-font-size"), setting(.terminal, "agent-auto-resume", String(localized: "settings.terminal.agentAutoResume", defaultValue: "Resume Agent Sessions on Reopen"), "terminal.autoResumeAgentSessions auto resume restore reopen relaunch quit sessions agents claude code codex opencode rovo dev rovodev toggle"), setting(.terminal, "agent-hibernation", String(localized: "settings.terminal.agentHibernation", defaultValue: "Agent Hibernation"), "terminal.agentHibernation idle hibernate suspend background agents claude code codex opencode live terminals"), + setting(.terminal, "surface-hibernation", String(localized: "settings.terminal.surfaceHibernation", defaultValue: "Surface Hibernation"), "terminal.surfaceHibernation idle hibernate suspend background shell surfaces memory unmounted workspaces live cap"), setting(.terminal, "resume-commands", String(localized: "settings.terminal.resumeCommands", defaultValue: "Resume Commands"), "surface resume command approvals prefixes auto restore prompt manual tmux hibernation"), setting(.textBox, "show-textbox-new-terminals", String(localized: "settings.textBox.showOnNewTerminals", defaultValue: "Show TextBox on New Terminals"), "terminal.showTextBoxOnNewTerminals textbox text box rich input prompt default new workspace split tab beta"), setting(.textBox, "focus-textbox-new-terminals", String(localized: "settings.textBox.focusOnNewTerminals", defaultValue: "Focus TextBox on New Terminals"), "terminal.focusTextBoxOnNewTerminals textbox text box rich input prompt default new workspace split tab beta"), @@ -498,6 +499,10 @@ enum SettingsSearchIndex { "terminal.agentHibernation.enabled": settingID(for: .terminal, idSuffix: "agent-hibernation"), "terminal.agentHibernation.idleSeconds": settingID(for: .terminal, idSuffix: "agent-hibernation"), "terminal.agentHibernation.maxLiveTerminals": settingID(for: .terminal, idSuffix: "agent-hibernation"), + "terminal.surfaceHibernation.enabled": settingID(for: .terminal, idSuffix: "surface-hibernation"), + "terminal.surfaceHibernation.idleSeconds": settingID(for: .terminal, idSuffix: "surface-hibernation"), + "terminal.surfaceHibernation.unmountedIdleSeconds": settingID(for: .terminal, idSuffix: "surface-hibernation"), + "terminal.surfaceHibernation.maxLiveSurfaces": settingID(for: .terminal, idSuffix: "surface-hibernation"), "workspaceColors.indicatorStyle": settingID(for: .workspaceColors, idSuffix: "indicator"), "workspaceColors.selectionColor": settingID(for: .workspaceColors, idSuffix: "selection"), "workspaceColors.notificationBadgeColor": settingID(for: .workspaceColors, idSuffix: "badge"), diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 1661011616df..36e330f57d4d 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -9423,6 +9423,10 @@ extension TabManager { (workspace.panels[panelId] as? TerminalPanel)?.agentHibernationState, into: &hasher ) + Self.hashSurfaceHibernationPanelState( + (workspace.panels[panelId] as? TerminalPanel)?.surfaceHibernationState, + into: &hasher + ) Self.hashSurfaceResumeBindingSnapshot( workspace.effectiveSurfaceResumeBinding( panelId: panelId, @@ -9526,6 +9530,22 @@ extension TabManager { hasher.combine(state.lastActivityAt.timeIntervalSince1970) } + private static func hashSurfaceHibernationPanelState( + _ state: SurfaceHibernationPanelState?, + into hasher: inout Hasher + ) { + guard let state else { + hasher.combine(false) + return + } + + // The captured scrollback is immutable while hibernated, so the + // timestamps uniquely identify the transition without hashing it. + hasher.combine(true) + hasher.combine(state.hibernatedAt.timeIntervalSince1970) + hasher.combine(state.lastActivityAt.timeIntervalSince1970) + } + nonisolated private static func hashSurfaceResumeBindingSnapshot( _ snapshot: SurfaceResumeBindingSnapshot?, into hasher: inout Hasher diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 393ef692d472..0c2c3d69afe1 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -130,6 +130,7 @@ class TerminalController { private static let mobileViewportReportTTL: TimeInterval = 5 private var mobileViewportReportsBySurfaceID: [UUID: [String: MobileViewportReport]] = [:] private var mobileViewportReportCleanupTimersBySurfaceID: [UUID: DispatchSourceTimer] = [:] + private var mobileViewportSurfaceReadyObserver: NSObjectProtocol? #if DEBUG private nonisolated static let socketCommandDebugLogEnvironmentKey = "CMUX_DEBUG_SOCKET_COMMAND_LOG" private nonisolated static let socketCommandSlowThresholdMs: Double = 500 @@ -286,6 +287,20 @@ class TerminalController { } } serverEventTarget.controller = self + // Mobile viewport limits no-op while a panel has no live surface + // (lazy start, surface hibernation); re-apply the stored reports once + // the runtime surface materializes so the restored grid matches the + // phone instead of the Mac size. + mobileViewportSurfaceReadyObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { [weak self] note in + guard let surfaceID = (note.object as? TerminalSurface)?.id else { return } + Task { @MainActor [weak self] in + self?.reapplyMobileViewportLimitAfterSurfaceReady(surfaceID: surfaceID) + } + } browserDownloadObserver = NotificationCenter.default.addObserver( forName: .browserDownloadEventDidArrive, object: nil, @@ -1594,6 +1609,9 @@ class TerminalController { case "agent_hibernation": return agentHibernation(args) + case "surface_hibernation": + return surfaceHibernation(args) + case "clear_agent_pid": return clearAgentPID(args) @@ -20030,6 +20048,23 @@ class TerminalController { } } + private func surfaceHibernation(_ args: String) -> String { + let parsed = parseOptions(args) + let subcommand = parsed.positional.first?.lowercased() + let usage = "surface_hibernation " + + switch subcommand { + case "on", "enable", "enabled", "true": + SurfaceHibernationSettings.setValues(enabled: true) + return "OK" + case "off", "disable", "disabled", "false": + SurfaceHibernationSettings.setValues(enabled: false) + return "OK" + default: + return "ERROR: Usage: \(usage)" + } + } + /// Unregister an agent PID. Usage: clear_agent_pid [--tab=] [--panel=] [--clear-status] private func clearAgentPID(_ args: String) -> String { let parsed = parseOptions(args) @@ -22155,7 +22190,9 @@ class TerminalController { #if DEBUG let sendStart = ProcessInfo.processInfo.systemUptime #endif - let sendResult = terminalPanel.surface.sendInputResult(text) + // Send through the panel so a hibernated surface restores and queues + // instead of reporting surfaceUnavailable. + let sendResult = terminalPanel.sendInputResult(text) switch sendResult { case .sent: terminalPanel.surface.forceRefresh(reason: "mobileHost.terminalInput") @@ -22214,7 +22251,9 @@ class TerminalController { return .err(code: "invalid_params", message: "Image payload was empty or exceeded the size limit", data: nil) } - let sendResult = terminalPanel.surface.sendInputResult(escapedPath) + // Send through the panel so a hibernated surface restores and queues + // instead of reporting surfaceUnavailable. + let sendResult = terminalPanel.sendInputResult(escapedPath) switch sendResult { case .sent: terminalPanel.surface.forceRefresh(reason: "mobileHost.terminalPasteImage") @@ -22277,6 +22316,21 @@ class TerminalController { ) } + @MainActor + private func reapplyMobileViewportLimitAfterSurfaceReady(surfaceID: UUID) { + guard let reports = mobileViewportReportsBySurfaceID[surfaceID], + let minColumns = reports.values.map(\.columns).min(), + let minRows = reports.values.map(\.rows).min(), + let surface = TerminalSurfaceRegistry.shared.surface(id: surfaceID) else { + return + } + _ = surface.applyMobileViewportLimit( + columns: minColumns, + rows: minRows, + reason: "mobile.terminal.surfaceReady" + ) + } + /// Remove a single client's viewport report for a surface (dedicated /// `mobile.terminal.viewport` clear, or a disconnect), then recompute the /// remaining min and re-apply or clear the surface's viewport limit so the @@ -22414,6 +22468,13 @@ class TerminalController { if requireTerminal, let surfaceId, let panel = workspace.terminalPanel(for: surfaceId) { + // A surface-hibernated panel suspends runtime creation entirely, + // so a background start alone would no-op and the phone would + // read a blank terminal until the user types. A mobile client + // resolving the terminal is viewing it: restore first. + if panel.isSurfaceHibernated { + _ = workspace.restoreSurfaceHibernation(panelId: surfaceId, focus: false) + } panel.surface.requestBackgroundSurfaceStartIfNeeded() } diff --git a/Sources/TerminalStartupEnvironment.swift b/Sources/TerminalStartupEnvironment.swift index 09ee46521821..2eedadc17e41 100644 --- a/Sources/TerminalStartupEnvironment.swift +++ b/Sources/TerminalStartupEnvironment.swift @@ -255,6 +255,30 @@ extension TerminalSurface { return nil } + /// Whether `applyManagedShellSpecificStartupEnvironment` actually + /// installed the integration that consumes CMUX_RESTORE_SCROLLBACK_FILE + /// for this launch. The helper declines silently — unreadable bundled + /// bootstrap, or a custom startup command displacing the fish wrapper — + /// and a shell launched without the hook would never replay (or delete) + /// a staged scrollback file, so restartability must key off these + /// installation artifacts rather than the shell's name. + static func shellIntegrationInstalledReplayHook( + shellName: String, + managedKeysAdded: Set, + shellSpecificCommandApplied: Bool + ) -> Bool { + switch shellName { + case "zsh": + return managedKeysAdded.contains("ZDOTDIR") + case "bash": + return managedKeysAdded.contains("PROMPT_COMMAND") + case "fish": + return shellSpecificCommandApplied + default: + return false + } + } + static func managedFishShellCommand(shell: String) -> String { let initCommand = #"source "$CMUX_FISH_INTEGRATION_FILE""# return "\(shellSingleQuoted(shell)) -il --init-command \(shellSingleQuoted(initCommand))" diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 053193bcd803..66f3641fdfbc 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -488,6 +488,7 @@ extension Workspace { } } let hibernationState = (panel as? TerminalPanel)?.agentHibernationState + let surfaceHibernationState = (panel as? TerminalPanel)?.surfaceHibernationState let effectiveRestorableAgent = hibernationState?.agent ?? restoredAgentSnapshotsByPanelId[panelId] let panelTitle = panelTitle(panelId: panelId) @@ -582,22 +583,55 @@ extension Workspace { #else let allowDebugFallbackScrollback = false #endif - let capturedScrollback = includeScrollback && shouldPersistScrollback && hibernationState == nil - ? TerminalController.shared.readTerminalTextForSnapshot( - terminalPanel: terminalPanel, - includeScrollback: true, - lineLimit: SessionPersistencePolicy.maxScrollbackLinesPerTerminal + let resolvedScrollback: String? + if let surfaceHibernationState { + // The runtime surface was freed; the scrollback captured at + // hibernation time is the only copy of the terminal content, + // so persist it on every save — including scrollback-free + // autosaves, which would otherwise overwrite the session + // snapshot with nil before a terminating full save runs. + resolvedScrollback = surfaceHibernationState.scrollback + if let resolvedScrollback { + restoredTerminalScrollbackByPanelId[panelId] = resolvedScrollback + } else { + restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) + } + } else if !includeScrollback, + !terminalPanel.surface.hasLiveSurface, + let fallbackScrollback = restoredTerminalScrollbackByPanelId[panelId] { + // A surface-less panel (lazy start, hibernation restore + // window) has no live buffer a later full save could + // recapture, so scrollback-free saves keep the last captured + // copy instead of overwriting it with nil. + resolvedScrollback = fallbackScrollback + } else { + let capturedScrollback: String? + if includeScrollback, shouldPersistScrollback, hibernationState == nil { + capturedScrollback = TerminalController.shared.readTerminalTextForSnapshot( + terminalPanel: terminalPanel, + includeScrollback: true, + lineLimit: SessionPersistencePolicy.maxScrollbackLinesPerTerminal + ) + } else { + capturedScrollback = nil + } + let hasRestoredScrollbackFallback = restoredTerminalScrollbackByPanelId[panelId] != nil + resolvedScrollback = terminalSnapshotScrollback( + panelId: panelId, + capturedScrollback: capturedScrollback, + includeScrollback: includeScrollback, + allowFallbackScrollback: shouldPersistScrollback || allowDebugFallbackScrollback || hasRestoredScrollbackFallback ) - : nil - let hasRestoredScrollbackFallback = restoredTerminalScrollbackByPanelId[panelId] != nil - let resolvedScrollback = terminalSnapshotScrollback( - panelId: panelId, - capturedScrollback: capturedScrollback, - includeScrollback: includeScrollback, - allowFallbackScrollback: shouldPersistScrollback || allowDebugFallbackScrollback || hasRestoredScrollbackFallback - ) + } + // A hibernated panel's captured directory travels with the panel + // (e.g. across workspace moves), so prefer it over workspace + // metadata, which may be missing or stale for the freed surface. + let hibernatedWorkingDirectory = surfaceHibernationState?.workingDirectory? + .trimmingCharacters(in: .whitespacesAndNewlines) terminalSnapshot = SessionTerminalPanelSnapshot( - workingDirectory: directory, + workingDirectory: hibernatedWorkingDirectory?.isEmpty == false + ? hibernatedWorkingDirectory + : directory, scrollback: resolvedScrollback, agent: effectiveRestorableAgent, tmuxStartCommand: restorableTmuxStartCommand, @@ -2281,7 +2315,10 @@ extension Workspace { to panel: TerminalPanel, reason: WorkspacePendingTerminalInputReason = .configurationCommand ) { - if panel.surface.surface != nil { + // Hibernated panels never produce a surface-ready notification until + // they are restored; sending through the panel restores first and + // queues the input. + if panel.surface.surface != nil || panel.isAgentHibernated || panel.isSurfaceHibernated { panel.sendInput(text) return } @@ -11593,6 +11630,10 @@ final class Workspace: Identifiable, ObservableObject { private var pendingReparentFocusSuppressionViews: [ObjectIdentifier: GhosttySurfaceScrollView] = [:] private var portalRenderingEnabled = true private var agentHibernationAutoResumePresentationVisible = true + /// When this workspace last stopped rendering (unmounted), or nil while it + /// is mounted. Starts non-nil so restored-but-never-mounted workspaces age + /// toward surface hibernation from creation time. + private(set) var portalRenderingDisabledAt: Date? = Date() private var isAttemptingLayoutFollowUp = false private var isNormalizingPinnedTabOrder = false private var pendingNonFocusSplitFocusReassert: PendingNonFocusSplitFocusReassert? @@ -12535,6 +12576,16 @@ final class Workspace: Identifiable, ObservableObject { func updatePanelShellActivityState(panelId: UUID, state: PanelShellActivityState) { guard panels[panelId] != nil else { return } + // Every report reaches the hibernation controller before the dedupe: + // each one is a real precmd/preexec execution, and a repeat promptIdle + // (empty Enter, ^C at an already-idle prompt) is exactly the signal + // that clears pre-tracking seeded pending input. The dedupe below + // still guards the state write and the restored-agent state machine. + AgentHibernationController.shared.recordShellActivityTransition( + workspaceId: id, + panelId: panelId, + state: state + ) let previousState = panelShellActivityStates[panelId] ?? .unknown guard previousState != state else { return } panelShellActivityStates[panelId] = state @@ -12638,7 +12689,8 @@ final class Workspace: Identifiable, ObservableObject { lastActivityAt: Date ) { guard let terminalPanel = panels[panelId] as? TerminalPanel, - !terminalPanel.isAgentHibernated else { + !terminalPanel.isAgentHibernated, + !terminalPanel.isSurfaceHibernated else { return } guard agent.resumeCommand != nil else { return } @@ -12692,6 +12744,120 @@ final class Workspace: Identifiable, ObservableObject { return didResume } + struct SurfaceHibernationCapture: Sendable { + let scrollback: String? + let workingDirectory: String? + } + + /// Read the state a surface hibernation must preserve, without mutating + /// anything. The hibernation timer writes the replay file off the main + /// actor between this and `commitSurfaceHibernation`. + func captureSurfaceHibernation(panelId: UUID) -> SurfaceHibernationCapture? { + guard let terminalPanel = panels[panelId] as? TerminalPanel, + !terminalPanel.isAgentHibernated, + !terminalPanel.isSurfaceHibernated else { + return nil + } + let capturedText = TerminalController.shared.readTerminalTextForSnapshot( + terminalPanel: terminalPanel, + includeScrollback: true, + lineLimit: SessionPersistencePolicy.maxScrollbackLinesPerTerminal + ) + if terminalPanel.surface.hasLiveSurface, capturedText == nil { + // A live surface read came back nil, which means the read failed + // (an empty terminal reads as ""). The capture is the only copy + // restore will ever have, so keep the surface and let a later + // evaluation retry. + return nil + } + let scrollback = SessionPersistencePolicy.truncatedScrollback(capturedText) + let panelDirectory = panelDirectories[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines) + let fallbackDirectory = terminalPanel.directory.trimmingCharacters(in: .whitespacesAndNewlines) + let workingDirectory = panelDirectory?.isEmpty == false + ? panelDirectory + : (fallbackDirectory.isEmpty ? nil : fallbackDirectory) + return SurfaceHibernationCapture(scrollback: scrollback, workingDirectory: workingDirectory) + } + + /// Free the panel's runtime surface using a capture whose replay file was + /// already written. Deletes the file when the transition aborts (the + /// panel closed or hibernated through another path meanwhile). + @discardableResult + func commitSurfaceHibernation( + panelId: UUID, + capture: SurfaceHibernationCapture, + replayFilePath: String?, + lastActivityAt: Date + ) -> Bool { + guard let terminalPanel = panels[panelId] as? TerminalPanel, + terminalPanel.enterSurfaceHibernation( + scrollback: capture.scrollback, + workingDirectory: capture.workingDirectory, + lastActivityAt: lastActivityAt, + replayFilePath: replayFilePath + ) else { + if let replayFilePath { + try? FileManager.default.removeItem(atPath: replayFilePath) + } + return false + } + // Seed the session-snapshot fallback immediately: an autosave can land + // in the restore window after the hibernation state clears but before + // a replacement surface is readable, and must not persist nil over + // the only copy of the content. An empty capture must clear any older + // entry (e.g. session-restore seeding) for the same reason — the + // restore-window save would otherwise resurrect scrollback that no + // longer reflects this terminal. + if let scrollback = capture.scrollback { + restoredTerminalScrollbackByPanelId[panelId] = scrollback + } else { + restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) + } + return true + } + + /// Capture scrollback and working directory, then free the panel's runtime + /// surface while keeping the panel in the layout. The shell ends; restoring + /// starts a fresh one with the captured state replayed. Writes the replay + /// file synchronously; the hibernation timer uses the capture/commit split + /// with an off-actor write instead. + @discardableResult + func enterSurfaceHibernation(panelId: UUID, lastActivityAt: Date) -> Bool { + guard let capture = captureSurfaceHibernation(panelId: panelId) else { return false } + return commitSurfaceHibernation( + panelId: panelId, + capture: capture, + replayFilePath: SessionScrollbackReplayStore.replayFilePath(for: capture.scrollback), + lastActivityAt: lastActivityAt + ) + } + + @discardableResult + func restoreSurfaceHibernation(panelId: UUID, focus: Bool) -> Bool { + guard let terminalPanel = panels[panelId] as? TerminalPanel, + terminalPanel.isSurfaceHibernated, + terminalPanel.prepareSurfaceHibernationRestore() else { + return false + } + if focus { + focusPanel(panelId) + } + return true + } + + @discardableResult + func restoreVisibleSurfaceHibernatedPanels(panelIds: Set) -> Bool { + var didRestore = false + for panelId in panelIds { + guard let terminalPanel = panels[panelId] as? TerminalPanel, + terminalPanel.isSurfaceHibernated else { + continue + } + didRestore = restoreSurfaceHibernation(panelId: panelId, focus: false) || didRestore + } + return didRestore + } + private func restoredAgentResumeStateForAcceptedSnapshot(panelId: UUID) -> RestoredAgentResumeState { panelShellActivityStates[panelId] == .commandRunning ? .observedAgentCommandRunning @@ -17056,6 +17222,7 @@ final class Workspace: Identifiable, ObservableObject { let changed = portalRenderingEnabled != enabled portalRenderingEnabled = enabled if enabled { + portalRenderingDisabledAt = nil if changed { beginEventDrivenLayoutFollowUp( reason: reason, @@ -17063,6 +17230,9 @@ final class Workspace: Identifiable, ObservableObject { ) } } else { + if portalRenderingDisabledAt == nil { + portalRenderingDisabledAt = Date() + } clearLayoutFollowUp() hideAllTerminalPortalViews() hideAllBrowserPortalViews() @@ -17752,6 +17922,14 @@ final class Workspace: Identifiable, ObservableObject { return visiblePanelIds } + /// Panels rendered in the current layout, for hibernation protection. + /// Unlike the agent auto-resume set, this is independent of the + /// input-active presentation flag: a terminal the user can see — e.g. in a + /// visible but non-key window — must never be evicted. + func surfaceHibernationProtectedPanelIdsForCurrentLayout() -> Set { + renderedVisiblePanelIdsForCurrentLayout() + } + func agentHibernationVisiblePanelIdsForCurrentLayout() -> Set { guard agentHibernationAutoResumePresentationVisible else { return [] } return renderedVisiblePanelIdsForCurrentLayout() @@ -17763,6 +17941,11 @@ final class Workspace: Identifiable, ObservableObject { var didChange = agentHibernationAutoResumePresentationVisible ? resumeVisibleAgentHibernationPanels(panelIds: visiblePanelIds) : false + // Surface restore is silent (no resume command to approve), and a + // hibernated panel has no placeholder UI, so any rendered panel must + // restore even when the workspace is visible but not input-active — + // otherwise it would sit blank in a non-key window. + didChange = restoreVisibleSurfaceHibernatedPanels(panelIds: visiblePanelIds) || didChange for panel in panels.values { guard let terminalPanel = panel as? TerminalPanel else { continue } @@ -18734,6 +18917,9 @@ extension Workspace: BonsplitDelegate { if terminalPanel.isAgentHibernated, shouldResumeHibernatedAgent { _ = resumeAgentHibernation(panelId: panelId, focus: false) } + if terminalPanel.isSurfaceHibernated { + _ = restoreSurfaceHibernation(panelId: panelId, focus: false) + } AgentHibernationController.shared.recordTerminalFocus(workspaceId: id, panelId: panelId) } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 2ebd65ecb175..80e81ba6133e 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -601,6 +601,8 @@ F6355600A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6355601A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift */; }; F20F85FC5900550685FA33AD /* StackAuth in Frameworks */ = {isa = PBXBuildFile; productRef = A8BD195031FC4B82B4354297 /* StackAuth */; }; D35B71010000000000000001 /* StartupBreadcrumbLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35B71010000000000000002 /* StartupBreadcrumbLog.swift */; }; + D36A00050000000000000001 /* SurfaceHibernationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36A00050000000000000002 /* SurfaceHibernationPolicy.swift */; }; + D36A00060000000000000001 /* SurfaceHibernationPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36A00060000000000000002 /* SurfaceHibernationPolicyTests.swift */; }; A5001303 /* SurfaceSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001301 /* SurfaceSearchOverlay.swift */; }; C9A5720BC9A5720BC9A5720B /* TabItemView+WorkspaceGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5720CC9A5720CC9A5720C /* TabItemView+WorkspaceGroups.swift */; }; C3677003000000000000001 /* TabManager+CompatibilityTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3677003000000000000002 /* TabManager+CompatibilityTypes.swift */; }; @@ -1351,6 +1353,8 @@ E3309A0E /* SplitEqualizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitEqualizer.swift; sourceTree = ""; }; F6355601A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHStartupSignalLifecycleTests.swift; sourceTree = ""; }; D35B71010000000000000002 /* StartupBreadcrumbLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/StartupBreadcrumbLog.swift; sourceTree = ""; }; + D36A00050000000000000002 /* SurfaceHibernationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/SurfaceHibernationPolicy.swift; sourceTree = ""; }; + D36A00060000000000000002 /* SurfaceHibernationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceHibernationPolicyTests.swift; sourceTree = ""; }; A5001301 /* SurfaceSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/SurfaceSearchOverlay.swift; sourceTree = ""; }; C9A5720CC9A5720CC9A5720C /* TabItemView+WorkspaceGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabItemView+WorkspaceGroups.swift"; sourceTree = ""; }; C3677003000000000000002 /* TabManager+CompatibilityTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+CompatibilityTypes.swift"; sourceTree = ""; }; @@ -1752,6 +1756,7 @@ C0DE32470000000000000002 /* ContentViewIdentifierCopyCommands.swift */, 9960CC3992F3C44489E7627C /* WorkspaceRuntimeSettings.swift */, D36A00010000000000000002 /* AgentHibernationController.swift */, + D36A00050000000000000002 /* SurfaceHibernationPolicy.swift */, 243632BA1DBBA36FD46E0610 /* SidebarAppearanceSupport.swift */, D50D9BCE46701FBA91C2EFB6 /* SidebarWorkspaceSnapshotRefreshPolicy.swift */, D35450020000000000000002 /* SidebarWorkspaceRowHoverTracker.swift */, @@ -2172,6 +2177,7 @@ A9E050000000000000000001 /* AgentSessionWebRendererTests.swift */, A9E040000000000000000001 /* CodexAppServerSessionTests.swift */, D36A00020000000000000002 /* AgentHibernationTests.swift */, + D36A00060000000000000002 /* SurfaceHibernationPolicyTests.swift */, F5410001A1B2C3D4E5F60718 /* RestorableAgentHookProviderResumeTests.swift */, F5410005A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift */, F5410003A1B2C3D4E5F60718 /* RestorableAgentNonInteractiveTests.swift */, @@ -3057,6 +3063,7 @@ A5001226 /* SocketControlMode+Display.swift in Sources */, E3309A0D /* SplitEqualizer.swift in Sources */, D35B71010000000000000001 /* StartupBreadcrumbLog.swift in Sources */, + D36A00050000000000000001 /* SurfaceHibernationPolicy.swift in Sources */, A5001303 /* SurfaceSearchOverlay.swift in Sources */, C9A5720BC9A5720BC9A5720B /* TabItemView+WorkspaceGroups.swift in Sources */, C3677003000000000000001 /* TabManager+CompatibilityTypes.swift in Sources */, @@ -3391,6 +3398,7 @@ 62270F3DCECB4787D789CCE3 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift in Sources */, F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, F6355600A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift in Sources */, + D36A00060000000000000001 /* SurfaceHibernationPolicyTests.swift in Sources */, 2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */, B6BF3DC98DB1495E57900199 /* TabManagerUnitTests.swift in Sources */, C7A507000000000000000002 /* TaskManagerResourcesTests.swift in Sources */, diff --git a/cmuxTests/AgentHibernationTests.swift b/cmuxTests/AgentHibernationTests.swift index b1260a782618..273bea708382 100644 --- a/cmuxTests/AgentHibernationTests.swift +++ b/cmuxTests/AgentHibernationTests.swift @@ -142,17 +142,18 @@ final class AgentHibernationTests: XCTestCase { confirmationSeconds: 5 ) - let selected = AgentHibernationPlanner.selectedPanelKeys( + let selected = SurfaceHibernationPlanner.selectedPanelKeys( inputs: [ - .init(key: idleOld, hasRestorableAgent: true, isLive: true, isProtected: false, lifecycle: .idle, hasUnconfirmedTerminalInput: false, lastActivityAt: now - 300), - .init(key: idleNew, hasRestorableAgent: true, isLive: true, isProtected: false, lifecycle: .idle, hasUnconfirmedTerminalInput: false, lastActivityAt: now - 10), - .init(key: runningOld, hasRestorableAgent: true, isLive: true, isProtected: false, lifecycle: .running, hasUnconfirmedTerminalInput: false, lastActivityAt: now - 300), - .init(key: needsInputOld, hasRestorableAgent: true, isLive: true, isProtected: false, lifecycle: .needsInput, hasUnconfirmedTerminalInput: false, lastActivityAt: now - 300), - .init(key: unknownOld, hasRestorableAgent: true, isLive: true, isProtected: false, lifecycle: .unknown, hasUnconfirmedTerminalInput: false, lastActivityAt: now - 300), - .init(key: unconfirmedInputOld, hasRestorableAgent: true, isLive: true, isProtected: false, lifecycle: .idle, hasUnconfirmedTerminalInput: true, lastActivityAt: now - 300), - .init(key: visibleOld, hasRestorableAgent: true, isLive: true, isProtected: true, lifecycle: .idle, hasUnconfirmedTerminalInput: false, lastActivityAt: now - 300), + .init(key: idleOld, mechanism: .agentResume, isLive: true, isProtected: false, lifecycle: .idle, hasUnconfirmedTerminalInput: false, lastActivityAt: now - 300), + .init(key: idleNew, mechanism: .agentResume, isLive: true, isProtected: false, lifecycle: .idle, hasUnconfirmedTerminalInput: false, lastActivityAt: now - 10), + .init(key: runningOld, mechanism: .agentResume, isLive: true, isProtected: false, lifecycle: .running, hasUnconfirmedTerminalInput: false, lastActivityAt: now - 300), + .init(key: needsInputOld, mechanism: .agentResume, isLive: true, isProtected: false, lifecycle: .needsInput, hasUnconfirmedTerminalInput: false, lastActivityAt: now - 300), + .init(key: unknownOld, mechanism: .agentResume, isLive: true, isProtected: false, lifecycle: .unknown, hasUnconfirmedTerminalInput: false, lastActivityAt: now - 300), + .init(key: unconfirmedInputOld, mechanism: .agentResume, isLive: true, isProtected: false, lifecycle: .idle, hasUnconfirmedTerminalInput: true, lastActivityAt: now - 300), + .init(key: visibleOld, mechanism: .agentResume, isLive: true, isProtected: true, lifecycle: .idle, hasUnconfirmedTerminalInput: false, lastActivityAt: now - 300), ], - settings: settings, + agentSettings: settings, + surfaceSettings: disabledSurfaceSettings(), now: now ) @@ -168,11 +169,12 @@ final class AgentHibernationTests: XCTestCase { confirmationSeconds: 5 ) - let selected = AgentHibernationPlanner.selectedPanelKeys( + let selected = SurfaceHibernationPlanner.selectedPanelKeys( inputs: [ - .init(key: key, hasRestorableAgent: true, isLive: true, isProtected: false, lifecycle: .idle, hasUnconfirmedTerminalInput: false, lastActivityAt: 0), + .init(key: key, mechanism: .agentResume, isLive: true, isProtected: false, lifecycle: .idle, hasUnconfirmedTerminalInput: false, lastActivityAt: 0), ], - settings: settings, + agentSettings: settings, + surfaceSettings: disabledSurfaceSettings(), now: 1_000 ) @@ -1065,6 +1067,16 @@ final class AgentHibernationTests: XCTestCase { XCTAssertFalse(panel.surface.debugInitialInputMetadata().hasInitialInput) } + private func disabledSurfaceSettings() -> SurfaceHibernationSettings.Values { + SurfaceHibernationSettings.Values( + enabled: false, + idleSeconds: SurfaceHibernationSettings.defaultIdleSeconds, + unmountedIdleSeconds: SurfaceHibernationSettings.defaultUnmountedIdleSeconds, + maxLiveSurfaces: SurfaceHibernationSettings.defaultMaxLiveSurfaces, + confirmationSeconds: SurfaceHibernationSettings.defaultConfirmationSeconds + ) + } + private func launch( _ launcher: String, _ executablePath: String, diff --git a/cmuxTests/SurfaceHibernationPolicyTests.swift b/cmuxTests/SurfaceHibernationPolicyTests.swift new file mode 100644 index 000000000000..7fc876f09970 --- /dev/null +++ b/cmuxTests/SurfaceHibernationPolicyTests.swift @@ -0,0 +1,1163 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +// Workspace/TabManager round-trip tests below mutate shared app singletons +// (AgentHibernationController, portal registries), so the suite runs +// serialized rather than guarding that state with locks. +@Suite(.serialized) +struct SurfaceHibernationPolicyTests { + private let now: TimeInterval = 100_000 + + // MARK: - Global LRU cap + + @Test + func globalCapCountsPlainShellSurfacesAndEvictsLeastRecentlyUsed() { + let plainOld = panelKey() + let plainMid = panelKey() + let plainNew = panelKey() + + let selected = SurfaceHibernationPlanner.selectedPanelKeys( + inputs: [ + plainShell(plainOld, lastActivityAt: now - 3_600), + plainShell(plainMid, lastActivityAt: now - 1_800), + plainShell(plainNew, lastActivityAt: now - 10), + ], + agentSettings: agentSettings(enabled: false), + surfaceSettings: surfaceSettings(maxLiveSurfaces: 2), + now: now + ) + + #expect(selected == Set([plainOld])) + } + + @Test + func globalCapCensusIncludesExemptSurfacesMaterializedByBypassPaths() { + // Surfaces force-created for hidden panels (background prime, queued + // socket input) still count toward the live census even when they are + // individually exempt from eviction, so the cap reclaims an eligible + // surface instead of silently overflowing. + let busy = panelKey() + let deferredStartup = panelKey() + let plainOld = panelKey() + + let selected = SurfaceHibernationPlanner.selectedPanelKeys( + inputs: [ + plainShell(busy, lastActivityAt: now - 7_200, isBusy: true), + input(deferredStartup, mechanism: nil, lastActivityAt: now - 7_200), + plainShell(plainOld, lastActivityAt: now - 3_600), + ], + agentSettings: agentSettings(enabled: false), + surfaceSettings: surfaceSettings(maxLiveSurfaces: 2), + now: now + ) + + #expect(selected == Set([plainOld])) + } + + @Test + func globalCapEvictionInterleavesAgentAndPlainSurfacesByLRU() { + let agentOldest = panelKey() + let plainMiddle = panelKey() + let plainNewest = panelKey() + + let selected = SurfaceHibernationPlanner.selectedPanelKeys( + inputs: [ + agentPanel(agentOldest, lifecycle: .idle, lastActivityAt: now - 7_200), + plainShell(plainMiddle, lastActivityAt: now - 3_600), + plainShell(plainNewest, lastActivityAt: now - 400), + ], + agentSettings: agentSettings(enabled: true, maxLiveTerminals: 99), + surfaceSettings: surfaceSettings(maxLiveSurfaces: 1), + now: now + ) + + #expect(selected == Set([agentOldest, plainMiddle])) + } + + @Test + func globalCapSelectsNothingAtOrUnderLimit() { + let first = panelKey() + let second = panelKey() + + let selected = SurfaceHibernationPlanner.selectedPanelKeys( + inputs: [ + plainShell(first, lastActivityAt: now - 7_200), + plainShell(second, lastActivityAt: now - 3_600), + ], + agentSettings: agentSettings(enabled: false), + surfaceSettings: surfaceSettings(maxLiveSurfaces: 2), + now: now + ) + + #expect(selected.isEmpty) + } + + @Test + func globalCapHonorsIdleGateForRecentlyActiveSurfaces() { + // Two surfaces over a cap of one, but the only LRU candidate beyond the + // cap has been active within the idle window: nothing is evicted. + let recentOld = panelKey() + let recentNew = panelKey() + + let selected = SurfaceHibernationPlanner.selectedPanelKeys( + inputs: [ + plainShell(recentOld, lastActivityAt: now - 200), + plainShell(recentNew, lastActivityAt: now - 100), + ], + agentSettings: agentSettings(enabled: false), + surfaceSettings: surfaceSettings(idleSeconds: 300, maxLiveSurfaces: 1), + now: now + ) + + #expect(selected.isEmpty) + } + + @Test + func lruTieBreaksByPanelIdForDeterminism() { + let workspaceId = UUID() + let first = AgentHibernationPanelKey(workspaceId: workspaceId, panelId: UUID()) + let second = AgentHibernationPanelKey(workspaceId: workspaceId, panelId: UUID()) + let expected = first.panelId.uuidString < second.panelId.uuidString ? first : second + + let selected = SurfaceHibernationPlanner.selectedPanelKeys( + inputs: [ + plainShell(first, lastActivityAt: now - 3_600), + plainShell(second, lastActivityAt: now - 3_600), + ], + agentSettings: agentSettings(enabled: false), + surfaceSettings: surfaceSettings(maxLiveSurfaces: 1), + now: now + ) + + #expect(selected == Set([expected])) + } + + // MARK: - Unmounted-workspace idle rule + + @Test + func unmountedWorkspaceSurfacesHibernateAfterIdleWindowWithoutCapPressure() { + let unmounted = panelKey() + let mounted = panelKey() + + let selected = SurfaceHibernationPlanner.selectedPanelKeys( + inputs: [ + plainShell(unmounted, lastActivityAt: now - 3_600, workspaceUnmountedAt: now - 3_600), + plainShell(mounted, lastActivityAt: now - 3_600), + ], + agentSettings: agentSettings(enabled: false), + surfaceSettings: surfaceSettings(unmountedIdleSeconds: 1_800, maxLiveSurfaces: 12), + now: now + ) + + #expect(selected == Set([unmounted])) + } + + @Test + func unmountedRuleRequiresBothQuietAndUnmountedWindows() { + let quiet = panelKey() + let recentInput = panelKey() + let recentlyUnmounted = panelKey() + + let selected = SurfaceHibernationPlanner.selectedPanelKeys( + inputs: [ + plainShell(quiet, lastActivityAt: now - 3_600, workspaceUnmountedAt: now - 3_600), + plainShell(recentInput, lastActivityAt: now - 300, workspaceUnmountedAt: now - 3_600), + plainShell(recentlyUnmounted, lastActivityAt: now - 3_600, workspaceUnmountedAt: now - 300), + ], + agentSettings: agentSettings(enabled: false), + surfaceSettings: surfaceSettings(unmountedIdleSeconds: 1_800, maxLiveSurfaces: 12), + now: now + ) + + #expect(selected == Set([quiet])) + } + + @Test + func unmountedRuleHibernatesIdleAgentPanelsWhenAgentHibernationEnabled() { + let idleAgent = panelKey() + let runningAgent = panelKey() + + let selected = SurfaceHibernationPlanner.selectedPanelKeys( + inputs: [ + agentPanel(idleAgent, lifecycle: .idle, lastActivityAt: now - 3_600, workspaceUnmountedAt: now - 3_600), + agentPanel(runningAgent, lifecycle: .running, lastActivityAt: now - 3_600, workspaceUnmountedAt: now - 3_600), + ], + agentSettings: agentSettings(enabled: true, maxLiveTerminals: 99), + surfaceSettings: surfaceSettings(unmountedIdleSeconds: 1_800, maxLiveSurfaces: 12), + now: now + ) + + #expect(selected == Set([idleAgent])) + } + + @Test + func globalCapDiscountsSurfacesOtherRulesAlreadySelected() { + // Agent-cap and unmounted selections are about to stop being live, so + // the global cap must not reclaim additional plain shells for the + // same overflow. + let agentOld = panelKey() + let agentNew = panelKey() + let plainOld = panelKey() + + let selected = SurfaceHibernationPlanner.selectedPanelKeys( + inputs: [ + agentPanel(agentOld, lifecycle: .idle, lastActivityAt: now - 7_200), + agentPanel(agentNew, lifecycle: .idle, lastActivityAt: now - 3_600), + plainShell(plainOld, lastActivityAt: now - 3_600), + ], + agentSettings: agentSettings(enabled: true, maxLiveTerminals: 1), + surfaceSettings: surfaceSettings(maxLiveSurfaces: 2), + now: now + ) + + // The agent cap evicts agentOld, bringing the live count to the + // global cap of 2; the plain shell must survive. + #expect(selected == Set([agentOld])) + } + + @Test + func unmountedRuleDrainsOldestFirstBoundedPerEvaluation() { + // Hibernation work runs synchronously on the main actor, so a large + // cohort crossing the idle window together drains a few per pass. + let keys = (0..<6).map { _ in panelKey() } + let inputs = keys.enumerated().map { index, key in + plainShell( + key, + lastActivityAt: now - 7_200 - TimeInterval(index), + workspaceUnmountedAt: now - 7_200 - TimeInterval(index) + ) + } + + let selected = SurfaceHibernationPlanner.selectedPanelKeys( + inputs: inputs, + agentSettings: agentSettings(enabled: false), + surfaceSettings: surfaceSettings(unmountedIdleSeconds: 1_800, maxLiveSurfaces: 12), + now: now + ) + + #expect(selected.count == SurfaceHibernationPlanner.maxSelectionsPerEvaluation) + // Oldest first: the last inputs have the oldest activity timestamps. + let expected = Set(keys.suffix(SurfaceHibernationPlanner.maxSelectionsPerEvaluation)) + #expect(selected == expected) + } + + @Test + func mountedWorkspaceQuietSurfaceIsNotSelectedWithoutCapPressure() { + let mounted = panelKey() + + let selected = SurfaceHibernationPlanner.selectedPanelKeys( + inputs: [ + plainShell(mounted, lastActivityAt: now - 86_400), + ], + agentSettings: agentSettings(enabled: false), + surfaceSettings: surfaceSettings(maxLiveSurfaces: 12), + now: now + ) + + #expect(selected.isEmpty) + } + + // MARK: - Exemptions + + @Test + func busySurfaceIsExemptFromShellRestartHibernation() { + let busyOld = panelKey() + let idleOld = panelKey() + + let capSelected = SurfaceHibernationPlanner.selectedPanelKeys( + inputs: [ + plainShell(busyOld, lastActivityAt: now - 7_200, isBusy: true), + plainShell(idleOld, lastActivityAt: now - 3_600), + ], + agentSettings: agentSettings(enabled: false), + surfaceSettings: surfaceSettings(maxLiveSurfaces: 1), + now: now + ) + #expect(capSelected == Set([idleOld])) + + let unmountedSelected = SurfaceHibernationPlanner.selectedPanelKeys( + inputs: [ + plainShell(busyOld, lastActivityAt: now - 7_200, isBusy: true, workspaceUnmountedAt: now - 7_200), + plainShell(idleOld, lastActivityAt: now - 3_600, workspaceUnmountedAt: now - 3_600), + ], + agentSettings: agentSettings(enabled: false), + surfaceSettings: surfaceSettings(unmountedIdleSeconds: 1_800, maxLiveSurfaces: 12), + now: now + ) + #expect(unmountedSelected == Set([idleOld])) + } + + @Test + func agentPanelsAreNeverShellRestartedWhenAgentHibernationDisabled() { + let agentOld = panelKey() + let plainOld = panelKey() + + let selected = SurfaceHibernationPlanner.selectedPanelKeys( + inputs: [ + agentPanel(agentOld, lifecycle: .idle, lastActivityAt: now - 7_200, workspaceUnmountedAt: now - 7_200), + plainShell(plainOld, lastActivityAt: now - 3_600, workspaceUnmountedAt: now - 3_600), + ], + agentSettings: agentSettings(enabled: false), + surfaceSettings: surfaceSettings(unmountedIdleSeconds: 1_800, maxLiveSurfaces: 1), + now: now + ) + + #expect(selected == Set([plainOld])) + } + + @Test + func protectedVisibleSurfaceIsNeverEvicted() { + let protectedOld = panelKey() + let backgroundOld = panelKey() + + let selected = SurfaceHibernationPlanner.selectedPanelKeys( + inputs: [ + plainShell(protectedOld, lastActivityAt: now - 7_200, isProtected: true, workspaceUnmountedAt: now - 7_200), + plainShell(backgroundOld, lastActivityAt: now - 3_600, workspaceUnmountedAt: now - 3_600), + ], + agentSettings: agentSettings(enabled: false), + surfaceSettings: surfaceSettings(unmountedIdleSeconds: 1_800, maxLiveSurfaces: 1), + now: now + ) + + #expect(selected == Set([backgroundOld])) + } + + @Test + func unconfirmedTerminalInputExemptsSurface() { + let unconfirmedOld = panelKey() + let cleanOld = panelKey() + + let selected = SurfaceHibernationPlanner.selectedPanelKeys( + inputs: [ + plainShell(unconfirmedOld, lastActivityAt: now - 7_200, hasUnconfirmedTerminalInput: true), + plainShell(cleanOld, lastActivityAt: now - 3_600), + ], + agentSettings: agentSettings(enabled: false), + surfaceSettings: surfaceSettings(maxLiveSurfaces: 1), + now: now + ) + + #expect(selected == Set([cleanOld])) + } + + @Test + func surfaceWithoutHibernationMechanismIsExempt() { + let deferredStartupOld = panelKey() + let plainOld = panelKey() + + let selected = SurfaceHibernationPlanner.selectedPanelKeys( + inputs: [ + input(deferredStartupOld, mechanism: nil, lastActivityAt: now - 7_200, workspaceUnmountedAt: now - 7_200), + plainShell(plainOld, lastActivityAt: now - 3_600, workspaceUnmountedAt: now - 3_600), + ], + agentSettings: agentSettings(enabled: false), + surfaceSettings: surfaceSettings(unmountedIdleSeconds: 1_800, maxLiveSurfaces: 1), + now: now + ) + + #expect(selected == Set([plainOld])) + } + + @Test + func nonLiveSurfacesAreNeitherCountedNorSelected() { + let hibernated = panelKey() + let live = panelKey() + + let selected = SurfaceHibernationPlanner.selectedPanelKeys( + inputs: [ + input(hibernated, mechanism: .shellRestart, isLive: false, lastActivityAt: now - 7_200), + plainShell(live, lastActivityAt: now - 3_600), + ], + agentSettings: agentSettings(enabled: false), + surfaceSettings: surfaceSettings(maxLiveSurfaces: 1), + now: now + ) + + #expect(selected.isEmpty) + } + + // MARK: - Disabled states and agent-rule parity + + @Test + func disabledSurfaceHibernationSelectsNoPlainShells() { + let plainOld = panelKey() + let plainNew = panelKey() + + let selected = SurfaceHibernationPlanner.selectedPanelKeys( + inputs: [ + plainShell(plainOld, lastActivityAt: now - 7_200, workspaceUnmountedAt: now - 7_200), + plainShell(plainNew, lastActivityAt: now - 3_600), + ], + agentSettings: agentSettings(enabled: false), + surfaceSettings: surfaceSettings(enabled: false, maxLiveSurfaces: 1), + now: now + ) + + #expect(selected.isEmpty) + } + + @Test + func agentCapBehaviorIsPreservedWhenSurfaceHibernationDisabled() { + let workspaceId = UUID() + let idleOld = AgentHibernationPanelKey(workspaceId: workspaceId, panelId: UUID()) + let idleNew = AgentHibernationPanelKey(workspaceId: workspaceId, panelId: UUID()) + let runningOld = AgentHibernationPanelKey(workspaceId: workspaceId, panelId: UUID()) + let needsInputOld = AgentHibernationPanelKey(workspaceId: workspaceId, panelId: UUID()) + let unconfirmedOld = AgentHibernationPanelKey(workspaceId: workspaceId, panelId: UUID()) + let protectedOld = AgentHibernationPanelKey(workspaceId: workspaceId, panelId: UUID()) + + let selected = SurfaceHibernationPlanner.selectedPanelKeys( + inputs: [ + agentPanel(idleOld, lifecycle: .idle, lastActivityAt: now - 300), + agentPanel(idleNew, lifecycle: .idle, lastActivityAt: now - 10), + agentPanel(runningOld, lifecycle: .running, lastActivityAt: now - 300), + agentPanel(needsInputOld, lifecycle: .needsInput, lastActivityAt: now - 300), + agentPanel(unconfirmedOld, lifecycle: .idle, hasUnconfirmedTerminalInput: true, lastActivityAt: now - 300), + agentPanel(protectedOld, lifecycle: .idle, isProtected: true, lastActivityAt: now - 300), + ], + agentSettings: agentSettings(enabled: true, idleSeconds: 60, maxLiveTerminals: 1), + surfaceSettings: surfaceSettings(enabled: false), + now: now + ) + + #expect(selected == Set([idleOld])) + } + + // MARK: - Settings + + @Test + func surfaceHibernationSettingsDefaults() throws { + let suiteName = "cmux-surface-hibernation-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + #expect(SurfaceHibernationSettings.isEnabled(defaults: defaults)) + #expect(SurfaceHibernationSettings.idleSeconds(defaults: defaults) == 300) + #expect(SurfaceHibernationSettings.unmountedIdleSeconds(defaults: defaults) == 1_800) + #expect(SurfaceHibernationSettings.maxLiveSurfaces(defaults: defaults) == 12) + #expect(SurfaceHibernationSettings.confirmationSeconds(defaults: defaults) == 60) + } + + @Test + func surfaceHibernationSettingsSanitizeAndNotifyOnce() throws { + let suiteName = "cmux-surface-hibernation-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let notificationCenter = NotificationCenter() + var notificationCount = 0 + let observer = notificationCenter.addObserver( + forName: SurfaceHibernationSettings.didChangeNotification, + object: nil, + queue: nil + ) { _ in + notificationCount += 1 + } + defer { notificationCenter.removeObserver(observer) } + + SurfaceHibernationSettings.setValues( + enabled: false, + idleSeconds: 1, + unmountedIdleSeconds: 1, + maxLiveSurfaces: 0, + defaults: defaults, + notificationCenter: notificationCenter + ) + + let values = SurfaceHibernationSettings.values(defaults: defaults) + #expect(!(values.enabled)) + #expect(values.idleSeconds == 30) + #expect(values.unmountedIdleSeconds == 60) + #expect(values.maxLiveSurfaces == 1) + #expect(notificationCount == 1) + + #expect(SurfaceHibernationSettings.sanitizedMaxLiveSurfaces(9_999) == 256) + + #expect(SurfaceHibernationSettings.reset(defaults: defaults, notificationCenter: notificationCenter)) + #expect(SurfaceHibernationSettings.values(defaults: defaults).maxLiveSurfaces == 12) + #expect(notificationCount == 2) + + SurfaceHibernationSettings.setValues( + enabled: SurfaceHibernationSettings.defaultEnabled, + defaults: defaults, + notificationCenter: notificationCenter + ) + #expect(notificationCount == 2) + } + + // MARK: - Hibernate-then-restore round trip + + @MainActor + @Test + func surfaceHibernationRoundTripStagesScrollbackAndWorkingDirectory() throws { + // Restore staging validates the captured directory on disk, so the + // test must use one that actually exists. + let capturedDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-surface-hibernation-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: capturedDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: capturedDirectory) } + + let workspace = Workspace() + let panelId = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.panels[panelId] as? TerminalPanel) + + panel.enterSurfaceHibernation( + scrollback: "hibernated-shell-content", + workingDirectory: capturedDirectory.path, + lastActivityAt: Date(timeIntervalSince1970: 100) + ) + #expect(panel.isSurfaceHibernated) + #expect(panel.surfaceHibernationState?.scrollback == "hibernated-shell-content") + #expect(panel.surfaceHibernationState?.workingDirectory == capturedDirectory.path) + + #expect(workspace.restoreSurfaceHibernation(panelId: panelId, focus: false)) + #expect(!(panel.isSurfaceHibernated)) + + let environment = panel.surface.debugAdditionalEnvironmentForTesting() + let replayPath = try #require(environment[SessionScrollbackReplayStore.environmentKey]) + let replayContents = try String(contentsOfFile: replayPath, encoding: .utf8) + #expect(replayContents.contains("hibernated-shell-content")) + #expect(panel.surface.debugNextRuntimeWorkingDirectoryForTesting() == capturedDirectory.path) + } + + @MainActor + @Test + func surfaceHibernationRestoreStagesCapturedDirectoryWithoutDiskChecks() throws { + // Staging must not stat the path (a dead network mount would hang the + // main thread); Ghostty ignores an unspawnable cwd at spawn time and + // the override is consumed even when creation fails. + let missingDirectory = "/tmp/cmux-surface-hibernation-missing-\(UUID().uuidString)" + let workspace = Workspace() + let panelId = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.panels[panelId] as? TerminalPanel) + + panel.enterSurfaceHibernation( + scrollback: nil, + workingDirectory: missingDirectory, + lastActivityAt: Date(timeIntervalSince1970: 100) + ) + + #expect(workspace.restoreSurfaceHibernation(panelId: panelId, focus: false)) + #expect(panel.surface.debugNextRuntimeWorkingDirectoryForTesting() == missingDirectory) + } + + @MainActor + @Test + func workspaceEnterSurfaceHibernationCapturesPanelDirectory() throws { + let workspace = Workspace() + let panelId = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.panels[panelId] as? TerminalPanel) + workspace.panelDirectories[panelId] = "/tmp/cmux-surface-hibernation-dir" + + #expect(workspace.enterSurfaceHibernation(panelId: panelId, lastActivityAt: Date(timeIntervalSince1970: 50))) + #expect(panel.isSurfaceHibernated) + #expect(panel.surfaceHibernationState?.workingDirectory == "/tmp/cmux-surface-hibernation-dir") + #expect( + !workspace.enterSurfaceHibernation(panelId: panelId, lastActivityAt: Date(timeIntervalSince1970: 60)), + "A hibernated panel must not be hibernated twice" + ) + } + + @MainActor + @Test + func explicitInputToSurfaceHibernatedPanelRestoresAndQueues() throws { + let workspace = Workspace() + let panelId = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.panels[panelId] as? TerminalPanel) + + panel.enterSurfaceHibernation( + scrollback: nil, + workingDirectory: nil, + lastActivityAt: Date(timeIntervalSince1970: 0) + ) + #expect(panel.isSurfaceHibernated) + + let result = panel.sendInputResult("pwd\r") + + #expect(result == .queued) + #expect(!(panel.isSurfaceHibernated)) + } + + @MainActor + @Test + func focusingSurfaceHibernatedPanelRestoresIt() throws { + let workspace = Workspace() + let panelId = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.panels[panelId] as? TerminalPanel) + + panel.enterSurfaceHibernation( + scrollback: nil, + workingDirectory: nil, + lastActivityAt: Date(timeIntervalSince1970: 0) + ) + #expect(panel.isSurfaceHibernated) + + workspace.focusPanel(panelId) + + #expect(!(panel.isSurfaceHibernated)) + } + + @MainActor + @Test + func reconcileRestoresRenderedSurfaceHibernatedPanelEvenWhenInputInactive() throws { + // Surface hibernation has no placeholder UI, so a rendered panel must + // restore even when the workspace is visible but not input-active + // (e.g. in a non-key window) — unlike agent resume, which stays gated + // on the presentation flag. + let workspace = Workspace() + let panelId = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.panels[panelId] as? TerminalPanel) + + panel.enterSurfaceHibernation( + scrollback: nil, + workingDirectory: nil, + lastActivityAt: Date(timeIntervalSince1970: 0) + ) + #expect(panel.isSurfaceHibernated) + + workspace.setAgentHibernationAutoResumePresentationVisible(false) + _ = workspace.debugReconcileTerminalPortalVisibilityForTesting() + + #expect(!(panel.isSurfaceHibernated)) + } + + @MainActor + @Test + func agentHibernatedPanelCannotEnterSurfaceHibernation() throws { + let workspace = Workspace() + let panelId = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.panels[panelId] as? TerminalPanel) + let snapshot = SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: "codex-surface-cross-guard", + workingDirectory: "/tmp/cmux-surface-hibernation", + launchCommand: AgentLaunchCommandSnapshot( + launcher: "codex", + executablePath: "/usr/local/bin/codex", + arguments: ["/usr/local/bin/codex"], + workingDirectory: "/tmp/cmux-surface-hibernation", + environment: nil, + capturedAt: nil, + source: nil + ) + ) + + workspace.enterAgentHibernation( + panelId: panelId, + agent: snapshot, + lastActivityAt: Date(timeIntervalSince1970: 0) + ) + #expect(panel.isAgentHibernated) + + #expect(!workspace.enterSurfaceHibernation(panelId: panelId, lastActivityAt: Date(timeIntervalSince1970: 10))) + #expect(!(panel.isSurfaceHibernated)) + } + + @MainActor + @Test + func sessionSnapshotCarriesHibernatedScrollback() throws { + let workspace = Workspace() + let panelId = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.panels[panelId] as? TerminalPanel) + + panel.enterSurfaceHibernation( + scrollback: "hibernated-snapshot-content", + workingDirectory: nil, + lastActivityAt: Date(timeIntervalSince1970: 0) + ) + + let snapshot = workspace.sessionSnapshot(includeScrollback: true) + let panelSnapshot = try #require(snapshot.panels.first { $0.id == panelId }) + + #expect(panelSnapshot.terminal?.scrollback == "hibernated-snapshot-content") + } + + @MainActor + @Test + func scrollbackFreeSnapshotStillPersistsHibernatedScrollback() throws { + // The freed surface's content exists only in the hibernation state, so + // even autosaves that skip scrollback capture must not overwrite the + // session snapshot with nil. + let workspace = Workspace() + let panelId = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.panels[panelId] as? TerminalPanel) + + panel.enterSurfaceHibernation( + scrollback: "hibernated-autosave-content", + workingDirectory: "/tmp/cmux-hibernated-autosave-dir", + lastActivityAt: Date(timeIntervalSince1970: 0) + ) + + let snapshot = workspace.sessionSnapshot(includeScrollback: false) + let panelSnapshot = try #require(snapshot.panels.first { $0.id == panelId }) + + #expect(panelSnapshot.terminal?.scrollback == "hibernated-autosave-content") + #expect(panelSnapshot.terminal?.workingDirectory == "/tmp/cmux-hibernated-autosave-dir") + } + + @MainActor + @Test + func autosaveFingerprintTracksSurfaceHibernationTransitions() throws { + let manager = TabManager() + let workspace = try #require(manager.selectedWorkspace) + let panelId = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.panels[panelId] as? TerminalPanel) + + let liveFingerprint = manager.sessionAutosaveFingerprint() + panel.enterSurfaceHibernation( + scrollback: nil, + workingDirectory: nil, + lastActivityAt: Date(timeIntervalSince1970: 100) + ) + let hibernatedFingerprint = manager.sessionAutosaveFingerprint() + + #expect(liveFingerprint != hibernatedFingerprint) + #expect(workspace.restoreSurfaceHibernation(panelId: panelId, focus: false)) + #expect(hibernatedFingerprint != manager.sessionAutosaveFingerprint()) + } + + @Test + func firstTailSampleUsesUnmountedFloorOnlyWhenProvided() { + // Cap-rule candidates keep the conservative now-based stability start. + #expect( + AgentHibernationController.tailFingerprintStableSince( + previousFingerprint: nil, + previousStableSince: nil, + currentFingerprint: "tail-a", + lastActivityAt: 100, + now: 500 + ) == 500 + ) + // Unmounted-workspace candidates pass the wall-clock floor so the + // documented hidden-workspace window is not observed twice. + #expect( + AgentHibernationController.tailFingerprintStableSince( + previousFingerprint: nil, + previousStableSince: nil, + currentFingerprint: "tail-a", + lastActivityAt: 100, + now: 500, + firstSampleFallback: 150 + ) == 150 + ) + // A genuinely changed fingerprint always restarts the window at now. + #expect( + AgentHibernationController.tailFingerprintStableSince( + previousFingerprint: "tail-a", + previousStableSince: 100, + currentFingerprint: "tail-b", + lastActivityAt: 100, + now: 500, + firstSampleFallback: 150 + ) == 500 + ) + } + + @MainActor + @Test + func workspaceUnmountTimestampTracksPortalRendering() { + let workspace = Workspace() + #expect( + workspace.portalRenderingDisabledAt != nil, + "A workspace that has never rendered must age toward unmounted-idle hibernation" + ) + + workspace.setPortalRenderingEnabled(true, reason: "test") + #expect(workspace.portalRenderingDisabledAt == nil) + + workspace.setPortalRenderingEnabled(false, reason: "test") + let disabledAt = workspace.portalRenderingDisabledAt + #expect(disabledAt != nil) + + workspace.setPortalRenderingEnabled(false, reason: "test") + #expect( + workspace.portalRenderingDisabledAt == disabledAt, + "Repeated unmount reconciles must not restart the unmounted clock" + ) + } + + @MainActor + @Test + func emptyHibernationCaptureClearsStaleScrollbackFallback() throws { + let workspace = Workspace() + let panelId = try #require(workspace.focusedPanelId) + // A leftover entry from session-restore seeding that no longer + // reflects the terminal (it was cleared, or replay never ran). + workspace.restoredTerminalScrollbackByPanelId[panelId] = "stale old-session text" + + #expect(workspace.enterSurfaceHibernation(panelId: panelId, lastActivityAt: Date(timeIntervalSince1970: 50))) + + #expect( + workspace.restoredTerminalScrollbackByPanelId[panelId] == nil, + "An empty capture must clear the fallback, or a restore-window save resurrects unrelated scrollback" + ) + } + + // MARK: - Pending command-line survivals + + @MainActor + @Test + func plainTextInputDoesNotDropBatchedPromptSurvivals() { + let wasTracking = AgentHibernationTrackingGate.isEnabled() + AgentHibernationTrackingGate.setEnabled(true) + defer { AgentHibernationTrackingGate.setEnabled(wasTracking) } + let controller = AgentHibernationController.shared + let workspaceId = UUID() + let panelId = UUID() + let base = Date() + + // Queued payload "cmd\npartial": one settling newline, trailing text. + controller.recordTerminalInput( + workspaceId: workspaceId, + panelId: panelId, + recordedAt: base, + armsPendingCommandLine: true, + pendingPromptSurvivals: 1 + ) + // "x" arrives before the shell reports cmd's preexec; it appends to + // the eventual editable line and must not consume the survival. + controller.recordTerminalInput( + workspaceId: workspaceId, + panelId: panelId, + recordedAt: base.addingTimeInterval(0.01), + armsPendingCommandLine: true, + pendingPromptSurvivals: 0 + ) + controller.recordShellActivityTransition( + workspaceId: workspaceId, + panelId: panelId, + state: .commandRunning, + recordedAt: base.addingTimeInterval(0.02) + ) + controller.recordShellActivityTransition( + workspaceId: workspaceId, + panelId: panelId, + state: .promptIdle, + recordedAt: base.addingTimeInterval(0.03) + ) + + let state = controller.debugPendingCommandLineStateForTesting( + workspaceId: workspaceId, + panelId: panelId + ) + #expect( + state.pendingAt != nil, + "cmd's own prompt return must consume the survival, not the pending guard for the editable \"partialx\"" + ) + } + + @MainActor + @Test + func batchedPromptSurvivalsAccumulateAcrossPayloads() { + let wasTracking = AgentHibernationTrackingGate.isEnabled() + AgentHibernationTrackingGate.setEnabled(true) + defer { AgentHibernationTrackingGate.setEnabled(wasTracking) } + let controller = AgentHibernationController.shared + let workspaceId = UUID() + let panelId = UUID() + let base = Date() + + // Two queued payloads, each with one settling newline and trailing + // text: the first batch's leftovers get submitted by the second + // batch's newline, so both transitions must be survived. + for offset in [0.0, 0.01] { + controller.recordTerminalInput( + workspaceId: workspaceId, + panelId: panelId, + recordedAt: base.addingTimeInterval(offset), + armsPendingCommandLine: true, + pendingPromptSurvivals: 1 + ) + } + for (index, offset) in [0.02, 0.03, 0.04, 0.05].enumerated() { + controller.recordShellActivityTransition( + workspaceId: workspaceId, + panelId: panelId, + state: index.isMultiple(of: 2) ? .commandRunning : .promptIdle, + recordedAt: base.addingTimeInterval(offset) + ) + } + + let state = controller.debugPendingCommandLineStateForTesting( + workspaceId: workspaceId, + panelId: panelId + ) + #expect(state.survivals == nil, "Both survivals must be consumed by the two prompt returns") + #expect( + state.pendingAt != nil, + "The second batch's trailing text is still editable after both transitions" + ) + } + + @MainActor + @Test + func seededPendingClearsOnBareEnter() { + let wasTracking = AgentHibernationTrackingGate.isEnabled() + AgentHibernationTrackingGate.setEnabled(true) + defer { AgentHibernationTrackingGate.setEnabled(wasTracking) } + let controller = AgentHibernationController.shared + let workspaceId = UUID() + let panelId = UUID() + let base = Date() + + controller.debugSeedPendingCommandLineForTesting( + workspaceId: workspaceId, + panelId: panelId, + recordedAt: base + ) + // A bare Enter settles whatever the seed guarded: the line submits + // (runs as a command), opens a busy-gated PS2 continuation, or was + // empty. The shell integrations dedupe repeat promptIdle reports at + // the source, so this keystroke is the only requalification signal + // an already-idle shell produces. + controller.recordTerminalInput( + workspaceId: workspaceId, + panelId: panelId, + recordedAt: base.addingTimeInterval(1), + armsPendingCommandLine: false, + pendingPromptSurvivals: 0, + clearsSeededPendingCommandLine: true + ) + + let state = controller.debugPendingCommandLineStateForTesting( + workspaceId: workspaceId, + panelId: panelId + ) + #expect(state.pendingAt == nil) + } + + @MainActor + @Test + func inputBackedPendingSurvivesBareEnterWithoutCommand() { + let wasTracking = AgentHibernationTrackingGate.isEnabled() + AgentHibernationTrackingGate.setEnabled(true) + defer { AgentHibernationTrackingGate.setEnabled(wasTracking) } + let controller = AgentHibernationController.shared + let workspaceId = UUID() + let panelId = UUID() + let base = Date() + + controller.debugSeedPendingCommandLineForTesting( + workspaceId: workspaceId, + panelId: panelId, + recordedAt: base + ) + // Observed text replaces the seed; from then on only a command cycle + // may clear the guard (typed-ahead text reappears at the next + // prompt), so the bare Enter alone must not. + controller.recordTerminalInput( + workspaceId: workspaceId, + panelId: panelId, + recordedAt: base.addingTimeInterval(1), + armsPendingCommandLine: true, + pendingPromptSurvivals: 0 + ) + controller.recordTerminalInput( + workspaceId: workspaceId, + panelId: panelId, + recordedAt: base.addingTimeInterval(2), + armsPendingCommandLine: false, + pendingPromptSurvivals: 0, + clearsSeededPendingCommandLine: true + ) + controller.recordShellActivityTransition( + workspaceId: workspaceId, + panelId: panelId, + state: .promptIdle, + recordedAt: base.addingTimeInterval(3) + ) + + let state = controller.debugPendingCommandLineStateForTesting( + workspaceId: workspaceId, + panelId: panelId + ) + #expect( + state.pendingAt != nil, + "Observed input must keep the guard until a command consumes the line" + ) + } + + @Test + func bareEnterClassifiesAsSeededPendingClear() { + #expect(terminalInputClearsSeededPending("\r")) + #expect(terminalInputClearsSeededPending("\n")) + #expect(terminalInputClearsSeededPending("\r\n")) + #expect(!terminalInputClearsSeededPending("")) + #expect(!terminalInputClearsSeededPending("x\r")) + #expect(!terminalInputClearsSeededPending("\u{03}")) + } + + // MARK: - Replay-hook installation evidence + + @Test + func replayHookEvidenceRequiresInstalledIntegration() { + #expect(TerminalSurface.shellIntegrationInstalledReplayHook( + shellName: "zsh", managedKeysAdded: ["ZDOTDIR"], shellSpecificCommandApplied: false + )) + #expect(!TerminalSurface.shellIntegrationInstalledReplayHook( + shellName: "zsh", managedKeysAdded: [], shellSpecificCommandApplied: false + )) + #expect(TerminalSurface.shellIntegrationInstalledReplayHook( + shellName: "bash", managedKeysAdded: ["PROMPT_COMMAND"], shellSpecificCommandApplied: false + )) + #expect(!TerminalSurface.shellIntegrationInstalledReplayHook( + shellName: "bash", managedKeysAdded: ["CMUX_LOAD_GHOSTTY_BASH_INTEGRATION"], + shellSpecificCommandApplied: false + )) + #expect(TerminalSurface.shellIntegrationInstalledReplayHook( + shellName: "fish", managedKeysAdded: [], shellSpecificCommandApplied: true + )) + #expect(!TerminalSurface.shellIntegrationInstalledReplayHook( + shellName: "fish", managedKeysAdded: [], shellSpecificCommandApplied: false + )) + #expect(!TerminalSurface.shellIntegrationInstalledReplayHook( + shellName: "nu", managedKeysAdded: ["ZDOTDIR"], shellSpecificCommandApplied: true + )) + } + + @Test + func unreadableBashBootstrapInstallsNoReplayHook() { + var environment: [String: String] = [:] + var protectedKeys: Set = [] + let command = TerminalSurface.applyManagedShellSpecificStartupEnvironment( + shell: "/bin/bash", + integrationDir: "/nonexistent-cmux-integration", + userGhosttyShellIntegrationMode: "none", + to: &environment, + protectedKeys: &protectedKeys, + readFile: { _ in throw CocoaError(.fileReadNoSuchFile) } + ) + #expect(command == nil) + #expect(!TerminalSurface.shellIntegrationInstalledReplayHook( + shellName: "bash", + managedKeysAdded: protectedKeys, + shellSpecificCommandApplied: false + )) + } + + @Test + func unreadableZshBootstrapInstallsNoReplayHook() { + var environment: [String: String] = [:] + var protectedKeys: Set = [] + let command = TerminalSurface.applyManagedShellSpecificStartupEnvironment( + shell: "/bin/zsh", + integrationDir: "/nonexistent-cmux-integration", + userGhosttyShellIntegrationMode: "none", + to: &environment, + protectedKeys: &protectedKeys + ) + #expect(command == nil) + #expect(protectedKeys.isEmpty, "A missing .zshenv must skip the ZDOTDIR redirection entirely") + #expect(!TerminalSurface.shellIntegrationInstalledReplayHook( + shellName: "zsh", + managedKeysAdded: protectedKeys, + shellSpecificCommandApplied: false + )) + } + + // MARK: - Helpers + + private func panelKey() -> AgentHibernationPanelKey { + AgentHibernationPanelKey(workspaceId: UUID(), panelId: UUID()) + } + + private func plainShell( + _ key: AgentHibernationPanelKey, + lastActivityAt: TimeInterval, + isProtected: Bool = false, + isBusy: Bool = false, + hasUnconfirmedTerminalInput: Bool = false, + workspaceUnmountedAt: TimeInterval? = nil + ) -> SurfaceHibernationPlannerInput { + SurfaceHibernationPlannerInput( + key: key, + mechanism: .shellRestart, + isLive: true, + isProtected: isProtected, + isBusy: isBusy, + lifecycle: .unknown, + hasUnconfirmedTerminalInput: hasUnconfirmedTerminalInput, + lastActivityAt: lastActivityAt, + workspaceUnmountedAt: workspaceUnmountedAt + ) + } + + private func agentPanel( + _ key: AgentHibernationPanelKey, + lifecycle: AgentHibernationLifecycleState, + isProtected: Bool = false, + hasUnconfirmedTerminalInput: Bool = false, + lastActivityAt: TimeInterval, + workspaceUnmountedAt: TimeInterval? = nil + ) -> SurfaceHibernationPlannerInput { + SurfaceHibernationPlannerInput( + key: key, + mechanism: .agentResume, + isLive: true, + isProtected: isProtected, + // A running agent TUI keeps the surface away from the shell prompt, + // so real agent panels always report busy. Busy only exempts the + // shellRestart mechanism; the agent mechanism is gated on the + // hook-reported lifecycle instead, which these tests exercise. + isBusy: true, + lifecycle: lifecycle, + hasUnconfirmedTerminalInput: hasUnconfirmedTerminalInput, + lastActivityAt: lastActivityAt, + workspaceUnmountedAt: workspaceUnmountedAt + ) + } + + private func input( + _ key: AgentHibernationPanelKey, + mechanism: SurfaceHibernationMechanism?, + isLive: Bool = true, + lastActivityAt: TimeInterval, + workspaceUnmountedAt: TimeInterval? = nil + ) -> SurfaceHibernationPlannerInput { + SurfaceHibernationPlannerInput( + key: key, + mechanism: mechanism, + isLive: isLive, + isProtected: false, + lastActivityAt: lastActivityAt, + workspaceUnmountedAt: workspaceUnmountedAt + ) + } + + private func agentSettings( + enabled: Bool, + idleSeconds: TimeInterval = 60, + maxLiveTerminals: Int = 12, + confirmationSeconds: TimeInterval = 5 + ) -> AgentHibernationSettings.Values { + AgentHibernationSettings.Values( + enabled: enabled, + idleSeconds: idleSeconds, + maxLiveTerminals: maxLiveTerminals, + confirmationSeconds: confirmationSeconds + ) + } + + private func surfaceSettings( + enabled: Bool = true, + idleSeconds: TimeInterval = 300, + unmountedIdleSeconds: TimeInterval = 1_800, + maxLiveSurfaces: Int = 12, + confirmationSeconds: TimeInterval = 60 + ) -> SurfaceHibernationSettings.Values { + SurfaceHibernationSettings.Values( + enabled: enabled, + idleSeconds: idleSeconds, + unmountedIdleSeconds: unmountedIdleSeconds, + maxLiveSurfaces: maxLiveSurfaces, + confirmationSeconds: confirmationSeconds + ) + } +} diff --git a/docs/configuration.md b/docs/configuration.md index 851474739510..7e7fb3894d52 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -44,6 +44,32 @@ Opt-in Agent Hibernation. cmux kills idle background agent processes to free RAM Enable it from the command palette (`⌘⇧P` -> Enable Agent Hibernation), from **Settings > Terminal > Agent Hibernation**, or with `cmux agent-hibernation on`. +## `terminal.surfaceHibernation` + +Surface Hibernation frees the terminal renderer (Metal buffers, render threads, PTY) of idle plain-shell terminals in hidden workspaces. cmux captures the scrollback and working directory first, and when you visit the terminal again it starts a fresh shell in that directory with the scrollback replayed. It also enforces a least-recently-used cap on how many terminal surfaces stay live at once, so background workspaces cannot accumulate unbounded memory. + +A terminal is only reclaimed when it is off-screen, safely at a shell prompt with no foreground process, no background jobs, and no listening ports, quiet for the idle window, and its output has stayed unchanged for a ~60s confirmation settle window. Agent terminals (covered by Agent Hibernation), remote terminals, tmux-bound terminals, and terminals with pending startup work or queued input are never reclaimed. + +```json +{ + "terminal": { + "surfaceHibernation": { + "enabled": true, + "idleSeconds": 300, + "unmountedIdleSeconds": 1800, + "maxLiveSurfaces": 12 + } + } +} +``` + +- `enabled`: turn Surface Hibernation on. Default: `true`. +- `idleSeconds`: seconds a background shell terminal must be quiet before the live-surface cap may reclaim it. Default: `300`. Range: `30`-`604800`. +- `unmountedIdleSeconds`: seconds a workspace must stay hidden — with its terminal quiet — before its idle shell surfaces hibernate even without cap pressure. Default: `1800`. Range: `60`-`2592000`. +- `maxLiveSurfaces`: how many terminal surfaces may stay live at once before cmux hibernates the oldest eligible background ones. Every live surface counts toward the limit; only idle, non-busy, non-visible ones are reclaimed. Default: `12`. Range: `1`-`256`. + +Toggle it from the command palette (`⌘⇧P` -> Surface Hibernation), from **Settings > Terminal > Surface Hibernation**, or with `cmux surface-hibernation `. + ## `diffViewer.defaultLayout` Controls the initial layout for newly opened diff viewers. diff --git a/web/app/[locale]/docs/configuration/page.tsx b/web/app/[locale]/docs/configuration/page.tsx index 446f5d55676e..64f9052524ce 100644 --- a/web/app/[locale]/docs/configuration/page.tsx +++ b/web/app/[locale]/docs/configuration/page.tsx @@ -81,6 +81,12 @@ function buildSettingsFileExample(t: ConfigurationTranslation) { // "idleSeconds": 5, // "maxLiveTerminals": 12 // }, + // "surfaceHibernation": { + // "enabled": true, + // "idleSeconds": 300, + // "unmountedIdleSeconds": 1800, + // "maxLiveSurfaces": 12 + // }, // "textBoxMaxLines": 10 // }, diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 3e32415a8f4a..7158425cd696 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -436,6 +436,43 @@ } } }, + "surfaceHibernation": { + "type": "object", + "additionalProperties": false, + "description": "Surface Hibernation settings. cmux frees the terminal renderer (Metal buffers, render threads, PTY) of idle plain-shell terminals in hidden workspaces after capturing scrollback, then starts a fresh shell in the captured working directory with the scrollback replayed when the terminal is visited again. Terminals running a foreground process, with background jobs or listening ports, agent terminals, remote terminals, and terminals with pending startup work are never reclaimed. Also enforces a least-recently-used cap on simultaneously live terminal surfaces.", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "descriptionKey": "schemaDescriptions.terminal.surfaceHibernation.enabled", + "description": "Enable Surface Hibernation." + }, + "idleSeconds": { + "type": "integer", + "minimum": 30, + "maximum": 604800, + "default": 300, + "descriptionKey": "schemaDescriptions.terminal.surfaceHibernation.idleSeconds", + "description": "Minimum seconds without input or focus before the live-surface cap may reclaim a background shell terminal. A short confirmation settle window (~60s) still applies on top of this." + }, + "unmountedIdleSeconds": { + "type": "integer", + "minimum": 60, + "maximum": 2592000, + "default": 1800, + "descriptionKey": "schemaDescriptions.terminal.surfaceHibernation.unmountedIdleSeconds", + "description": "Seconds a workspace must stay unmounted (hidden) — with its terminal quiet — before its idle shell surfaces hibernate even when the live-surface cap is not exceeded." + }, + "maxLiveSurfaces": { + "type": "integer", + "minimum": 1, + "maximum": 256, + "default": 12, + "descriptionKey": "schemaDescriptions.terminal.surfaceHibernation.maxLiveSurfaces", + "description": "Maximum simultaneously live terminal surfaces to keep before cmux hibernates eligible background terminals, oldest first. Every live surface counts toward the limit; only idle, non-busy, non-visible ones are reclaimed." + } + } + }, "textBoxMaxLines": { "type": "integer", "minimum": 1, diff --git a/web/messages/en.json b/web/messages/en.json index af10b8a069e3..0c39e3091d0b 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -873,7 +873,13 @@ "terminal": { "copyOnSelect": "Copy selected terminal text to the system clipboard when the selection is committed, including word and line selections from double- and triple-click.", "showTextBoxOnNewTerminals": "Show the beta TextBox input by default for newly created workspaces, terminal tabs, and terminal splits.", - "focusTextBoxOnNewTerminals": "Focus the beta TextBox input by default for newly created workspaces, terminal tabs, and terminal splits. Focusing also shows the TextBox." + "focusTextBoxOnNewTerminals": "Focus the beta TextBox input by default for newly created workspaces, terminal tabs, and terminal splits. Focusing also shows the TextBox.", + "surfaceHibernation": { + "enabled": "Enable Surface Hibernation.", + "idleSeconds": "Minimum seconds without input or focus before the live-surface cap may reclaim a background shell terminal. A short confirmation settle window (~60s) still applies on top of this.", + "unmountedIdleSeconds": "Seconds a workspace must stay unmounted (hidden) — with its terminal quiet — before its idle shell surfaces hibernate even when the live-surface cap is not exceeded.", + "maxLiveSurfaces": "Maximum simultaneously live terminal surfaces to keep before cmux hibernates eligible background terminals, oldest first. Every live surface counts toward the limit; only idle, non-busy, non-visible ones are reclaimed." + } }, "browser": { "defaultSearchEngine": "Default search engine for non-URL browser address bar queries. Use custom with customSearchEngineURLTemplate for arbitrary providers.", diff --git a/web/messages/ja.json b/web/messages/ja.json index b8fbc35f3f4c..d4194b1d809a 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -832,7 +832,13 @@ "terminal": { "copyOnSelect": "選択が確定したとき、ダブルクリックやトリプルクリックによる単語・行の選択を含め、選択したターミナルテキストをシステムクリップボードへコピーします。", "showTextBoxOnNewTerminals": "新しいワークスペース、ターミナルタブ、ターミナル分割でベータ版の TextBox 入力を既定で表示します。", - "focusTextBoxOnNewTerminals": "新しいワークスペース、ターミナルタブ、ターミナル分割でベータ版の TextBox 入力に既定でフォーカスします。フォーカスする場合は TextBox も表示されます。" + "focusTextBoxOnNewTerminals": "新しいワークスペース、ターミナルタブ、ターミナル分割でベータ版の TextBox 入力に既定でフォーカスします。フォーカスする場合は TextBox も表示されます。", + "surfaceHibernation": { + "enabled": "サーフェス休止を有効にします。", + "idleSeconds": "ライブサーフェス数の上限がバックグラウンドのシェル端末を回収できるようになるまでに、入力もフォーカスもない状態が続く必要のある最小秒数です。これに加えて約60秒の確認待機ウィンドウが適用されます。", + "unmountedIdleSeconds": "ライブサーフェス数の上限を超えていなくても、ワークスペースが非表示(アンマウント)のままで端末が静かな状態が続いたとき、アイドルなシェルサーフェスが休止するまでの秒数です。", + "maxLiveSurfaces": "同時にライブ状態を維持できるターミナルサーフェスの最大数です。超過すると cmux は対象となるバックグラウンド端末を古い順に休止します。すべてのライブサーフェスが上限のカウント対象ですが、回収されるのはアイドルでビジーでなく非表示のものだけです。" + } }, "browser": { "defaultSearchEngine": "ブラウザのアドレスバーでURLではない入力に使う既定の検索エンジンです。任意のプロバイダには custom と customSearchEngineURLTemplate を使います。",