/ services / preventSleep.ts
preventSleep.ts
  1  /**
  2   * Prevents macOS from sleeping while Claude is working.
  3   *
  4   * Uses the built-in `caffeinate` command to create a power assertion that
  5   * prevents idle sleep. This keeps the Mac awake during API requests and
  6   * tool execution so long-running operations don't get interrupted.
  7   *
  8   * The caffeinate process is spawned with a timeout and periodically restarted.
  9   * This provides self-healing behavior: if the Node process is killed with
 10   * SIGKILL (which doesn't run cleanup handlers), the orphaned caffeinate will
 11   * automatically exit after the timeout expires.
 12   *
 13   * Only runs on macOS - no-op on other platforms.
 14   */
 15  import { type ChildProcess, spawn } from 'child_process'
 16  import { registerCleanup } from '../utils/cleanupRegistry.js'
 17  import { logForDebugging } from '../utils/debug.js'
 18  
 19  // Caffeinate timeout in seconds. Process auto-exits after this duration.
 20  // We restart it before expiry to maintain continuous sleep prevention.
 21  const CAFFEINATE_TIMEOUT_SECONDS = 300 // 5 minutes
 22  
 23  // Restart interval - restart caffeinate before it expires.
 24  // Use 4 minutes to give plenty of buffer before the 5 minute timeout.
 25  const RESTART_INTERVAL_MS = 4 * 60 * 1000
 26  
 27  let caffeinateProcess: ChildProcess | null = null
 28  let restartInterval: ReturnType<typeof setInterval> | null = null
 29  let refCount = 0
 30  let cleanupRegistered = false
 31  
 32  /**
 33   * Increment the reference count and start preventing sleep if needed.
 34   * Call this when starting work that should keep the Mac awake.
 35   */
 36  export function startPreventSleep(): void {
 37    refCount++
 38  
 39    if (refCount === 1) {
 40      spawnCaffeinate()
 41      startRestartInterval()
 42    }
 43  }
 44  
 45  /**
 46   * Decrement the reference count and allow sleep if no more work is pending.
 47   * Call this when work completes.
 48   */
 49  export function stopPreventSleep(): void {
 50    if (refCount > 0) {
 51      refCount--
 52    }
 53  
 54    if (refCount === 0) {
 55      stopRestartInterval()
 56      killCaffeinate()
 57    }
 58  }
 59  
 60  /**
 61   * Force stop preventing sleep, regardless of reference count.
 62   * Use this for cleanup on exit.
 63   */
 64  export function forceStopPreventSleep(): void {
 65    refCount = 0
 66    stopRestartInterval()
 67    killCaffeinate()
 68  }
 69  
 70  function startRestartInterval(): void {
 71    // Only run on macOS
 72    if (process.platform !== 'darwin') {
 73      return
 74    }
 75  
 76    // Already running
 77    if (restartInterval !== null) {
 78      return
 79    }
 80  
 81    restartInterval = setInterval(() => {
 82      // Only restart if we still need sleep prevention
 83      if (refCount > 0) {
 84        logForDebugging('Restarting caffeinate to maintain sleep prevention')
 85        killCaffeinate()
 86        spawnCaffeinate()
 87      }
 88    }, RESTART_INTERVAL_MS)
 89  
 90    // Don't let the interval keep the Node process alive
 91    restartInterval.unref()
 92  }
 93  
 94  function stopRestartInterval(): void {
 95    if (restartInterval !== null) {
 96      clearInterval(restartInterval)
 97      restartInterval = null
 98    }
 99  }
100  
101  function spawnCaffeinate(): void {
102    // Only run on macOS
103    if (process.platform !== 'darwin') {
104      return
105    }
106  
107    // Already running
108    if (caffeinateProcess !== null) {
109      return
110    }
111  
112    // Register cleanup on first use to ensure caffeinate is killed on exit
113    if (!cleanupRegistered) {
114      cleanupRegistered = true
115      registerCleanup(async () => {
116        forceStopPreventSleep()
117      })
118    }
119  
120    try {
121      // -i: Create an assertion to prevent idle sleep
122      //     This is the least aggressive option - display can still sleep
123      // -t: Timeout in seconds - caffeinate exits automatically after this
124      //     This provides self-healing if Node is killed with SIGKILL
125      caffeinateProcess = spawn(
126        'caffeinate',
127        ['-i', '-t', String(CAFFEINATE_TIMEOUT_SECONDS)],
128        {
129          stdio: 'ignore',
130        },
131      )
132  
133      // Don't let caffeinate keep the Node process alive
134      caffeinateProcess.unref()
135  
136      const thisProc = caffeinateProcess
137      caffeinateProcess.on('error', err => {
138        logForDebugging(`caffeinate spawn error: ${err.message}`)
139        if (caffeinateProcess === thisProc) caffeinateProcess = null
140      })
141  
142      caffeinateProcess.on('exit', () => {
143        if (caffeinateProcess === thisProc) caffeinateProcess = null
144      })
145  
146      logForDebugging('Started caffeinate to prevent sleep')
147    } catch {
148      // Silently fail - caffeinate not available or spawn failed
149      caffeinateProcess = null
150    }
151  }
152  
153  function killCaffeinate(): void {
154    if (caffeinateProcess !== null) {
155      const proc = caffeinateProcess
156      caffeinateProcess = null
157      try {
158        // SIGKILL for immediate termination - SIGTERM could be delayed
159        proc.kill('SIGKILL')
160        logForDebugging('Stopped caffeinate, allowing sleep')
161      } catch {
162        // Process may have already exited
163      }
164    }
165  }