Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
ec732ed
Prevent Mac sleep while agents run or iPhone is connected
lawrencecchen Jul 8, 2026
e033dc0
Address review: fix stale-tab reconcile, gate agent census behind set…
lawrencecchen Jul 8, 2026
3f3757e
Make power assertion logger nonisolated, align subsystem with app con…
lawrencecchen Jul 8, 2026
026e6e4
Gate mobile keep-awake on authenticated connections; bound agent-even…
lawrencecchen Jul 8, 2026
fa7bfc5
Only record client IDs for verbs that required authorization; lean mo…
lawrencecchen Jul 8, 2026
67bb288
Count authenticated connections at the authorization gate, not the cl…
lawrencecchen Jul 8, 2026
3fba9f1
Refuse stale authenticated-connection inserts after mid-authorization…
lawrencecchen Jul 8, 2026
820a696
Make PreventSleepDecision a value type instead of a top-level function
lawrencecchen Jul 8, 2026
5b3c05b
Replace new debug seams with internal members reached via @testable i…
lawrencecchen Jul 8, 2026
a66aadc
Merge current main into feat-mac-awake
azooz2003-bit Aug 13, 2026
0d46df4
Add Keep Mac Awake control on Mac and iOS
azooz2003-bit Aug 13, 2026
7fba71d
Tolerate headless app activation in caffeine UI test
azooz2003-bit Aug 13, 2026
b1130ac
Fix iOS caffeine settings compilation
azooz2003-bit Aug 13, 2026
4dd1bd7
Use mobile localization support in caffeine settings
azooz2003-bit Aug 13, 2026
02963d7
Remove reintroduced disabled toast setting
azooz2003-bit Aug 13, 2026
03d680c
Initialize task composer setting on iOS
azooz2003-bit Aug 13, 2026
b13d84a
Test iOS Keep Mac Awake control
azooz2003-bit Aug 13, 2026
1c647d8
Capture Keep Mac Awake menu evidence
azooz2003-bit Aug 13, 2026
37897fc
Fix iOS test Foundation import
azooz2003-bit Aug 13, 2026
6085862
Revert "Fix iOS test Foundation import"
azooz2003-bit Aug 13, 2026
5406c44
Merge origin/main into feat-mac-awake
azooz2003-bit Aug 14, 2026
bf54ebe
Harden mobile caffeine status handling
azooz2003-bit Aug 14, 2026
384fc10
Annotate caffeine RPC handlers on main actor
azooz2003-bit Aug 15, 2026
eb1bb4d
Harden ambiguous mobile caffeine mutations
azooz2003-bit Aug 15, 2026
8b5bb25
Report failed caffeine mutations accurately
azooz2003-bit Aug 15, 2026
60f17cc
Keep caffeine retry available after unknown failure
azooz2003-bit Aug 15, 2026
a94b910
Isolate caffeine controller injection on main actor
azooz2003-bit Aug 15, 2026
fbe790e
Fence caffeine snapshots by state revision
azooz2003-bit Aug 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Format: max_lines<TAB>relative path
# Reduce counts as files shrink. CI fails if tracked files exceed this budget.
35600 CLI/cmux.swift
18229 Sources/AppDelegate.swift
18231 Sources/AppDelegate.swift
16443 Sources/ContentView.swift
15163 Sources/TerminalController.swift
13232 Sources/Workspace.swift
Expand Down Expand Up @@ -39,7 +39,7 @@
2563 Sources/Panels/CmuxWebView.swift
2546 cmuxTests/WorkspaceManualUnreadTests.swift
2524 cmuxTests/CommandPaletteSearchEngineTests.swift
2404 Sources/Mobile/MobileHostService.swift
2443 Sources/Mobile/MobileHostService.swift
2328 cmuxTests/CJKIMEInputTests.swift
2242 Sources/TerminalNotificationStore.swift
2229 Sources/TerminalWindowPortal.swift
Expand Down Expand Up @@ -71,7 +71,7 @@
1363 Sources/CMUXInstalledExtensionSidebarHostView.swift
1360 Sources/Feed/FeedButtonStyleDebugWindowController.swift
1317 Sources/FileExplorerStore.swift
1295 cmuxTests/MobileHostAuthorizationTests.swift
1315 cmuxTests/MobileHostAuthorizationTests.swift
1291 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Config/GhosttyConfig.swift
1290 cmuxTests/TextBoxSubmitActionTests.swift
1285 cmuxUITests/SidebarHelpMenuUITests.swift
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Foundation

/// Power management settings for keeping the Mac reachable during work.
public struct PowerCatalogSection: SettingCatalogSection {
/// Keeps the Mac from idle system sleep while cmux observes running agent sessions.
public let preventSleepWhileAgentsRunning = DefaultsKey<Bool>(
id: "power.preventSleepWhileAgentsRunning.enabled",
defaultValue: false,
userDefaultsKey: "power.preventSleepWhileAgentsRunning.enabled"
)

/// Keeps the Mac from idle system sleep while an iPhone or iPad is connected.
public let preventSleepWhileMobileConnected = DefaultsKey<Bool>(
id: "power.preventSleepWhileMobileConnected.enabled",
defaultValue: true,
userDefaultsKey: "power.preventSleepWhileMobileConnected.enabled"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 JSON Power Keys Are Ignored

These new power.* settings are DefaultsKeys, but the config store only reads JSONKeys from cmux.json and PreventSleepManager reads UserDefaults.standard directly. When a user or managed setup puts the advertised power.preventSleepWhileAgentsRunning.enabled or power.preventSleepWhileMobileConnected.enabled key in cmux.json, the runtime never sees it, so the keep-awake behavior stays at the UserDefaults/default value.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

There is no cmux.json path for these keys to be ignored on: DefaultsKey settings are UserDefaults-only by type (JSONConfigStore only accepts JSONKey), and no boolean setting in the catalog is cmux.json-backed today, so power.* follows the same pattern as every other toggle (e.g. mobile.iOSPairingHost.enabled). The PR description advertised cmux.json support incorrectly; it has been corrected instead of introducing a first-of-its-kind JSONKey.

— Claude Code

)

/// Creates the Power settings catalog section.
public init() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ public struct SettingCatalog: SettingCatalogSection {
public let fileEditor = FileEditorCatalogSection()
/// Settings for Mobile pairing and sync.
public let mobile = MobileCatalogSection()
/// Settings for keeping the Mac awake while work or mobile clients are active.
public let power = PowerCatalogSection()
public let betaFeatures = BetaFeaturesCatalogSection()
/// Settings for custom (user/agent-authored) sidebars (the `customSidebars.*` keys).
public let customSidebars = CustomSidebarsCatalogSection()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ extension Array where Element == CuratedSettingEntry {
.init(section: .mobile, id: "iOSPairingHost", title: "iOS Pairing", synonyms: "ios iphone ipad mobile pairing local network permission sync"),
.init(section: .mobile, id: "iOSPairingPort", title: String(localized: "settings.mobile.port", defaultValue: "Pairing Port"), synonyms: "mobile.iOSPairingHost.port ios iphone mobile pairing port tcp listener firewall conflict"),
.init(section: .mobile, id: "iOSPairingDisplayName", title: String(localized: "settings.mobile.displayName", defaultValue: "Display Name"), synonyms: "mobile.iOSPairingHost.displayName ios iphone mobile pairing display name mac hostname device label"),
.init(section: .mobile, id: "preventSleepWhileAgentsRunning", title: String(localized: "settings.mobile.preventSleepWhileAgentsRunning", defaultValue: "Prevent sleep while agents are running"), synonyms: "power.preventSleepWhileAgentsRunning.enabled agents running keep awake caffeinate prevent sleep insomnia system sleep idle"),
.init(section: .mobile, id: "preventSleepWhileMobileConnected", title: String(localized: "settings.mobile.preventSleepWhileMobileConnected", defaultValue: "Prevent sleep while iPhone is connected"), synonyms: "power.preventSleepWhileMobileConnected.enabled iphone mobile connected keep awake caffeinate prevent sleep insomnia system sleep idle"),

// Custom Sidebars
.init(section: .customSidebars, id: "enabled", title: String(localized: "settings.customSidebars.enabled", defaultValue: "Show Custom Sidebars"), synonyms: "custom sidebars enable show vibe swift json interpreted picker beta"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ public enum SettingsSectionID: String, CaseIterable, Identifiable, Sendable, Has
case .terminal: return "scrollbar copy on select agent resume hibernation"
case .textBox: return "textbox text box rich input prompt default new terminal workspace split tab focus show beta"
case .sleepyMode: return "sleepy mode screensaver caffeinate keep awake lock touch id battery wifi clock mascot theme glow pixel"
case .mobile: return "ios iphone ipad mobile pairing local network sync"
case .mobile: return "ios iphone ipad mobile pairing local network sync keep awake caffeinate prevent sleep insomnia"
case .sidebarAppearance: return "sidebar details branches material terminal background"
case .customSidebars: return "custom sidebars vibe swift json interpreted renderer in-process remote worker isolated"
case .betaFeatures: return "beta experimental unstable feed dock right sidebar"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ public struct MobileSection: View {
@State private var iOSPairingHost: DefaultsValueModel<Bool>
@State private var port: DefaultsValueModel<Int>
@State private var displayName: DefaultsValueModel<String>
@State private var preventSleepWhileAgentsRunning: DefaultsValueModel<Bool>
@State private var preventSleepWhileMobileConnected: DefaultsValueModel<Bool>
@State private var status: MobilePairingStatusModel

/// The user's in-progress port edit, or `nil` when the field should track
Expand Down Expand Up @@ -45,6 +47,8 @@ public struct MobileSection: View {
_iOSPairingHost = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.mobile.iOSPairingHost))
_port = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.mobile.iOSPairingPort))
_displayName = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.mobile.iOSPairingDisplayName))
_preventSleepWhileAgentsRunning = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.power.preventSleepWhileAgentsRunning))
_preventSleepWhileMobileConnected = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.power.preventSleepWhileMobileConnected))
_status = State(initialValue: MobilePairingStatusModel(hostActions: hostActions))
self.hostActions = hostActions
}
Expand Down Expand Up @@ -78,6 +82,11 @@ public struct MobileSection: View {
boundPortStatusRow
SettingsCardDivider()
displayNameRow
SettingsCardDivider()
SettingsCardNote(String(localized: "settings.mobile.keepAwake", defaultValue: "Keep Awake"))
preventSleepWhileAgentsRunningRow
SettingsCardDivider()
preventSleepWhileMobileConnectedRow
if iOSPairingHost.current {
SettingsCardDivider()
diagnostics
Expand All @@ -96,6 +105,8 @@ public struct MobileSection: View {
iOSPairingHost,
port,
displayName,
preventSleepWhileAgentsRunning,
preventSleepWhileMobileConnected,
status,
]
models.forEach { $0.startObserving() }
Expand Down Expand Up @@ -135,6 +146,36 @@ public struct MobileSection: View {
}
}

@ViewBuilder
private var preventSleepWhileAgentsRunningRow: some View {
SettingsCardRow(
configurationReview: .settingsOnly,
searchAnchorID: "setting:mobile:preventSleepWhileAgentsRunning",
String(localized: "settings.mobile.preventSleepWhileAgentsRunning", defaultValue: "Prevent sleep while agents are running"),
subtitle: String(localized: "settings.mobile.preventSleepWhileAgentsRunning.subtitle", defaultValue: "Keeps system sleep off while agents run; the display can still sleep.")
) {
Toggle("", isOn: Binding(get: { preventSleepWhileAgentsRunning.current }, set: { preventSleepWhileAgentsRunning.set($0) }))
.labelsHidden()
.controlSize(.small)
.accessibilityIdentifier("SettingsMobilePreventSleepWhileAgentsRunningToggle")
}
}

@ViewBuilder
private var preventSleepWhileMobileConnectedRow: some View {
SettingsCardRow(
configurationReview: .settingsOnly,
searchAnchorID: "setting:mobile:preventSleepWhileMobileConnected",
String(localized: "settings.mobile.preventSleepWhileMobileConnected", defaultValue: "Prevent sleep while iPhone is connected"),
subtitle: String(localized: "settings.mobile.preventSleepWhileMobileConnected.subtitle", defaultValue: "Keeps system sleep off while a mobile client is connected; the display can still sleep.")
) {
Toggle("", isOn: Binding(get: { preventSleepWhileMobileConnected.current }, set: { preventSleepWhileMobileConnected.set($0) }))
.labelsHidden()
.controlSize(.small)
.accessibilityIdentifier("SettingsMobilePreventSleepWhileMobileConnectedToggle")
}
}

@ViewBuilder
private var portRow: some View {
SettingsCardRow(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ struct SettingsRowAnchorResolutionTests {
"setting:mobile:iOSPairingHost",
"setting:mobile:iOSPairingPort",
"setting:mobile:iOSPairingDisplayName",
"setting:mobile:preventSleepWhileAgentsRunning",
"setting:mobile:preventSleepWhileMobileConnected",
"setting:betaFeatures:feed",
"setting:betaFeatures:dock",
"setting:betaFeatures:customSidebars",
Expand Down
85 changes: 85 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -159000,6 +159000,23 @@
}
}
},
"settings.mobile.keepAwake": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Keep Awake"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "スリープ防止"
}
}
}
},
"settings.mobile.pairDevice": {
"extractionState": "manual",
"localizations": {
Expand Down Expand Up @@ -159051,6 +159068,74 @@
}
}
},
"settings.mobile.preventSleepWhileAgentsRunning": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Prevent sleep while agents are running"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "エージェント実行中はスリープを防止"
}
}
}
},
"settings.mobile.preventSleepWhileAgentsRunning.subtitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Keeps system sleep off while agents run; the display can still sleep."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "エージェントの実行中はシステムスリープを防止します。ディスプレイはスリープできます。"
}
}
}
},
"settings.mobile.preventSleepWhileMobileConnected": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Prevent sleep while iPhone is connected"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "iPhone 接続中はスリープを防止"
}
}
}
},
"settings.mobile.preventSleepWhileMobileConnected.subtitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Keeps system sleep off while a mobile client is connected; the display can still sleep."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "モバイルクライアントの接続中はシステムスリープを防止します。ディスプレイはスリープできます。"
}
}
}
},
"settings.mobile.port": {
"extractionState": "manual",
"localizations": {
Expand Down
2 changes: 2 additions & 0 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2008,6 +2008,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
CloudVMActionLauncher.shared.terminateAll()
CmuxSSHURLProcessLauncher.shared.terminateAll()
MobileHostService.shared.stop()
PreventSleepManager.shared.stop()
TerminalController.shared.stop()
GhosttyApp.terminalPasteboard.cleanupAllOwnedTemporaryImageFiles()
VSCodeServeWebController.shared.stop()
Expand Down Expand Up @@ -2065,6 +2066,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
MobileTerminalRenderObserver.shared.start()
agentChatTranscriptService.start()
installMobileHostSettingsObserver()
PreventSleepManager.shared.start()
scheduleGhosttyCrashBreadcrumbIfNeeded(notificationStore: notificationStore)
startPaneMemoryGuardrailIfNeeded()
disableSuddenTerminationIfNeeded()
Expand Down
39 changes: 39 additions & 0 deletions Sources/Mobile/MobileHostService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,12 @@ struct MobileHostServiceStatus {
let usesEphemeralFallback: Bool
let routes: [CmxAttachRoute]
let activeConnectionCount: Int
/// Connections that have completed at least one authorized request.
/// `activeConnectionCount` counts raw accepted TCP sessions, which include
/// peers that never authenticate (`mobile.host.status` is auth-exempt), so
/// consumers gating behavior on "a phone is really connected" — like the
/// keep-awake power assertion — must use this count instead.
let authenticatedConnectionCount: Int
let lastErrorDescription: String?

var payload: [String: Any] {
Expand All @@ -260,6 +266,7 @@ struct MobileHostServiceStatus {
"uses_ephemeral_fallback": usesEphemeralFallback,
"routes": routes.map(\.mobileHostJSONObject),
"active_connection_count": activeConnectionCount,
"authenticated_connection_count": authenticatedConnectionCount,
"last_error": lastErrorDescription ?? NSNull()
]
}
Expand Down Expand Up @@ -941,10 +948,17 @@ final class MobileHostService {
usesEphemeralFallback: isRunning && listenerUsesEphemeralFallback,
routes: routes,
activeConnectionCount: MobileHostConnectionRegistry.shared.count,
authenticatedConnectionCount: authenticatedConnectionCount,
lastErrorDescription: lastErrorDescription
)
}

/// Lean accessor for ``MobileHostServiceStatus/authenticatedConnectionCount``:
/// callers that only need the count (keep-awake reconciliation on every
/// settings/agent event) must not pay ``statusSnapshot()``'s route
/// resolution, which enumerates network interfaces on each call.
var authenticatedConnectionCount: Int { clientIDsByConnectionID.count }

Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
/// Reconcile the live listener with current settings (enable/disable and
/// preferred-port changes). Safe to call on any settings change: it no-ops
/// unless the enabled state or the configured port actually changed, so an
Expand Down Expand Up @@ -1024,6 +1038,14 @@ final class MobileHostService {
return await MobileHostService.shared.authorizationError(for: request)
},
onAuthorizedRequest: { request in
// Auth-exempt verbs (mobile.host.status) reach this hook
// too, because authorizeRequest returns nil for them.
// Recording their client_id would let an unauthenticated
// peer count as an authenticated connection and hold the
// keep-awake assertion.
guard Self.requiresAuthorization(method: request.method) else {
return
}
guard let clientID = Self.clientID(from: request.params) else {
return
}
Expand Down Expand Up @@ -1144,6 +1166,12 @@ final class MobileHostService {
await MobileHostService.shared.authorizationError(for: request)
},
onAuthorizedRequest: { request in
// Same guard as the network listener above: authorizationError
// exempts mobile.host.status internally, so this hook also
// fires for unauthenticated status requests.
guard Self.requiresAuthorization(method: request.method) else {
return
}
if let clientID = Self.clientID(from: request.params) {
await MobileHostService.shared.recordClientID(clientID, for: id)
}
Expand Down Expand Up @@ -1211,14 +1239,25 @@ final class MobileHostService {
clientIDs: clientIDs,
reason: "mobile.connection.closed"
)
// The registry's own remove() post can be delivered before this
// main-actor cleanup runs (onClose posts from the network callback
// thread), so re-post after the authenticated count actually
// dropped or keep-awake would hold until an unrelated event.
NotificationCenter.default.post(name: .mobileHostStatusDidChange, object: nil)
}
MobileHostRequestActivity.endConnection()
}

private func recordClientID(_ clientID: String, for connectionID: UUID) {
var clientIDs = clientIDsByConnectionID[connectionID] ?? []
let isFirstAuthorizedRequestForConnection = clientIDs.isEmpty
clientIDs.insert(clientID)
clientIDsByConnectionID[connectionID] = clientIDs
if isFirstAuthorizedRequestForConnection {
// The connection just transitioned unauthenticated -> authenticated;
// authenticated-count consumers (keep-awake) resync on this signal.
NotificationCenter.default.post(name: .mobileHostStatusDidChange, object: nil)
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}

private nonisolated static func clientID(from params: [String: Any]) -> String? {
Expand Down
Loading
Loading