Nuit Blanche for agents

How Claude Code, Codex, scripts and another machine switch Nuit Blanche on and off, and what they can read from it. Nuit Blanche doesn’t watch your agents and never guesses whether they’re done. A hold ends when the session says so (nuitblanche off at once, or the Stop hook and ten quiet minutes), when its process is gone, or when its timer runs out.

The command is /usr/local/bin/nuitblanche, installed by the app’s setup. nuitblanche --help lists all of it.

Two lines in CLAUDE.md

When I say I’m heading out, run `nuitblanche on 4h`.
Run `nuitblanche off` when the whole task is done.

Codex gets the same two lines with --as codex on each (below). The 4h is a ceiling, not a plan: the Mac sleeps earlier when the session runs off, ten quiet minutes after the Stop hook releases its hold, at 20% battery, or if it runs hot. Pick a time you’d be fine finding it awake for.

Several sessions

Every nuitblanche on from a Claude Code session takes a hold named after that session (Claude Code · <folder>, after the session’s working folder) and tied to the claude process. The Mac stays awake while any hold is active, and off releases the caller’s hold only.

A session in shop-api is told its person is heading out:

$ nuitblanche on 4h
Awake with the lid closed for 4h.

A second session, in nuit-blanche, does the same an hour later and finishes first:

$ nuitblanche on 2h
Awake with the lid closed for 2h.
$ nuitblanche off
Still awake: held by Claude Code · shop-api (2h 10m left). nuitblanche off --all sleeps anyway.

That off exits 0 and changes nothing: the Mac stays awake for the first session. When that one is done:

$ nuitblanche off
Sleeps normally again.

With the lid shut, the Mac goes to sleep right then. The app does the same on its own when the last hold ends without an off: a hold is gone when its process is gone (a closed or crashed session never leaves the Mac awake) or when its time runs out, and the menu then says All sessions done (14:32).

What else to know:

The Claude Code hooks

nuitblanche hook claude-code prints this, for ~/.claude/settings.json (every project) or a project’s .claude/settings.json. Nothing is installed for you: paste it in, merging with any hooks you already have. The JSON goes to stdout and the explanation after it to stderr, so nuitblanche hook claude-code | pbcopy copies clean JSON.

{
  "hooks": {
    "Stop": [
      {"hooks": [{"type": "command", "command": "/usr/local/bin/nuitblanche release --stdin", "timeout": 10}]}
    ],
    "UserPromptSubmit": [
      {"hooks": [{"type": "command", "command": "/usr/local/bin/nuitblanche resume --stdin", "timeout": 10}]}
    ]
  }
}
For ~/.claude/settings.json: when Claude Code finishes a turn, the Stop hook marks this session’s hold released, and the next prompt takes it back.
The Mac sleeps 10 minutes after the last session has gone quiet, and only if a session was what turned it on.
nuitblanche off still sleeps it at once.

When a turn ends, Claude Code runs the first with the session id on stdin, and release marks that session’s hold as released. When your next prompt arrives, the second runs resume, which takes the hold back. Ten minutes after the last session has gone quiet, and only if a session was what turned the Mac on, the flag goes off, the before-sleep script runs, and with the lid shut the Mac sleeps. If the person pressed the key, the hooks leave it on. nuitblanche off from the session still sleeps the Mac at once.

The Stop hook does nothing in two cases: while Claude Code Remote Control is connected (the person is driving the session from their phone, lid shut), and while the session still has background tasks running. Neither hook ever blocks Claude Code: both exit 0 whatever happens, and in a session that holds nothing they do nothing.

Stop fires when a turn ends, and a turn also ends when Claude asks you a question. Under a closed lid nothing is running then either, so the Mac sleeping ten minutes later is right. An answer within those ten minutes takes the hold back through resume; later than that, say you’re heading out again.

With the hooks, the off line in CLAUDE.md is the fast path rather than the only one. Keep it: the hooks may not be on every machine the session runs on, and off doesn’t wait ten minutes.

To remove them, delete the Stop and UserPromptSubmit entries from the settings file. nuitblanche release and resume without --stdin act on the caller’s hold the same way. release never sleeps the Mac itself: the app does, ten quiet minutes later, with the reason sessions, and never when the person turned it on themselves.

Codex

Codex gives the commands it runs no session variable, so Nuit Blanche treats every Codex session as one shared hold named codex. The AGENTS.md lines are:

When I say I’m heading out, run `nuitblanche on 4h --as codex`.
Run `nuitblanche off --as codex` when the whole task is done.

nuitblanche hook codex prints the hook for ~/.codex/hooks.json (Codex also takes the same hooks as an inline [hooks] table in ~/.codex/config.toml):

{
  "hooks": {
    "Stop": [
      {"hooks": [{"type": "command", "command": "/usr/local/bin/nuitblanche release --stdin --as codex", "timeout": 10}]}
    ]
  }
}
In AGENTS.md: When I say I’m heading out, run nuitblanche on 4h --as codex.
              Run nuitblanche off --as codex when the whole task is done.
All Codex sessions share one hold named codex (Codex gives its commands no session variable). The snippet goes in ~/.codex/hooks.json; the file name may differ by Codex version, so check its docs.

Codex’s Stop hook gets session_id, turn_id, stop_hook_active and last_assistant_message on stdin, the same way Claude Code’s does. There’s no resume hook for Codex in 1.1: a prompt in a second Codex session doesn’t take the hold back, so a session still working after another one’s turn ended should run nuitblanche on 4h --as codex again. nuitblanche off --as codex from the agent sleeps the Mac at once.

Codex’s older notify = ["python3", "/path/to/notify.py"] setting also still exists. It runs a program on agent-turn-complete with one JSON argument (type, thread-id, turn-id, cwd, input-messages, last-assistant-message), and a two-line script there can call nuitblanche release --as codex the same way.

Other tools

Untested, but the same shape: a hold named with --as, released by the tool’s end-of-turn hook with nuitblanche release --as <name>, then ten quiet minutes. Any tool that runs each command in a fresh shell needs --as on every call, since without a session variable each on, off and release would get a different id.

What an agent can read

nuitblanche status --json prints one JSON object: the app’s status.json, written every poll to ~/Library/Application Support/Nuit Blanche/, or the same shape built by the command when the app’s file is older than 20 seconds (then app_running is false and thermal, online, watts and temperature_c are null).

{"version": "1.1.0", "at": 1761601234, "app_running": true,
 "awake": true, "until": 1761615634, "left": "3h 59m", "via": "cli",
 "lid": "closed", "display_keeps_awake": false,
 "battery": {"percent": 36, "on_battery": true, "charging": false, "minutes_left": 319, "watts": 3.4, "temperature_c": 30.2},
 "thermal": "nominal", "online": true, "hotspot": false,
 "guards": {"battery_floor": 20, "battery": true, "heat": true, "offline_minutes": null},
 "holds": [{"id": "claude:eb5ad181-ba41-4589-8222-cda1709ea34f", "name": "Claude Code · nuit-blanche", "pid": 47338, "since": 1761601234, "until": 1761615634}],
 "last_reason": null}
FieldWhat it is
awakeThe flag: true while closing the lid doesn’t sleep the Mac
until, leftWhen it switches off, as epoch seconds and as 3h 59m; null when it’s until turned off, or off
viaWhat turned it on: app (the key or the menu), cli, outside (the flag set outside Nuit Blanche), or null when off
lidopen, closed, or null on a desktop
display_keeps_awaketrue in clamshell mode on an external display, where macOS keeps the Mac awake with the lid shut on its own
batterynull without one. percent, on_battery, charging, minutes_left (macOS’s own estimate; null on power or before it has one), watts (the draw while discharging; null when charging or unknown), temperature_c
thermalmacOS’s thermal state: nominal, fair, serious or critical
onlineWhether macOS has a network route (nothing is sent); null until the first reading
hotspottrue while the Mac is tethered to an iPhone’s Personal Hotspot (the router is 172.20.10.1)
guardsbattery_floor (20), battery and heat (on or off), offline_minutes (15 when the offline guard is on, null when off)
holdsThe active holds: id, name, pid (absent when no process is watched), since, until (absent when the hold has no end), released (epoch seconds, present once the Stop hook has marked it)
last_reasonThe menu’s reason line, like All sessions done (14:32), Went to sleep at 20% battery (14:32) or Went to sleep: unplugged (14:32), or null
version, at, app_runningThe app’s version, when the object was written (epoch seconds), and whether the app is running (holds and the timer need it)

Three decisions an agent can make from it:

  1. The battery has under 30 minutes left (battery.on_battery true and minutes_left under 30, or percent close to the 20% floor). Wrap up: commit, push, write down where things stand, then nuitblanche off. The floor doesn’t wait for a good moment, and the before-sleep script is a net, not a plan.
  2. It’s offline (online false). The API is out of reach, so a retry loop only spends battery. Wait a few minutes and read it again, or stop here. With the offline guard on (guards.offline_minutes is 15), the Mac sleeps after 15 minutes without a network anyway.
  3. The lid is open again (lid is open after it was closed). The person is back. Ask before starting another long run instead of assuming they’ve left.

One line of shell for the first, with the jq that ships with macOS 15 and later:

nuitblanche status --json | jq -r '.battery.minutes_left // empty'

Before it sleeps

An executable at ~/Library/Application Support/Nuit Blanche/before-sleep runs, with no arguments, before every automatic turn-off (the timer, the battery floor, the heat cut-off, the offline guard, the last hold gone) and before nuitblanche off sleeps a closed Mac. Its environment has NB_REASON (timer, battery, heat, offline, unplugged, sessions or off), NB_BATTERY (the percent, or empty without a battery) and NB_LID (open or closed). After the hooks’ ten quiet minutes the reason is sessions: release itself never sleeps the Mac, the app does. It has 60 seconds, then it’s sent SIGTERM, and the first 20 lines of its output go to ~/Library/Logs/Nuit Blanche.log. The app runs it outside your shell, so your .zshrc isn’t read: use full paths, and anything that needs credentials must work without a prompt.

Commit and push whatever the agents left:

#!/bin/sh
cd "$HOME/code/myproject" || exit 0
git add -A && git commit -qm "Before sleep ($NB_REASON, battery ${NB_BATTERY:-?}%)"
git push -q

Or leave a note the next session, or you, will find:

#!/bin/sh
printf '%s: went to sleep (%s), battery %s%%, lid %s\n' "$(date '+%F %R')" "$NB_REASON" "${NB_BATTERY:-?}" "$NB_LID" \
  >> "$HOME/Desktop/nuit-blanche.log"

chmod +x it, then run it once by hand with NB_REASON=test NB_LID=open in front.

Scripts and cron

A script that isn’t a Claude Code session gets a hold named after its terminal session, or after its parent process, and nothing is watched: ssh mac nuitblanche on 4h and a zsh -c from an agent exit at once, so a hold tied to them would end before it started. Name the hold with --as and tie it to the script with --pid $$:

nuitblanche on 2h --as nightly-backup --pid $$
./backup.sh

The menu shows nightly-backup, and the hold ends when the script exits or when the two hours are up, whichever comes first. --as takes letters, digits, dots, dashes and underscores, 64 at most; anything else gets nuitblanche: “bad name” isn’t a name for a hold. Use letters, digits, dots, dashes and underscores, 64 at most., and a bad --pid gets nuitblanche: “abc” isn’t a process id. A hold with no duration lists as nightly-backup, until released. From cron or launchd the line is the same; the timer is the ceiling if the script hangs.

Without --pid, a hold lasts until its time runs out or until it’s released by name: nuitblanche release --as nightly-backup marks it released and the Mac sleeps ten quiet minutes later, nuitblanche off --as nightly-backup sleeps it at once, and nuitblanche off --all drops every hold, the other sessions’ included.

From another machine

With Remote Login on (System Settings › General › Sharing), any machine on the same network can run the command over SSH:

ssh my-macbook-air.local nuitblanche status
ssh my-macbook-air.local nuitblanche on 4h --as laptop
ssh my-macbook-air.local nuitblanche off --all

Every SSH command is its own shell, so an on over SSH isn’t tied to the connection: give it a duration and a name, and end it with off --as laptop or off --all (a plain off over SSH has a different id from the on and prints Still awake: held by laptop (3h 59m left). nuitblanche off --all sleeps anyway.). status --json over SSH is how another machine reads the battery and the holds. From an iPhone, in the bag, on the phone’s own hotspot: iphone.md.