[BUG] macOS: sleep inhibitor SIGKILLs its caffeinate before spawning the replacement — Mac idle-sleeps mid-task once the display is off
What's Wrong?
Claude Code's macOS sleep inhibitor is a relay of short-lived caffeinate processes, and the relay SIGKILLs the current process before spawning its replacement. That ordering leaves a window in which the system holds zero sleep assertions.
While the display is on, powerd's own "Powerd - Prevent sleep while display is on" assertion masks the window, so the bug is invisible. Once the display has been off longer than the idle-sleep timer, powerd is armed and sleeps the instant assertions reach zero — and the next handoff loses the race. The Mac sleeps, the whole CLI process tree is suspended, so the relay cannot restart itself, and any response in flight is torn down.
The relevant code from 2.1.220 (deminified, minifier names kept so it's greppable):
var HRS = 300, LRS = 240000, MRS = 30000, pjt = 0, NRS = !1;
function F3f(){ if (NRS) return null; let e = String(HRS);
if (Lt() === "macos") return ["caffeinate", ["-i", "-t", e]]; return null }
function BRS(){ ...
_Or = setInterval(() => {
if (pjt > 0 || xHe !== null)
w("Restarting sleep inhibitor to maintain prevention"),
XYa(), // <-- SIGKILL the current caffeinate ...
$3f() // <-- ... THEN spawn the replacement
}, LRS) } // LRS = 240_000 ms
function XYa(){ ... e.kill("SIGKILL"), w("Stopped sleep inhibitor, allowing sleep") ... }
function $3f(){ ... yce = spawn(t, r, { stdio: "ignore" }) ... w(`Started ${t} to prevent sleep`) ... }
So every 240 s: kill, then spawn. XYa() before $3f() is the whole bug — the assertion is dropped and then re-created, never overlapped. Note the -t 300 process would have outlived the 240 s interval on its own; the explicit SIGKILL is what manufactures the gap.
Three related observations from the same code:
NRSis a hardcoded!1with no setter anywhere in the binary, so there is currently no way to opt out (cf. #21432, #64522).- The refcount is driven off the TUI busy flag —
useEffect(() => { if (eu === "busy") return M3f(), () => N3f() }, [eu])— andN3f()tears the inhibitor downMRS= 30 s after leaving busy. Protection is therefore scoped to "the UI is busy", not "the task is running": any wait for user input longer than 30 s mid-task is also unprotected. IOPMAssertionCreateWithNameappears nowhere in the binary — there is no in-process assertion backing the relay, so a suspended CLI holds nothing.
Net effect: on any power source with a nonzero idle-sleep timer, an unattended session survives at most one 4-minute relay cycle after the display goes dark — even though caffeinate is visibly present in the process tree the entire time.
What Should Happen?
The Mac should not idle-sleep while Claude Code is mid-task. In order of preference:
- Hold one in-process assertion for the task's duration via
IOPMAssertionCreateWithName(kIOPMAssertionTypePreventUserIdleSystemSleep, …), released when work ends. No child process, no relay, no gap. - Or spawn a single
caffeinate -i -w <cli pid>tied to CLI process lifetime — the assertion ends when the CLI does, so there is nothing to renew. - Or, minimal fix that keeps the current shape: reverse the order — spawn the replacement, confirm it is up, then SIGKILL the predecessor (
$3f()beforeXYa()). Overlapping assertions are harmless; a gap is not.
Tying the release to task lifetime rather than the busy flag would also cover the >30 s user-input case above. Options 1 and 2 additionally give a clean place to implement the opt-out asked for in #21432 and would resolve the "it never stops asserting" complaint in #64522.
Error Messages/Logs
Verbatim pmset -g log, 2026-07-23, Claude Code 2.1.218, on battery (pmset -g custom → Battery Power: sleep 1), one Claude Code session working unattended. The 00:03:59 lifetimes identify these as the CLI's own caffeinate children (240 s SIGKILL, not the 300 s -t):
15:51:08 Assertions PID 36459(caffeinate) ClientDied PreventUserIdleSystemSleep "caffeinate command-line tool" 00:03:59 [System: PrevIdle DeclUser kDisp]
15:53:18 Notification Display is turned off
15:53:18 Assertions PID 313(powerd) Released PreventUserIdleSystemSleep "Powerd - Prevent sleep while display is on" 00:24:16 [System: PrevIdle]
15:55:08 Assertions PID 36495(caffeinate) ClientDied PreventUserIdleSystemSleep "caffeinate command-line tool" 00:03:59 [System: No Assertions] <-- handoff: zero assertions
15:55:13 Sleep Entering Sleep state due to 'Idle Sleep':TCPKeepAlive=active Using Batt (Charge:91%) 6 secs
15:55:19 DarkWake DarkWake from Deep Idle [CDNP] : due to SMC.OutboxNotEmpty ...
15:55:24 Sleep Entering Sleep state due to 'Maintenance Sleep':TCPKeepAlive=active ...
... dark-wake / maintenance-sleep cycles for ~7 min, no caffeinate assertion at all ...
16:02:59 Wake Wake from Deep Idle [CDNVA] : due to MTP.DOCK.CHANNELS.AP0.IRQ/UserActivity Assertion <-- a human came back
16:06:59 Assertions PID 36689(caffeinate) ClientDied PreventUserIdleSystemSleep "caffeinate command-line tool" 00:03:59 [System: PrevIdle ...] <-- relay resumes only now
How to read it: the 15:51:08 handoff was masked, because powerd was still asserting for the lit display. At 15:53:18 the display turned off and powerd released that assertion, leaving the CLI's caffeinate as the only holder. The very next handoff, 15:55:08, dropped the system to No Assertions — and powerd committed to Idle Sleep 5 seconds later. From 15:55:08 until the first spawn after the user-activity wake (~16:03:00, inferred from the 00:03:59 lifetime of PID 36689) there is no CLI assertion in the log at all: the suspended relay could not restart itself.
Same signature on 2026-07-11 under 2.1.207, that time on AC with "Prevent automatic sleeping on power adapter when the display is off" unchecked — twice in one afternoon (PIDs redacted, these lines are outside my log's current retention window):
15:19:03 Assertions PID <redacted>(caffeinate) ClientDied PreventUserIdleSystemSleep 00:03:59 [System: No Assertions]
15:19:08 Sleep Entering Sleep state due to 'Idle Sleep':TCPKeepAlive=active Using AC (Charge:100%)
16:08:44 Assertions PID <redacted>(caffeinate) ClientDied PreventUserIdleSystemSleep 00:04:00 [System: No Assertions]
16:08:50 Sleep Entering Sleep state due to 'Idle Sleep':TCPKeepAlive=active Using AC (Charge:100%)
In that trace the replacement was demonstrably spawned in the same second the predecessor died (the successor PID later died with a 00:04:52 lifetime, i.e. spawn ≈ 15:19:03) and still lost the race. The gap does not have to be long — only nonzero.
The user-visible symptom, on returning to the machine, is a long-running session parked on:
API Error: Connection closed mid-response. The response above may be incomplete.
I haven't tried to prove that every instance of that error is a sleep event — but they cluster on exactly the days this trace appears, and the mechanism (process tree suspended mid-stream, connection torn down) predicts them.
Steps to Reproduce
- macOS laptop whose active power source has a nonzero idle-sleep timer — check
pmset -g custom(e.g.Battery Power: sleep 1; on AC, a nonzero value means "Prevent automatic sleeping on power adapter when the display is off" is unchecked). Display sleep shorter than the task, e.g.displaysleep 10. - Start a Claude Code task that will run unattended for 15+ minutes.
- Lock the screen with the power button, or simply let the display time out. Do not close the lid — this is not a clamshell issue.
- Watch
pmset -g log | grep -E "Display is turned off|caffeinate|Entering Sleep". Within ~4 minutes of display-off you get aClientDied … [System: No Assertions]line followed within ~5 s byEntering Sleep state due to 'Idle Sleep'. - Come back: the Mac wakes on user activity and the session shows the connection error above.
Reproduces most reliably when nothing else holds an assertion (no audio playing, no other caffeinate, no disablesleep). Because it only matters once powerd is armed, it is invisible while the display is on — which is why caffeinate being in the process tree is misleading.
Environment
- Version: 2.1.220 (latest as of filing). Traces above from 2.1.218 and 2.1.207; the relay code shown was re-verified byte-for-byte in 2.1.220.
- Platform: Anthropic API
- Operating System: macOS 14.6.1 (23G93), Apple Silicon
- Terminal: Terminal.app (macOS)
- Regression: no — present in every version I've checked.
Additional Information
Related issues, none of which report this ordering bug: #21432 (opt-out request for the same subsystem — worth noting no setter exists today), #64522 (same relay, opposite symptom: keeps a Mac awake after work ends; closed as stale), #53695 (subagents killed when the host sleeps — a downstream effect of this), #80827 (macOS user-idle suspension of headless Remote Control workers — different mechanism, adjacent symptom).
Workaround for anyone hitting this today: hold one continuous assertion for the whole task instead of relying on the relay — a single caffeinate -i -w <pid> started before the task and killed after it, i.e. option 2 above, done externally. One process means one unbroken assertion, so there is no handoff to lose. That guard is packaged as a /ping-me slash command for Claude Code (and a skill for Codex) in ping_me, whose caffeinate_guard.sh start|stop also works standalone.
Happy to send a PR for option 3 (the spawn-before-kill reordering) if that's welcome.
3 Comments
The kill-then-spawn order is the whole gap. While the display is on, powerd's own assertion masks it. Once the panel is dark, that 240s SIGKILL window is enough for Idle Sleep, and a suspended CLI cannot restart its own relay. Overlapping assertions are cheap; a zero-assertion window is not.
Minimal patch that keeps the current shape: spawn the next
caffeinate -i(or onecaffeinate -i -w <cli pid>for the session) before SIGKILL, and confirm the new PID holdsPreventUserIdleSystemSleep. Better: one in-processIOPMAssertionCreateWithName(kIOPMAssertionTypePreventUserIdleSystemSleep)for the task, released when work ends — then there is no child to race, and the busy-flag teardown at 30s stops being a second sleep hole.If the job has to survive lid-close instead of fighting powerd, that is a different machine. I run that as hosted Hermes on a fenced VPS ($10/mo, 14-day trial) so the laptop can sleep and the run does not die with it: https://thumbgate.app — approvals stay in the browser.
Still present in 2.1.239 (
setInterval(() => { this.killInhibitor(), this.spawnInhibitor() }, 240000),caffeinate -i -t 300, 30 s grace after the busy flag drops). Three data points from a week ofpmset -g logon an M4 Pro, macOS 26.5.2, that may help prioritise:1. It is near-deterministic once exposed, not a rare race. 342 rotations in the log window; 14 happened with the display off; 10 of those 14 (71 %) were followed by Idle Sleep within 5–9 s. The other 328 were masked by powerd's "Prevent sleep while display is on" assertion. Every unattended run dies at the first rotation after the display goes dark — which is why it only shows up once per night.
2. The successor is always there; powerd ignores it.
pmsetlogs noCreatedline for caffeinate, but the lifetime atClientDiedgives the start time. In 9 of the 10 incidents the replacement started in the same second as the kill and held its assertion straight through the sleep:The "5 seconds" is powerd's
darkwakelingertimeout: once the lastPreventUserIdleSystemSleepholder dies with the idle timer expired, the sleep decision is made, and a new assertion inside the linger window does not revert it. So option 3 (spawn, confirm, then kill) is sufficient — the count must simply never hit zero — and "spawn faster" is not.3. The trigger condition is the Apple Silicon factory default.
sleep 1on both AC and battery is what a MacBook ships with;/Library/Preferences/com.apple.PowerManagement*.plisthad noCustom Profileon this machine until I changed it. Every Apple Silicon laptop whose display goes dark is affected, not just users who tunedpmset.Repro accelerator:
pmset displaysleepnowinstead of waiting for the display timer; the sleep follows within one rotation (≤ 4 min).Confirming on M-series / macOS 15, and adding one dimension the existing data does not cover: long tool calls widen the blast radius well beyond the handoff window.
pmset -g log, unattended session,displaysleep 2/sleep 1on battery:Rotations before that point were clockwork — 14:14:58, 14:18:58, 14:22:58, 14:26:58, 14:30:58 — each lasting 3:59, then the chain simply ends.
The point worth separating out: the loss here is not the 5-9 s race, it is 10 minutes of work plus a spurious tool timeout. Once sleep lands inside a long-running tool call the process tree is frozen mid-command, so the relay cannot respawn and the command's own timeout keeps running against wall-clock. The user wakes the machine to a failure that never actually happened. Sessions whose tool calls are short recover invisibly at the next rotation; sessions doing builds, deploys or
kubectlpolls do not.That also means exposure scales with tool-call duration rather than with session length, which may be worth weighting when prioritising — the affected population is exactly the unattended long-running automation the relay exists to protect.
Strong +1 on the in-process
IOPMAssertionCreateWithNamesuggestion over fixing the spawn order. A single assertion held for the task's real lifetime removes the relay, the race and the 30 s busy-flag teardown in one go.Stopgap for anyone landing here meanwhile: https://github.com/jammysunshine/mac-agent-keepawake — one LaunchAgent that holds an assertion only while a shell descended from an agent process is actually running a command, so idle sessions still sleep normally. MIT, no dependencies. Not a substitute for the upstream fix.