Skip to content

Lid close: land #138, #135 and #71 - #150

Open
krishhgg wants to merge 28 commits into
integration/wave1-basefrom
integration/lid-close-sleep
Open

krishhgg wants to merge 28 commits into
integration/wave1-basefrom
integration/lid-close-sleep

Conversation

@krishhgg

@krishhgg krishhgg commented Oct 27, 2026 •

Copy link
Copy Markdown
Owner

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

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 from AppleClamshellCausesSleep. 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.

9bf3bd6 to cb1df1c came out of five review rounds on this branch, fe42561 from 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 to cb1df1c were clean at round 5 (184f77e, the same tree as cb1df1c); rounds 1 to 4 found the problems 9bf3bd6 to cb1df1c fix. Greptile's finding on the unreadable display list is fixed in fe42561; its review found two edge cases, fixed in 2ae53f6 and d334147, 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.

RetriggerView in GreptileConfidence 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.

  • Certain session ends ask macOS to sleep when the lid is closed.
  • An external display changes how Insomnia treats a closed lid.
  • A setting can lock the screen when the lid closes.

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]
Loading

Reviews (2) · Last reviewed commit: "Lid tests: an open found by the read aga..." · Reviewed by Greptile

shikhar1729 and others added 24 commits October 10, 2026 15:36
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]>
…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
Conflicts: LidActions keeps #71's lock before #135's darken check; Settings puts #71's toggle in #147's lid section; release-validation keeps both records in date order.

# Conflicts:
#	Sources/Insomnia/Core/LidActions.swift
#	Sources/Insomnia/UI/SettingsView.swift
#	docs/release-validation.md
…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.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-27T10:38:34.667186Z cb1df1c PR opened
🔒 Security Review ✅ Completed 2026-10-27T10:40:22.063414Z cb1df1c PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Comment thread Sources/Insomnia/System/LidObserver.swift Outdated
@krishhgg

Copy link
Copy Markdown
Owner Author

MacBook run on macOS 26.2 at 184f77e: lid close actions with the lock switch off

Every 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 cb1df1c, which has the same tree as 184f77e: only commit messages changed (git rev-parse 184f77e^{tree} cb1df1c^{tree} prints f85a3066025f twice).

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. 184f77e installed from a clean checkout as the lid simulation build, INSOMNIA_LID_SIMULATION=1 scripts/install.sh, starting from default settings. install.sh exited 0 and /etc/sudoers.d/insomnia held exactly the four expected pmset lines. The run was unattended, so install.sh's own password prompt was not exercised. The freeze list was emptied before any close, so nothing could be frozen. scripts/simulate-lid.sh moved the lid; the real lid stayed open.

Step Result
Defaults lockScreenOnLidClose is false. Turn off the display and keyboard backlight, Mute audio and Turn on Low Power Mode are on
Lock switch "Lock the screen" is the first row under "When the lid closes", off by default. Turning it on changes config.json to lockScreenOnLidClose: true and nothing else; turning it off again restores the earlier file. The text under it is the same either way
Start A 4 hour session from the pills logs "session started" and "lid observer started (lid open)"
Simulated close The log shows "display darkened", "display sleep requested", "muted BuiltInSpeakerDevice (was volume 0.25, muted false)" and "low power mode on (lid closed)"
50 s closed The screen does not lock: no "screen locked" line, and the login session reports no CGSSessionScreenIsLocked. No sleep is requested, and pmset -g log has no Sleep or DarkWake entry. SleepDisabled stays 1 with the same end time, Low Power Mode is on, the output is muted, and no process is stopped
Simulated open The log shows "audio restored on MacBook Pro Speakers (volume 0.25, muted false)", "display restored", "low power mode off (lid opened)" and "display restore re-asserted". Low Power Mode is off in both profiles, the output is unmuted at 25%, and state.json holds nothing of the close. The countdown shows the real time left
End by hold, lid open "session end (user)" and "sleep restored". No sleep request
#135's line The close took the built-in display path. #135's "display and keyboard backlight left alone" line did not appear, as expected with no external display
Close and open with no session Neither writes a log line or changes session.json, SleepDisabled, Low Power Mode, the mute or state.json
Next start Logs lid simulation: ignored stale trigger "open" and removes the leftover trigger file

When the lid closes, with Lock the screen off by default and its On and Off text

The same section with Lock the screen on

The session running before the close: 3:59:50

During the simulated close: 3:59:43, held while the lid is closed

After the simulated open: 3:58:46, the real time left

After the hold: the eye alone

Logs: app, run, install.

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.
krishhgg added a commit that referenced this pull request Oct 27, 2026
Brings in #150's fix for a lid closed while the display list can't be
read (fe42561, 2ae53f6, d334147, 7843047). Git merged the files
cleanly.
@krishhgg

Copy link
Copy Markdown
Owner Author

The full suite at 7843047, this head, passes on macOS 26.2 on the maintainer's MacBook: 1466 tests, 0 failures, in 35 minutes.

@fbserg

fbserg commented Oct 27, 2026

Copy link
Copy Markdown

Tested 7843047 on hardware with the #97 setup: MacBook (Mac17,2, macOS 26.5.2) closed on an external 3440x1440 display as the only screen, on power. The build was 7843047 plus a local patch that starts sessions from an agent monitor. The lid code was not touched.

  • A session started with the lid already closed logged lid observer started (lid closed with an external display connected: counted as open). Low Power Mode stayed off and nothing on the freeze list was stopped. On the 0.1.0 release, the same setup froze Discord and turned Low Power Mode on for every session.
  • Upgrading from 0.1.0 with install.sh --app went through cleanly. The running session ended, and its 6 frozen Discord pids resumed.

Not tested: unplugging the display while the lid is closed.

The #135 part works here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants