A SwiftBar plugin that keeps macOS awake only while a watched process is running, and only inside the days and hours you configure.
Originally built for Claude Code Remote Control sessions: the Mac should stay awake while a session is reachable from a phone, but go back to sleeping normally outside working hours.
| ☕️ Keeping the Mac awake | 💤 Not keeping the Mac awake |
|---|---|
![]() |
![]() |
- Menu bar icon shows whether sleep is currently being prevented, and why
- Auto / always-on / always-off, switchable from the menu
- One-off "keep awake for the next hour" override
- Detects
caffeinateprocesses started elsewhere (terminal, other apps) and can stop them - Menu in English or Japanese, following your system locale by default
- Wraps Apple's own
caffeinate(1). No kexts, no extra permissions, no daemons
- macOS 10.15+ with SwiftBar (
brew install swiftbar) - Bash — the stock
/bin/bash(3.2) is enough - Also runs under xbar, with emoji instead of SF Symbols
curl -o "$(defaults read com.ameba.SwiftBar PluginDirectory)/caffeinate-scheduler.30s.sh" \
https://raw.githubusercontent.com/take-m/swiftbar-caffeinate-scheduler/main/caffeinate-scheduler.30s.sh
chmod +x "$(defaults read com.ameba.SwiftBar PluginDirectory)/caffeinate-scheduler.30s.sh"Or drop the file into your SwiftBar plugin folder by hand. SwiftBar picks it up immediately.
The 30s in the filename is the refresh interval, which is also how often the rules are re-evaluated. Rename it (caffeinate-scheduler.1m.sh) to poll less often — but note that this is also the worst-case delay before sleep prevention kicks in after you start a session.
On first run the plugin writes ~/.config/caffeinate-scheduler/config.sh. Edit it from the menu (Edit configuration) or directly:
# Menu language: auto | en | ja. "auto" follows the macOS system locale.
UI_LANGUAGE="auto"
# Processes to watch. Substring matches against the `ps` argument line, separated by |.
WATCH_PATTERNS="claude remote-control|claude --remote-control|claude --rc"
# When the rules are allowed to apply. Multiple windows separated by ;
# Weekday numbers follow `date +%u`: 1=Mon … 7=Sun. Ranges (1-5) and lists (1,3,5) both work.
# If start > end the window wraps past midnight, e.g. "1-5 22:00-02:00".
# Empty string means no time restriction.
SCHEDULE="1-5 09:00-22:00"
# Keep preventing sleep for this many minutes after the watched process disappears.
GRACE_MINUTES=5
# Flags passed to caffeinate.
# -i prevent idle system sleep (display still turns off) — usually what you want
# -di also keep the display on
CAFFEINATE_FLAGS="-i"Remote Control is hosted by the CLI or the VS Code extension, so the local claude process is a reliable signal — if it stops, the session ends anyway.
The default patterns match explicit invocations (claude remote-control, claude --rc, /remote-control). If you have auto-connect turned on — /config → Enable Remote Control for all sessions, or remoteControlAtStartup: true in ~/.claude/settings.json — then plain claude sessions are remote-controllable too, and you should loosen the pattern:
WATCH_PATTERNS="claude"The trade-off is false positives: anything with claude in its command line (grep claude, an editor with the string in a filename) will match. Narrow it back down if that becomes annoying.
UI_LANGUAGE accepts auto, en, or ja. On auto the plugin reads defaults read -g AppleLocale, falls back to $LANG, and settles on English if neither says Japanese. Anything unrecognised in the config falls back to auto rather than erroring.
Adding a language means adding one msg_<code>() function to the plugin — nothing else. t() falls back to English for any key a catalogue is missing, so a partial translation still renders. tests/test_i18n.py checks that every catalogue has the same keys and the same printf format specifiers, and that no key is defined but unused.
SwiftBar standard plugins are stateless — they run, print, and exit. So instead of holding state in a daemon, every refresh performs a full reconcile:
- Read config and the current mode
- Decide whether sleep should be prevented right now (override → mode → schedule → process match → grace period)
- Compare against reality and start or stop
caffeinateaccordingly - Print the menu
A side effect is self-healing: if the caffeinate child is killed by anything, the next refresh notices and restarts it.
State lives in $SWIFTBAR_PLUGIN_DATA_PATH (mode, override deadline, PID of the managed caffeinate, last time the watched process was seen). The plugin only ever kills the caffeinate it started itself, unless you explicitly choose Stop all of them.
- Closed lid.
caffeinateprevents idle sleep. A MacBook with the lid shut still sleeps. Defeating that needssudo pmset -a disablesleep 1, which requires root, so it is deliberately out of scope here. - Substring matching.
WATCH_PATTERNSis matched against the fullpsargument line withgrep -F. It is simple and dependency-free, not precise. - Up to one refresh interval of lag. Starting a session does not instantly prevent sleep; the next refresh does. Use the Keep awake for 1 hour override if you need it immediately.
./tests/test_schedule.sh # schedule parsing and window matching
./tests/test_i18n.py caffeinate-scheduler.30s.sh # translation catalogue parity
./tests/test_version.sh # version numbers agree
./tests/lint_bash32.py *.sh tests/*.sh # bash 3.2 compatibility
shellcheck -x *.sh tests/*.sh
shfmt -d -i 4 -ci *.sh tests/*.shThe plugin can be sourced with CAFFEINATE_SCHEDULER_LIB_ONLY=1 to load only the pure functions, which is how the tests inject a fake clock.
macOS still ships bash 3.2.57 (2007) as /bin/bash, and SwiftBar runs plugins through it. Anything written against bash 4 or 5 will pass locally on a Homebrew bash and then break for every user. Two footguns that have already bitten this repo:
caseinside$( ). The 3.2 parser mistakes the)that closes a case pattern for the end of the command substitution, and you getsyntax error near unexpected token 'newline'. Restructure to avoid the substitution, or write the pattern as(*-*)so the parens balance.- Nested quoted parameter expansion such as
${v#"${v%%[![:space:]]*}"}. Use word splitting instead.
tests/lint_bash32.py checks for these and for bash 4-only features. The macos-latest CI job is the real backstop — its /bin/bash is 3.2.
MIT — see LICENSE.

