SleepSwitch is a macOS menu bar toggle that stops your Mac from sleeping — lid closed included. One click, no password prompts, and a battery guard so you don't cook your laptop in a bag.
You kick off a long agent run — Claude Code refactoring half a service, Codex grinding through a test suite, some pipeline that's going to take 40 minutes. You close the lid and walk away.
You come back to a session that died somewhere in the middle. Half the edits applied. The agent's context gone. Whatever it was in the middle of writing, gone with it.
Your Mac went to sleep. That's it. That's the whole bug.
Why caffeinate doesn't save you
The standard answer is caffeinate -dimsu. It works — right up until you close the lid, at which point the Mac sleeps anyway.
Lid-close sleep is a separate setting, it lives behind sudo, and no user-level assertion touches it:
sudo pmset -a disablesleep 1 # the one that actually matters
So the real workflow becomes: remember the flag, type your password, remember to turn it back off later, and — the fun part — forget once, drop the laptop in a bag, and it runs hot in there until the battery is flat.
I got tired of that loop and wrote SleepSwitch. One menu bar icon, one click, both layers at once.
The icon is the state
| Icon | Meaning |
|---|---|
| 🌙 | Normal — your Mac sleeps as configured |
| ☕️ | Mode on — sleep fully blocked, lid included |
| ⚠️ | Partial — idle sleep blocked, but the lid still sleeps |
Left click toggles. Right click is the menu — launch behaviour, login item, updates, the sudo rule.
Close the lid with the mode on and you get a muted porcelain tone; open it and a higher one. Enough to confirm the machine stayed awake without opening anything to check.
Why this matters for agentic workflows specifically
This started as a "keep the build running" utility. Then AI coding agents happened, and it turned out they're the worst possible workload for macOS power management:
- They run long. A serious agent session is tens of minutes of tool calls, not a 30-second command.
- They look idle to the OS. The heavy lifting is on a remote API. Your Mac is waiting on network I/O with no keyboard, no mouse, no display activity. macOS sees a machine nobody is using.
- They fail badly, not gracefully. A dropped SSH session you just reconnect. An agent halfway through a multi-file edit leaves you diffing to figure out what actually landed.
- The natural instinct is to close the lid. You started the run precisely so you could go do something else.
So: hit the switch, close the lid, come back to a finished run. Same for npm run build on a monorepo, a long download, a render, an SSH session you need to survive lunch.
Two layers, because macOS treats these as two different things
| Layer | Blocks | Needs root |
|---|---|---|
pmset -a disablesleep 1 |
Lid-close sleep, and all sleep | Yes |
PreventUserIdleSystemSleep assertion |
Idle sleep | No |
PreventUserIdleDisplaySleep assertion |
Display turning off | No |
The IOKit assertions are held by the process, so they evaporate the moment the app dies — they can't get stuck. The pmset setting is different: it's a system setting that outlives the process. So the app clears it on quit, on SIGTERM, and reports the real state at launch by reading SleepDisabled straight from IOPMrootDomain rather than trusting its own memory of what it did.
The password question
pmset disablesleep needs root, and prompting on every toggle makes the whole thing pointless. So the installer writes /etc/sudoers.d/sleepswitch:
you ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0
you ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 1
Two commands, no wildcards. sudo matches arguments exactly, so this grants "toggle one sleep setting" and nothing else — pmset -a sleep 0 still asks for a password.
The rule is written, validated with visudo and installed entirely as root inside a mode-0700 temp directory, so there's no user-writable file to swap in between the check and the install. The username is validated before it reaches sudoers, and only an account in the admin group gets it.
Don't want it? Untick the box at install time, remove it later from the menu, or:
sudo rm /etc/sudoers.d/sleepswitch
The battery guard
The failure mode I mentioned above is real and it's the reason this feature exists: mode on, lid closed, laptop in a bag, cooking.
So the mode switches itself off once the battery drops to a threshold you pick — 20% out of the box — and, optionally, the instant the power adapter is unplugged. A notification tells you which of the two fired.
Two small decisions I'd defend:
- On a Mac with no built-in battery those settings aren't shown at all. An inert control is worse than no control. The check is by power-source type, since a UPS on a desktop counts as one too.
- If the battery reading is missing, nothing trips. Switching the mode off for a reason the app can't state to you is worse than leaving it alone.
Install
Grab the .pkg from the latest release and open it.
It's not signed with an Apple Developer certificate, so macOS blocks the first open. On Sequoia and newer, Control-click no longer overrides that: open the .pkg, let it be refused, then System Settings → Privacy & Security → Open Anyway. One time.
Or from source:
./make-installer.sh # → dist/SleepSwitch-<version>.pkg
./install.sh # or straight into /Applications
Command Line Tools is the only requirement. The icon is drawn in code at build time, the lid tones are synthesised at build time — no binary assets in the repo. A missing translation fails the build.
One warning worth repeating: turn the mode off before deleting the app. The pmset setting is a system setting; dragging the app to the Trash while the mode is on leaves you with a Mac that won't sleep and no switch left to turn it off. The Uninstall SleepSwitch… menu item handles the ordering for you.
The rest of it
- Updates: asks GitHub once a day, offers a notification, never swaps binaries behind your back — it downloads the
.pkgand hands it to the system Installer. Requests pinned tohttpson GitHub hosts, redirects included. Turn the check off and nothing goes over the network. - Screen lock is a separate macOS setting and SleepSwitch doesn't touch it. If your Mac asks for a password on lid-open, that's the lock, and macOS deliberately requires your account password to change it.
- macOS 13+, universal binary, English and Russian, plain Swift, no dependencies, no daemon, no telemetry. MIT.
github.com/AppsGanin/SleepSwitch — stars are appreciated, issues more so. ☕️
Top comments (6)
Both recovery paths for
disablesleeplive in the process that sets it. The IOKit assertions really cannot get stuck, butSleepDisabledsurvivesSIGKILL, a force quit and a panic, none of which reach a quit orSIGTERMhandler, and the state is only reconciled at the next launch - so a crash with the mode on leaves the Mac unable to sleep with no menu bar icon present to say so. The battery guard sits in that same process, which means the one failure that leaves the setting armed is also the failure that removes the guard against the bag scenario the guard exists for. Making those independent needs something outside the app, either a helper that re-armsdisablesleep 0unless the app keeps renewing a lease, or readingIOPMrootDomainat login rather than only at app launch.You're right. I reproduced it before changing anything: mode on, kill -9 — assertions released, SleepDisabled still Yes, nothing left to clear it. All four relinquish call sites were in the app delegate, which SIGKILL never reaches.
And the part I hadn't thought through is yours: the battery guard runs on that same process's timer, so the failure that arms the setting is the one that removes the guard against it.
1.6.0 adds a user LaunchAgent, at login and every minute. While the ban is the app's, the app renews a lease file; a stale lease means it died holding it. A missing lease means the ban was never ours — someone ran pmset by hand — so the agent leaves it alone. It clears the ban through the same two-command sudoers rule the app uses, so without that rule it has no quiet way to act either.
Thanks — that was a real hole.
One ordering detail worth pinning down in 1.6.0: if the app writes the lease after it calls pmset, there is a window where SIGKILL lands between the two and leaves the ban set with no lease on disk. The agent then reads a missing lease as "someone ran pmset by hand" and takes the leave-it-alone branch, which is the wrong branch for the exact crash you just closed - narrower window, same ending. Writing the lease first inverts what the missing case can mean, and the new failure it introduces is a lease with no ban, where the agent finds it stale and runs disablesleep 0 on a Mac that was already sleeping fine. I do the same thing with scheduled jobs, a start marker written before the work rather than after, because that is what makes a missing record mean "never ran" instead of "ran and died early".
The messy part of a killed agent run is the cleanup. When a long refactor dies halfway through, you are left with dirty working trees, untracked temporary files, and broken lockfiles. I ended up moving longer agent runs into tmux sessions on an always-on machine specifically so local client sleep states could not corrupt the workspace.
Windows is having the same kind of issue lately... I've noticed that I set my tasks to run, but with the modern 'advanced hibernate', which means you close the lid, despite everything set to "DO NOTHING", it still stops whatever was running and logs me out (which means all processes paused)... It's really annoying... what's strange is it does it on my new laptop (core 5 210h), but not on my older laptop (i7 10500u)?
Given you're on mac, you're actually the perfect person to test something for me... Mind pulling: github.com/UnitBuilds-CC/V.E.L.O.C... and giving it a try and configure it to run the disable before it uninstalls? That might be a cleaner solution than having a user disable it before removing