Skip to content

feat: add low-battery auto-off guardrail - #5

Merged
hyamero merged 4 commits into
mainfrom
feat/battery-auto-off
Jul 26, 2026
Merged

hyamero merged 4 commits into
mainfrom
feat/battery-auto-off

Conversation

@hyamero

@hyamero hyamero commented Jul 24, 2026 •

Copy link
Copy Markdown
Member

What

nosleep on now restores sleep when the charge drops to/below 20% while running on battery, not just when the window ends. On AC power a low reading is ignored, since it's charging. --battery=N (1–99) moves the threshold; --no-battery opts out and auto-offs on DURATION alone.

This is on by default: an unplugged Mac left awake in clamshell mode would otherwise drain flat waiting out its window, and the old behavior is one flag away.

Usage

nosleep on 3h                  # auto-off at 3h OR at 20% charge (while unplugged)
nosleep on 3h --battery=10     # ...with a custom threshold (1–99)
nosleep on 3h --no-battery     # time-only, the previous behavior

The threshold is only read from --battery=N, never a separate --battery N token: DURATION accepts a bare number of hours, so the two would be ambiguous — nosleep on --battery 3 means three hours at the default threshold.

Example status with the guardrail armed:

disablesleep : ON  (Mac will not sleep, even with the lid closed)
caffeinate   : running
lid watcher  : running
auto-off in  : 2h 47m (at 04:53 AM)
battery      : auto-off <= 20% (currently 63%, on battery)

Why

An unplugged Mac running lid-closed with sleep disabled could drain its battery flat instead of falling back to normal sleep. 20% restores sleep with reserve to spare, ahead of macOS's own low-battery warning (~10%) and emergency sleep (~2–3%) — a graceful sleep rather than a scramble at the bottom.

How

  • The inline root-owned auto-off timer is refactored into a _guardrail subcommand. With --no-battery it just sleeps out DURATION (byte-for-byte the previous behavior). Otherwise the same root process polls pmset -g batt every 30s and, on a low-while-unplugged read, tears down caffeinate and the lid watcher (which restores brightness) before pmset -a disablesleep 0.
  • Because that guardrail is now a separate process rather than an inline snippet with literal paths baked in, cmd_on hands it the resolved state dir explicitly (NOSLEEP_STATE_DIR, alongside NOSLEEP_PMSET_BIN / NOSLEEP_POLL_INTERVAL). sudo's env_reset drops XDG_STATE_HOME, so a guardrail left to re-derive its own paths would operate on a different directory and silently skip the teardown.
  • The poll loop sleeps min(poll, remaining) so the timed auto-off can't land after the moment status advertised.
  • Thresholds are normalized to base 10 before comparison (bash reads a leading zero as octal, which would reject 08 and quietly turn 010 into 8), and armed-ness is tracked separately from the value so that no input a user can type — 0, empty — disarms the guardrail silently.
  • A Mac with no battery skips the guardrail rather than reporting it as armed, and only says so when it was asked for by name.
  • is_our_timer now matches the _guardrail command line; battery_read does one pmset call and one parse, shared by the poll loop and status.
  • status shows the armed threshold and current charge; state adds a battery_threshold file, cleaned up alongside the rest.

@hyamero
hyamero merged commit cc5ada1 into main Jul 26, 2026
1 check passed
@github-actions github-actions Bot mentioned this pull request Jul 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant