Awake while agents work. Asleep the moment they stop. cc-vigil keeps your Mac awake while Claude Code agents are truly working — a transcript-oracle sleep inhibitor with clamshell support.
A long Claude Code run dies the moment macOS decides you're idle — you come back to a sleeping Mac and a half-finished task. cc-vigil watches the session transcripts and holds a sleep assertion for exactly as long as an agent is doing real work.
A blanket caffeinate outlives the work it was started for; forget it once and the fans run all night. cc-vigil's oracle reads the transcripts themselves, so the assertion drops the moment the last agent goes idle — no timers to guess, nothing to remember to kill.
Clamshell sleep ignores ordinary idle assertions — shutting a MacBook normally ends the run no matter what. cc-vigil's clamshell support keeps agents working with the lid closed, and releases the machine to sleep as soon as they finish.
brew install yasyf/tap/cc-vigilApple Silicon, macOS 15 or newer. Launch cc-vigil once afterward to run first-run setup.
You need an Apple Silicon Mac (the bundled cc-transcript parser ships arm64-only for now), Xcode 26, and XcodeGen (brew install xcodegen).
git clone https://github.com/yasyf/cc-vigil.git
cd cc-vigil
xcodegen generate
xcodebuild -project CCVigil.xcodeproj -scheme CCVigil \
-configuration Release -derivedDataPath build ONLY_ACTIVE_ARCH=YES build
cp -R build/Build/Products/Release/CCVigil.app /Applications/
open /Applications/CCVigil.appThe first run walks you through everything macOS requires:
- Background services — CCVigil registers a per-user agent (the transcript oracle) and a root helper (the only process that touches power management). Approve both under System Settings → General → Login Items & Extensions when prompted.
- Claude Code hooks — the installer adds tagged
cc-vigil nudgehooks to~/.claude/settings.json. Your existing hooks are preserved untouched, andcc-vigil uninstall-hooksremoves only the tagged entries. - CLI on your PATH — the bundled
cc-vigilbinary is symlinked into/usr/local/bin(or~/.local/binwhen that isn't writable).
After that the eye in your menu bar fills whenever the Mac is held awake, and the menu names the sessions holding it. You don't have to watch it: cc-vigil posts a notification when the last agent finishes and the Mac may sleep, and another when a battery, thermal, or Low Power cutout drops protection mid-run — the one moment sleep can catch working agents. Both are on by default; the Settings window turns them off.
If macOS keeps refusing the background items — registration fails twice in a row — the installer points at the fix: run sfltool resetbtm in Terminal and approve the items again. macOS occasionally wedges background-item registration machine-wide, and only the reset clears it.
The obvious design counts hooks: acquire a sleep hold on UserPromptSubmit, release it on Stop. adrafinil works that way, and its issue #7 showed where that leads: an agent kicks off a background workflow, posts its "running in background" reply, Stop fires, the refcount hits zero — and the Mac sleeps while sub-agents are still streaming tokens. adrafinil patched that case in v1.4.0 with SubagentStop refcounting and process sniffing, but the patch rides the same refcount substrate that produced its issue #2 leak: every new way work can start or stop needs another matched acquire/release pair, and any miss either sleeps a working Mac or holds an idle one awake. Hook events describe the conversation loop, not the work.
cc-vigil inverts the relationship. Hooks carry no idle semantics at all; every hook is a nudge meaning "re-read the transcripts now". The truth lives in the transcripts under ~/.claude/projects, parsed by cc-transcript: pending tool calls, background tasks, sub-agent trees, and waiting workflows are all visible there, whether or not any hook ever fires. There is no counter to corrupt — idle is a fresh judgment from evidence on every poll, and background work a Stop payload reports still running holds the block across new prompts and auto-compaction until a top-level Stop reports none.
Relocated setups count too. A session running with CLAUDE_CONFIG_DIR set writes its transcripts somewhere the daemon can't guess, but the nudge hook runs inside that session and forwards the relocated root; the daemon starts scanning it alongside ~/.claude/projects and remembers it across restarts. Extra roots can also be pinned with transcriptsRoots in the config.
A session counts as active only if live claude processes exist and the transcript shows one of: an event inside the activity window, an unfinished tool call, or a wait on long-running work (background tasks, sub-agents, workflows). Two discounts keep that honest:
- Human-wait hint — an idle or permission
Notificationnewer than the last transcript event means the agent is parked on you, so a session with nothing else running lets the Mac sleep until you reply. Live machine work overrides it: while a background task, sub-agent, or workflow is still pending, the block holds until that work clears or ages out past the backstop — Claude Code fires the same idle notification the moment such a job detaches from the turn, and nothing advances the transcript while it runs. - Max-age backstop — pending async work whose transcript hasn't advanced in 12 hours (configurable) stops counting, with a loud log. This is a real case, not paranoia: a stopped workflow leaves "pending" entries in the transcript forever.
The oracle re-evaluates every 15 seconds while blocking, every 45 seconds while idle, and immediately on any nudge.
While the oracle says "working", cc-vigil holds a PreventUserIdleSystemSleep assertion and, because clamshell sleep ignores assertions, sets pmset -a disablesleep 1 through a minimal root helper whose entire interface is set, get, and version. No policy runs as root. The assertion carries cc-vigil's name and reason — pmset -g assertions and Activity Monitor's Energy tab show exactly who is holding the Mac awake — and a 15-minute timeout the daemon's re-push keeps re-arming. The blocking poll cadence is capped at five minutes, so a healthy daemon re-arms with over nine minutes to spare and a wedged helper loses the hold on its own instead of pinning the machine.
Two invariants:
- The display always sleeps normally. cc-vigil never takes
PreventUserIdleDisplaySleepand never runscaffeinate. The screen goes dark on schedule while the system stays up. disablesleepnever outlives the daemon. It's a persistent machine-wide setting, so the helper force-clears it on boot, on shutdown signals, on a 60-second dead-man after the daemon disappears while blocked, and re-reconciles after every wake.
Clamshell runs also survive a charger swap: plugging or unplugging with the lid closed can instant-sleep an Apple Silicon Mac and drop the assertion, so the daemon re-asserts the block whenever the power source flips between AC and battery.
Cutouts protect the hardware: on battery below the floor, lid-closed at high temperature, or whenever macOS Low Power Mode is on, the block releases and latches off until conditions recover — battery and thermal latch with hysteresis so they don't flap; Low Power clears the moment the mode turns off. Manual cc-vigil hold and pause override the oracle in either direction.
One machine, one disablesleep. The clamshell guard is a machine-wide pmset setting: two macOS users running cc-vigil on the same Mac contend for it, and whichever daemon clears it last wins. Single-user Macs never notice; on a shared Mac, pick one account to run cc-vigil.
Config lives at ~/Library/Application Support/cc-vigil/config.json. When the file is absent, cc-vigil uses its built-in defaults. Once present, it is an exact v1 envelope containing schema, schemaVersion, schemaFingerprint, and payload; every payload key in the table below is required and extra keys are rejected. A stale, partial, or malformed file fails startup instead of being repaired. The Settings window writes the complete current envelope.
| Payload key | Default | Range | Meaning |
|---|---|---|---|
batteryFloorPercent |
20 |
5–50 | On battery below this charge, release the block and latch until charge reaches floor+5 or AC power returns. |
thermalCutoutCelsius |
80 |
70–95 | Lid closed, blocking, and at or above this temperature: release and latch until 5°C cooler or the lid opens. |
activityWindowSeconds |
300 |
≥1 | How recent a transcript event must be to keep a session active on its own. |
pendingAsyncMaxAgeSeconds |
43200 |
≥1 | Pending async work with no transcript advance for longer than this stops counting (the backstop above). |
pollBlockingSeconds |
15 |
1–300 | Oracle cadence while blocking; the cap keeps the re-push inside the assertion's 15-minute timeout. |
pollIdleSeconds |
45 |
1–600 | Oracle cadence while idle. |
hideMenuBarExtra |
false |
— | Hide the menu-bar icon; relaunch CCVigil.app to bring it back. |
notifyOnRelease |
true |
— | Post a notification when the block releases because the agents finished. |
notifyOnCutout |
true |
— | Post a notification when a battery, thermal, or Low Power cutout latches and drops protection mid-block. |
lowPowerCutout |
true |
— | Release the block and latch while macOS Low Power Mode is on; clears when the mode turns off. |
transcriptsRoots |
[] |
— | Extra transcript roots to scan besides ~/.claude/projects. Roots nudged from CLAUDE_CONFIG_DIR sessions register themselves. |
Durations are bare seconds or compound units such as 90, 30m, 1h30m, and 1d.
| Command | What it does |
|---|---|
cc-vigil status [--json] |
Blocking state, helper connectivity, active sessions with reasons, holds, cutouts, pause. |
cc-vigil hold --for 2h --reason "big rebuild" |
Keep the Mac awake regardless of the oracle (24h cap); prints the key to release. --key to name it. |
cc-vigil release <key> |
Release a hold. |
cc-vigil pause --for 30m / cc-vigil resume |
Stop all blocking for a while / resume immediately. |
cc-vigil log [-f] [-n N] |
Show events.log (block edges with oracle snapshots, cutouts, holds); -f follows across rotation. |
cc-vigil install-hooks / cc-vigil uninstall-hooks |
Add or remove the tagged hooks in ~/.claude/settings.json (--settings for another file). |
cc-vigil nudge |
The hook entry point: reads hook JSON on stdin, always exits 0. Not for humans. |
cc-vigil version |
Print the version. |
Every block and unblock lands in ~/Library/Application Support/cc-vigil/events.log as JSONL with the full oracle snapshot — per-session reasons for why the Mac was held awake or let go.
The app registers five App Intents — hold, release, pause, resume, and status — so Shortcuts, Spotlight, and Siri can drive the daemon without the CLI: "Hold cc-vigil awake", "Release cc-vigil", "Pause cc-vigil", "Resume cc-vigil", and "cc-vigil status". Hold and Pause take an optional duration under the same 24-hour cap as the CLI, and Status returns the same summary cc-vigil status prints, so a shortcut can chain on it. Launch CCVigil.app once after installing so Shortcuts indexes the intents; when the daemon is unreachable, an intent returns the error instead of hanging.
swift test --package-path CCVigilShared exercises the policy core. The parts a unit test cannot reach — real SMAppService approvals, pmset and IOPM state, the lid, the battery, launchd crash recovery — have a hardware acceptance playbook stored in this repo's cc-notes refs. In a clone, run cc-notes init once and git pull to fetch the refs, then cc-notes doc list to find the "cc-vigil hardware E2E playbook" and cc-notes doc show <id> to open it. Its companion checklist task tracks a run drill by drill.
Licensed under PolyForm-Noncommercial-1.0.0.