Keep your Mac awake only while Claude Code runs, with hooks

By Matyas Rathonyi. Updated October 11, 2026. Guides

Add two Claude Code hooks: SessionStart runs a command that keeps the Mac awake, and SessionEnd runs one that lets it sleep again. With the lid open, that command can be the free built-in caffeinate. With the lid closed and no monitor, caffeinate is not enough, and the hook calls a lid-closed app instead, such as open "clamshell://arm".

Arm onSessionStart
Disarm onSessionEnd, last session only
Lid opencaffeinate, free
Lid closedClamshell, $9.99 once

Clamshell for Mac is the lid-closed half of this recipe: it keeps a MacBook awake with the lid shut and no monitor, and a hook can arm it. The lid-open half needs nothing but macOS. Free for 7 days.

Download free trial

Apple Silicon, macOS 14+. Notarized by Apple. 3.6 MB. Or buy now, $9.99. Reading this on your phone? Send the Mac download link to yourself.

A Claude Code session starts in a terminal. Its SessionStart hook sends an arm request to the Mac, which then stays awake and keeps working while its lid closes. arm Claude Code SessionStart hook Your Mac awake until SessionEnd
The session tells the Mac when to stay up.Claude Code runs your command when a session starts and again when it ends. What that command does is up to you.

Some newer keep-awake apps advertise that they detect coding agents and keep the Mac up only while one works. Clamshell does not detect agents. You do not need detection, though: Claude Code already announces the start and end of every session to any command you register. This recipe is useful even if you never install Clamshell, and the test notes say exactly what was run and what was not.

First, check you need this at all

On macOS, Claude Code already starts a caffeinate process of its own while it is working. Anthropic's changelog mentions it, and the open GitHub issue anthropics/claude-code #81832 describes it as caffeinate -i -t 300, restarted every four minutes with a short gap each time (both checked October 11, 2026). So:

Lid open, one terminal

You probably need nothing. If the Mac still dozes off mid-task, start the session as caffeinate -i claude. That holds one idle-sleep assertion until Claude Code exits, and it is free.

Lid open, sessions started from many places

Use the hooks below with the caffeinate backend. Every session keeps the Mac awake however it was launched: terminal, editor or a script.

Lid closed, no monitor

caffeinate cannot help: it does not survive a closed lid. Use the hooks with a lid-closed tool. This page uses Clamshell's URL scheme.

Which hook events to use

Anthropic's hooks reference (checked October 11, 2026) lists over thirty events. Four matter here:

EventWhen it firesCatch for keep-awakeUse it?
UserPromptSubmit You submit a prompt, before Claude processes it Only marks the start of a turn Per-turn variant
Stop Claude finishes responding The docs say it "does not run if the stoppage occurred due to a user interrupt", and API errors fire StopFailure instead Per-turn variant

The tempting design is per turn: arm on UserPromptSubmit, disarm on Stop. It is the wrong default for a closed lid. Once Claude finishes a turn the Mac is free to sleep, and a sleeping, closed MacBook cannot receive your next message from a phone or over SSH. Per session is the safer rule: the Mac stays reachable while a session exists, and sleeps when you end it.

Two rows over the same session. With per-session hooks the Mac is awake from start to end. With per-turn hooks it is awake during each of three turns and asleep in the gaps between them. Per session Per turn z z start end
Per turn leaves gaps. Disarming on every Stop lets the Mac sleep between turns. With the lid closed, nothing you send from another device can start the next turn.

Why two one-line hooks are not enough

You could put open "clamshell://arm" straight into SessionStart and open "clamshell://disarm" into SessionEnd. It works until you run two sessions: close the first and its end hook disarms the Mac while the second is mid-task. /clear has the same problem, since the docs list it as both a SessionEnd reason and a SessionStart source.

The fix is to count. Each session drops a marker file when it starts and removes it when it ends, and the Mac is released only when no markers are left.

Session A runs first and ends while session B is still running. The Mac stays awake from the start of A to the end of B, and is not disarmed when A ends. Session A Session B Mac awake A ends, 1 marker left
One session ending must not disarm the other. When A exits, B's marker is still there, so the script does nothing. The disarm request goes out only when the last marker is removed.
Hook firesClaude Code pipes JSON with session_id to the script
→
Marker updatedOne file per session, holding the Claude Code process ID
→
Dead sessions prunedMarkers whose process has gone are deleted
→
Count decidesArm on every start. Disarm only at zero

The script

Save this as ~/.claude/hooks/agent-awake.sh. It needs nothing beyond what ships with macOS: no jq, no Python.

#!/bin/bash
# agent-awake.sh arm|disarm
# Called by agent hooks. Reads the hook's JSON from stdin.
# Keeps the Mac awake while at least one agent session is open.

BACKEND="${AGENT_AWAKE_BACKEND:-clamshell}"   # clamshell | caffeinate | dryrun
DIR="${TMPDIR:-/tmp}/agent-awake"
mkdir -p "$DIR/sessions"

input="$(cat)"
field() {  # pull one string value out of the hook JSON, no jq needed
  printf '%s' "$input" | sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n 1
}
sid="$(field session_id | tr -cd 'A-Za-z0-9_-')"
reason="$(field reason)"
[ -n "$sid" ] || exit 0

# One hook at a time: mkdir is atomic. Give up waiting after about a second.
tries=0
until mkdir "$DIR/lock" 2>/dev/null; do
  tries=$((tries + 1))
  [ "$tries" -ge 20 ] && break
  sleep 0.05
done
trap 'rmdir "$DIR/lock" 2>/dev/null' EXIT

# A marker per session, holding the agent's process ID ($PPID is the agent).
case "$1" in
  arm)    echo "$PPID" > "$DIR/sessions/$sid" ;;
  disarm) rm -f "$DIR/sessions/$sid" ;;
  *)      exit 0 ;;
esac

# Drop markers whose agent process is gone (crash, kill -9, closed terminal).
count=0
for marker in "$DIR/sessions"/*; do
  [ -e "$marker" ] || continue
  if kill -0 "$(cat "$marker")" 2>/dev/null; then
    count=$((count + 1))
  else
    rm -f "$marker"
  fi
done

keep_awake() {
  case "$BACKEND" in
    clamshell) open "clamshell://arm" ;;
    caffeinate)
      if ! kill -0 "$(cat "$DIR/caffeinate.pid" 2>/dev/null)" 2>/dev/null; then
        nohup caffeinate -i >/dev/null 2>&1 &
        echo $! > "$DIR/caffeinate.pid"
      fi ;;
  esac
}
allow_sleep() {
  case "$BACKEND" in
    clamshell) open "clamshell://disarm" ;;
    caffeinate)
      kill "$(cat "$DIR/caffeinate.pid" 2>/dev/null)" 2>/dev/null
      rm -f "$DIR/caffeinate.pid" ;;
  esac
}

if [ "$1" = arm ]; then
  keep_awake
  echo "$(date '+%F %T') arm    $sid sessions=$count" >> "$DIR/log"
elif [ "$count" -eq 0 ] && [ "$reason" != clear ] && [ "$reason" != resume ]; then
  # /clear and /resume end one session and start the next at once,
  # so the start hook that follows keeps the Mac awake.
  allow_sleep
  echo "$(date '+%F %T') disarm $sid sessions=0" >> "$DIR/log"
else
  echo "$(date '+%F %T') end    $sid sessions=$count (still awake)" >> "$DIR/log"
fi
exit 0

Then make it executable:

mkdir -p ~/.claude/hooks
chmod +x ~/.claude/hooks/agent-awake.sh

Three details are deliberate. The mkdir lock makes hooks take turns, because the docs say matching hooks run in parallel and two sessions can start in the same second. The marker stores $PPID, which in testing was the Claude Code process itself, so a later run can tell a live session from a dead one. And a SessionEnd with reason clear or resume never disarms, because a SessionStart follows.

The Claude Code settings

Per the hooks reference, ~/.claude/settings.json applies to all your projects, .claude/settings.json to one project, and .claude/settings.local.json to one project on your machine only. For a keep-awake rule you want the first. Add this, merging it into any "hooks" object you already have:

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/agent-awake.sh arm"
          }
        ]
      }
    ],
    "SessionEnd": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/agent-awake.sh disarm",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

This uses the default backend, Clamshell. To use caffeinate instead, put the variable in front of both commands:

"command": "AGENT_AWAKE_BACKEND=caffeinate ~/.claude/hooks/agent-awake.sh arm"

With that backend the script starts a single caffeinate -i when the first session opens and kills it when the last one closes, so idle sleep is held off for the whole session, including while Claude waits for you. Issue #81832 reports that the built-in keep-awake lets go about 30 seconds after Claude stops being busy. Neither does anything for a closed lid.

Clamshell keeps a closed MacBook awake with no sudo and no monitor, and takes arm and disarm requests from a hook. Free for 7 days, then $9.99 once.

Download free trial

Apple Silicon, macOS 14+. Notarized by Apple. 3.6 MB. Also on Setapp.

Test it before you trust it

  1. Dry run first. Set AGENT_AWAKE_BACKEND=dryrun in both commands. The script then keeps its markers and its log but touches nothing else.
  2. Start and end a session. The quickest way is a one-shot run, which fires both hooks:
    claude -p "Reply with the single word ok."
    cat "$TMPDIR/agent-awake/log"
    You should see one arm line and one disarm line with the same session ID.
  3. Overlap two sessions. Open Claude Code in two terminals, then quit them one at a time. The first exit logs end ... sessions=1 (still awake); only the second logs disarm.
  4. Switch to the real backend. Remove the variable (or set caffeinate). For caffeinate, confirm with pmset -g assertions | grep caffeinate while a session is open. For Clamshell, open its menu and check it shows armed.
  5. If nothing happens, start Claude Code with claude --debug-file /tmp/claude.log and read the hook entries in that file.

open "clamshell://arm" only sends a request, and it returns before the app has acted on it. A hook that exits cleanly is not proof the Mac is armed. Look at the menu bar before you close the lid, at least until you have seen the recipe work a few times on your own Mac.

What happens if a session crashes

If Claude Code dies without running its end hook, its marker stays behind and the Mac stays awake. The script cleans up after it: every time any hook runs, markers whose process ID is no longer alive are deleted before counting. A crashed session is forgotten the next time another session starts or ends.

Tested with Claude Code 2.1.296 on October 11, 2026, by killing a running claude -p session:

The gap that remains: if the only session is killed outright and you start no other, nothing runs to clean up. Disarm by hand, from Clamshell's menu or with open "clamshell://disarm", or with pkill caffeinate for the free backend.

The per-turn variant, if you want it

With the lid open and caffeinate, per turn is reasonable: the Mac stays up while Claude works and can idle-sleep while a finished session sits in a forgotten tab. The same script handles it:

{
  "hooks": {
    "UserPromptSubmit": [
      { "hooks": [ { "type": "command", "command": "AGENT_AWAKE_BACKEND=caffeinate ~/.claude/hooks/agent-awake.sh arm" } ] }
    ],
    "Stop": [
      { "hooks": [ { "type": "command", "command": "AGENT_AWAKE_BACKEND=caffeinate ~/.claude/hooks/agent-awake.sh disarm" } ] }
    ],
    "StopFailure": [
      { "hooks": [ { "type": "command", "command": "AGENT_AWAKE_BACKEND=caffeinate ~/.claude/hooks/agent-awake.sh disarm" } ] }
    ],
    "SessionEnd": [
      { "hooks": [ { "type": "command", "command": "AGENT_AWAKE_BACKEND=caffeinate ~/.claude/hooks/agent-awake.sh disarm", "timeout": 10 } ] }
    ]
  }
}

Know its edges. Stop does not run when you interrupt a turn, so after pressing Esc the Mac stays awake until the next turn finishes or the session ends. StopFailure covers turns that end on an API error. Background work Claude left running after its reply is not protected once Stop has fired.

The Codex variant

Codex has the same pair of events. OpenAI's Codex hooks documentation (checked October 11, 2026) lists SessionStart and SessionEnd, read from ~/.codex/hooks.json or from [hooks] tables in ~/.codex/config.toml, and each command hook receives a JSON object with session_id on standard input. Copy the script to ~/.codex/hooks/agent-awake.sh and add:

{
  "hooks": {
    "SessionStart": [
      { "hooks": [ { "type": "command", "command": "~/.codex/hooks/agent-awake.sh arm" } ] }
    ],
    "SessionEnd": [
      { "hooks": [ { "type": "command", "command": "~/.codex/hooks/agent-awake.sh disarm", "timeout": 3 } ] }
    ]
  }
}

Three differences from Claude Code, all from OpenAI's page:

If both tools see the same $TMPDIR, their markers land in one folder and the sessions count together, so the Mac is released when the last of either ends. The Codex configuration is written from the documentation and has not been run for this page, so test it with the dryrun backend first. If your Codex build has no hooks, or for any other command-line agent, a shell function does the same job for one terminal:

agent-awake() (
  trap 'open "clamshell://disarm"' EXIT
  trap 'exit 130' INT TERM HUP
  open "clamshell://arm"
  "$@"
)

agent-awake codex

The parentheses run it in a subshell, so the EXIT trap sends the disarm request however the agent ends, in both zsh and bash. It does not count sessions: two wrapped agents at once will disarm when the first one exits.

What this recipe does not do

What was tested for this page

Frequently asked questions

Does Claude Code keep my Mac awake by itself?

Partly. On macOS, Claude Code starts a caffeinate process while it is working, which holds off idle sleep with the lid open. It does not stop the sleep that happens when you close a MacBook lid, and a GitHub report says it follows the busy state, so a session waiting for your input is not covered. Hooks let you add your own keep-awake for the whole session.

Which Claude Code hook events should I use to keep the Mac awake?

SessionStart to start keeping the Mac awake and SessionEnd to stop. Using UserPromptSubmit and Stop instead ties it to each turn, which lets the Mac sleep between turns and misses turns you interrupt, because Stop does not run after a user interrupt.

What happens if Claude Code crashes before the SessionEnd hook runs?

The Mac stays awake until something cleans up. The script on this page removes markers whose process has gone the next time any hook runs; if no other session starts or ends, disarm by hand. In testing with Claude Code 2.1.296, only SIGKILL skipped the SessionEnd hook.

Do these hooks work with caffeinate instead of Clamshell?

Yes. Set AGENT_AWAKE_BACKEND=caffeinate and the same script starts one caffeinate -i process for as long as any session is open. That is free and built into macOS, but it only prevents idle sleep. Closing a MacBook lid still puts the Mac to sleep.

Does Codex have the same hooks?

Yes. OpenAI's Codex documentation lists SessionStart and SessionEnd hook events configured in ~/.codex/hooks.json with the same nested shape. Codex requires you to review and trust each hook with /hooks before it runs, and its SessionEnd hooks get one second by default and three seconds at most.

Related guides

Sources checked October 11, 2026: Anthropic's Claude Code hooks reference and changelog, GitHub issue anthropics/claude-code #81832, OpenAI's Codex hooks documentation, and the macOS caffeinate manual page. Hook events change between releases; check Anthropic's current hooks reference before relying on a detail. Read the script before you run it. Clamshell is an independent app, not affiliated with or endorsed by Anthropic or OpenAI. Claude and Claude Code are trademarks of Anthropic.