[BUG] macOS: sleep inhibitor SIGKILLs its caffeinate before spawning the replacement — Mac idle-sleeps mid-task once the display is off

Status Open
Reported on v2.1.220
Maintainer reply None cached
Activity 3 comments · opened Jul 28, 2026

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:

  • NRS is a hardcoded !1 with 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]) — and N3f() tears the inhibitor down MRS = 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.
  • IOPMAssertionCreateWithName appears 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:

  1. Hold one in-process assertion for the task's duration via IOPMAssertionCreateWithName(kIOPMAssertionTypePreventUserIdleSystemSleep, …), released when work ends. No child process, no relay, no gap.
  2. 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.
  3. Or, minimal fix that keeps the current shape: reverse the order — spawn the replacement, confirm it is up, then SIGKILL the predecessor ($3f() before XYa()). 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 customBattery 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

  1. 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.
  2. Start a Claude Code task that will run unattended for 15+ minutes.
  3. 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.
  4. Watch pmset -g log | grep -E "Display is turned off|caffeinate|Entering Sleep". Within ~4 minutes of display-off you get a ClientDied … [System: No Assertions] line followed within ~5 s by Entering Sleep state due to 'Idle Sleep'.
  5. 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.

View original on GitHub ↗

3 Comments

IgorGanapolsky · 10 days ago

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 one caffeinate -i -w <cli pid> for the session) before SIGKILL, and confirm the new PID holds PreventUserIdleSystemSleep. Better: one in-process IOPMAssertionCreateWithName(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.

sebastian-hick · 9 days ago

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 of pmset -g log on 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. pmset logs no Created line for caffeinate, but the lifetime at ClientDied gives 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:

20:20:07  PID 88277(caffeinate) ClientDied … 00:03:59                      ← rotation
20:20:07  PID 347(powerd) Created  InternalPreventSleep "…darkwakelinger"
20:20:08  Summary- [System: PrevIdle DeclUser SRPrevSleep kCPU kDisp]     ← PrevIdle: a holder exists again
20:20:12  PID 347(powerd) TimedOut InternalPreventSleep "…darkwakelinger" 00:00:05
20:20:12  Entering Sleep state due to 'Idle Sleep' … Using AC
20:35:59  PID 89676(caffeinate) ClientDied … 00:15:52                      ← 20:35:59 − 15:52 = 20:20:07

The "5 seconds" is powerd's darkwakelinger timeout: once the last PreventUserIdleSystemSleep holder 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 1 on both AC and battery is what a MacBook ships with; /Library/Preferences/com.apple.PowerManagement*.plist had no Custom Profile on this machine until I changed it. Every Apple Silicon laptop whose display goes dark is affected, not just users who tuned pmset.

Repro accelerator: pmset displaysleepnow instead of waiting for the display timer; the sleep follows within one rotation (≤ 4 min).

jammysunshine · 1 day ago

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 1 on battery:

14:34:12  agent enters a ~10-minute polling Bash command
14:34:58  PID 60076(caffeinate) ClientDied  ...  [System: No Assertions]
14:35:04  Sleep: Entering Sleep state due to 'Idle Sleep' ... Using Batt
14:45:08  Wake (manual). The suspended command immediately reports
          "Exit code 143 / Command timed out after 10m 0s"

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 kubectl polls 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 IOPMAssertionCreateWithName suggestion 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.