In the Korean army, bulchimbeon (불침번) is the night-watch shift: one soldier stays awake all night so everyone else can sleep. Fall asleep on watch and you're in trouble.

My Mac had the opposite problem. It slept too well.

I'd tell Claude Code "go refactor this" and go to bed. In the morning, one of three things had happened:

  • The Mac had gone to sleep and the agent was frozen halfway 😇
  • I'd left caffeinate -d running and the screen had been on all night
  • Worst case: I'd forgotten to undo sudo pmset disablesleep 1 and the battery was at 0%

So I built my Mac a night-watch soldier. A pixel-art private patrols under the menu bar with a flashlight while an agent works, then salutes "Shift over! 🫡" and lets the Mac sleep.

Bulchimbeon demo

How do you know an agent is "working"?

Version one was simple: if a Claude Code process exists, stay on duty.

Result: the shift never ended. I keep several Claude Code sessions open at all times. Counting open sessions meant the private would stand watch until discharge.

What I ended up with:

Signal Why
The agent has a caffeinate child process Claude Code spawns caffeinate -i -t 300 itself, only while it's working
Its process tree uses ≥ 2% CPU averaged over 60 s Catches tool runs like builds and tests
Headless sessions launched with --tools= are ignored Plugins like claude-mem keep helper sessions running

I started with a 5-second CPU sample. A file watcher the agent had started would nibble a little CPU now and then, and the shift didn't end for 18 minutes. Switching to a 60-second average fixed it. The private wasn't slacking; my measurement was.

The duty logic lives in a pure state machine (DutyEngine) with no system calls. With a fake clock I wrote 70 scenario tests: "quiet for 5 minutes → hand over", "battery under 20% → stand down", "overheating → rest, then resume".

All green. Deployed with full confidence.

Incident 1: the watchdog went home with its officer

Staying awake with the lid closed needs pmset -a disablesleep 1. The catch: if the app gets force-quit, that setting stays on. Next stop, a hot Mac in a backpack.

So when enabling it with admin rights, I also launched a watchdog script with nohup. If the app dies, the watchdog restores the original setting.

Measured on a real machine: the watchdog vanished the moment the admin shell exited. The soldier posted on watch clocked out with his officer.

It looked like osascript's do shell script ... with administrator privileges cleans up child processes in the same session when it finishes. nohup only ignores SIGHUP, so it didn't survive.

Fix: fork via /usr/bin/perl and call setsid() to fully detach into a new session. That caused incident 1b: A && B & left a subshell behind and osascript never returned. The final version synchronizes over a pipe: the parent exits only after the child reports "detached".

Final measurement: even after kill -9 on the app, the sleep setting is restored within 3 seconds.

Incident 2: the shift evaporated on restart

Feature: remaining watch time survives app restarts and updates. Tests passed.

Reality: restart, and the watch was gone.

The cause was humbling. init called persist() first, overwriting the saved watch with an empty state, and only then ran resume(). Like shredding the handover log before the next soldier could read it.

The tests covered the engine, not the app shell's initialization order. One line in the wrong place.

Incident 3: no report ever arrived

Shift changes, heat advisories, a handover report when you come back from being away: I built lots of notifications.

Reality: not a single notification, ever. No errors either.

UNErrorDomain Code=1 "Notifications are not allowed for this application" 

macOS doesn't grant notification permission at all to ad-hoc signed apps without a Developer ID (Team ID). And my permission request looked like this:

center.requestAuthorization(options: [.alert, .sound]) { _, _ in } 

{ _, _ in }. Elegantly ignoring failure. The private had been writing reports and filing them straight into the trash.

Now, without permission, it falls back to osascript's display notification. The text is passed as argv rather than spliced into the script string, so there's no AppleScript injection surface.

Lessons

  • Pure logic tests only verify what you intend to do. Whether the OS actually lets you do it takes a real machine. None of the three incidents could have been caught by unit tests.
  • Callbacks that swallow failure ({ _, _ in }) are the quietest, most dangerous bugs, whether an AI or a human wrote them.
  • Building this with Claude Code, the most useful habit was asking "did you actually verify that?" over and over. One real measurement beat a wall of green tests.

Where the private stands today

  • On duty only while Claude Code, Codex, Gemini CLI, Cursor Agent, Copilot CLI, Aider or OpenCode are actually working
  • Lid-closed mode, with a watchdog that restores your sleep setting even if the app dies
  • 🥵 Rests when the Mac overheats and resumes once it cools; stands down below 20% battery
  • Back after 10+ minutes away? "Handover report, sir! 📋"
  • Installs no hooks, daemons or root helpers. Works on macOS 13+

If you're on macOS 26.4+ and want hook-level precision, Adrafinil is a great alternative; the README has an honest comparison.

It's MIT open source. Ideas for the private's lines or sprites are welcome. He hasn't passed his promotion review yet (Apple notarization), so the first launch needs "Open Anyway" once.

brew install --cask s3onghyun/tap/bulchimbeon 

👉 https://github.com/s3onghyun/bulchimbeon

End of watch report. 🫡
감사합니다.