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

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.
AskUserQuestion prompt or an MCP input prompt is waiting on you, and resumes once you answer.caffeinate, and macOS keeps the display on while any of them asks, so the screen sleeps only once no session is working.npx, no admin rights, no background server.caffeinate -t 300 renewed while the turn runs, so a session that dies leaves no stray hold for more than 5 minutes.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.
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).
/usr/bin/caffeinate)| Claude is… | Screen |
|---|---|
| running a turn (thinking, calling tools) | held awake |
| showing a permission dialog, asking a question, or waiting on MCP input | released |
| done with the turn, or the session ended | released |
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
| Option | Default | Effect |
|---|---|---|
keepAwakeLidClosed | false | Also 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. |
showStatus | true | Pins 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.
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 -w | This mod | |
|---|---|---|
| Screen held while Claude runs a turn | yes | yes |
| Screen can sleep when the turn ends | no | yes |
| Screen can sleep while Claude waits on you | no | yes |
| Several sessions | one command per session, run by hand | automatic |
| Shows when the screen is held | no | ☕ 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.
pmset disablesleep.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 .
hooks/register.ts 145 lines1import 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