Idle sleep
triggered by inactivity
Stopped by a power assertion — IOPMAssertionCreateWithName.
This is what caffeinate and every menu-bar keep-awake app uses. It works, and it
is not the problem.
23:41 · overnight run in progress
Every keep-awake tool on the Mac fights idle sleep. None of them survive the lid closing. Liddy is a guarded wrapper around the one setting that does — owned by a root daemon on a deadman switch, released the moment the work is done.
Kill it and watch. The setting goes back — that revert is the whole
difference between Liddy and a raw pmset.
23:41:02 app connected · heartbeat every 5s
23:41:02 helper: pmset -a disablesleep 1
liddy claim "overnight migration" --ttl 300 # declare live work
liddy lid on --timer 8h # close the lid and walk away
liddy log # in the morning: why it stopped
Both paths above are real, and both run at the product's own timings — the
disconnect reverts immediately, the heartbeat gap takes the full 15 seconds. Two more paths
exist that are awkward to click: SIGTERM, and the helper's own startup, which reverts
the setting if it finds it left on.
23:41 · why nothing else does this
Idle sleep
triggered by inactivity
Stopped by a power assertion — IOPMAssertionCreateWithName.
This is what caffeinate and every menu-bar keep-awake app uses. It works, and it
is not the problem.
Clamshell sleep
triggered the instant the lid closes
No power assertion can stop it. There are exactly two public
ways through: attach an external display on AC power, or set disablesleep. No third
lever, no kernel extension, no private API.
the one lever
sudo pmset -a disablesleep 1
Global. Sticky. Root-only. No indicator anywhere on screen, and a reboot does not reliably clear it. That risk is the product. Everything Liddy does is built to make this line safe to run and impossible to forget.
23:42 · four independent ways back
The setting is written by a root launchd daemon, never by the app. While it is on, the app must hold a socket open and heartbeat every five seconds. The helper reverts on any one of these — and reverting only on next launch, which is what comparable tools do, leaves a wedged Mac unable to sleep for however long it takes you to notice.
kill -9 — immediatelyThe script enables closed-lid mode, confirms disablesleep is 1,
kill -9s the app, and watches for the setting to come back. If it does not pass,
closed-lid mode is not safe to use overnight — that revert is the entire difference between
Liddy and a raw pmset.
./Script/verify-deadman.sh
enabling closed-lid mode…
✓ disablesleep = 1
kill -9 <app pid>
✓ disablesleep = 0 after 0.4s
PASS — deadman switch verified
00:02 · the differentiator
Name-matching a process is a guess. Liddy would rather the work say out loud that it is working, and say for how long — a lease has a TTL, so a claimer that dies stops holding the Mac on its own. Nothing has to notice it crashed.
TOKEN=$(liddy claim "fine-tune run" --ttl 300)
trap 'liddy release $TOKEN' EXIT
./train.sh
Claude Code, hooked
One command wires Liddy to the hooks, and the Mac is held exactly while a turn is running.
liddy hook install
UserPromptSubmit | claim, keyed by session_id |
Stop | release |
SessionEnd | release (backstop) |
Hooking SessionStart instead is the obvious choice and it is
wrong: a session left open in a terminal overnight would hold the Mac awake with nothing
running.
And when nothing claims
Auto-watch is the fallback, and it treats a matched process as working by existing, never by CPU — an agent sits near 0% waiting on an API between tool calls, so a CPU threshold would drop the Mac mid-task.
It matches the executable basename exactly, not a substring of the path.
Otherwise rsync matches macOS's own appplaceholdersyncd,
uv matches UVCAssistant, and docker matches the
always-running com.docker.vmnetd. Any one of those holds an idle Mac awake
forever. It is locked down in a test.
One row per agent, with two signals that are deliberately not collapsed into one: whether it is working, and whether its API answers. An agent burning battery against a down API is the case worth waking up for.
AGENTS 1 working
✦ Claude working ×2 ● API ok · 110ms
⌗ Antigravity idle ○ not checked
◇ Codex idle ○ not checked
02:14 · battery 34%, falling
Readings in, verdict out, no side effects. The governor is a pure function, so the rules deciding whether an overnight run survives can be tested directly rather than guessed at from the app looking plausible.
| Rule | Default | What it does |
|---|---|---|
| Hard battery floor | 4% | pmset sleepnow — must beat every assertion, not just ours |
| Critical thermal, lid shut | — | force sleep; with no airflow, releasing is not enough |
| Soft battery floor | 20% | release, and sleep normally with headroom to resume |
| Thermal ceiling | critical | release |
| Charging-only | off | release the moment it comes off mains |
Both floors are on for everyone.
A guardrail behind a paywall is not a guardrail.
A safety stop stays stopped
Releasing the Mac does not stop the work — claude and
cargo are still running, so the very next scan wants to hold again. Left alone
that turns a battery floor into a ten-second flap. So a governor stop also blocks
re-arming, and the block lifts only when the governor is fully satisfied: a release at 20%
re-arms at 30%, or on mains power.
Off stays off
Turning the hold off by hand pauses auto-watch too. Without that, "off" is meaningless in the only situation you would use it — the watched processes are still running, which is why the Mac was awake, so the switch would flip itself back on while you are looking at it. The panel says Paused by you, and names what it is now ignoring.
02:15 · the neighbours
| Liddy | LidRun | Lidless | caffeinate |
|
|---|---|---|---|---|
| Survives a lid close | yes | yes | yes | no |
| Source available | MIT | closed | MIT | Apple |
| Price | free | $9–$59 | free | free |
| Battery & thermal floors | yes | yes | none | none |
| Reverts if the app is killed | 4 paths | unpublished | on next launch | n/a |
| A script you can run to prove it | verify-deadman.sh | no | no | n/a |
| Lease API the work can call | claim / renew / release | no | no | no |
| Agent-aware hooks | Claude Code | process match | no | no |
| Notarised build | not yet | yes | — | built in |
Compiled from each project's own published material in July 2026, and it will drift — tell us where this table is unfair and it gets fixed. LidRun is a good product that solves the same problem and got there first; the honest last row is ours to fix, not theirs. Lidless is MIT and free with the same architecture, which is why price is not the argument on this page.
03:50 · thermal state: very hot
A closed lid cuts off the main airflow path. Hard ventilated surface, on mains power. Never a bag, a bed, or a drawer.
This list lives on the landing page on purpose. It is in the README too, and the roadmap keeps a public file of the ideas that were considered and rejected, with the reason.
06:12 · the run finished at 05:58
liddy log
23:41 hold acquired lid closed · timer 8h
00:02 lease claude · session 4f2a · renewed ×38
02:14 soft floor battery 20% · released, slept with headroom
05:58 finished last lease gone · smart release
06:12 ended finished — not battery, not thermal, not a timer
Download
Grab the latest zip from Releases and drag Liddy.app to
/Applications.
xattr -dr com.apple.quarantine /Applications/Liddy.app
open /Applications/Liddy.app
That first line is not optional, and the reason is in the limits.
Or build it
Swift toolchain, no external dependency, nothing to sign up for.
git clone https://github.com/bygelo/liddy
./Script/install-cli.sh # liddy → ~/.local/bin
./Script/build-app.sh # → dist/Liddy.app
Building with a Developer ID identity on the machine skips the quarantine dance entirely.
Keep-awake, timers, charging-only, both battery floors and thermal watch all work without the helper. The root daemon is required only to close the lid, and the app installs it from the menu with one authorisation prompt — no Terminal step.
the part that needs you
Liddy is a small tree with no external dependencies:
swift build && swift test runs 155 tests and that is the whole gate. Every open
roadmap row names a surface (the thing that will exist) and a
benchmark (how you would know it worked), so a contribution has a finish line
before it has a first line. Gaps are written down as gaps, not opportunities.
one entry
Adding an agent to the panel is a label, a URL, and the lease-label fragments
that identify it. Matching is case-insensitive substring against active leases, so it catches
both an explicit claim and a process picked up by auto-watch.
// ServiceEndpoint.known
.init(label: "Windsurf",
url: "https://api.windsurf.com",
leaseFragment: ["windsurf", "cascade"])
open, and load-bearing
Cursor, Codex and Amp only get process-matching today — the guess this project argues against everywhere else. The benchmark is written already: install the hook, drive one turn, and a keyed lease has to appear during it and be gone within the TTL after.
Sources/LiddyKit/Work/Hook.swift
open, and the honest one
When the app is not running, liddy -- <cmd> falls back to a
bare assertion with no battery or thermal floor, and only warns on stderr. The safety table
implies those floors are unconditional. Right now they are not.
Sources/LiddyCli/main.swift
the contract
claim, renew and release are real and
keyed, but undocumented outside --help, so nobody can integrate without reading
Swift. This is the one thing here that the alternatives do not have, and it is currently an
internal detail of one binary. It passes when a fresh reader can claim and release from
nc without opening a .swift file.
on money
There is no licence check, no account, no telemetry, and no tier where the battery floor is the thing you unlock. Lidless is MIT and free with the same architecture; there is no price below free, so price was never the argument.
If Liddy saved an overnight run, sponsorship funds the boring things that cost real money — the Apple Developer account that gets releases notarised, so the next person does not have to clear a quarantine flag by hand. Companies whose agent fleets depend on it can put their name in the README.