Repository navigation
Conversation
macOS asks for a closed lid's sleep once, at the close, and disablesleep 1 refuses it. disablesleep 0 does not make it ask again, so a Mac ended for heat or a dying battery kept running in the bag until the lid opened. Claude-Session: https://claude.ai/code/session_01Jco1ESE6NxipTXFsZE31rZ
After a failed disablesleep 0, macOS refuses the request, and the recovery agent that later restores sleep does not ask. A refused request now asks again every 30 s while the lid stays closed and no session starts. Also tests an end that cannot remove session.json and a quit during an end. Claude-Session: https://claude.ai/code/session_01Jco1ESE6NxipTXFsZE31rZ
main's README is now short (#65) and its lid-close detail lives in docs/lid-close.md, so this PR's sentence about a battery or heat end with the lid closed moves there. The README's battery and heat paragraph gets one short line saying the Mac goes to sleep. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Fixes the three review findings on the retry. The sleep is now owed from the floor end, not tied to one end's reason, so a quit that replaces the pending end no longer drops it. It is asked only with no session, no lifecycle operation queued or running, and no live sudo pmset, so it never lands inside a queued start. A start clears it only once it holds the lock, so a start refused for a busy lock keeps it. Claude-Session: https://claude.ai/code/session_01Jco1ESE6NxipTXFsZE31rZ
Add an off-by-default lockScreenOnLidClose setting. During a session, a lid close now locks the login session first, before darkening and freezing, with login.framework's SACLockScreenImmediate, so opening the lid asks for the login password while the Mac stays awake. The lock is not journaled and has no undo. A failure (the private call can break with an OS update) is shown in the menu and the remaining close actions still run. Co-authored-by: Claude <[email protected]>
Co-authored-by: Claude <[email protected]>
Co-authored-by: Claude <[email protected]>
…uit waits for it A timer end with the lid closed left the Mac awake for hours when another app held off idle sleep (#94). A quit whose disablesleep 0 failed again exited and dropped the owed sleep, which only the app asks for. Claude-Session: https://claude.ai/code/session_01Jco1ESE6NxipTXFsZE31rZ
A MacBook used closed on an external display is in use at a desk, but Insomnia took that lid for one in a bag: a session started there showed a frozen countdown and switched Low Power Mode on, and closing the lid at the desk froze the freeze list, muted the output and tried to darken the external display (#97). The lid observer now also checks for an external display online, and reads the lid again when displays come and go (NSApplication.didChangeScreenParametersNotification). A lid closed with an external display connected stays open. Unplugging the last one delivers the close, without the display and keyboard darkening, which macOS already does under that lid, and plugging one in again the open: a KVM switch or a monitor turned off and on undoes what it did. A lid closed in a bag stays closed when a display is plugged in, until it opens, so the built-in panel and keyboard are only lit again once they are uncovered. With the lid closed and nothing plugged in, macOS keeps a stand-in display online, of vendor kDisplayVendorIDUnknown and model 'virt'; it does not count. A display asleep stays online. The kernel's AppleClamshellCausesSleep is no substitute: on Apple Silicon it is set only when the lid moves, and it stayed No with the display unplugged. A session started at a desk takes the lid as open. A relaunch at a desk does too, unless the journal still holds lid-close actions from that session, which are kept as before. The brightness sampler still waits for the lid to be up.
- A close replayed after a power command lost the reason passed with a close that unplugging the external display delivered, and darkened as for a lid closing in a bag. Whether to darken is now decided when the close runs: only while the built-in display is online. Under a lid closed at a desk, and once its display is unplugged, the panel is offline and macOS keeps it and the keyboard dark, so a close, fresh or replayed, leaves them alone. That replaces the flag. - A display plugged back in opened a lid its unplugging had closed even while the journal held a brightness, from an earlier open whose restore failed, so the open would have written it under the shut lid. The lid now stays closed while one is journaled, until it opens.
The only conflict was in the imports: IOKit for FakeClosedLidSleep on this branch, Security from #100 on main. Both stay. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…t at launch The three fixes from the 18 Oct review of #61: - A sleep macOS never grants no longer holds every quit. After 10 refused requests (at most about 5 minutes at the 30 s retry) the owed sleep is given up and logged, and a quit no longer waits for it. A postponed request does not count, and a newly owed sleep starts a new count. AppDelegate asks SessionManager.letsQuitGo(after:). - A cleanup retry no longer asks again. Only an end that finds the session owes the sleep, so a retry of an end that has run (session.json kept in place) owes nothing new. - A launch that finds session.json expired owes the sleep for that timer end, and asks once the reconcile has released the lock. A request also waits until a sudo pmset left running has released the lock and its end has begun, not only until it exits. Co-Authored-By: Claude Opus 5.5 <[email protected]>
From Greptile's review of #138: sleepClosedLidIfOwed saw an open lid only when an ask ran, and the end had already stopped the session's lid observer. A lid opened and closed again between two asks, or while the end waited for a sudo pmset left running, went unseen, and the next ask put the Mac to sleep. SessionManager now watches the lid with a LidObserver of its own from the end that owes the sleep until the sleep is dropped. An open that holds for the 2 s debounce drops it, and closing the lid again does not bring it back. Every place that dropped the sleep goes through dropClosedLidSleep, which also cancels the next ask and stops the watcher. Co-Authored-By: Claude Opus 5.5 <[email protected]>
… Mac to sleep # Conflicts: # Sources/Insomnia/Core/SessionManager.swift # Tests/InsomniaTests/TestSupport.swift # docs/lid-close.md
# Conflicts: # Sources/Insomnia/Core/SessionManager.swift # Tests/InsomniaTests/TestSupport.swift
…esk does not lock
…shellCausesSleep A timer or floor-rule end with the lid closed asks macOS to sleep only if AppleClamshellCausesSleep is true. On Apple Silicon macOS sets that at lid changes only, so after a lid closed at a desk has its display unplugged it stays false (#135, spec section 3). #135 delivers that unplug as a close, but an end afterwards dropped the owed sleep, and a closed Mac in a bag stayed awake as long as another app held an idle assertion, after a critical thermal end too. The request now goes ahead when the lid is closed and not closed at a desk (no external display online, the reading #135 uses for the lid actions). A closed Mac plugged into a display later, which the old check would have put to sleep, now stays awake as macOS keeps it.
…ever on an unreadable display list The check in the previous commit read the display list once, at the request, through `externalDisplayConnected()`. That returns false when `CGGetOnlineDisplayList` fails, so a failed read asked for sleep with the lid closed at a desk. And a desk display that dropped out for under 2 s as the session ended, or as a retry fired, read as gone, though the lid observer itself waits 2 s before it believes a display change. `LidObserver.readExternalDisplay()` now returns nil for a failed read (`externalDisplayConnected()` is unchanged for the lid observer), and the request uses it: - an external display: the sleep is dropped, as before - nil: the request waits `recoveryRetryDelay` and reads again; each such read counts toward `closedLidSleepRefusalLimit`, so a quit does not wait forever - none: the request reads again after `LidObserver.debounce` (2 s) and goes ahead only if there is still none; every guard runs again then The confirmation delay is injectable; the test harness sets 0, so the existing tests ask at once as before. Four new tests (a display back within the confirmation, none through it, a failed read then a good one, a read that never succeeds) each fail against the previous commit's behavior. The unplug test from that commit cannot fail against the code before it: the fake no longer models `AppleClamshellCausesSleep`, the property that code read, so it records the intended behavior only.
…over Round 3 of review found that two reads 2 s apart do not prove 2 s without a display: a dock that drops out at the end, comes back, and drops out again as the second read happens passed both reads, though neither outage lasted the debounce, and the owed-sleep watcher keeps a lid closed at a desk counted closed, so nothing else stopped the ask. While the confirmation waits, the manager now watches `LidObserver.displaysChanged` (the screen-parameters notification the lid observer already uses). Any change starts the check over: a display back drops the sleep, as at the end; none starts a new 2 s wait. The watch stops with the confirmation, a new ask or a drop. Two new tests, each failing without the watch: a display back for a moment between two outages asks nothing, and a change with still no display delays the ask by a full debounce.
The confirmation's timer queues its ask for the main actor. A display change that came in between the timer firing and that ask running started the check over, and the queued ask then cancelled the new wait and asked for sleep at once. Each scheduled ask now has a number, and a queued ask runs only if no later one replaced it. The new test holds the main thread past the confirmation's timer and a display change due just after it, so the run loop fires both before the queued ask runs. Against the previous code it fails with two assertion failures: sleep asked at once and no longer owed.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
MacBook run on macOS 26.2 at 184f77e: lid close actions with the lock switch offEvery step passes. With "Lock the screen" off, a simulated lid close during a session runs the close actions, does not lock the screen and asks for no sleep. The simulated open undoes every action. A close and an open with no session change nothing. The head is now Setup. The maintainer's MacBook Pro (M5), macOS 26.2 (25C56), on AC power at 100%, with a dark menu bar and the built-in display only.
Not covered yet, and owed at the final head as the description says: the real hinge, a timed end with the lid closed (#138's sleep), a lid closed at a desk display (#135), and a close with the lock on. A simulated close calls the lid handler directly and skips LidObserver. The display was already at brightness 0, so "darkened" and "restored" show no change in level. Each step ran once. |
…e nor an open `readLid()` read the displays through `externalDisplayConnected()`, which turns a failed `CGGetOnlineDisplayList` into no external display. A lid closed at a desk whose list failed to read at the event and again when the 2 s debounce ended got a close: mute, freeze and the optional screen lock on a Mac in use. A later good read did not undo it, since `closedByDisplayLoss` was false, so the lid at the desk kept counting as closed until it opened. The closed-lid sleep check already takes a failed read as unknown (9bf3bd6); the lid observer now does too. - `LidReading.closedDisplaysUnknown` is a closed lid with an unreadable list. nil still means no lid. `externalDisplayConnected()` is gone. - That reading keeps the state delivered last. At an event, or when the debounce ends, it drops a waiting change (the waiter hears `false`) and delivers nothing. - While it lasts, the observer reads the lid again every debounce with no event needed, until the list reads or the lid opens, and the usual rules take that reading. It is the only timer between events, and `stop()` cancels it. One log line when it starts, one when it ends. - An unreadable read leaves the last known reading alone. Display loss now comes from the lid having read at a desk since it last read open, not from the reading just before, so a display loss whose waiting close an unreadable read dropped is still one when the close comes. - `isShut` stays true, `isClosed` returns the delivered state, and `seed` takes `closed` as for a lid at a desk and starts the reads. SessionManager's `lidAtDesk` still returns false for such a lid, so a session that starts or relaunches under it takes it as closed, as before: the countdown redraw stops and the lid is a Low Power Mode cause, with no mute, freeze, darkening or lock. Its comments say so. Eleven new tests in LidObserverTests. Each fails against the previous observer with an unreadable list read as `.closed`, as `readLid()` did. Partial reverts fail the matching tests too: no cancel in `stop()`, an unknown read that replaces the last reading, display loss from the last reading only, and no reads again. The existing tests are unchanged; the test helper now counts reads. Spec section 3, its event sources table and docs/lid-close.md describe the read again.
`atDeskSinceOpen` changed only at an event. A lid closed at a desk whose display was unplugged got a waiting close. If the debounce then read the lid open with no event for it, the close was dropped, but the lid still counted as at a desk. Closing it again away from the desk was then a display loss, and with no saved brightness, a display plugged in later delivered an open under the shut lid. At cb1df1c that second close was an ordinary one. The end of the debounce now takes any reading other than `closedDisplaysUnknown` into the lid's history, including one that drops the waiting change. It goes through `record`, the same code an event uses, which also keeps the last reading and logs the desk lines. The new test runs that sequence. Without the record at the end of the debounce it fails three ways: the second close is a display loss, the display plugged in opens the lid, and `isClosed` is false.
The read again of a closed lid whose display list could not be read clears its timer, then reads the lid. If that read found no lid at all, `handleInterest()` returned before arming the next read, so the reads stopped while the list was still unknown, against what spec section 3 says. A close that came after that was never delivered without another event. While the list is unknown, a read that finds no lid now arms the next read and changes nothing else, so the state delivered last stays. Spec section 3 now says so. The new test reads the list unknown, then no lid, then a closed lid with no display, with no event after the first, and expects the close. Without the fix the close never comes.
…cross a restart Two cases of the display list reads that had no test. - A lid closed in a bag whose list then cannot be read opens with no event for it. The read again finds it open, the open comes one debounce later, and the reads stop. With the read again never armed, the open never comes and the test fails. - The read again has fired, and its callback waits for the main actor when the observer stops and starts again under the same lid. The test holds the main thread past that read and a restart due just after it, as the test in cb1df1c does, so the run loop fires both before the callback runs. Without the ticket check in `displaysTimerFired`, the callback reads the lid once more after the restart and the test fails. The restart's timer cannot capture the test case, which is not Sendable, so this test reads a small fake lid of its own.
|
The full suite at |
|
Tested
Not tested: unplugging the display while the lid is closed. The #135 part works here. |





A session that ends with the lid closed now puts the Mac to sleep, a lid closed on an external display no longer counts as closed, and closing the lid can lock the screen if you turn that on.
Each commit is merged here unchanged, so GitHub marks the three pull requests merged when this one merges and their authors keep the credit. Each conflicts with main or with one of the others, so none can merge as it is.
To @stevehoyek, @salvcy and @BurakErdemci: please don't push to your branches now. A new commit on one isn't in this pull request, so that pull request would stay open instead of showing as merged.
The base is
integration/wave1-base: main with #146 and #147 (which carry #145) merged, as main will be once they land. When #147 merges, this pull request's base changes to main and nothing else changes.Conflicts resolved
displaysAsleepand test clock, Lid: a timer, heat or battery end with the lid closed puts the Mac to sleep #138's closed-lid sleep and lid watcher, Lid: a lid closed on an external display counts as open #135'slidAtDesk. All are kept.Maintainer commits
21f7ed0: Lid: a timer, heat or battery end with the lid closed puts the Mac to sleep #138's lid watcher, which drops an owed sleep when the lid opens, starts by counting a lid closed at a desk as closed. Lid: a lid closed on an external display counts as open #135 reads such a lid as open, so without this the watcher never saw it open.cf1500a: the lock switch says what on and off do, and that a lid closed on an external display does not lock until the display is unplugged. README and docs/lid-close.md say the same.c7bf09b: the closed-lid sleep is decided from the lid and the displays, not fromAppleClamshellCausesSleep. On Apple Silicon macOS sets that only at lid changes, so after a desk display was unplugged from a closed Mac it stayed false and the end dropped the sleep.9bf3bd6: "no desk display" must hold through the lid observer's 2 s debounce before the sleep is asked, and a display list that cannot be read never counts as no display.0b58fa9: a display change while that confirmation waits starts it over.cb1df1c: an ask queued before a display change does nothing.fe42561: a closed lid whose display list can't be read is neither a close nor an open. Before, an unreadable list counted as no external display, so a lid closed at a desk got mute, freeze and the optional lock, and a later good read did not undo them. The observer now keeps the state it delivered last, drops a waiting change, and reads the lid again every 2 s until the list reads or the lid opens. A session that starts under such a lid takes it as closed, as before, with no close actions.2ae53f6,d334147,7843047: a lid found open when the 2 s wait ends is no longer counted as at a desk, a read that finds no lid keeps the reads going, and tests for an open found by a read again and a read queued across a restart.9bf3bd6tocb1df1ccame out of five review rounds on this branch,fe42561from Greptile, and the last three from that fix's own review. Each has tests that fail without it.Tests
The full suite at this head,
7843047: 1466 tests, 0 failures, on macOS 26.2 on the maintainer's MacBook. The release build with warnings as errors is clean. Hosted CI runs on macOS 26 and 27.Review
Clean at
7843047. The merges and the commits up tocb1df1cwere clean at round 5 (184f77e, the same tree ascb1df1c); rounds 1 to 4 found the problems9bf3bd6tocb1df1cfix. Greptile's finding on the unreadable display list is fixed infe42561; its review found two edge cases, fixed in2ae53f6andd334147, and round 2 was clean.Evidence
A simulated close and open with the lock off, and the lock switch text, on the maintainer's MacBook at
cb1df1c's tree. A real lid close follows at the final head: a timed end with the lid closed puts the Mac to sleep; with a desk display the Mac stays awake; with the lock on, opening the lid asks for the password.

Confidence Score: 5/5
The PR appears safe to merge; the previous desk-freeze finding is fixed and no new actionable issues remain.Summary
This PR adds sleep after timer and floor-rule ends with the lid closed, leaves desk use alone, and adds an optional screen lock.
Diagram
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Lid or display changes] --> B{Lid reading} B -->|Display list unreadable| C[Keep last delivered state] C --> D[Drop pending change] D --> E[Read again after 2 seconds] E --> B B -->|Known reading| F[Apply desk and brightness rules] F --> G{State changed?} G -->|Yes| H[Confirm after 2 seconds] H --> I[Deliver lid actions] G -->|No| J[Leave apps alone]Reviews (2) · Last reviewed commit: "Lid tests: an open found by the read aga..." · Reviewed by Greptile