The short version: closing the lid is a separate sleep path from going idle, and none of the assertions
caffeinatecreates cover it. Keeping a MacBook running with the lid shut means settingSleepDisabled, a root-only setting that survives a reboot. That combination is why no one-line command does the job, and why the apps that do it ship a privileged helper.
You want a long download, a test run, or a compile to finish after you shut the laptop and walk away. You close the lid. The Mac sleeps anyway, and the job stops.
The advice you find online says to run caffeinate. It does not work, and the reason is worth knowing, because it explains what the tools that do work are actually doing.
Two sleeps, two mechanisms
macOS puts a Mac to sleep for reasons that have nothing to do with each other.
Idle sleep happens when nothing has happened for a while. Applications hold this off by taking power assertions, and caffeinate takes them on your behalf.
Clamshell sleep happens when you close the lid. That is a hardware event, and it takes a second path through the system. Power assertions do not reach it.
The manual is explicit about what each flag asserts:
| Command | Asserts | Covers a closed lid |
|---|---|---|
caffeinate -i |
the system will not idle sleep | no |
caffeinate -d |
the display will not idle sleep | no |
caffeinate -m |
the disk will not idle sleep | no |
caffeinate -s |
the system will not sleep, on AC power only | no |
sudo pmset -a disablesleep 1 |
clamshell sleep is off | yes |
So caffeinate answers the wrong question. It answers “the Mac is awake but nothing is using it.” Closing the lid asks “the lid is shut.”
You can watch the machine decide
pmset -g prints the current power state and names the processes holding it. This is the real output from the Mac I am typing on:
$ pmset -g
sleep 5 (sleep prevented by coreaudiod, caffeinate, powerd)
displaysleep 10 (display sleep prevented by Kipless)
SleepDisabled 0
Two things to read there. The parenthesised lists name the assertion holders, which is how you find out what is keeping a Mac awake when you did not ask it to stay awake. The SleepDisabled line is the one that governs the lid.
SleepDisabled is stored, not held
An assertion belongs to a process. When the process exits, the assertion goes with it. That is a useful property: a crashed tool cannot leave your Mac permanently unable to sleep.
SleepDisabled works the other way. It is a stored setting.
- Only root can change it.
- It outlives a restart. Set it today, and a Mac that boots tomorrow still ignores the lid.
- Nothing clears it when the process that set it dies.
That last point matters. A helper that sets SleepDisabled to 1, then crashes, then is unloaded on the next login, leaves a machine that never sleeps and an owner with no idea why. Whoever writes the helper has to take responsibility for the release path, because the system will not.
Doing it by hand is one command:
$ sudo pmset -a disablesleep 1
$ pmset -g | grep SleepDisabled
SleepDisabled 1
Undoing it is the same command with a zero:
$ sudo pmset -a disablesleep 0
What an app has to do instead
An app cannot set this from its own process, because it does not run as root and should not. It has to delegate to something that does, and the supported way to do that is a LaunchDaemon registered through SMAppService.
The shape:
- Ship a small helper executable inside the app bundle.
- Register it with
SMAppService.daemonat runtime. - The user approves it once, in System Settings under General → Login Items.
- Talk to the daemon over XPC.
The interface design is where this goes right or wrong. The daemon runs as root, so whatever it accepts is code running as root. Give it a fixed list of operations rather than a command to execute. In Kipless the daemon accepts three: report the current state with /usr/bin/pmset -g, and set disablesleep to 1 or 0. There is no path from that interface to an arbitrary shell.
Three more things the lease has to get right, all of them learned from the version that got them wrong:
- Do not clobber a setting you did not change. If
SleepDisabledis already 1 when the session starts, the session does not own it and must not write 0 on release. Quitting the app should not silently defeat someone else’s configuration. - Read back after writing.
pmsetcan fail without a useful exit code, and the only proof the setting took is reading it again. - Release the lease when the connection drops. XPC invalidation is the moment the daemon learns the app is gone. If nobody acts on it, the setting stays on with no owner.
The signing trap
This part has nothing to do with pmset, and it cost me a day.
The daemon registered, the user approved it, and it died the moment launchd tried to start it. The crash reports in /Library/Logs/DiagnosticReports/ said the same thing every time:
signal = SIGKILL (Code Signature Invalid)
termination.indicator = "Launch Constraint Violation"
The namespace on that violation was CODESIGNING, which pointed at the signature rather than the code. I assumed ad-hoc signing, which was wrong.
The answer was in the launch constraint itself:
$ launchctl print system/<label>
...
LWCR = { "reqs" => { "cdhash" => { "$in" => [ ] } } }
Read that requirement literally. The system was demanding that the daemon’s code hash be a member of an empty list. No binary can satisfy it, and no signature change would have helped. The record in the Background Task Management database had gone bad, and it was reapplied on every start attempt.
Registering the daemon again does recompute the constraint. That fix worked once and then failed again, because the recomputed requirement pinned a signing identifier that no longer matched the binary. I had changed the identifier between attempts without realising the system had already recorded the old one.
The rule that finally held: the label, the Mach service name, the plist filename, and the code signing identifier all have to be the same string.
Getting the identifier right has its own trap. codesign decides an identifier from the embedded Info.plist. When a tool has no Info.plist, it falls back to the filename and takes everything before the last dot. My helper was named com.kaoru.kipless.lidsleep, so it was signed with the identifier com.kaoru.kipless, which is the app’s own bundle identifier. The app and its root daemon were claiming the same identity. Giving the helper target an embedded Info.plist and an explicit PRODUCT_BUNDLE_IDENTIFIER ended that.
Two more constraints, both measured rather than assumed:
- The executable has to live in
Contents/MacOS. I had it inContents/Resources, where launchd could not exec it at all. The constraint check never even ran. - It has to carry a Developer ID signature. The launch constraint carries
validation-category = 6. An ad-hoc signature is a different category and fails it. Developer ID is required for distribution anyway, and here it is also what lets the daemon start on the developer’s own machine.
If you are debugging this yourself, launchctl print system/<label> and the crash report in DiagnosticReports will tell you more than any amount of reinstalling. Print the constraint and read the requirement literally.
Before you leave it running
A MacBook with the lid shut and no external display has nowhere to put its heat. The screen is off, the fans still have to move air through a closed chassis, and the machine sits on whatever surface you left it on. A closed-lid session on a bed is a worse idea than the same session on a desk.
Check the state before you walk away:
$ pmset -g | grep SleepDisabled
SleepDisabled 1
Check it again when you come back, particularly if the app you used is no longer running.
Kipless
I built Kipless for this. It sits in the menu bar, offers three wake modes (system, display, and closed lid), and ends the session on a timer you set. The closed-lid mode holds an idle sleep assertion first, then asks the helper for the SleepDisabled lease, and rolls both back if either step fails. When the session ends, the setting returns to whatever it was before, including when it was already on.
It is open source under MPL-2.0, and brew install --cask ShiinaLabs/apps/kipless installs it.