[BUG] Windows: the CLI sleep inhibitor is a macOS-only branch, so nothing holds the host awake while a turn runs

Status Open
Reported on v2.1.280
Maintainer reply None cached
Activity 0 comments · opened Sep 24, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report
  • [x] I am using the latest version of Claude Code

What's Wrong?

Claude Code ships a sleep inhibitor: refcounted around work, restarted on a 4 minute relay, cleaned up on exit. On Windows it does nothing, because the only branch that returns spawn args is the macOS one.

Deminified from the shipped claude.exe (2.1.280, npm global install). Minified names kept so it stays greppable:

var E6t = 300, D6t = 240000, O6t = 30000;

class jYe {
  inhibitorProcess = null; restartInterval = null; pendingKillTimeout = null;
  refCount = 0; cleanupRegistered = !1; spawnDisabledForTests = !1;
  linuxInhibitor = "systemd-inhibit"; linuxDetectionStarted = !1;

  acquire() { if (this.refCount++, this.refCount === 1) { ...; this.spawnInhibitor(), this.startRestartInterval() } }
  release() { if (this.refCount > 0) this.refCount--;
              if (this.refCount === 0 && ...) this.pendingKillTimeout = setTimeout(() => { ... this.killInhibitor() }, O6t) }

  getSpawnArgs() {
    if (this.spawnDisabledForTests) return null;
    let h = String(E6t);
    if (M() === "macos") return ["caffeinate", ["-i", "-t", h]];
    return null                       // <- Windows lands here
  }

  startRestartInterval() { if (this.getSpawnArgs() === null) return; ... }
  spawnInhibitor()       { let h = this.getSpawnArgs(); if (h === null) return; ... }
}

acquire() and release() are live. The session status hook calls them:

var N6t = new q(() => new jYe);
function WYe() { return N6t.of(W().host) }
function gme() { WYe().acquire() }
function yme() { WYe().release() }

// in the status effect:
Ko = () => { if (uo === "busy") { return gme(), L4t } }        // L4t is () => yme()

So in an interactive session on Windows the refcount goes up and down with the status and nothing happens underneath it. The busy/idle/waiting classifier right next to this already separates a query in flight from a session parked on user input, so the signal is there too. The platform layer has one branch.

Grepping the same binary for any Windows power API (grep -a -o -F "<string>" claude.exe | wc -l, so these are occurrences and not matching lines):

| string | occurrences |
|---|---|
| PowerSetRequest | 0 |
| PowerCreateRequest | 0 |
| PowerRequestExecutionRequired | 0 |
| SetThreadExecutionState | 0 |
| ES_CONTINUOUS | 0 |
| caffeinate | 7 |
| systemd-inhibit | 2 |

Side note: linuxInhibitor and linuxDetectionStarted are assigned in the field initializers and read nowhere in the bundle. Each string occurs exactly twice in the binary, the initializer and the property-name table. getSpawnArgs() never checks for "linux" either, so Linux falls through the same return null.

It shows up at runtime. On 2026-09-17 I ran powercfg /requests from an elevated prompt with 18 live claude.exe processes and one session mid-turn, having stopped my own keep-awake daemon first so it could not pollute the result. Every category came back None. Nothing held, by anything, while the agent was working.

And you already ship the behaviour I am asking for. Claude Desktop has a setting called "Keep computer awake while Claude works", it scopes the hold to a turn, and it is battery aware (see the log lines below, from #94420). The CLI has the same idea hardcoded to macOS with no setting at all, and nothing on Windows.

The CLI behaviour is also undocumented: the settings reference at code.claude.com/docs/en/settings has no mention of sleep, caffeinate, awake, idle, standby or power. Users find out it exists when it misfires, which is roughly what #21432 is.

What Should Happen?

A Windows session that is mid-turn should hold the machine awake, and stop holding it as soon as it is not working.

Concretely, add a Windows arm next to the macOS one, bounded the way Desktop already bounds it:

  • Behind a setting. #21432 has been open since January asking for the macOS one to be switchable. There is no switch today: getSpawnArgs() consults nothing but spawnDisabledForTests, which has no caller in the bundle beyond its own definition, and the acquire() call site is an unconditional if (status === "busy") acquire().
  • Turn bounded. The refCount already does this, and the 30s grace in O6t already covers the gap between one tool finishing and the next starting, which is what makes naive implementations flap.
  • Power aware. Desktop already logs battery=false on the hold. #85261 is what ignoring the power source looks like: the inhibitor runs the machine to 0% and loses the work it was protecting.
  • Time capped, so a wedged session cannot hold the box awake forever.
  • System plus execution, never display. A background agent has nothing to show, and the display assertion is the expensive one.

Verifiable with an elevated powercfg /requests: the reason string should appear under both SYSTEM and EXECUTION while a turn runs, and be gone within ~30s of the turn ending.

Error Messages/Logs

# powercfg /a on this laptop: S0 only, no S3 to fall back to
The following sleep states are available on this system:
    Standby (S0 Low Power Idle) Network Connected
    Hibernate
    Fast Startup

The following sleep states are not available on this system:
    Standby (S1)
        The system firmware does not support this standby state.
        This standby state is disabled when S0 low power idle is supported.
    Standby (S2)
        The system firmware does not support this standby state.
        This standby state is disabled when S0 low power idle is supported.

# Claude Desktop already does exactly what is being asked for here (from #94420):
[keep-awake] code session hold taken (turn, battery=false)
[keep-awake] code session hold released (idle, held 166725ms)

Steps to Reproduce

  1. Windows 11 laptop where powercfg /a reports S0 and not S3
  2. Drop the AC sleep timeout: powercfg /change standby-timeout-ac 2
  3. Start a session, give it a task that runs 8+ minutes, do not touch the keyboard
  4. From an elevated prompt, powercfg /requests while it works: all categories None
  5. Past the timeout, check Get-WinEvent -FilterHashtable @{LogName='System';ProviderName='Microsoft-Windows-Kernel-Power';Id=42,506,507}

Measured here: step 4, repeatedly. Not measured: a clean trace of a turn dying to S0 suspension. What is reported here is the missing assertion; the user-visible half is #56127, #94887 and the +1 on #71875.

Additional Information

Why Windows needs different plumbing than macOS. On machines that support Modern Standby (S0 Low Power Idle), the firmware disables S1/S2/S3 outright, so there is no falling back to the old behaviour (see powercfg /a above). S0 does not only stop the machine, it suspends processes inside it, so two flags do two different jobs:

  • PowerRequestSystemRequired stops the sleep transition
  • PowerRequestExecutionRequired keeps the process running: "The calling process continues to run instead of being suspended or terminated by process lifetime management mechanisms."

A Windows arm wants both, and on that doc text the second is the load-bearing half: SystemRequired says nothing about whether the calling process keeps executing.

SetThreadExecutionState cannot do it. Its usable flags are ES_CONTINUOUS, ES_SYSTEM_REQUIRED, ES_DISPLAY_REQUIRED and ES_AWAYMODE_REQUIRED (ES_USER_PRESENT is documented as unsupported), and none is an execution-required equivalent. It is the easy API to reach for and it will look like it works. PowerToys Awake has the same gap on record in microsoft/PowerToys#48965, open since June.

Reaching Win32 from this build. The usual objection is that this is a JS bundle. Not a problem here, because the same binary already dlopens kernel32 through bun:ffi on Windows, with a spawn fallback when FFI is unavailable:

function pen() {
  if (l !== void 0) return l;
  if (M() !== "windows") return l = null, l;
  try {
    let e = Ae("bun:ffi");
    l = e.dlopen("kernel32.dll", {
      OpenProcess:              { args: ["u32","i32","u32"], returns: "ptr" },
      GetProcessTimes:          { args: ["ptr","ptr","ptr","ptr","ptr"], returns: "i32" },
      CreateToolhelp32Snapshot: { args: ["u32","u32"], returns: "ptr" },
      Process32FirstW:          { args: ["ptr","ptr"], returns: "i32" },
      Process32NextW:           { args: ["ptr","ptr"], returns: "i32" },
      CloseHandle:              { args: ["ptr"], returns: "i32" }
    }).symbols, t("[win32-kernel32] bun:ffi loaded")
  } catch (e) { t(`[win32-kernel32] bun:ffi unavailable, falling back to spawn: ...`), l = null }
  return l
}

PowerCreateRequest / PowerSetRequest / PowerClearRequest go in a symbol table like that one. No new dependency, no native addon, no helper exe. Two consequences of it being in-process rather than a spawned child: there is no Windows CLI that holds an execution-required request, so the caffeinate shape does not port, and the 4 minute relay is unnecessary, because a power request object has no -t expiry to outrun.

Two ceilings no user-mode program gets past. Verbatim from the PowerSetRequest reference:

"On Modern Standby systems on DC power, system and execution required power requests are terminated 5 minutes after the system sleep timeout has expired."
"Except for PowerRequestAwayModeRequired on Traditional Sleep (S3) systems, power requests are terminated upon user-initiated system sleep entry (power button, lid close or selecting Sleep from the Start menu)."

So on battery the ceiling is the DC sleep timeout plus 5 minutes, and a closed lid always wins. Any setting text should say so.

Prior art, so this is not a duplicate. @habassa5 filed #71875 in June. The title is scoped to Remote Control, but the body already makes the full case: bring the macOS inhibitor to Windows, use PowerSetRequest with PowerRequestExecutionRequired rather than SetThreadExecutionState, keep it AC aware, and it links PowerToys#48965. It got no reply, went stale, and the sweep closed it on 2026-08-13 with "Closing for now - inactive for too long. Please open a new issue if this is still relevant." It picked up a +1 on 2026-09-15 and a cross-reference from #94887 the day after. This is that re-file, at the bot's invitation. What is new here is the located branch, the powercfg /requests reading, the bun:ffi precedent, and the Desktop comparison.

Before it, #56127 reported the user-facing half in May, Windows locks and remote sessions drop, and was closed the same way. Third time on Windows, first time with the branch located.

Also searched for sleep / awake / caffeinate / standby / suspend. Adjacent but distinct, all CLI on macOS: #21432 asks for a setting to turn the macOS inhibitor off, open since January; #81832 is a race between caffeinate restarts; #85261 is the inhibitor ignoring power source; #64522 is closed and labeled duplicate of #21432. #82945, #89110, #92192 and #94420 are Claude Desktop or Cowork rather than the CLI, listed so nobody has to re-check them.

To be explicit, because the titles look alike: this is not #21432 turned around. That one wants the macOS default off. This one wants a Windows arm to exist at all, behind a setting.

View original on GitHub ↗