SLOPSHOPPER

cc-mod-caffeinate

Keep the Mac's screen awake with caffeinate only while Claude is actively working

newguardstatusprocess
v0.2.0MITupdated 2026-10-06williamchong/cc-mod-caffeinate
A shopper browsing a rack in a slop shop
README

cc-mod-caffeinate ☕

A Claude Code mod that keeps your Mac's screen awake only while Claude is actively working. The screen can sleep again as soon as Claude finishes, or while it is waiting on you.

  • Holds only during active work. It stops holding the screen while a permission dialog, an AskUserQuestion prompt or an MCP input prompt is waiting on you, and resumes once you answer.
  • Works across sessions. Each session holds its own caffeinate, and macOS keeps the display on while any of them asks, so the screen sleeps only once no session is working.
  • Nothing to install or run. It runs inside Claude Code as a function-hooks mod: no app, no npx, no admin rights, no background server.
  • Recovers from crashes. Each hold is a caffeinate -t 300 renewed while the turn runs, so a session that dies leaves no stray hold for more than 5 minutes.
  • Shows its state. A ☕ appears in the status line while the screen is held.

Claude Code already runs caffeinate -i while it works, which stops the Mac from going to sleep but still lets the screen turn off. This mod adds -d to keep the display on as well.

Install

At the prompt of a terminal session:

/plugin install cc-mod-caffeinate --marketplace williamchong/cc-mod-caffeinate

Answer y to add the marketplace, then choose a scope (user scope loads it in every session).

Requirements

  • macOS (uses /usr/bin/caffeinate)
  • Claude Code with function-hooks mods (tested on 2.1.291). The mod API is early access and may change between releases.

How it decides

Claude is…Screen
running a turn (thinking, calling tools)held awake
showing a permission dialog, asking a question, or waiting on MCP inputreleased
done with the turn, or the session endedreleased

Claude Code has no event for "a permission prompt was answered", so holding resumes at the next sign of progress: the tool finishing, a denial, or the next model request. While a tool you just approved is running, the screen is not held. Your key press to approve resets the idle timer anyway.

To check it, run this while Claude is working:

pmset -g assertions | grep caffeinate

Options

OptionDefaultEffect
keepAwakeLidClosedfalseAlso passes -s to caffeinate, so Claude keeps working when you close the lid. AC power only: on battery, closing the lid still puts the Mac to sleep.
showStatustruePins a ☕ under the prompt while the screen is held. Turn it off to free the line.

Set it on the plugin's config screen in /plugin. When you run the mod from a checkout (--plugin-dir or CLAUDE_CODE_PLUGIN_DIRS), set it under pluginConfigs in ~/.claude/settings.json instead:

"pluginConfigs": {
  "cc-mod-caffeinate": { "options": { "keepAwakeLidClosed": true } }
}

The same rules apply with the lid closed: the Mac is released while Claude waits on you and when the turn ends, so it sleeps once there is nothing left to do. Even so, avoid putting a Mac that is plugged in and working into a bag.

Why not just caffeinate -w?

A one-liner keeps the screen on for as long as Claude Code is open:

caffeinate -d -w $(pgrep -n claude)

That holds for the whole session, not for the turn. A session left open overnight at an idle prompt keeps the screen on all night, which is the same as turning display sleep off.

caffeinate -d -wThis mod
Screen held while Claude runs a turnyesyes
Screen can sleep when the turn endsnoyes
Screen can sleep while Claude waits on younoyes
Several sessionsone command per session, run by handautomatic
Shows when the screen is heldno☕ in the status line

Two shell hooks (UserPromptSubmit to start caffeinate, Stop to kill it) get closer, but they keep holding during permission dialogs and questions, and you manage the pid files and stale holds from crashed sessions yourself.

If you quit Claude Code whenever you step away, the one-liner is enough.

Alternatives

Development

Run it from a checkout:

claude --plugin-dir /path/to/cc-mod-caffeinate

To load it in every session, set CLAUDE_CODE_PLUGIN_DIRS to the checkout's path in the env block of ~/.claude/settings.json. Saving a file reloads the mod in interactive sessions.

Check and test it:

claude plugin validate .
claude plugin test .

License

MIT

Source 1 files
hooks/register.ts 145 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// Each session holds its own `caffeinate` while it works. macOS keeps the
4// display awake while any process holds an assertion, so several sessions
5// combine on their own: the screen may sleep only once none is working.
6
7// A bounded lease, renewed while active, so a session that dies without
8// unloading never leaves an orphan caffeinate holding the screen for long.
9const LEASE_SECONDS = 300
10
11const WAITING_NOTIFICATION = /permission|elicitation|idle/
12
13// -d keeps the display on; -i stops idle sleep; -s, opted into, also stops
14// lid-closed sleep, which macOS honours on AC power only.
15let flags: string[] = []
16let showStatus = true
17
18let isTurnRunning = false
19let isWaiting = false
20let lease: AsyncGenerator<unknown, unknown> | undefined
21
22function shouldHold() {
23  return isTurnRunning && !isWaiting
24}
25
26async function hold($: EngineInterface) {
27  while (shouldHold()) {
28    let child: ReturnType<EngineInterface['process']['spawn']> | undefined
29    let code: number | null = null
30    try {
31      child = $.process.spawn({
32        argv: ['/usr/bin/caffeinate', ...flags, '-t', String(LEASE_SECONDS)],
33      })
34      lease = child
35      for await (const _ of child) {
36        // caffeinate writes nothing; the loop lasts the child's life
37      }
38      code = (await child.result).code
39    } catch {
40      // could not start, or killed by a release
41    }
42    if (lease !== child) return // released, or a newer hold took over
43    // Only a lease that ran out is renewed; any other end would respawn in a spin.
44    if (code !== 0) {
45      lease = undefined
46      return
47    }
48  }
49}
50
51function sync($: EngineInterface) {
52  const isHolding = shouldHold()
53  if (showStatus) $.ui.status(isHolding ? '☕' : undefined)
54  if (isHolding && !lease) void hold($)
55  if (!isHolding && lease) {
56    // Not awaited: a pending pull on a silent child may hold return() back.
57    void lease.return(undefined).catch(() => {})
58    lease = undefined
59  }
60}
61
62function setRunning($: EngineInterface, value: boolean) {
63  isTurnRunning = value
64  isWaiting = false
65  sync($)
66}
67
68function setWaiting($: EngineInterface, value: boolean) {
69  if (isWaiting === value) return
70  isWaiting = value
71  sync($)
72}
73
74export const register: Register = (on, options) => {
75  flags = ['-d', '-i', ...(options.keepAwakeLidClosed === true ? ['-s'] : [])]
76  showStatus = options.showStatus !== false
77
78  on('turn.start', ($, e, next) => {
79    setRunning($, true)
80    return next(e)
81  })
82
83  on('turn.complete', ($, e, next) => {
84    // A subagent's turn ending is not the session going idle.
85    if (e.agentId === undefined) setRunning($, false)
86    return next(e)
87  })
88
89  on('session.end', ($, e, next) => {
90    setRunning($, false)
91    return next(e)
92  })
93
94  on('classic.PermissionRequest', ($, e, next) => {
95    setWaiting($, true)
96    return next(e)
97  })
98
99  on('classic.Elicitation', ($, e, next) => {
100    setWaiting($, true)
101    return next(e)
102  })
103
104  on('classic.Notification', ($, e, next) => {
105    if (WAITING_NOTIFICATION.test(e.notification_type)) setWaiting($, true)
106    return next(e)
107  })
108
109  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
110    setWaiting($, true)
111    try {
112      return await next(e)
113    } finally {
114      setWaiting($, false)
115    }
116  })
117
118  // No event says a permission prompt was answered; these are the first
119  // signs that the turn went on after one.
120  on('classic.PostToolUse', ($, e, next) => {
121    setWaiting($, false)
122    return next(e)
123  })
124
125  on('classic.PostToolUseFailure', ($, e, next) => {
126    setWaiting($, false)
127    return next(e)
128  })
129
130  on('classic.PermissionDenied', ($, e, next) => {
131    setWaiting($, false)
132    return next(e)
133  })
134
135  on('classic.ElicitationResult', ($, e, next) => {
136    setWaiting($, false)
137    return next(e)
138  })
139
140  on('turn.step', async function* ($, e, next) {
141    setWaiting($, false)
142    return yield* next(e)
143  })
144}
145