Keep your Mac awake, lid closed included (pmset disablesleep), while Claude works or always, from buttons above the prompt or /awake.

A Claude Code mod that keeps your Mac awake, lid closed included, from a button above the prompt or /awake.

Unofficial community mod, not affiliated with Anthropic. macOS only. Built on the Claude Code Mods API (early access), tested with Claude Code 2.1.299.
It switches macOS's pmset disablesleep, which stops all system sleep, closed lid included. The Claude desktop app's own "Keep computer awake while Claude works" setting only prevents idle sleep, so a closed lid still sleeps; this covers the lid, in the terminal and the desktop app alike.
The band above the prompt has two buttons, never on together: turning one on turns the other off, and the one that is on uses the primary style.
☕ Always awake: awake until you turn it off.☕ Awake while Claude works (default): awake while a session's turn runs, or background work it started (a background shell, monitor or agent), then the Mac sleeps as usual. It switches at every turn, so it acts only after /awake setup install; until then the band says so and nothing asks for a password.At the prompt of a terminal session:
/plugin install keep-awake --marketplace gecitemre/claude-keep-awake
From a shell:
claude plugin install keep-awake --marketplace gecitemre/claude-keep-awake
In the desktop app: click + next to the prompt box, then Plugins, Add marketplace with gecitemre/claude-keep-awake, then Add plugin.
/awake on Always awake (pmset disablesleep 1); turns Awake while Claude works off
/awake off turn Always awake off (pmset disablesleep 0)
/awake working on Awake while Claude works (the default); turns Always awake off
/awake working off turn Awake while Claude works off
/awake status the real setting, both modes, whether a password is needed, the recorded original value
/awake restore return to the value from before keep-awake first changed it
/awake setup explain passwordless switching
/awake setup install switch without a password from now on
/awake setup remove ask for the password again
pmset disablesleep needs root. By default every change opens macOS's own authentication dialog: nothing is installed, no privilege stays behind, and the mod never sees your password.
To switch without the dialog, run /awake setup install. macOS asks for your password once, and the mod adds one file, /etc/sudoers.d/keep-awake (root, 0440):
<you> ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 1, /usr/bin/pmset -a disablesleep 0
The file is checked with visudo -cf before it is installed and the whole configuration with visudo -c after; if that fails, the file is removed again. Nothing else in sudoers is touched. /awake setup remove deletes it.
turn.start to turn.complete of its main loop, and after that while the turn's Stop event still lists background tasks. Such work starts a new turn when it finishes, which checks again.disablesleep is one value for the whole Mac, so every working session leaves a holder file, ~/.claude/keep-awake/holders/<session id>, naming its Claude Code process. The Mac sleeps again once no holder's process is alive.sudo pmset in a terminal is never undone.disablesleep is not in Apple's man pmset, but it is what every tool that keeps a Mac awake with the lid closed relies on; caffeinate's assertions do not survive a closed lid.disablesleep is one system-wide value. pmset -g reports it as SleepDisabled; pmset -g custom lists only the per-power-source (AC, battery, UPS) settings and omits it.com.apple.system.powermanagement.prefschange notification (notifyutil -w), which powerd posts whenever a pmset setting changes, so a change made in another session or a terminal shows in every band at once. No polling loop runs. The state is also re-read at session start, on every prompt and right before every change, so a toggle always acts on the real value./awake off or /awake restore changes it back.Run /awake setup remove first if you installed the passwordless rule (it also ends a while-working hold), then:
claude plugin uninstall keep-awake
If the plugin is already gone, remove the rule by hand with sudo rm /etc/sudoers.d/keep-awake, and ~/.claude/keep-awake with rm -r. Uninstalling leaves the sleep setting as it is; sudo pmset -a disablesleep 0 turns it off.
/usr/bin/pmset -a disablesleep 1 and … 0 as root without a password. sudo matches the full argument list, and /usr/bin/pmset is protected by System Integrity Protection, so no other command or argument gains root. The remaining risk: any process running as you (a script, a dependency, the model's Bash tool) can keep the Mac from sleeping with the lid closed, which can drain the battery or let a closed laptop overheat in a bag. It cannot read data or gain further privileges through this rule.claude plugin validate .
claude plugin test .
MIT
hooks/register.tsx 405 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SleepStatus } from '../types'
5import {
6 claudeProcessId,
7 countLiveHolders,
8 isAutoHeld,
9 isWhileWorkingOn,
10 markAutoHeld,
11 registerHolder,
12 releaseHolder,
13 setWhileWorking,
14} from './hold'
15import {
16 currentUserName,
17 installPasswordlessRule,
18 PMSET,
19 readSystemStatus,
20 removePasswordlessRule,
21 RULE_FILE,
22 sudoersRule,
23 watchPowerPreferences,
24 writeSleepDisabled,
25} from './power'
26
27const status = atom({ plugin: 'keep-awake', key: 'status' } as const, null)
28const isChanging = atom({ plugin: 'keep-awake', key: 'isChanging' } as const, false)
29const error = atom({ plugin: 'keep-awake', key: 'error' } as const, null)
30
31const ORIGINAL_KEY = 'originalSleepDisabled'
32type OriginalValue = { isPreventingSleep: boolean; recordedAt: string }
33
34const USAGE = 'Usage: /awake on | off | working on | working off | status | restore | setup [install | remove]'
35
36const messageOf = (failure: unknown) => (failure instanceof Error ? failure.message : String(failure))
37const onOff = (isOn: boolean) => (isOn ? 'on' : 'off')
38
39type ReadStatus = SleepStatus & { kind: 'read' }
40const isAlwaysAwake = (current: ReadStatus) => current.isPreventingSleep && !current.isAutoHeld
41// The two modes are never on together: Always awake already covers every turn.
42const isAwakeWhileWorking = (current: ReadStatus) => current.isWhileWorkingOn && !isAlwaysAwake(current)
43
44// This session's share of the while-working hold; a reload starts it over at session.start.
45let sessionId: string | undefined
46let claudePid = ''
47let isTurnRunning = false
48let backgroundTaskCount = 0
49
50// Changes run one at a time, so a turn's end never races a press or another turn's start.
51let queue: Promise<unknown> = Promise.resolve()
52function serially<T>(task: () => Promise<T>): Promise<T> {
53 const run = queue.then(task)
54 queue = run.catch(() => {})
55
56 return run
57}
58
59async function readStatus($: EngineInterface): Promise<SleepStatus> {
60 const system = await readSystemStatus($)
61 if (system.kind === 'unsupported') return system
62
63 const [whileWorking, autoHeld] = await Promise.all([isWhileWorkingOn($), isAutoHeld($)])
64 return { ...system, isWhileWorkingOn: whileWorking, isAutoHeld: autoHeld }
65}
66
67async function refresh($: EngineInterface) {
68 const current: SleepStatus = await readStatus($).catch(failure => ({
69 kind: 'unreadable' as const,
70 reason: messageOf(failure),
71 }))
72 await update($, status, () => current)
73
74 return current
75}
76
77async function readUsableStatus($: EngineInterface) {
78 const current = await refresh($)
79 if (current.kind !== 'read') throw new Error(describeUnusable(current))
80
81 return current
82}
83
84function describeUnusable(current: SleepStatus) {
85 if (current.kind === 'unsupported') return `keep-awake works on macOS only: ${PMSET} is missing.`
86 if (current.kind === 'unreadable') return `Could not read the sleep setting: ${current.reason}`
87
88 throw new Error(`describeUnusable called on a readable status`)
89}
90
91// Only the first change is recorded, so /awake restore returns to the value before keep-awake touched it.
92async function recordOriginalValue($: EngineInterface, isPreventingSleep: boolean) {
93 if ((await $.store.get(ORIGINAL_KEY)) !== undefined) return
94
95 const original: OriginalValue = { isPreventingSleep, recordedAt: new Date(await $.clock.now()).toISOString() }
96 await $.store.set(ORIGINAL_KEY, original)
97}
98
99// Every change reads the system first, so a toggle acts on the real value even when
100// another session or a terminal changed it since the band was drawn.
101async function changeAlwaysAwake($: EngineInterface, choose: (isAlwaysOn: boolean) => boolean) {
102 await update($, isChanging, () => true)
103 try {
104 const before = await readUsableStatus($)
105 const wanted = choose(isAlwaysAwake(before))
106 if (wanted !== before.isPreventingSleep) {
107 await recordOriginalValue($, before.isPreventingSleep)
108 await writeSleepDisabled($, wanted, before.isPasswordless)
109 }
110 // Turning it on takes over a while-working hold, so the end of the work leaves it on.
111 await markAutoHeld($, false)
112 if (wanted) await setWhileWorking($, false)
113
114 const after = await readUsableStatus($)
115 if (after.isPreventingSleep !== wanted) throw new Error(`pmset still reports sleep prevention ${onOff(after.isPreventingSleep)}.`)
116 await update($, error, () => null)
117
118 return `Always awake is ${onOff(wanted)} (pmset disablesleep ${wanted ? 1 : 0}).`
119 } catch (failure) {
120 await update($, error, () => messageOf(failure))
121 throw failure
122 } finally {
123 await update($, isChanging, () => false)
124 }
125}
126
127async function startAutoHold($: EngineInterface) {
128 await markAutoHeld($, true)
129 await writeSleepDisabled($, true, true)
130}
131
132async function holdForWork($: EngineInterface, current: ReadStatus) {
133 if (sessionId === undefined) throw new Error('The while-working hold ran before session.start.')
134
135 await registerHolder($, sessionId, claudePid)
136 if (!current.isPreventingSleep) await startAutoHold($)
137}
138
139async function releaseWork($: EngineInterface, current: ReadStatus) {
140 if (sessionId !== undefined) await releaseHolder($, sessionId)
141 if (!current.isAutoHeld) return
142 if (current.isWhileWorkingOn && (await countLiveHolders($)) > 0) return
143
144 if (current.isPreventingSleep && current.isPasswordless) await writeSleepDisabled($, false, true)
145 await markAutoHeld($, false)
146 // Another session may have started work after the count, seeing sleep prevention still on.
147 if (current.isWhileWorkingOn && (await countLiveHolders($)) > 0) await startAutoHold($)
148}
149
150// Keeps the Mac awake while this session's turn or its background work runs, and lets it sleep
151// once no session is working. It switches at every turn, so it acts only without a password.
152async function followClaudeWork($: EngineInterface) {
153 const current = await refresh($)
154 if (current.kind !== 'read') return
155
156 const isWorking = isTurnRunning || backgroundTaskCount > 0
157 if (isWorking && current.isWhileWorkingOn && current.isPasswordless) await holdForWork($, current)
158 else await releaseWork($, current)
159 await refresh($)
160}
161
162function followClaudeWorkLater($: EngineInterface) {
163 void serially(() => followClaudeWork($)).catch(failure => update($, error, () => messageOf(failure)))
164}
165
166async function changeWhileWorking($: EngineInterface, isOn: boolean) {
167 if (isOn && isAlwaysAwake(await readUsableStatus($))) await changeAlwaysAwake($, () => false)
168 await setWhileWorking($, isOn)
169 await followClaudeWork($)
170 const current = await readUsableStatus($)
171 const hint = isOn && !current.isPasswordless ? ' It acts once /awake setup install lets it switch without a password.' : ''
172
173 return `Awake while Claude works is ${onOff(isOn)}.${hint}`
174}
175
176async function restoreOriginalValue($: EngineInterface) {
177 const original = (await $.store.get(ORIGINAL_KEY)) as OriginalValue | undefined
178 if (original === undefined) return 'No original value is recorded: keep-awake has not changed the setting.'
179
180 const result = await changeAlwaysAwake($, () => original.isPreventingSleep)
181 await $.store.delete(ORIGINAL_KEY)
182
183 return `Restored the value recorded ${original.recordedAt}. ${result}`
184}
185
186function describeWhileWorking(current: ReadStatus) {
187 if (!isAwakeWhileWorking(current)) return 'off'
188 if (!current.isPasswordless) return 'on, but waiting for /awake setup install'
189
190 return current.isAutoHeld ? 'on, keeping the Mac awake now' : 'on, nothing running'
191}
192
193async function statusReport($: EngineInterface) {
194 const current = await refresh($)
195 if (current.kind !== 'read') return describeUnusable(current)
196
197 const original = (await $.store.get(ORIGINAL_KEY)) as OriginalValue | undefined
198
199 return [
200 `Sleep prevention (pmset disablesleep, system-wide): ${onOff(current.isPreventingSleep)}`,
201 `Awake while Claude works: ${describeWhileWorking(current)}`,
202 `Always awake: ${onOff(isAlwaysAwake(current))}`,
203 `Password: ${current.isPasswordless ? `not needed (${RULE_FILE})` : 'macOS asks on each change; /awake setup explains how to skip it'}`,
204 original
205 ? `Original value: ${onOff(original.isPreventingSleep)}, recorded ${original.recordedAt}; /awake restore puts it back`
206 : 'Original value: not recorded (keep-awake has not changed the setting by hand)',
207 ].join('\n')
208}
209
210async function explainSetup($: EngineInterface) {
211 const current = await readUsableStatus($)
212 const rule = sudoersRule(await currentUserName($))
213 if (current.isPasswordless) {
214 return `keep-awake switches without a password: ${RULE_FILE} holds\n ${rule}\n/awake setup remove deletes it.`
215 }
216
217 return [
218 'Always awake asks for your password in a macOS dialog on each change, and Awake while Claude works waits until it needs none.',
219 `/awake setup install adds one visudo-checked file, ${RULE_FILE}:`,
220 ` ${rule}`,
221 'It allows only those two pmset commands. macOS asks for your password once to add it; keep-awake never sees it.',
222 'Any process running as you can then keep this Mac awake. /awake setup remove deletes the file.',
223 ].join('\n')
224}
225
226async function installSetup($: EngineInterface) {
227 const before = await readUsableStatus($)
228 if (before.isPasswordless) return 'keep-awake already switches without a password.'
229
230 await installPasswordlessRule($, await currentUserName($))
231 const after = await readUsableStatus($)
232 if (!after.isPasswordless) throw new Error(`${RULE_FILE} was added, but sudo still asks for a password.`)
233 await followClaudeWork($)
234
235 return `Added ${RULE_FILE}. keep-awake now switches without a password.`
236}
237
238async function removeSetup($: EngineInterface) {
239 const before = await readUsableStatus($)
240 if (!before.isPasswordless) return 'Nothing to remove: keep-awake already asks for a password.'
241
242 // Without the rule the while-working hold could not end by itself, so it ends now.
243 if (before.isAutoHeld) {
244 await writeSleepDisabled($, false, true)
245 await markAutoHeld($, false)
246 }
247 await removePasswordlessRule($)
248 const after = await readUsableStatus($)
249 if (after.isPasswordless) throw new Error(`sudo still runs pmset without a password after removing ${RULE_FILE}.`)
250
251 return `Removed ${RULE_FILE}. Always awake asks for your password again and is ${onOff(after.isPreventingSleep)}.`
252}
253
254function runAwakeCommand($: EngineInterface, args: string) {
255 switch (args) {
256 case 'on':
257 return serially(() => changeAlwaysAwake($, () => true))
258 case 'off':
259 return serially(() => changeAlwaysAwake($, () => false))
260 case 'working on':
261 return serially(() => changeWhileWorking($, true))
262 case 'working off':
263 return serially(() => changeWhileWorking($, false))
264 case '':
265 case 'status':
266 return statusReport($)
267 case 'setup':
268 return explainSetup($)
269 case 'setup install':
270 return serially(() => installSetup($))
271 case 'setup remove':
272 return serially(() => removeSetup($))
273 case 'restore':
274 return serially(() => restoreOriginalValue($))
275 default:
276 return USAGE
277 }
278}
279
280async function pressing($: EngineInterface, action: () => Promise<string>) {
281 try {
282 await serially(action)
283 } catch (failure) {
284 $.ui.toast(`keep-awake: ${messageOf(failure)}`)
285 }
286}
287
288function stateLabel(name: string, isOn: boolean, isBusy: boolean) {
289 if (isBusy) return `⏳ ${name}: …`
290
291 return `${isOn ? '☕' : '🌙'} ${name}: ${onOff(isOn)}`
292}
293
294// Each change reaches every session through the Darwin notification, so no band shows a stale value.
295async function followSystemChanges($: EngineInterface) {
296 const current = await refresh($)
297 if (current.kind === 'unsupported') return
298
299 try {
300 await watchPowerPreferences($, () => refresh($))
301 } catch (failure) {
302 await update($, error, () => `Live updates stopped: ${messageOf(failure)}`)
303 }
304}
305
306export const register: Register = on => {
307 on('session.start', async ($, e, next) => {
308 await $.command.register({
309 name: 'awake',
310 description: 'Keep this Mac awake, lid closed included: on, off, working on|off, status, restore, setup',
311 argumentHint: 'on|off|working on|working off|status|restore|setup [install|remove]',
312 immediate: true,
313 })
314 // A reload unloads the previous module and kills its notifyutil child with it.
315 await update($, isChanging, () => false)
316 sessionId = await $.session.id()
317 claudePid = await claudeProcessId($)
318 void followSystemChanges($)
319 followClaudeWorkLater($)
320
321 return next(e)
322 })
323
324 on('turn.start', ($, e, next) => {
325 if (e.agentId === undefined) {
326 isTurnRunning = true
327 followClaudeWorkLater($)
328 }
329
330 return next(e)
331 })
332
333 // Raised at a main-loop turn's end, before turn.complete: what the turn leaves running wakes the session later.
334 on('classic.Stop', ($, e, next) => {
335 backgroundTaskCount = e.background_tasks?.length ?? 0
336
337 return next(e)
338 })
339
340 on('turn.complete', ($, e, next) => {
341 if (e.agentId === undefined) {
342 isTurnRunning = false
343 followClaudeWorkLater($)
344 }
345
346 return next(e)
347 })
348
349 on('session.end', async ($, e, next) => {
350 isTurnRunning = false
351 backgroundTaskCount = 0
352 await serially(() => followClaudeWork($)).catch(() => {})
353
354 return next(e)
355 })
356
357 on('command.run', { command: 'awake' }, async ($, e) => {
358 try {
359 return { text: await runAwakeCommand($, e.args.trim().replace(/\s+/g, ' ')) }
360 } catch (failure) {
361 return { text: `keep-awake: ${messageOf(failure)}` }
362 }
363 })
364
365 // The notification covers the sleep value; this catches a sudoers rule installed or removed meanwhile.
366 on('prompt.submit', ($, e, next) => {
367 void refresh($)
368
369 return next(e)
370 })
371
372 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
373 const current = await read($, status)
374 if (e.props.hasSurvey || current === null || current.kind === 'unsupported') return next(e)
375
376 const { Box, Button, Text } = $.ui.resolve(e)
377 if (current.kind === 'unreadable') return <Text color="warning">⚠ keep-awake: {current.reason}</Text>
378
379 const failure = await read($, error)
380 const isBusy = await read($, isChanging)
381 const isAlwaysOn = isAlwaysAwake(current)
382 const isWhileWorking = isAwakeWhileWorking(current)
383
384 return (
385 <Box gap={2}>
386 <Button
387 key="toggle-sleep"
388 variant={isAlwaysOn ? 'primary' : 'secondary'}
389 label={stateLabel('Always awake', isAlwaysOn, isBusy)}
390 onPress={() => pressing($, () => changeAlwaysAwake($, isOn => !isOn))}
391 />
392 <Button
393 key="toggle-working"
394 variant={isWhileWorking ? 'primary' : 'secondary'}
395 label={stateLabel('Awake while Claude works', isWhileWorking, false)}
396 onPress={() => pressing($, () => changeWhileWorking($, !isWhileWorking))}
397 />
398 {isWhileWorking && !current.isPasswordless && <Text dimColor>needs /awake setup install</Text>}
399 {current.isAutoHeld && <Text dimColor>awake now</Text>}
400 {failure !== null && <Text color="error">{failure}</Text>}
401 </Box>
402 )
403 })
404}
405hooks/hold.ts 84 lines1import type { EngineInterface } from 'claude-code'
2
3// disablesleep is one system-wide value, so every session on this Mac shares one hold:
4// each working session leaves a holder file naming its Claude process.
5async function holdPaths($: EngineInterface) {
6 const home = await $.env.get('HOME')
7 if (home === undefined) throw new Error('HOME is not set.')
8
9 const dir = `${home}/.claude/keep-awake`
10 return {
11 dir,
12 holders: `${dir}/holders`,
13 // Present while the while-working mode, not a person, turned sleep prevention on.
14 autoHeld: `${dir}/auto-held`,
15 whileWorkingOff: `${dir}/while-working-off`,
16 }
17}
18
19function removeFile($: EngineInterface, path: string) {
20 return $.process.run(['/bin/rm', '-f', path])
21}
22
23async function isProcessAlive($: EngineInterface, pid: string) {
24 const signalled = await $.process.run(['/bin/kill', '-0', pid])
25
26 return signalled.exitCode === 0
27}
28
29// The shell's parent is the Claude Code process that runs this mod.
30export async function claudeProcessId($: EngineInterface) {
31 const ran = await $.process.run(['/bin/sh', '-c', 'echo $PPID'])
32
33 return ran.stdout.trim()
34}
35
36export async function isWhileWorkingOn($: EngineInterface) {
37 return !(await $.fs.exists((await holdPaths($)).whileWorkingOff))
38}
39
40export async function setWhileWorking($: EngineInterface, isOn: boolean) {
41 const paths = await holdPaths($)
42 if (isOn) return removeFile($, paths.whileWorkingOff)
43
44 await $.process.run(['/bin/mkdir', '-p', paths.dir])
45 await $.fs.write(paths.whileWorkingOff, '')
46}
47
48export async function isAutoHeld($: EngineInterface) {
49 return $.fs.exists((await holdPaths($)).autoHeld)
50}
51
52export async function markAutoHeld($: EngineInterface, isHeld: boolean) {
53 const paths = await holdPaths($)
54 if (!isHeld) return removeFile($, paths.autoHeld)
55
56 await $.process.run(['/bin/mkdir', '-p', paths.dir])
57 await $.fs.write(paths.autoHeld, '')
58}
59
60export async function registerHolder($: EngineInterface, sessionId: string, pid: string) {
61 const paths = await holdPaths($)
62 await $.process.run(['/bin/mkdir', '-p', paths.holders])
63 await $.fs.write(`${paths.holders}/${sessionId}`, pid)
64}
65
66export async function releaseHolder($: EngineInterface, sessionId: string) {
67 await removeFile($, `${(await holdPaths($)).holders}/${sessionId}`)
68}
69
70// Counts the holders whose Claude process still runs, and clears the ones a crash left behind.
71export async function countLiveHolders($: EngineInterface) {
72 const paths = await holdPaths($)
73 if (!(await $.fs.exists(paths.holders))) return 0
74
75 let live = 0
76 for (const holder of await $.fs.list(paths.holders)) {
77 const path = `${paths.holders}/${holder.name}`
78 if (await isProcessAlive($, (await $.fs.read(path)).trim())) live += 1
79 else await removeFile($, path)
80 }
81
82 return live
83}
84hooks/power.ts 135 lines1import type { EngineInterface } from 'claude-code'
2
3export const PMSET = '/usr/bin/pmset'
4export const RULE_FILE = '/etc/sudoers.d/keep-awake'
5const SUDO = '/usr/bin/sudo'
6
7// The sudoers rule allows exactly these argument vectors, so they must never vary beyond the 0/1.
8const disableSleepArgv = (isPreventingSleep: boolean) => [
9 PMSET,
10 '-a',
11 'disablesleep',
12 isPreventingSleep ? '1' : '0',
13]
14
15// disablesleep is one system-wide value: `pmset -g` reports it as SleepDisabled,
16// while `pmset -g custom` lists only the per-power-source settings and omits it.
17export function parseSleepDisabled(pmsetOutput: string): boolean {
18 const match = /^\s*SleepDisabled\s+([01])\s*$/m.exec(pmsetOutput)
19 if (match === null) throw new Error('pmset -g reported no SleepDisabled value.')
20
21 return match[1] === '1'
22}
23
24// -k ignores a cached sudo ticket, so only the NOPASSWD rule can make this succeed.
25async function isAllowedWithoutPassword($: EngineInterface, isPreventingSleep: boolean) {
26 const listed = await $.process.run([SUDO, '-n', '-k', '-l', ...disableSleepArgv(isPreventingSleep)])
27
28 return listed.exitCode === 0
29}
30
31export type SystemStatus = { kind: 'unsupported' } | { kind: 'read'; isPreventingSleep: boolean; isPasswordless: boolean }
32
33export async function readSystemStatus($: EngineInterface): Promise<SystemStatus> {
34 const hasPmset = await $.fs.stat(PMSET).then(() => true, () => false)
35 if (!hasPmset) return { kind: 'unsupported' }
36
37 const [settings, canEnable, canDisable] = await Promise.all([
38 $.process.run([PMSET, '-g']),
39 isAllowedWithoutPassword($, true),
40 isAllowedWithoutPassword($, false),
41 ])
42 if (settings.exitCode !== 0) throw new Error(`pmset -g failed: ${settings.stderr.trim()}`)
43
44 return {
45 kind: 'read',
46 isPreventingSleep: parseSleepDisabled(settings.stdout),
47 isPasswordless: canEnable && canDisable,
48 }
49}
50
51// macOS's own authentication dialog takes the password; the command reaches AppleScript
52// as an argument, never spliced into script source.
53async function runAsAdministrator($: EngineInterface, command: string, prompt: string) {
54 const ran = await $.process.run(
55 [
56 '/usr/bin/osascript',
57 '-e',
58 'on run argv',
59 '-e',
60 'do shell script (item 1 of argv) with prompt (item 2 of argv) with administrator privileges',
61 '-e',
62 'end run',
63 '--',
64 command,
65 prompt,
66 ],
67 // The person may take a while to authenticate.
68 { timeoutMs: 600_000 },
69 )
70 if (ran.exitCode === 0) return
71 if (ran.stderr.includes('(-128)')) throw new Error('Cancelled in the macOS dialog.')
72
73 throw new Error(ran.stderr.trim() || `osascript failed with exit code ${ran.exitCode}`)
74}
75
76export async function writeSleepDisabled($: EngineInterface, isPreventingSleep: boolean, isPasswordless: boolean) {
77 const argv = disableSleepArgv(isPreventingSleep)
78 if (!isPasswordless) {
79 const action = isPreventingSleep ? 'keep this Mac awake, lid closed included' : 'let this Mac sleep again'
80 return runAsAdministrator($, argv.join(' '), `keep-awake wants to ${action} (${argv.join(' ')}).`)
81 }
82
83 const ran = await $.process.run([SUDO, '-n', ...argv])
84 if (ran.exitCode !== 0) throw new Error(`pmset failed: ${ran.stderr.trim() || `exit code ${ran.exitCode}`}`)
85}
86
87export function sudoersRule(user: string) {
88 if (!/^[A-Za-z0-9._-]+$/.test(user)) throw new Error(`User name "${user}" has characters keep-awake does not put in sudoers.`)
89
90 return `${user} ALL=(root) NOPASSWD: ${disableSleepArgv(true).join(' ')}, ${disableSleepArgv(false).join(' ')}`
91}
92
93// Checks the new file alone, then the whole configuration with it, and takes it out again
94// if the whole fails, so a bad rule never stays installed.
95export async function installPasswordlessRule($: EngineInterface, user: string) {
96 const script = [
97 'set -e',
98 'tmp=$(/usr/bin/mktemp)',
99 `trap '/bin/rm -f "$tmp"' EXIT`,
100 `/usr/bin/printf '%s\\n' '# Installed by the keep-awake Claude Code mod; /awake setup remove deletes it.' '${sudoersRule(user)}' > "$tmp"`,
101 '/usr/sbin/visudo -cf "$tmp" > /dev/null',
102 `/usr/bin/grep -Eq '^[#@]includedir[[:space:]]+(/private)?/etc/sudoers\\.d[[:space:]]*$' /etc/sudoers || { echo '/etc/sudoers does not include /etc/sudoers.d' >&2; exit 1; }`,
103 `/usr/bin/install -m 0440 -o root -g wheel "$tmp" ${RULE_FILE}`,
104 `/usr/sbin/visudo -c > /dev/null || { /bin/rm -f ${RULE_FILE}; echo 'sudoers did not validate with the rule; it was removed' >&2; exit 1; }`,
105 ].join('\n')
106
107 await runAsAdministrator(
108 $,
109 script,
110 `keep-awake wants to add ${RULE_FILE}, letting ${user} switch Always awake without a password. It allows only "pmset -a disablesleep 1" and "pmset -a disablesleep 0".`,
111 )
112}
113
114export async function removePasswordlessRule($: EngineInterface) {
115 await runAsAdministrator(
116 $,
117 `/bin/rm -f ${RULE_FILE}\n/usr/sbin/visudo -c > /dev/null`,
118 `keep-awake wants to remove ${RULE_FILE}; Always awake will ask for your password again.`,
119 )
120}
121
122// powerd posts this Darwin notification whenever a pmset setting changes, from any process.
123export async function watchPowerPreferences($: EngineInterface, onChange: () => Promise<unknown>) {
124 const changes = $.process.spawn({
125 argv: ['/usr/bin/notifyutil', '-w', 'com.apple.system.powermanagement.prefschange'],
126 })
127 for await (const _change of changes) await onChange()
128}
129
130export async function currentUserName($: EngineInterface) {
131 const ran = await $.process.run(['/usr/bin/id', '-un'])
132
133 return ran.stdout.trim()
134}
135types/index.d.ts 22 lines1export type SleepStatus =
2 | { kind: 'unsupported' }
3 | { kind: 'unreadable'; reason: string }
4 | {
5 kind: 'read'
6 isPreventingSleep: boolean
7 isPasswordless: boolean
8 isWhileWorkingOn: boolean
9 // The while-working mode, not a person, turned sleep prevention on.
10 isAutoHeld: boolean
11 }
12
13declare module 'claude-code' {
14 interface PluginState {
15 'keep-awake': {
16 status: SleepStatus | null
17 isChanging: boolean
18 error: string | null
19 }
20 }
21}
22