23:41 · overnight run in progress

Close the lid.
The agent keeps working.

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.

  • MIT
  • no account
  • no telemetry
  • macOS 14+
A Mac holding the sleep setting open on a five-second heartbeat 1 disablesleep HOLDING · LID CLOSED

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

macOS has two sleeps. Most tools only fight one.

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

A guard, not a wrapper.

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.

  1. the app disconnectscrash, force-quit, kill -9 — immediately
  2. 15s without a heartbeatthe app is hung or wedged
  3. SIGTERM / SIGINTshutdown, logout, an uninstall script
  4. the helper's own startupit finds the setting left on and clears it

Don't take the claim on trust.

The 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

The work declares itself. Nothing has to guess.

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
UserPromptSubmitclaim, keyed by session_id
Stoprelease
SessionEndrelease (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

The rules that end a run are testable, not inferred.

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.

RuleDefaultWhat it does
Hard battery floor4%pmset sleepnow — must beat every assertion, not just ours
Critical thermal, lid shutforce sleep; with no airflow, releasing is not enough
Soft battery floor20%release, and sleep normally with headroom to resume
Thermal ceilingcriticalrelease
Charging-onlyoffrelease 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

Four tools flip the same flag. This is what differs.

Liddy LidRun Lidless caffeinate
Survives a lid closeyesyesyesno
Source availableMITclosedMITApple
Pricefree$9–$59freefree
Battery & thermal floorsyesyesnonenone
Reverts if the app is killed4 pathsunpublishedon next launchn/a
A script you can run to prove itverify-deadman.shnonon/a
Lease API the work can callclaim / renew / releasenonono
Agent-aware hooksClaude Codeprocess matchnono
Notarised buildnot yetyesbuilt 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

What Liddy cannot do.

A closed lid cuts off the main airflow path. Hard ventilated surface, on mains power. Never a bag, a bed, or a drawer.

  • It cannot stop your Mac getting hot. It reads the thermal state macOS reports and backs off. It does not control fans — third-party fan control is restricted on Apple silicon, and reading SMC temperature directly misfires.
  • Releases are ad-hoc signed, not notarised — that needs a paid Apple Developer account. Gatekeeper refuses to launch until you clear the quarantine flag by hand, and macOS treats every update as a new app.
  • The health panel is not a system monitor. No CPU graph, no memory breakdown, no throughput. Everything on it is a quantity that can end a hold; Activity Monitor already draws the rest.
  • v0 builds in Swift 5 language mode. Full Swift 6 sendability annotation is a follow-up, not a shipped claim.

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

In the morning, it tells you why it stopped.

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

The roadmap is public, and every row is falsifiable.

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

Teach it a new agent

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

Hooks beyond Claude Code

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

Wrapped commands get no floors

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

Document the lease protocol

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

Free, and staying free — every guardrail included.

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.