Skip to content

Instantly share code, notes, and snippets.

@vyskoczilova
Last active July 7, 2026 09:54
Show Gist options
  • Select an option

  • Save vyskoczilova/d5206f81f80e999cd4b2133c24712d8b to your computer and use it in GitHub Desktop.

Select an option

Save vyskoczilova/d5206f81f80e999cd4b2133c24712d8b to your computer and use it in GitHub Desktop.
Claude Code hooks: keep the Mac awake exactly while Claude Code is working (refcounted across concurrent sessions)

Claude Code caffeinate hooks

Keep your Mac awake for exactly as long as Claude Code is actively working — no more, no less — even across several concurrent sessions.

Why

Claude Code can run for a while unattended (long tool calls, big builds), and by default your Mac will happily go to sleep mid-task if you walk away. These two hooks start a shared caffeinate -is (prevent idle + system sleep) on the first prompt of a session, and stop it once every session that asked for it has ended — refcounted, so two Claude Code windows open at once don't race to kill each other's caffeinate.

How it works

  • caffeinate-start.sh runs on UserPromptSubmit. It registers the current session (keyed by Claude's session_id) in /tmp/.claude-caffeinate/, prunes any sessions whose process has died, and starts caffeinate -is only if nothing is running yet.
  • caffeinate-stop.sh runs on Stop and SessionEnd. It unregisters the session and kills the shared caffeinate only when the session count drops to zero.
  • Both use an atomic mkdir-based lock so concurrent hook invocations (from parallel sessions) never corrupt the session registry.
  • If the tamp CLI (tamp.kybernaut.cz) is installed, both scripts also mirror the shared session into Tamp's own state file at the two moments they actually start/stop it — so Tamp's menu bar and tamp status show it as their own "On" session, not just a generic external detection. This is a no-op if Tamp isn't installed; neither script has a hard dependency on it.

Setup

  1. Save both scripts to ~/.claude/hooks/ and chmod +x them.
  2. Merge the hooks block from settings.json into your ~/.claude/settings.json (or ~/.claude/settings.local.json).
  3. Requires jq (brew install jq) to parse the hook's JSON payload.

Why this exists

This was also the exact blind spot that led me to build Tamp in the first place — a macOS menu bar app + CLI that notices any external caffeinate process and shows "caffeinated by another app" instead of pretending the Mac is free to sleep.

#!/bin/bash
CAFFEINATE_DIR="/tmp/.claude-caffeinate"
LOCK_DIR="$CAFFEINATE_DIR/.lock"
PID_FILE="$CAFFEINATE_DIR/caffeinate.pid"
# Read session_id from stdin JSON
input=$(cat 2>/dev/null) || input=""
session_id=""
if [ -n "$input" ]; then
session_id=$(echo "$input" | jq -r '.session_id // empty' 2>/dev/null) || session_id=""
fi
if [ -z "$session_id" ]; then
session_id="fallback-$$-$(date +%s)"
fi
mkdir -p "$CAFFEINATE_DIR"
# mkdir-based atomic lock
acquire_lock() {
local attempts=0
while ! mkdir "$LOCK_DIR" 2>/dev/null; do
attempts=$((attempts + 1))
if [ "$attempts" -ge 50 ]; then
if [ -f "$LOCK_DIR/pid" ]; then
local lock_pid
lock_pid=$(cat "$LOCK_DIR/pid" 2>/dev/null) || lock_pid=""
if [ -n "$lock_pid" ] && ! kill -0 "$lock_pid" 2>/dev/null; then
rm -rf "$LOCK_DIR"; continue
fi
else
rm -rf "$LOCK_DIR"; continue
fi
return 1
fi
sleep 0.1
done
echo $$ > "$LOCK_DIR/pid" 2>/dev/null || true
}
release_lock() { rm -rf "$LOCK_DIR" 2>/dev/null || true; }
trap 'release_lock' EXIT
# If Tamp (https://tamp.kybernaut.cz) is installed, mirror this shared session
# into its state file so the menu bar shows "On" for exactly as long as any
# Claude Code session is active. Pure no-op if tamp isn't installed — this
# script has no hard dependency on it. Only called at the moment we actually
# start the shared caffeinate (once per refcount group, never per-session).
sync_tamp_active() {
command -v tamp >/dev/null 2>&1 || return 0
local pid="$1" dir="$HOME/Library/Application Support/Tamp"
mkdir -p "$dir"
cat > "$dir/state.json.tmp" <<JSON
{"active":true,"pid":$pid,"endsAt":null,"flags":{"display":false,"system":true,"disk":false}}
JSON
mv "$dir/state.json.tmp" "$dir/state.json"
}
if ! acquire_lock; then exit 0; fi
# Prune stale sessions (dead PPIDs from crashed instances)
for session_file in "$CAFFEINATE_DIR"/session-*; do
[ -f "$session_file" ] || continue
stored_pid=$(cat "$session_file" 2>/dev/null) || stored_pid=""
if [ -n "$stored_pid" ] && ! kill -0 "$stored_pid" 2>/dev/null; then
rm -f "$session_file"
fi
done
# Register this session with Claude's PPID for liveness checks
echo "$PPID" > "$CAFFEINATE_DIR/session-$session_id"
# Start caffeinate if not already running
if [ -f "$PID_FILE" ]; then
existing_pid=$(cat "$PID_FILE" 2>/dev/null) || existing_pid=""
if [ -n "$existing_pid" ] && kill -0 "$existing_pid" 2>/dev/null; then
exit 0 # Already running
fi
rm -f "$PID_FILE"
fi
nohup caffeinate -is </dev/null >/dev/null 2>&1 &
CAFF_PID=$!
disown $CAFF_PID 2>/dev/null || true
echo "$CAFF_PID" > "$PID_FILE"
sync_tamp_active "$CAFF_PID"
exit 0
#!/bin/bash
CAFFEINATE_DIR="/tmp/.claude-caffeinate"
LOCK_DIR="$CAFFEINATE_DIR/.lock"
PID_FILE="$CAFFEINATE_DIR/caffeinate.pid"
input=$(cat 2>/dev/null) || input=""
session_id=""
if [ -n "$input" ]; then
session_id=$(echo "$input" | jq -r '.session_id // empty' 2>/dev/null) || session_id=""
fi
if [ -z "$session_id" ] || [ ! -d "$CAFFEINATE_DIR" ]; then exit 0; fi
acquire_lock() {
local attempts=0
while ! mkdir "$LOCK_DIR" 2>/dev/null; do
attempts=$((attempts + 1))
if [ "$attempts" -ge 50 ]; then
if [ -f "$LOCK_DIR/pid" ]; then
local lock_pid
lock_pid=$(cat "$LOCK_DIR/pid" 2>/dev/null) || lock_pid=""
if [ -n "$lock_pid" ] && ! kill -0 "$lock_pid" 2>/dev/null; then
rm -rf "$LOCK_DIR"; continue
fi
else
rm -rf "$LOCK_DIR"; continue
fi
return 1
fi
sleep 0.1
done
echo $$ > "$LOCK_DIR/pid" 2>/dev/null || true
}
release_lock() { rm -rf "$LOCK_DIR" 2>/dev/null || true; }
trap 'release_lock' EXIT
# Mirror image of sync_tamp_active() in caffeinate-start.sh — see there for
# why this is safe to call unconditionally (no-op if tamp isn't installed).
sync_tamp_inactive() {
command -v tamp >/dev/null 2>&1 || return 0
local dir="$HOME/Library/Application Support/Tamp"
[ -d "$dir" ] || return 0
cat > "$dir/state.json.tmp" <<JSON
{"active":false,"pid":null,"endsAt":null,"flags":{"display":false,"system":true,"disk":false}}
JSON
mv "$dir/state.json.tmp" "$dir/state.json"
}
if ! acquire_lock; then exit 0; fi
# Unregister this session
rm -f "$CAFFEINATE_DIR/session-$session_id"
# Prune any stale sessions
for session_file in "$CAFFEINATE_DIR"/session-*; do
[ -f "$session_file" ] || continue
stored_pid=$(cat "$session_file" 2>/dev/null) || stored_pid=""
if [ -n "$stored_pid" ] && ! kill -0 "$stored_pid" 2>/dev/null; then
rm -f "$session_file"
fi
done
# Kill caffeinate only if no sessions remain
remaining=0
for f in "$CAFFEINATE_DIR"/session-*; do
[ -f "$f" ] && remaining=$((remaining + 1))
done
if [ "$remaining" -eq 0 ] && [ -f "$PID_FILE" ]; then
pid=$(cat "$PID_FILE" 2>/dev/null) || pid=""
[ -n "$pid" ] && kill "$pid" 2>/dev/null || true
rm -f "$PID_FILE"
sync_tamp_inactive
fi
exit 0
{
"hooks": {
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{ "type": "command", "command": "bash $HOME/.claude/hooks/caffeinate-start.sh", "timeout": 10 }
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{ "type": "command", "command": "bash $HOME/.claude/hooks/caffeinate-stop.sh", "timeout": 10 }
]
}
],
"SessionEnd": [
{
"matcher": "",
"hooks": [
{ "type": "command", "command": "bash $HOME/.claude/hooks/caffeinate-stop.sh", "timeout": 10 }
]
}
]
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment