Skip to content

Hibernate idle background terminal surfaces and cap live surfaces (LRU) - #5739

Closed
austinywang wants to merge 64 commits into
mainfrom
issue-5731-surface-hibernation
Closed

austinywang wants to merge 64 commits into
mainfrom
issue-5731-surface-hibernation

Conversation

@austinywang

@austinywang austinywang commented Jun 10, 2026 •

Copy link
Copy Markdown
Contributor

Part of #5731 (P0: surface hibernation + LRU cap)

Problem

The issue's live audit found 112 live Ghostty surfaces while one workspace was visible — ~3.3 GB of IOSurface across 850 regions, ~1 GB of scrollback malloc, and four threads per surface. Unmounting a workspace only hides portal views (hideAllTerminalPortalViews); ghostty_surface_free runs solely on panel close. The only reclaim path that exists today — agent hibernation — covers restorable-agent panels only, is opt-in (default off), and its cap counts only those panels. Plain-shell surfaces are immortal, and surfaces force-created by bypass paths (background workspace priming, queued socket input on surface-less panels) never create eviction pressure.

What this adds

One pure selection policy, SurfaceHibernationPlanner, over all terminal panels. Three rules compose (union):

  1. Agent cap — the pre-existing agent-hibernation cap, preserved exactly (the old AgentHibernationPlanner is replaced by this rule; its tests were migrated 1:1).
  2. Global LRU cap (maxLiveSurfaces, default 12) — the census counts every live surface, including exempt ones and bypass-path materializations, so they still create pressure; only idle, non-busy, non-visible, mechanism-capable surfaces are evicted, oldest first.
  3. Unmounted-idle — surfaces whose workspace has been unmounted (and quiet) longer than unmountedIdleSeconds (default 30 min) hibernate even without cap pressure.

A shellRestart hibernation mechanism for plain shells: capture scrollback (session-persistence truncation policy) and the panel's working directory, free the runtime surface (ghostty_surface_free → Metal/IOSurface buffers, the four surface threads, PTY), and on the next visit start a fresh shell in the captured directory with the scrollback replayed through the existing CMUX_RESTORE_SCROLLBACK_FILE plumbing.

Restore triggers mirror agent-hibernation auto-resume: workspace selection / visibility reconcile, panel focus, focus-intent restore, and every input path (keyboard, socket, CLI — including the two observer-wait paths that would otherwise wedge on a panel whose surface can never materialize while suspended).

Safety exemptions (all planner-level and unit-tested): busy surfaces (needs_confirm_quit, i.e. not safely at a prompt — conservatively true when shell integration is absent), visible panels of the visible workspace, unconfirmed terminal input, deferred startup work (initial command, tmux attach, initial input, queued input bytes), remote terminals, and agent panels (which hibernate only through their own opt-in agent mechanism). On top of eligibility, the existing 60-second output-stability confirmation window must pass before anything is reclaimed.

Settings (default ON) — terminal.surfaceHibernation.{enabled, idleSeconds, unmountedIdleSeconds, maxLiveSurfaces} in cmux.json, settings search row, command-palette toggle, cmux surface-hibernation <on|off> CLI, surface_hibernation socket command, JSON schema, and docs. Defaulting on is the point of the P0: it only ever reclaims surfaces that are off-screen, at a prompt, quiet, and output-stable, and the visible state (scrollback + cwd) is restored on return.

Persistence — the autosave fingerprint tracks hibernation transitions, and the session snapshot uses the captured scrollback for hibernated panels, so after an app relaunch they restore as normal (surface-less) panels with replay staged.

Red/green structure

  • Commit 1 adds SurfaceHibernationPolicyTests against a planner scaffold that faithfully reproduces the shipped agent-only policy — the new policy tests fail (CI red), proving the gap.
  • Commit 2 implements the real policy and mechanics — CI green.

Commit 1 also adds a dedicated Run surface hibernation policy regression CI step (-only-testing:cmuxTests/SurfaceHibernationPolicyTests, mirroring the existing split-theme step). This was forced by a pre-existing hole in the tests job discovered while proving the red: the full unit pass intermittently crashes inside BrowserDeveloperToolsVisibilityPersistenceTests (Signal 11), every suite that sorts after the crash is skipped, and the job's expected-failure classifier then reports "All failures are expected, treating as pass" — so new suites late in the alphabet can silently never execute (two consecutive runs reproduced this). The dedicated step makes the policy tests a real gate regardless. The crash/soft-pass hole itself is worth a separate issue — it affects every suite from C to Z on crashy runs, not just this one.

Unit-tested vs. verified by inspection

  • Unit-tested: LRU eviction order and tie-breaks, idle gates, busy/protected/unconfirmed-input/deferred-startup/remote exemptions, census including bypass materializations, unmounted-idle rule (both clocks), agent-rule parity with surface hibernation disabled, settings defaults/sanitization/change notification, and the hibernate→restore round trip (state capture, replay-file staging, working-directory override, input queueing on restore, focus/visibility-driven restore, cross-guards with agent hibernation, session-snapshot fallback, autosave fingerprint, workspace unmount clock).
  • Code-inspection only (not cleanly unit-testable): the Metal/IOSurface/render-thread teardown itself happens inside ghostty_surface_free; suspendRuntimeSurfaceForHibernation reaches it on the next main-actor turn — the same already-shipping path agent hibernation uses. End-to-end memory reclamation was not measured in this PR.

Localization audit

  • New user-facing Swift string: settings.terminal.surfaceHibernation — added to Resources/Localizable.xcstrings with en + ja translations; the palette toggle and settings row reuse it, and enable/disable wrappers use existing localized formats.
  • JSON-schema descriptions, docs/configuration.md, and the web docs example follow the agentHibernation precedent exactly (plain description strings; that section has no entries in web/messages/en.json/ja.json to mirror).
  • CLI and socket help text is English-only by existing convention (agent-hibernation likewise).

Not in this PR

The third P0 item (stop mass agent auto-resume at launch) is a separate workstream item of #5731.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Surface hibernation for terminal panels: suspend idle panels to free runtime surfaces and later restore scrollback/working directory or restart shells.
  • Settings

    • User-configurable surface hibernation (enable, idle/unmounted timeouts, max live terminals, confirmation); persisted, clamped, and emits single-change notifications.
  • CLI & UI

    • Added surface-hibernation CLI command, command-palette toggle, settings entry, localized label, and JSON/OK CLI output.
  • Behavior

    • Input/focus now queues and restores for hibernated panels; autosave fingerprint includes hibernation state.
  • Docs & Tests

    • Docs, JSON schema, template updates, CI step, and extensive tests for policy, restore, and settings.

@vercel

vercel Bot commented Jun 10, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 11, 2026 10:39am
cmux-staging Building Building Preview, Comment Jun 11, 2026 10:39am

@coderabbitai

coderabbitai Bot commented Jun 10, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a surface-hibernation planner and types, UserDefaults-backed SurfaceHibernationSettings, workspace/panel runtime support for entering/restoring suspended surfaces, CLI/command-palette/settings UI wiring, Xcode/CI/test integration, and comprehensive unit and integration tests.

Changes

Surface Hibernation Feature

Layer / File(s) Summary
Policy types & planner
Sources/App/SurfaceHibernationPolicy.swift
Defines SurfaceHibernationMechanism, SurfaceHibernationPlannerInput, and SurfaceHibernationPlanner.selectedPanelKeys with agent-cap, global-cap (LRU), and unmounted-idle selection plus isEvictable eligibility and deterministic LRU ordering.
SurfaceHibernationSettings API
Sources/App/WorkspaceRuntimeSettings.swift
Adds SurfaceHibernationSettings with Values (enabled, idleSeconds, unmountedIdleSeconds, maxLiveSurfaces, confirmationSeconds), typed getters with clamping, setValues/reset, and did-change notification.
AgentHibernationController integration
Sources/App/AgentHibernationController.swift
Controller now reads agent+surface settings, builds SurfaceHibernationPlannerInput records, uses SurfaceHibernationPlanner for selection, and branches confirmation/hibernate behavior for agent vs surface hibernation.
TerminalPanel surface lifecycle
Sources/Panels/TerminalPanel.swift
Adds SurfaceHibernationPanelState, enterSurfaceHibernation / prepareSurfaceHibernationRestore(), and updates focus/resume/input paths to handle surface-hibernated panels.
Workspace lifecycle & restore APIs
Sources/Workspace.swift
Tracks portalRenderingDisabledAt, captures authoritative scrollback from hibernated surfaces, adds APIs to enter/restore/bulk-restore surface hibernation, and routes pending input via restore paths.
Ghostty runtime surface staging
Sources/GhosttyTerminalView.swift
Adds suspend flag, input-record throttle, one-shot working-directory and scrollback staging for restores, and gates runtime surface creation during hibernation.
CLI, command palette, localization, navigation
CLI/cmux.swift, Sources/CommandPalette/CommandPaletteSettingsToggle.swift, Resources/Localizable.xcstrings, Sources/SettingsNavigation.swift
Adds surface-hibernation CLI command, command-palette toggle, localization key, and settings navigation/search anchors.
Settings file parsing & templates
Sources/KeyboardShortcutSettingsFileStore+Template.swift, Sources/KeyboardShortcutSettingsFileStore.swift, Sources/CmuxSettingsJSONPathSupport.swift
Adds terminal.surfaceHibernation to default template, parses managed/default settings into SurfaceHibernationSettings keys, and whitelists JSON paths.
Autosave fingerprinting
Sources/TabManager.swift
Includes surfaceHibernationState in session autosave fingerprint via a stable hash helper.
Xcode project & CI integration
cmux.xcodeproj/project.pbxproj, .github/workflows/ci.yml
Registers SurfaceHibernationPolicy.swift and tests in the Xcode project and adds a CI xcodebuild step to run the new tests.
Tests: planner, settings, integrations
cmuxTests/SurfaceHibernationPolicyTests.swift, cmuxTests/AgentHibernationTests.swift
Adds a comprehensive test suite covering planner selection rules, settings defaults/sanitization/notifications, MainActor integration for entering/restoring surface hibernation, and updates existing agent planner tests to the new API.
Docs & schema updates
docs/configuration.md, web/app/[locale]/docs/configuration/page.tsx, web/data/cmux.schema.json
Documents terminal.surfaceHibernation config, adds example template block, and adds JSON schema validation for the new config fields.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I nibble through idle shells at night,

LRU lanes in silver light,
I hush the busy, spare the seen,
Replay scrollback in a dream,
Memory meadow, soft and bright.


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (6 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Actor Isolation ❌ Error SurfaceHibernationSettings.isEnabled() is implicitly MainActor-isolated but called at file scope in AgentHibernationTrackingGate static initializer without nonisolated annotation. Mark SurfaceHibernationSettings.isEnabled() and other UserDefaults accessors with nonisolated since they are pure functions reading thread-safe UserDefaults.
Cmux Swift Blocking Runtime ❌ Error Introduces NSLock (agentHibernationInputRecordLock) in GhosttyTerminalView.swift keyDown hot path without justifying why actor-based synchronization cannot be used. Replace NSLock-based throttle with MainActor-isolated state or a background actor that doesn't block the input path.
Cmux Swift Logging ❌ Error Three NSLog statements in AppDelegate.swift lack #if DEBUG guards: "Command send: surface not ready", "Debug stress workspaces", "LaunchServices registration failed". Add #if DEBUG guards around the three NSLog statements or replace with cmuxDebugLog.
Cmux User-Facing Error Privacy ❌ Error docs/configuration.md exposes internal details: "Metal buffers, render threads, PTY" violating the rule against exposing internal implementation details in user-facing text. Replace implementation details with generic user-friendly descriptions: remove "Metal buffers, render threads, PTY" and use generic terms like "renderer resources" instead.
Cmux Full Internationalization ❌ Error PR adds web schema descriptions for surfaceHibernation without descriptionKey, rendering as English-only across 19 supported web locales instead of using next-intl. Add descriptionKey to schema properties and translate keys in all web/messages/*.json files for the 19 supported locales.
Cmux Architecture Rethink ❌ Error PR introduces NSLock to throttle activity-record allocations (GhosttyTerminalView), a symptom patch violating swift-architectural-rethink.md rule against locks papering over optimization problems. Move throttle logic into AgentHibernationController as owned state rather than using lock-protected cache at call site in hot path.
Docstring Coverage ⚠️ Warning Docstring coverage is 12.69% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (14 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main change: hibernating idle background terminal surfaces and capping live surfaces using LRU eviction.
Description check ✅ Passed The description comprehensively covers the problem, solution, testing, and implementation details. It includes problem context, what was added, red/green structure, unit tests vs. inspection, localization, and notably omitted items—exceeding template requirements.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Expensive Synchronous Load ✅ Passed Index.load() properly uses Task.detached (off-main), cold-cache fallbacks use cached accessor. New surface hibernation files have no expensive I/O.
Cmux Cache Substitution Correctness ✅ Passed Surface hibernation's cached scrollback is safe: the runtime surface is freed (surface = nil, suspended flag = true) during hibernation, preventing any new terminal I/O or staleness.
Cmux No Hacky Sleeps ✅ Passed No hacky sleeps in non-Swift production code. CI workflow is out of scope; TypeScript docs template addition has no timers or delays.
Cmux Algorithmic Complexity ✅ Passed No nested full-collection scans, no per-target rescans, O(N log N) on capped collections (maxLiveSurfaces=12), runs every 30 seconds, all bounds explicit, appropriate complexity.
Cmux Swift Concurrency ✅ Passed No dispatch queues, completion handlers, or fire-and-forget tasks added. New @Published property follows established agentHibernationState pattern in TerminalPanel ObservableObject.
Cmux Swift @Concurrent ✅ Passed No violations of swift-concurrent-annotation rules. All new sync methods properly isolated: static utilities, @MainActor methods sync-only, timer pattern uses correct Task.detached+MainActor.run.
Cmux Swift File And Package Boundaries ✅ Passed New 204-line SurfaceHibernationPolicy.swift has single responsibility. Large file additions (TerminalPanel +77, Workspace +87) stay under 250-line threshold. No mixed responsibilities found.
Cmux Swiftui State Layout ✅ Passed Only adds @Published to existing ObservableObject TerminalPanel, matching agentHibernationState pattern. No new SwiftUI state violations per swiftui-state-layout.md rules.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed No user-visible auxiliary windows (NSWindow, NSPanel, NSWindowController, SwiftUI Window/WindowGroup) are introduced. PR only modifies hibernation planning, settings, and test code.
Cmux Source Artifacts ✅ Passed All 23 changed files are legitimate source, tests, configuration, documentation, or localization artifacts; no build output, logs, caches, or temp directories are present.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-5731-surface-hibernation

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

❤️ Share

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

@greptile-apps

greptile-apps Bot commented Jun 10, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces SurfaceHibernationPlanner, a unified selection policy over all terminal panels, and a shellRestart hibernation mechanism for plain-shell surfaces. When a panel is eligible, the runtime surface is freed (Ghostty surface, Metal/IOSurface buffers, four threads, PTY) while the panel stays in the layout; on the next user visit, a fresh shell starts in the captured working directory with scrollback replayed through the existing CMUX_RESTORE_SCROLLBACK_FILE plumbing.

  • Policy (SurfaceHibernationPolicy.swift): three composing rules — agent cap (pre-existing), global LRU cap over all live surfaces (maxLiveSurfaces, default 12), and unmounted-idle eviction — united in a pure, fully unit-tested planner that replaced the removed AgentHibernationPlanner.
  • Mechanism (AgentHibernationController, TerminalPanel, GhosttyTerminalView, Workspace): timer path captures scrollback on main actor, writes the replay file off-actor, re-validates quietness, then commits; restore stages the one-shot file and override directory before the next surface creation; runtimeSupportsScrollbackReplay gates the feature to shells where integration is confirmed to have installed a replay hook.
  • Safety (SurfaceHibernationSettings, TerminalPanel, GhosttyTerminalView): busy surfaces, visible panels, unconfirmed/pending command-line input, deferred startup work, remote terminals, and agent panels are all explicitly exempted; foreground-process verification (child scan + argv read) is bounded to 32 syscalls per tick with caching so a busy prefix cannot starve the verification budget.

Confidence Score: 4/5

Safe to merge with one outstanding architectural concern from a prior round: foreground-process verification calls blocking kernel syscalls on the main actor; the new code bounds these to 32 per tick with caching, which substantially reduces worst-case impact but does not move them off-actor.

The new policy, mechanism, and restore paths are well-designed and thoroughly tested. All previously flagged blocking I/O issues on interactive paths have been addressed in earlier commits. The outstanding concern is the bounded-but-still-main-actor foreground verification (proc_listchildpids + KERN_PROCARGS2), flagged in a prior review round and partially mitigated here by capping at 32 calls per tick with a 600s busy-result cache. New findings this round are style-level: a blanket UserDefaults.didChangeNotification subscription that spawns a task on every app-wide defaults write, and a minor double pasteboard read in paste(_:).

Sources/App/AgentHibernationController.swift deserves a second look for the UserDefaults.didChangeNotification subscription and the remaining main-actor foreground verification path.

Important Files Changed

Filename Overview
Sources/App/SurfaceHibernationPolicy.swift New pure-policy planner composing three rules (agent cap, global LRU cap, unmounted-idle); clean separation of concerns, well-guarded evictability predicate, correct LRU tie-breaking.
Sources/App/AgentHibernationController.swift Heavily extended to drive shell-restart hibernation; foreground verification bounded to 32 syscalls/tick with caching; blanket UserDefaults.didChangeNotification subscription spawns per-defaults-write tasks (P2); pending-command-line seeding and prompt-survival accounting logic is complex but consistent with tests.
Sources/Panels/TerminalPanel.swift Adds SurfaceHibernationPanelState, enterSurfaceHibernation/prepareSurfaceHibernationRestore, and replay-file ownership on close; all hibernation/focus/input guard sites updated symmetrically for both hibernation modes.
Sources/GhosttyTerminalView.swift Adds runtimeSupportsScrollbackReplay tracking, stageHibernationRestore/discardStagedHibernationReplayFile, foregroundProcessAllowsShellRestart, and prompt-text/settling-character input classification; pasteboard read twice in paste(_:) (P2).
Sources/Workspace.swift Adds captureSurfaceHibernation/commitSurfaceHibernation split for off-actor replay writes, restoreSurfaceHibernation, portalRenderingDisabledAt tracking for unmounted-idle rule, and scrollback-fallback handling for hibernated panels in session snapshots.
Sources/App/WorkspaceRuntimeSettings.swift New SurfaceHibernationSettings enum mirrors AgentHibernationSettings pattern exactly: sanitized reads/writes, single-notification change detection, correct defaults (enabled=true by design).
Sources/SessionPersistence.swift purgeStaleReplayFilesOnce uses O(1) synchronous rename to isolate stale tree, then recursive delete off-actor; replayFilePath factored out for off-actor callers; bundle-scoped subdirectory prevents cross-instance collisions.
Resources/shell-integration/fish/config.fish Adds _cmux_restore_scrollback_once to fish integration, mirroring zsh/bash behaviour; correctly unsets the env var before reading so the file is deleted one-shot even if cat fails.
Packages/CmuxControlSocket/Sources/CmuxControlSocket/Transport/SocketTransport+ClientSocket.swift Adds configureCloseOnExec and makeListenerSocket; FD_CLOEXEC is set immediately on accept before any other configuration step, preventing PTY-child fd leaks; F_GETFD read-modify-write is correct POSIX pattern.
cmuxTests/SurfaceHibernationPolicyTests.swift Comprehensive 1163-line test suite covering LRU eviction order, idle gates, all exemption categories, census bypass paths, unmounted-idle rule (both clocks), settings defaults/sanitization, hibernate→restore round trip, and agent-rule parity.

Sequence Diagram

sequenceDiagram
    participant Timer as 30s Timer
    participant AHC as AgentHibernationController
    participant Planner as SurfaceHibernationPlanner
    participant Workspace
    participant Panel as TerminalPanel
    participant Surface as TerminalSurface
    participant IO as Utility Task

    Timer->>AHC: timer fires
    AHC->>AHC: agentHibernationRecords - census all panels
    AHC->>AHC: applyBoundedForegroundVerification - max 32 syscalls
    AHC->>Planner: selectedPanelKeys - agentCap union globalCap union unmountedIdle
    Planner-->>AHC: keys to hibernate
    AHC->>AHC: evaluateConfirmation - start 60s stability window

    alt agent panel - confirmed
        AHC->>Workspace: enterAgentHibernation
        Workspace->>Panel: enterAgentHibernation
        Panel->>Surface: suspendRuntimeSurfaceForHibernation
    else plain shell shellRestart - confirmed
        AHC->>Workspace: captureSurfaceHibernation
        Workspace-->>AHC: scrollback and cwd
        AHC->>IO: replayFilePath - atomic write off main actor
        IO-->>AHC: replayFilePath
        AHC->>AHC: stillQuiet check
        AHC->>Workspace: commitSurfaceHibernation
        Workspace->>Panel: enterSurfaceHibernation with replayFilePath
        Panel->>Surface: suspendRuntimeSurfaceForHibernation
    end

    Note over Panel,Surface: Panel stays in layout, surface freed

    Note over Panel: On user focus or input restore trigger
    Panel->>Panel: prepareSurfaceHibernationRestore
    Panel->>Surface: stageHibernationRestore - replayFilePath and cwd
    Panel->>Surface: prepareHibernationResume
    Surface->>Surface: createSurface - new shell in captured cwd
    Surface-->>Panel: shell consumes CMUX_RESTORE_SCROLLBACK_FILE
Loading

Reviews (40): Last reviewed commit: "Write replay files off-actor; fix CRLF c..." | Re-trigger Greptile

Comment thread Sources/App/SurfaceHibernationPolicy.swift
Comment thread cmuxTests/SurfaceHibernationPolicyTests.swift
@austinywang
austinywang force-pushed the issue-5731-surface-hibernation branch from 82ed136 to ee11525 Compare June 10, 2026 01:38

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/App/SurfaceHibernationPolicy.swift`:
- Around line 75-102: selectedPanelKeys only implements the agent-cap rule; add
the global LRU cap and unmounted-idle rules: (1) compute a separate list of all
live surfaces (include inputs where mechanism == .agentResume OR .shellRestart
and where input.isLive) and, regardless of agentSettings.enabled, if count >
surfaceSettings.maxLiveSurfaces evict the leastRecentlyUsedFirst ones to meet
that cap; (2) also consider any input whose workspaceUnmountedAt != nil and now
- workspaceUnmountedAt! >= surfaceSettings.unmountedIdleSeconds as eligible for
hibernation (even if not agentResume); merge keys from the agent-cap eviction
(current liveRestorable path), the global-cap eviction, and the unmounted-idle
rule into the returned Set; preserve existing protections (!input.isProtected,
lifecycle.allowsHibernation, !hasUnconfirmedTerminalInput) and reuse
Self.leastRecentlyUsedFirst sorting and eligible.prefix(excess).map(\.key) logic
for each rule.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 75097a9f-2bbb-47b9-af2b-bdb17cba994d

📥 Commits

Reviewing files that changed from the base of the PR and between 919d2d4 and 82ed136.

📒 Files selected for processing (4)
  • Sources/App/SurfaceHibernationPolicy.swift
  • Sources/App/WorkspaceRuntimeSettings.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/SurfaceHibernationPolicyTests.swift

Comment thread Sources/App/SurfaceHibernationPolicy.swift

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
Sources/App/SurfaceHibernationPolicy.swift (1)

75-102: ⚠️ Potential issue | 🔴 Critical

Implementation incomplete: missing global LRU cap and unmounted-idle rules.

This implements only the agent-cap rule (rule 1). Per the doc comment at lines 64-73 and PR objectives, two additional rules are missing:

  1. Global LRU cap (rule 2): Should count every live surface (including .shellRestart) and evict when exceeding surfaceSettings.maxLiveSurfaces, regardless of agentSettings.enabled.
  2. Unmounted-idle rule (rule 3): Should hibernate surfaces whose workspace has been unmounted longer than surfaceSettings.unmountedIdleSeconds.

Evidence:

  • Line 87: Returns [] when agentSettings.enabled is false, blocking global cap and unmounted-idle enforcement
  • Line 88: Filters only .agentResume, excluding .shellRestart surfaces from global cap census
  • surfaceSettings parameter unused (should drive maxLiveSurfaces and unmountedIdleSeconds)
  • workspaceUnmountedAt field never checked
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/App/SurfaceHibernationPolicy.swift` around lines 75 - 102,
selectedPanelKeys currently only applies the agent-cap rule; update it to also
enforce the global LRU cap and the unmounted-idle rule: 1) compute liveAll =
inputs.filter { $0.isLive } (include both .agentResume and .shellRestart) and,
regardless of agentSettings.enabled, compute excessGlobal = liveAll.count -
surfaceSettings.maxLiveSurfaces; if excessGlobal > 0 pick the
least-recently-used surfaces from liveAll (using Self.leastRecentlyUsedFirst)
that are eligible for hibernation (respecting isProtected,
lifecycle.allowsHibernation, !hasUnconfirmedTerminalInput, and lastActivityAt)
and include their keys; 2) compute unmountedIdleSet = inputs.filter {
$0.workspaceUnmountedAt != nil && now - ($0.workspaceUnmountedAt!) >=
surfaceSettings.unmountedIdleSeconds && $0.isLive && !$0.isProtected &&
$0.lifecycle.allowsHibernation && !$0.hasUnconfirmedTerminalInput }.map(\.key)
and include these keys; 3) keep the existing agent-cap logic (when
agentSettings.enabled) to produce agentEvict keys and then return the union of
agentEvict, globalEvict, and unmountedIdleSet (as a Set) while ensuring
selection order for capped evictions uses the LRU sort
(Self.leastRecentlyUsedFirst) and you don’t double-evict the same key.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@Sources/App/SurfaceHibernationPolicy.swift`:
- Around line 75-102: selectedPanelKeys currently only applies the agent-cap
rule; update it to also enforce the global LRU cap and the unmounted-idle rule:
1) compute liveAll = inputs.filter { $0.isLive } (include both .agentResume and
.shellRestart) and, regardless of agentSettings.enabled, compute excessGlobal =
liveAll.count - surfaceSettings.maxLiveSurfaces; if excessGlobal > 0 pick the
least-recently-used surfaces from liveAll (using Self.leastRecentlyUsedFirst)
that are eligible for hibernation (respecting isProtected,
lifecycle.allowsHibernation, !hasUnconfirmedTerminalInput, and lastActivityAt)
and include their keys; 2) compute unmountedIdleSet = inputs.filter {
$0.workspaceUnmountedAt != nil && now - ($0.workspaceUnmountedAt!) >=
surfaceSettings.unmountedIdleSeconds && $0.isLive && !$0.isProtected &&
$0.lifecycle.allowsHibernation && !$0.hasUnconfirmedTerminalInput }.map(\.key)
and include these keys; 3) keep the existing agent-cap logic (when
agentSettings.enabled) to produce agentEvict keys and then return the union of
agentEvict, globalEvict, and unmountedIdleSet (as a Set) while ensuring
selection order for capped evictions uses the LRU sort
(Self.leastRecentlyUsedFirst) and you don’t double-evict the same key.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 15e33d4f-a7b1-486f-aa68-e13630af7f98

📥 Commits

Reviewing files that changed from the base of the PR and between 82ed136 and ee11525.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (4)
  • Sources/App/SurfaceHibernationPolicy.swift
  • Sources/App/WorkspaceRuntimeSettings.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/SurfaceHibernationPolicyTests.swift

austinywang and others added 2 commits June 9, 2026 19:30
SurfaceHibernationPlanner starts as a faithful reproduction of the
shipped policy: only restorable-agent terminals are counted or evicted,
and only under agent-cap pressure. The new tests encode the policy the
fix must implement — a global LRU cap whose census covers every live
surface (including ones materialized by background priming and queued
socket input), hibernation of idle surfaces in long-unmounted
workspaces, and busy/protected/deferred-startup exemptions — and fail
against the shipped behavior.

Part of #5731

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Unmounting a workspace previously only hid its terminal portal views:
the Ghostty runtime surface — Metal/IOSurface buffers, four surface
threads, and the PTY — stayed alive until panel close, so hidden
workspaces accumulated live surfaces without bound (112 live surfaces
against one visible workspace in the issue audit).

SurfaceHibernationPlanner now selects surfaces to reclaim with three
composed rules: the pre-existing agent cap (unchanged), a global LRU
cap over every live surface, and an unmounted-workspace idle rule.
Plain shells hibernate via a new shellRestart mechanism: scrollback and
working directory are captured, the runtime surface is freed, and the
next visit starts a fresh shell in that directory with the scrollback
replayed through the existing session-restore plumbing. Agent panels
keep their opt-in agent-resume mechanism; busy surfaces (not at a
prompt), visible panels, remote terminals, and panels with deferred
startup work or queued input are never reclaimed, and the 60s
output-stability confirmation window still applies before anything is
freed.

Restore triggers mirror agent hibernation auto-resume: workspace
visibility reconcile, panel focus, and every input path (which would
otherwise wedge waiting for a surface that cannot materialize while
suspended). Session snapshots persist the captured scrollback, and the
autosave fingerprint tracks transitions.

Settings (default on): terminal.surfaceHibernation.{enabled,
idleSeconds, unmountedIdleSeconds, maxLiveSurfaces} via cmux.json,
settings search, command palette, `cmux surface-hibernation`, and the
`surface_hibernation` socket command, with schema and docs entries and
en/ja localization for the settings row.

Part of #5731

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@austinywang
austinywang force-pushed the issue-5731-surface-hibernation branch from ee11525 to 4b2ae3d Compare June 10, 2026 02:31

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
Sources/App/SurfaceHibernationPolicy.swift (1)

75-102: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Planner logic still implements only agent-cap mode; global-cap and unmounted-idle rules are missing.

selectedPanelKeys still exits when agent hibernation is disabled and only evaluates .agentResume inputs, so it never enforces global live-surface cap or unmounted-idle hibernation. This diverges from the policy contract and breaks expected behavior for plain-shell surfaces.

Suggested minimal fix
 static func selectedPanelKeys(
     inputs: [SurfaceHibernationPlannerInput],
     agentSettings: AgentHibernationSettings.Values,
     surfaceSettings: SurfaceHibernationSettings.Values,
     now: TimeInterval
 ) -> Set<AgentHibernationPanelKey> {
-    // This mirrors the shipped AgentHibernationPlanner policy: only
-    // restorable-agent terminals are counted or evicted, and only under
-    // agent-cap pressure. Plain-shell surfaces are invisible to it, the
-    // global cap census does not exist, and workspaces unmounted for long
-    // periods keep every runtime surface alive
-    // (https://github.com/manaflow-ai/cmux/issues/5731).
-    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
-            !input.isProtected &&
-                input.lifecycle.allowsHibernation &&
-                !input.hasUnconfirmedTerminalInput &&
-                now - input.lastActivityAt >= agentSettings.idleSeconds
-        }
-        .sorted(by: Self.leastRecentlyUsedFirst)
-
-    return Set(eligible.prefix(excess).map(\.key))
+    func isEligible(
+        _ input: SurfaceHibernationPlannerInput,
+        idleSeconds: TimeInterval
+    ) -> Bool {
+        guard input.isLive else { return false }
+        guard input.mechanism != nil else { return false }
+        guard !input.isProtected, !input.isBusy, !input.hasUnconfirmedTerminalInput else { return false }
+        guard input.lifecycle.allowsHibernation else { return false }
+        return now - input.lastActivityAt >= idleSeconds
+    }
+
+    var selected = Set<AgentHibernationPanelKey>()
+
+    // Rule 1: agent-cap
+    if agentSettings.enabled {
+        let liveAgent = inputs.filter { $0.mechanism == .agentResume && $0.isLive }
+        let excess = liveAgent.count - agentSettings.maxLiveTerminals
+        if excess > 0 {
+            let eligible = liveAgent
+                .filter { isEligible($0, idleSeconds: agentSettings.idleSeconds) }
+                .sorted(by: Self.leastRecentlyUsedFirst)
+            selected.formUnion(eligible.prefix(excess).map(\.key))
+        }
+    }
+
+    // Rule 2: global live-surface cap
+    let liveCount = inputs.filter { $0.isLive }.count
+    let globalExcess = liveCount - surfaceSettings.maxLiveSurfaces
+    if globalExcess > 0 {
+        let eligible = inputs
+            .filter { isEligible($0, idleSeconds: surfaceSettings.idleSeconds) }
+            .sorted(by: Self.leastRecentlyUsedFirst)
+        selected.formUnion(eligible.prefix(globalExcess).map(\.key))
+    }
+
+    // Rule 3: unmounted-idle
+    let unmountedEligible = inputs.filter { input in
+        guard isEligible(input, idleSeconds: surfaceSettings.idleSeconds) else { return false }
+        guard let unmountedAt = input.workspaceUnmountedAt else { return false }
+        return now - unmountedAt >= surfaceSettings.unmountedIdleSeconds
+    }
+    selected.formUnion(unmountedEligible.map(\.key))
+
+    return selected
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/App/SurfaceHibernationPolicy.swift` around lines 75 - 102,
selectedPanelKeys currently only runs when agentSettings.enabled and filters
inputs to .agentResume, so it never enforces the global live-surface cap or
unmounted-idle rules for plain-shell surfaces; update the function
(selectedPanelKeys) to (1) consider both agent-cap and global-cap pressure by
computing live counts across all inputs (not just .agentResume) and comparing to
agentSettings.maxLiveTerminals and surfaceSettings.maxLiveSurfaces, (2) include
plain-shell/unmounted surfaces when evaluating eligibility (remove the
.agentResume-only filter), and (3) apply the unmounted-idle eviction rule using
surfaceSettings.unmountedIdleSeconds (or the appropriate unmounted idle field)
so surfaces with now - lastActivityAt >= unmountedIdleSeconds become eligible;
preserve existing checks (isProtected, lifecycle.allowsHibernation,
hasUnconfirmedTerminalInput) and use the same LRU sort
(Self.leastRecentlyUsedFirst) to pick the needed number of keys for eviction
under each cap.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@Sources/App/SurfaceHibernationPolicy.swift`:
- Around line 75-102: selectedPanelKeys currently only runs when
agentSettings.enabled and filters inputs to .agentResume, so it never enforces
the global live-surface cap or unmounted-idle rules for plain-shell surfaces;
update the function (selectedPanelKeys) to (1) consider both agent-cap and
global-cap pressure by computing live counts across all inputs (not just
.agentResume) and comparing to agentSettings.maxLiveTerminals and
surfaceSettings.maxLiveSurfaces, (2) include plain-shell/unmounted surfaces when
evaluating eligibility (remove the .agentResume-only filter), and (3) apply the
unmounted-idle eviction rule using surfaceSettings.unmountedIdleSeconds (or the
appropriate unmounted idle field) so surfaces with now - lastActivityAt >=
unmountedIdleSeconds become eligible; preserve existing checks (isProtected,
lifecycle.allowsHibernation, hasUnconfirmedTerminalInput) and use the same LRU
sort (Self.leastRecentlyUsedFirst) to pick the needed number of keys for
eviction under each cap.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8ff88383-18bb-4abf-a34c-d90160147719

📥 Commits

Reviewing files that changed from the base of the PR and between ee11525 and 4b2ae3d.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (5)
  • .github/workflows/ci.yml
  • Sources/App/SurfaceHibernationPolicy.swift
  • Sources/App/WorkspaceRuntimeSettings.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/SurfaceHibernationPolicyTests.swift

austinywang and others added 3 commits June 9, 2026 20:05
…bernation

# Conflicts:
#	.github/swift-file-length-budget.tsv
Greptile flagged the hardcoded isBusy: true as unexplained. Real agent
panels are never at a shell prompt, and busy only exempts the
shellRestart mechanism, so the helper mirrors production while the
lifecycle gate stays the variable under test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
cmuxTests/SurfaceHibernationPolicyTests.swift (1)

10-10: ⚠️ Potential issue | 🔴 Critical

Wire cmuxTests/SurfaceHibernationPolicyTests.swift into the cmuxTests target sources build phase.

SurfaceHibernationPolicyTests.swift is present in cmux.xcodeproj/project.pbxproj, but it’s missing from the cmuxTests target PBXSourcesBuildPhase, so Xcode/CI will silently skip it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmuxTests/SurfaceHibernationPolicyTests.swift` at line 10, The test file
SurfaceHibernationPolicyTests.swift (contains final class
SurfaceHibernationPolicyTests) is not included in the cmuxTests target's
PBXSourcesBuildPhase; open the Xcode project file
(cmux.xcodeproj/project.pbxproj) or use Xcode target settings and add
SurfaceHibernationPolicyTests.swift to the cmuxTests target's Sources build
phase so the test class is compiled and run as part of the cmuxTests suite.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@web/data/cmux.schema.json`:
- Around line 439-471: The new terminal.surfaceHibernation schema (object
"surfaceHibernation" and its properties "enabled", "idleSeconds",
"unmountedIdleSeconds", "maxLiveSurfaces") includes English-only "description"
texts but lacks locale-backed descriptionKey entries; add a descriptionKey
string for each of those schema nodes (surfaceHibernation and each property) and
then add matching message keys in every locale file under web/messages/* as
defined by web/i18n/routing.ts so each locale has the corresponding translations
for these descriptionKey identifiers.

---

Outside diff comments:
In `@cmuxTests/SurfaceHibernationPolicyTests.swift`:
- Line 10: The test file SurfaceHibernationPolicyTests.swift (contains final
class SurfaceHibernationPolicyTests) is not included in the cmuxTests target's
PBXSourcesBuildPhase; open the Xcode project file
(cmux.xcodeproj/project.pbxproj) or use Xcode target settings and add
SurfaceHibernationPolicyTests.swift to the cmuxTests target's Sources build
phase so the test class is compiled and run as part of the cmuxTests suite.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 19e00c72-537e-49f9-ac89-9f561077a78a

📥 Commits

Reviewing files that changed from the base of the PR and between 4b2ae3d and 46f2606.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (21)
  • CLI/cmux.swift
  • Resources/Localizable.xcstrings
  • Sources/App/AgentHibernationController.swift
  • Sources/App/SurfaceHibernationPolicy.swift
  • Sources/App/WorkspaceRuntimeSettings.swift
  • Sources/AppDelegate.swift
  • Sources/CmuxSettingsJSONPathSupport.swift
  • Sources/CommandPalette/CommandPaletteSettingsToggle.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/KeyboardShortcutSettingsFileStore+Template.swift
  • Sources/KeyboardShortcutSettingsFileStore.swift
  • Sources/Panels/TerminalPanel.swift
  • Sources/SettingsNavigation.swift
  • Sources/TabManager.swift
  • Sources/TerminalController.swift
  • Sources/Workspace.swift
  • cmuxTests/AgentHibernationTests.swift
  • cmuxTests/SurfaceHibernationPolicyTests.swift
  • docs/configuration.md
  • web/app/[locale]/docs/configuration/page.tsx
  • web/data/cmux.schema.json

Comment thread web/data/cmux.schema.json
CodeRabbit flagged the new schema fields as missing descriptionKey
coverage. Follow the leaf-property convention: descriptionKey entries
resolve through docs.configuration.schemaDescriptions in both message
catalogs (en, ja), with the plain description kept as fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
austinywang and others added 2 commits June 9, 2026 20:33
main crossed the 500-line tracking threshold for this file without a
budget entry, which fails the guard on every PR merge ref.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
austinywang and others added 5 commits June 9, 2026 21:13
Address autoreview findings on the default-on behavior:

- Busy now also covers background jobs hanging off the prompt shell
  (ghostty_surface_foreground_pid + child-process check) and terminals
  with listening ports, so freeing the PTY cannot SIGHUP silent
  background work that produces no prompt or output signal.
- Surface-only ticks skip RestorableAgentSessionIndex's expensive
  disk/process scan; the index only loads when the opt-in agent
  mechanism is enabled. Restored agent panels stay recognized through
  in-memory snapshots and running agents are protected by the busy
  gates.
- Settings > Terminal now has real Surface Hibernation rows (toggle,
  idle seconds, hidden-workspace seconds, max live surfaces) backed by
  new SettingCatalog keys, matching the search entry and docs; the
  hibernation controller also reconciles its timer on UserDefaults
  changes so the Settings window toggles take effect immediately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two autoreview correctness findings: a scrollback-free autosave could
overwrite the session snapshot with nil — the hibernation state holds
the only copy of a freed surface's content — and the keystroke throttle
could suppress the input timestamp that the agent unconfirmed-input
guard compares against lifecycle changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…bernation

# Conflicts:
#	.github/swift-file-length-budget.tsv
Autoreview: the captured directory travels with the panel and must win
over workspace metadata, which can be missing after a cross-workspace
move while hibernated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The once-per-process stale replay purge ran a recursive removeItem in a
static-let initializer whose first caller sits on the main actor (session
restore, the hibernation timer); with many leftover files that delete is
unbounded main-thread I/O. The calling thread now pays a single O(1)
rename that moves the stale tree aside, and the recursive delete runs in
a detached utility task, which also sweeps bundle-scoped discard
directories left by earlier crashes. Renaming before the first write
keeps the delete from racing freshly written replay files, which a
startup-task purge could not guarantee.

Part of #5731

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread Sources/Panels/TerminalPanel.swift
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
austinywang and others added 2 commits June 10, 2026 16:55
…hooks

Two autoreview findings on state-loss edges:

- recordTerminalInput cleared an existing prompt-survival count whenever
  later text-bearing input carried zero survivals. Queued payloads make
  that real: "cmd\npartial" followed by "x" before cmd's preexec lands
  dropped the count, so cmd's own prompt return cleared the pending guard
  while "partialx" sat editable — hibernation could then drop it.
  Survival counts now accumulate and only shell transitions consume them;
  an overcount after ^C merely delays eviction by a few prompts.

- runtimeSupportsScrollbackReplay was keyed off the shell basename before
  applyManagedShellSpecificStartupEnvironment decided whether to install
  anything. The helper declines silently (unreadable bundled bootstrap;
  a custom startup command displacing the fish wrapper), leaving the flag
  overreported: restore would stage a replay file no hook consumes. The
  flag now requires installation evidence — the zsh ZDOTDIR redirection,
  the bash PROMPT_COMMAND bootstrap, or the applied fish wrapper — via a
  pure helper covered by unit tests alongside regression tests for both
  behaviors in the policy suite.

Part of #5731

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…inal

An empty capture left any older restoredTerminalScrollbackByPanelId entry
(e.g. session-restore seeding whose replay never ran) in place, so a
scrollback-free autosave landing in the restore window could persist
scrollback that no longer reflects the terminal, resurrecting it on the
next session restore. The hibernate-time seeding now mirrors the
hibernated-save branch: non-empty captures seed the fallback, empty
captures remove it. Regression test included.

The companion autoreview finding on the busy-scan fan-out is rejected
with analysis recorded: non-live panels short-circuit at the nil surface
handle, children-busy panels early-return after one proc_listchildpids
call, and the expensive childless-shell path only applies to panels the
planner is actively draining (bounded per tick until under cap), so the
fan-out is self-extinguishing rather than unbounded.

Part of #5731

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
austinywang and others added 2 commits June 10, 2026 17:34
…pending

Two autoreview findings on panels getting stuck hibernation-exempt:

- keyDown recorded pending command-line input before the consumption
  branches (sidebar mode shortcut, find escape, keyboard copy mode)
  decided the event never reaches the shell. A consumed printable key
  (e.g. copy-mode "y") armed a guard no shell transition can clear and
  reset the idle clock for input the shell never saw. The recording now
  sits past the consumption branches, where every remaining path delivers
  the event to the terminal.

- Surfaces created while tracking was off are seeded pending, but only a
  command's prompt transition cleared that, so shells already idle at an
  empty prompt when the user re-enables hibernation stayed exempt
  forever. Seeded entries (tracked separately from input-backed ones) now
  clear on a prompt redraw with no command since the seed — empty Enter
  or ^C, both of which leave the line empty. Observed input replaces the
  seed marker, so typed-ahead text keeps the strict command-only
  clearing. Regression tests cover both reconciliation directions.

Part of #5731

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Once a restore begins, the replay path moves from the hibernation state
into the surface's staged environment, where close() no longer saw it: a
panel closed before the relaunched shell consumed the file leaked it for
the rest of the process (the bundle-scoped purge only reclaims it at the
next launch). TerminalSurface now owns discarding the staged copy, panel
close calls it, and the createSurface one-shot consumption deletes the
file when this launch installed no replay hook (integration toggled off
between staging and creation), since nothing will ever consume it.

The companion finding asking restore to replay even when shell
integration is disabled is rejected with the reasoning recorded:
app-relaunch session restore has always staged replay unconditionally
with the same no-hook outcome, restoring a blank panel until integration
returns would be strictly worse, and force-installing a hook against the
user's explicit setting (or wrapping the launch command) adds disproportionate
failure modes for a bounded transition corner — only panels already
hibernated at the moment of the toggle are affected, and the record-time
gate stops new hibernations immediately.

Part of #5731

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rge damage

Two autoreview findings:

- The workspace-level shell-activity dedupe swallowed repeat promptIdle
  reports before the hibernation controller saw them, which is the common
  state for an already-idle shell: the empty Enter that should requalify
  a seeded panel re-reports the same state and was dropped, leaving those
  panels exempt indefinitely. Every report (a real precmd/preexec
  execution) now reaches the controller before the dedupe, which still
  guards the state write and the restored-agent state machine. The new
  regression test drives the workspace entry point, which the earlier
  controller-level test bypassed.

- An earlier merge resolution had stomped main's browser CLI feature:
  `cmux browser devtools/react-grab/focus-mode/zoom/history` verbs, the
  v2 browser socket methods and input-helper JS in TerminalController,
  TabManager's explicit-surface React Grab routing, the
  browser-automation docs page, and a skills file. All are restored from
  main, with this branch's additions (surface-hibernation CLI command,
  socket command, panel-state hashing, mobile hibernation plumbing)
  re-applied on top; the branch diff for those files is additive again.

Part of #5731

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
austinywang and others added 2 commits June 10, 2026 18:27
The record builder verified the foreground process (child scan + argv
read syscalls) for every hidden shell past the idle gate, so the
high-cardinality scenario this feature targets could fan hundreds of
main-actor syscalls per 30s tick during the drain-down. Verification now
runs as a bounded pass after the census: candidates that pass the cheap
gates (confirm-close, listening ports) are sorted oldest-first — the
planner's eviction order — and only the first
maxForegroundVerificationsPerEvaluation (8x the per-tick drain) pay the
syscalls; the rest stay conservatively busy until a later tick. Worst
case is now ~32 small syscalls per tick at any panel count. If the whole
verified window is genuinely busy, reclamation degrades to a no-op
rather than freeing an unverified PTY; that residual is documented at
the constant.

Part of #5731

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ntrols

Two autoreview findings on hibernation availability:

- The bounded foreground-verification window sorted oldest-first with a
  fixed budget, so a busy prefix (e.g. many long-lived attached tmux
  clients) was re-verified every tick while everything behind it stayed
  conservatively busy forever. Verified-busy candidates now enter a
  10-minute cache and are marked busy without consuming budget, so the
  per-tick budget flows to unverified candidates and the window provably
  advances past any busy prefix. The pass moved into the controller,
  which owns the cross-tick cache; the record builder returns the census
  plus candidates.

- The pending-line classifier only excluded a small allowlist of
  controls, so non-inserting editing/navigation keys (^L, ^A, ^E, ^K,
  ^W, ^R, …) armed a guard that only a full command cycle clears. All C0
  controls and DEL are now non-arming except the three that can insert
  text: tab (completion), ^V (quoted-insert), and ^Y (yank). The new
  non-arming set is a strict superset of the old one, so no previously
  guarded input becomes unguarded.

Part of #5731

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…idates

Two autoreview findings on the requalification and verification paths:

- The promptIdle-based seeded-pending clear was unreachable in production:
  the shell integrations dedupe repeat state reports at the source
  (_CMUX_SHELL_ACTIVITY_LAST), so an empty Enter at an already-idle prompt
  never arrives as a transition. Seeds now clear on the bare-Enter
  keystroke itself, threaded from keyDown and the socket send paths: after
  Enter the line either submits (runs as a command), opens a PS2
  continuation (needsConfirmClose keeps the panel busy), or was empty. ^C
  deliberately does not clear — it can resurface input typed ahead of a
  still-running pre-tracking command — and the hazardous
  promptIdle-without-command branch is removed for the same reason.
  Input-backed pending keeps the strict command-cycle rule.

- Planner-ineligible candidates could exhaust the per-tick verification
  budget every evaluation: panels with pending command-line input verify
  as allowed (never cached busy), and hibernated panels fail closed into
  the cache and churn it on TTL refresh. Pending panels are now skipped in
  the bounded pass and non-live panels never enter the candidate list, so
  the budget flows to candidates the planner could actually select.

Part of #5731

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… hardening

Three fixes:

- The hibernate-time replay write (createDirectory + atomic write of up
  to a full truncated scrollback) ran synchronously on the main actor
  inside the 30s timer, up to 4 panels per tick. The timer path now
  splits hibernation into capture (main), file write (detached utility
  task via the new SessionScrollbackReplayStore.replayFilePath), and a
  revalidated commit: any activity or input observed during the write
  hop aborts the transition and discards the file. The synchronous
  Workspace/TerminalPanel entry points remain for tests and compose the
  same capture/commit primitives.

- CI caught terminalInputClearsSeededPending("\r\n") returning false:
  "\r\n" is a single CRLF grapheme Character that matches neither "\r"
  nor "\n". All three input classifiers now operate on unicode scalars
  (with CRLF collapsed before survival counting), so CRLF payloads
  classify like their LF equivalents instead of arming an unclearable
  guard.

- An earlier merge resolution had also stomped main's control-socket
  FD_CLOEXEC hardening in CmuxControlSocket; restored from main (this
  branch never intended to touch that package).

Part of #5731

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026

This branch was successfully deployed

1 active deployment
Preview – cmux — 40c8368c Deployed Jun 11, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants