# Caffeinate AI: Keep macOS awake while one or more monitored apps are running (for long agentic sessions with ChatGPT/Codex or Claude)

> Source: <https://gist.github.com/sbolel/4b80f5fa5604d710a06054a85756e412>
> Published: 2026-10-02 01:17:36+00:00

|  | #!/bin/zsh | 
|  | # | 
|  | # caffeinate-ai | 
|  | # | 
|  | # Keep macOS awake while one or more monitored applications are running. | 
|  | # | 
|  | # Setup: | 
|  | # 1. Add to local bin dir: `~/.local/bin/caffeinate-ai` | 
|  | # 2. Make it executable: `chmod +x ~/.local/bin/caffeinate-ai` | 
|  | # 3. View help and usage: `caffeinate-ai --help` | 
|  | # | 
|  | # Defaults: | 
|  | # - Monitor ChatGPT/Codex #TODO: add Claude | 
|  | # - Poll every 5 seconds | 
|  | # - Prevent idle system sleep with `caffeinate -i` | 
|  | # - Allow the display to sleep normally | 
|  | # - Produce no background logs unless --verbose is supplied | 
|  | # | 
|  | # Commands: | 
|  | # caffeinate-ai --help | 
|  | # caffeinate-ai start | 
|  | # caffeinate-ai stop | 
|  | # caffeinate-ai restart | 
|  | # caffeinate-ai toggle | 
|  | # caffeinate-ai status | 
|  | # caffeinate-ai doctor | 
|  | # caffeinate-ai run | 
|  | # caffeinate-ai | 
|  | # When called without a command, defaults to `caffeinate-ai run`, | 
|  | # which runs caffeinate-ai in the foreground of the current shell. | 
|  | set -u | 
|  | set -o pipefail | 
|  | # ----------------------------------------------------------------------------- | 
|  | # Constants | 
|  | # ----------------------------------------------------------------------------- | 
|  | readonly SCRIPT_PATH="${0:A}" | 
|  | readonly SCRIPT_NAME="${0:t}" | 
|  | readonly STATE_DIR="${HOME}/Library/Caches/caffeinate-ai" | 
|  | readonly PID_FILE="${STATE_DIR}/caffeinate-ai.pid" | 
|  | readonly LOCK_DIR="${STATE_DIR}/operation.lock" | 
|  | readonly LOCK_PID_FILE="${LOCK_DIR}/pid" | 
|  | readonly LOG_FILE="${STATE_DIR}/caffeinate-ai.log" | 
|  | # ----------------------------------------------------------------------------- | 
|  | # Defaults / environment configuration | 
|  | # ----------------------------------------------------------------------------- | 
|  | # | 
|  | # These defaults can be overridden by environment variables: | 
|  | # | 
|  | # CAFFEINATE_AI_INTERVAL=10 | 
|  | # CAFFEINATE_AI_MATCH_MODE=exact | 
|  | # CAFFEINATE_AI_APPS='ChatGPT:Codex:Visual Studio Code' | 
|  | # CAFFEINATE_AI_CAFFEINATE_ARGS='-i' | 
|  | # | 
|  | # CLI arguments take precedence over environment configuration. | 
|  | # | 
|  | POLL_INTERVAL="${CAFFEINATE_AI_INTERVAL:-5}" | 
|  | MATCH_MODE="${CAFFEINATE_AI_MATCH_MODE:-exact}" | 
|  | VERBOSE=false | 
|  | DRY_RUN=false | 
|  | # | 
|  | # Add other applications to monitor here | 
|  | # | 
|  | TARGET_APPS=( | 
|  | "ChatGPT" | 
|  | "Codex" | 
|  | ) | 
|  | CAFFEINATE_ARGS=( | 
|  | "-i" | 
|  | ) | 
|  | if [[ -n "${CAFFEINATE_AI_APPS:-}" ]]; then | 
|  | TARGET_APPS=( | 
|  | "${(@s/:/)CAFFEINATE_AI_APPS}" | 
|  | ) | 
|  | fi | 
|  | if [[ -n "${CAFFEINATE_AI_CAFFEINATE_ARGS:-}" ]]; then | 
|  | CAFFEINATE_ARGS=( | 
|  | "${(@s/:/)CAFFEINATE_AI_CAFFEINATE_ARGS}" | 
|  | ) | 
|  | fi | 
|  | # | 
|  | # PID of the caffeinate process owned by this watcher. | 
|  | # | 
|  | CAFFEINATE_PID="" | 
|  | # | 
|  | # Set to 1 only for a detached watcher launched by `start`. | 
|  | # | 
|  | IS_DAEMON="${CAFFEINATE_AI_DAEMON:-0}" | 
|  | # ----------------------------------------------------------------------------- | 
|  | # Help | 
|  | # ----------------------------------------------------------------------------- | 
|  | usage() { | 
|  | cat <<EOF | 
|  | Usage: | 
|  | ${SCRIPT_NAME} [command] [options] | 
|  | Commands: | 
|  | start [options] | 
|  | Start caffeinate-ai as a detached background watcher. | 
|  | stop | 
|  | Stop the background watcher and its caffeinate child. | 
|  | restart [options] | 
|  | Stop the current watcher and start a new one. | 
|  | toggle [options] | 
|  | Stop the watcher if running; otherwise start it. | 
|  | Options are used only when starting. | 
|  | status | 
|  | Show the state of the background watcher and caffeinate child. | 
|  | doctor [options] | 
|  | Diagnose process detection, watcher state, and the macOS power | 
|  | assertion. | 
|  | run [options] | 
|  | Run the watcher in the foreground. | 
|  | This is the default when options are supplied without a command. | 
|  | help | 
|  | Show this help message. | 
|  | Options for start, restart, toggle, run, and doctor: | 
|  | -a, --app NAME | 
|  | Add an application/process name to monitor. | 
|  | Supplying --app replaces the default ChatGPT/Codex list. | 
|  | May be specified multiple times: | 
|  | ${SCRIPT_NAME} start \\ | 
|  | --app ChatGPT \\ | 
|  | --app Codex | 
|  | -i, --interval SECONDS | 
|  | Poll interval. | 
|  | Default: | 
|  | ${POLL_INTERVAL} | 
|  | -m, --match MODE | 
|  | Process matching mode. | 
|  | Supported: | 
|  | exact | 
|  | Exact process-name matching using pgrep -x. | 
|  | contains | 
|  | Match against the full process command using pgrep -f. | 
|  | Default: | 
|  | ${MATCH_MODE} | 
|  | -c, --caffeinate ARG | 
|  | Add an argument passed to caffeinate. | 
|  | Supplying this option replaces the default "-i". | 
|  | May be specified multiple times: | 
|  | ${SCRIPT_NAME} start \\ | 
|  | --caffeinate=-d \\ | 
|  | --caffeinate=-i | 
|  | -v, --verbose | 
|  | Enable diagnostic output. | 
|  | In background mode, verbose output is written to: | 
|  | ${LOG_FILE} | 
|  | The log is truncated each time a new verbose daemon starts. | 
|  | --dry-run | 
|  | Foreground-only mode. | 
|  | Detect applications and report when caffeinate would start or stop, | 
|  | without creating a power assertion. | 
|  | -h, --help | 
|  | Show this help message. | 
|  | Environment variables: | 
|  | CAFFEINATE_AI_INTERVAL | 
|  | Default polling interval. | 
|  | CAFFEINATE_AI_MATCH_MODE | 
|  | Default matching mode: exact or contains. | 
|  | CAFFEINATE_AI_APPS | 
|  | Colon-separated default application names. | 
|  | Example: | 
|  | export CAFFEINATE_AI_APPS='ChatGPT:Codex:Visual Studio Code' | 
|  | CAFFEINATE_AI_CAFFEINATE_ARGS | 
|  | Colon-separated caffeinate arguments. | 
|  | Example: | 
|  | export CAFFEINATE_AI_CAFFEINATE_ARGS='-i' | 
|  | Examples: | 
|  | Start: | 
|  | ${SCRIPT_NAME} start | 
|  | Status: | 
|  | ${SCRIPT_NAME} status | 
|  | Stop: | 
|  | ${SCRIPT_NAME} stop | 
|  | Restart: | 
|  | ${SCRIPT_NAME} restart | 
|  | Toggle: | 
|  | ${SCRIPT_NAME} toggle | 
|  | Diagnose: | 
|  | ${SCRIPT_NAME} doctor | 
|  | Run in the foreground: | 
|  | ${SCRIPT_NAME} run | 
|  | Dry-run process detection: | 
|  | ${SCRIPT_NAME} run --dry-run | 
|  | Run verbosely: | 
|  | ${SCRIPT_NAME} run --verbose | 
|  | Start with verbose background logging: | 
|  | ${SCRIPT_NAME} start --verbose | 
|  | Poll every 10 seconds: | 
|  | ${SCRIPT_NAME} start --interval 10 | 
|  | Watch only Codex: | 
|  | ${SCRIPT_NAME} start --app Codex | 
|  | Watch several applications: | 
|  | ${SCRIPT_NAME} start \\ | 
|  | --app ChatGPT \\ | 
|  | --app Codex \\ | 
|  | --app "Visual Studio Code" | 
|  | Prevent both idle system sleep and display sleep: | 
|  | ${SCRIPT_NAME} start \\ | 
|  | --caffeinate=-d \\ | 
|  | --caffeinate=-i | 
|  | State: | 
|  | PID: | 
|  | ${PID_FILE} | 
|  | Lock: | 
|  | ${LOCK_DIR} | 
|  | Verbose log: | 
|  | ${LOG_FILE} | 
|  | Exit codes: | 
|  | 0 Success | 
|  | 1 Runtime failure or inactive status | 
|  | 2 Invalid arguments | 
|  | 127 Required command not found | 
|  | EOF | 
|  | } | 
|  | # ----------------------------------------------------------------------------- | 
|  | # Output | 
|  | # ----------------------------------------------------------------------------- | 
|  | print_status() { | 
|  | print -r -- "$*" | 
|  | } | 
|  | log() { | 
|  | if [[ "${VERBOSE}" != true ]]; then | 
|  | return 0 | 
|  | fi | 
|  | printf '[%s] %s\n' \ | 
|  | "$(date '+%Y-%m-%d %H:%M:%S')" \ | 
|  | "$*" | 
|  | } | 
|  | debug() { | 
|  | if [[ "${VERBOSE}" == true ]]; then | 
|  | log "DEBUG: $*" | 
|  | fi | 
|  | } | 
|  | error() { | 
|  | printf '%s: error: %s\n' "${SCRIPT_NAME}" "$*" >&2 | 
|  | } | 
|  | die() { | 
|  | local message="$1" | 
|  | local exit_code="${2:-1}" | 
|  | error "${message}" | 
|  | exit "${exit_code}" | 
|  | } | 
|  | # ----------------------------------------------------------------------------- | 
|  | # Dependency / input validation | 
|  | # ----------------------------------------------------------------------------- | 
|  | require_command() { | 
|  | local command_name="$1" | 
|  | if ! command -v "${command_name}" >/dev/null 2>&1; then | 
|  | die "required command not found: ${command_name}" 127 | 
|  | fi | 
|  | } | 
|  | validate_positive_integer() { | 
|  | local value="$1" | 
|  | local name="$2" | 
|  | if [[ ! "${value}" =~ '^[0-9]+$' ]]; then | 
|  | die "${name} must be a positive integer; received: ${value}" 2 | 
|  | fi | 
|  | if (( value < 1 )); then | 
|  | die "${name} must be greater than zero; received: ${value}" 2 | 
|  | fi | 
|  | } | 
|  | validate_runtime_dependencies() { | 
|  | require_command caffeinate | 
|  | require_command pgrep | 
|  | require_command ps | 
|  | require_command date | 
|  | require_command kill | 
|  | require_command sleep | 
|  | require_command mkdir | 
|  | require_command mv | 
|  | require_command rm | 
|  | require_command rmdir | 
|  | } | 
|  | validate_lifecycle_dependencies() { | 
|  | require_command pgrep | 
|  | require_command ps | 
|  | require_command kill | 
|  | require_command sleep | 
|  | require_command mkdir | 
|  | require_command rm | 
|  | require_command rmdir | 
|  | } | 
|  | validate_doctor_dependencies() { | 
|  | validate_lifecycle_dependencies | 
|  | require_command pmset | 
|  | require_command grep | 
|  | } | 
|  | # ----------------------------------------------------------------------------- | 
|  | # State directory | 
|  | # ----------------------------------------------------------------------------- | 
|  | ensure_state_dir() { | 
|  | if ! mkdir -p "${STATE_DIR}"; then | 
|  | die "could not create state directory: ${STATE_DIR}" | 
|  | fi | 
|  | } | 
|  | # ----------------------------------------------------------------------------- | 
|  | # Generic process inspection | 
|  | # ----------------------------------------------------------------------------- | 
|  | process_is_alive() { | 
|  | local pid="$1" | 
|  | kill -0 "${pid}" 2>/dev/null | 
|  | } | 
|  | process_command() { | 
|  | local pid="$1" | 
|  | ps -p "${pid}" -o command= 2>/dev/null | 
|  | } | 
|  | process_matches_script_instance() { | 
|  | local pid="$1" | 
|  | if ! process_is_alive "${pid}"; then | 
|  | return 1 | 
|  | fi | 
|  | local command_line | 
|  | if ! command_line="$(process_command "${pid}")"; then | 
|  | return 1 | 
|  | fi | 
|  | if [[ "${command_line}" == *"${SCRIPT_PATH}"* \|\| | 
|  | "${command_line}" == *"${SCRIPT_NAME}"* ]]; then | 
|  | return 0 | 
|  | fi | 
|  | return 1 | 
|  | } | 
|  | # ----------------------------------------------------------------------------- | 
|  | # Atomic operation lock | 
|  | # ----------------------------------------------------------------------------- | 
|  | acquire_operation_lock() { | 
|  | validate_lifecycle_dependencies | 
|  | ensure_state_dir | 
|  | local attempts=2 | 
|  | while (( attempts > 0 )); do | 
|  | if mkdir "${LOCK_DIR}" 2>/dev/null; then | 
|  | if ! print -r -- "$$" >"${LOCK_PID_FILE}"; then | 
|  | rmdir "${LOCK_DIR}" 2>/dev/null \|\| true | 
|  | die "could not write operation lock PID" | 
|  | fi | 
|  | return 0 | 
|  | fi | 
|  | # | 
|  | # Another process may have created the directory but not yet written the | 
|  | # PID file. Give it a brief opportunity to finish. | 
|  | # | 
|  | if [[ ! -f "${LOCK_PID_FILE}" ]]; then | 
|  | sleep 0.1 | 
|  | fi | 
|  | local owner_pid="" | 
|  | if [[ -f "${LOCK_PID_FILE}" ]]; then | 
|  | owner_pid="$(<"${LOCK_PID_FILE}")" | 
|  | fi | 
|  | if [[ "${owner_pid}" =~ '^[0-9]+$' ]] && | 
|  | process_matches_script_instance "${owner_pid}"; then | 
|  | die \ | 
|  | "another caffeinate-ai operation is already in progress (PID ${owner_pid})" | 
|  | fi | 
|  | # | 
|  | # The lock owner no longer exists or the lock metadata is invalid. | 
|  | # | 
|  | rm -f "${LOCK_PID_FILE}" 2>/dev/null \|\| true | 
|  | if ! rmdir "${LOCK_DIR}" 2>/dev/null; then | 
|  | die "could not recover stale operation lock: ${LOCK_DIR}" | 
|  | fi | 
|  | (( attempts-- )) | 
|  | done | 
|  | die "could not acquire operation lock" | 
|  | } | 
|  | release_operation_lock() { | 
|  | if [[ ! -d "${LOCK_DIR}" ]]; then | 
|  | return 0 | 
|  | fi | 
|  | local owner_pid="" | 
|  | if [[ -f "${LOCK_PID_FILE}" ]]; then | 
|  | owner_pid="$(<"${LOCK_PID_FILE}")" | 
|  | fi | 
|  | # | 
|  | # Only remove a valid lock owned by this process. | 
|  | # | 
|  | if [[ "${owner_pid}" != "$$" ]]; then | 
|  | return 0 | 
|  | fi | 
|  | rm -f "${LOCK_PID_FILE}" 2>/dev/null \|\| true | 
|  | rmdir "${LOCK_DIR}" 2>/dev/null \|\| true | 
|  | } | 
|  | with_operation_lock() { | 
|  | acquire_operation_lock | 
|  | # | 
|  | # Ensure an unexpected exit still releases our lock. | 
|  | # | 
|  | trap 'release_operation_lock' EXIT | 
|  | trap 'release_operation_lock; exit 130' INT | 
|  | trap 'release_operation_lock; exit 143' TERM | 
|  | trap 'release_operation_lock; exit 129' HUP | 
|  | "$@" | 
|  | local exit_status=$? | 
|  | release_operation_lock | 
|  | trap - EXIT | 
|  | trap - INT | 
|  | trap - TERM | 
|  | trap - HUP | 
|  | return "${exit_status}" | 
|  | } | 
|  | # ----------------------------------------------------------------------------- | 
|  | # PID file | 
|  | # ----------------------------------------------------------------------------- | 
|  | read_daemon_pid() { | 
|  | if [[ ! -f "${PID_FILE}" ]]; then | 
|  | return 1 | 
|  | fi | 
|  | local pid | 
|  | pid="$(<"${PID_FILE}")" | 
|  | if [[ ! "${pid}" =~ '^[0-9]+$' ]]; then | 
|  | debug "invalid PID file; removing ${PID_FILE}" | 
|  | rm -f "${PID_FILE}" | 
|  | return 1 | 
|  | fi | 
|  | print -r -- "${pid}" | 
|  | } | 
|  | write_daemon_pid() { | 
|  | local pid="$1" | 
|  | local temp_file="${PID_FILE}.${pid}.tmp" | 
|  | ensure_state_dir | 
|  | if ! print -r -- "${pid}" >"${temp_file}"; then | 
|  | die "could not write temporary PID file: ${temp_file}" | 
|  | fi | 
|  | if ! mv -f "${temp_file}" "${PID_FILE}"; then | 
|  | rm -f "${temp_file}" | 
|  | die "could not write PID file: ${PID_FILE}" | 
|  | fi | 
|  | } | 
|  | remove_pid_file_if_owned() { | 
|  | local expected_pid="$1" | 
|  | if [[ ! -f "${PID_FILE}" ]]; then | 
|  | return 0 | 
|  | fi | 
|  | local pid | 
|  | pid="$(<"${PID_FILE}")" | 
|  | if [[ "${pid}" == "${expected_pid}" ]]; then | 
|  | rm -f "${PID_FILE}" | 
|  | fi | 
|  | } | 
|  | # ----------------------------------------------------------------------------- | 
|  | # Daemon validation | 
|  | # ----------------------------------------------------------------------------- | 
|  | process_matches_daemon() { | 
|  | local pid="$1" | 
|  | if ! process_matches_script_instance "${pid}"; then | 
|  | return 1 | 
|  | fi | 
|  | local command_line | 
|  | if ! command_line="$(process_command "${pid}")"; then | 
|  | return 1 | 
|  | fi | 
|  | # | 
|  | # A managed daemon is always re-executed using the explicit "run" | 
|  | # subcommand. | 
|  | # | 
|  | if [[ "${command_line}" != *" run"* ]]; then | 
|  | return 1 | 
|  | fi | 
|  | return 0 | 
|  | } | 
|  | daemon_is_running() { | 
|  | local pid | 
|  | if ! pid="$(read_daemon_pid)"; then | 
|  | return 1 | 
|  | fi | 
|  | if process_matches_daemon "${pid}"; then | 
|  | return 0 | 
|  | fi | 
|  | debug "removing stale PID file for PID ${pid}" | 
|  | rm -f "${PID_FILE}" | 
|  | return 1 | 
|  | } | 
|  | # ----------------------------------------------------------------------------- | 
|  | # Target process detection | 
|  | # ----------------------------------------------------------------------------- | 
|  | get_matching_pids() { | 
|  | local process_name="$1" | 
|  | local raw_pids="" | 
|  | case "${MATCH_MODE}" in | 
|  | exact) | 
|  | raw_pids="$( | 
|  | pgrep \ | 
|  | -U "${UID}" \ | 
|  | -x \ | 
|  | -- "${process_name}" \ | 
|  | 2>/dev/null \|\| | 
|  | true | 
|  | )" | 
|  | ;; | 
|  | contains) | 
|  | raw_pids="$( | 
|  | pgrep \ | 
|  | -U "${UID}" \ | 
|  | -f \ | 
|  | -- "${process_name}" \ | 
|  | 2>/dev/null \|\| | 
|  | true | 
|  | )" | 
|  | ;; | 
|  | *) | 
|  | return 2 | 
|  | ;; | 
|  | esac | 
|  | if [[ -z "${raw_pids}" ]]; then | 
|  | return 1 | 
|  | fi | 
|  | local pid | 
|  | for pid in ${(f)raw_pids}; do | 
|  | # | 
|  | # Do not allow the watcher or its invoking shell to satisfy its own | 
|  | # contains-mode query. | 
|  | # | 
|  | if [[ "${pid}" == "$$" \|\| "${pid}" == "${PPID}" ]]; then | 
|  | continue | 
|  | fi | 
|  | print -r -- "${pid}" | 
|  | done | 
|  | } | 
|  | process_is_running() { | 
|  | local process_name="$1" | 
|  | local matches | 
|  | matches="$(get_matching_pids "${process_name}" 2>/dev/null \|\| true)" | 
|  | [[ -n "${matches}" ]] | 
|  | } | 
|  | find_running_target() { | 
|  | local app | 
|  | for app in "${TARGET_APPS[@]}"; do | 
|  | if process_is_running "${app}"; then | 
|  | print -r -- "${app}" | 
|  | return 0 | 
|  | fi | 
|  | done | 
|  | return 1 | 
|  | } | 
|  | # ----------------------------------------------------------------------------- | 
|  | # caffeinate lifecycle | 
|  | # ----------------------------------------------------------------------------- | 
|  | caffeinate_is_running() { | 
|  | if [[ -z "${CAFFEINATE_PID}" ]]; then | 
|  | return 1 | 
|  | fi | 
|  | process_is_alive "${CAFFEINATE_PID}" | 
|  | } | 
|  | start_caffeinate() { | 
|  | if caffeinate_is_running; then | 
|  | debug "caffeinate already running as PID ${CAFFEINATE_PID}" | 
|  | return 0 | 
|  | fi | 
|  | CAFFEINATE_PID="" | 
|  | debug "starting: caffeinate ${(j: :)CAFFEINATE_ARGS}" | 
|  | caffeinate "${CAFFEINATE_ARGS[@]}" & | 
|  | local pid=$! | 
|  | # | 
|  | # Give caffeinate a short opportunity to reject invalid arguments. | 
|  | # | 
|  | sleep 0.1 | 
|  | if ! process_is_alive "${pid}"; then | 
|  | wait "${pid}" 2>/dev/null | 
|  | local exit_status=$? | 
|  | error "caffeinate failed to start (exit status ${exit_status})" | 
|  | return 1 | 
|  | fi | 
|  | CAFFEINATE_PID="${pid}" | 
|  | log "Started caffeinate (PID ${CAFFEINATE_PID})" | 
|  | return 0 | 
|  | } | 
|  | stop_caffeinate() { | 
|  | if [[ -z "${CAFFEINATE_PID}" ]]; then | 
|  | return 0 | 
|  | fi | 
|  | local pid="${CAFFEINATE_PID}" | 
|  | # | 
|  | # Clear state immediately so cleanup remains idempotent. | 
|  | # | 
|  | CAFFEINATE_PID="" | 
|  | if ! process_is_alive "${pid}"; then | 
|  | debug "caffeinate PID ${pid} is no longer running" | 
|  | wait "${pid}" 2>/dev/null \|\| true | 
|  | return 0 | 
|  | fi | 
|  | debug "sending TERM to caffeinate PID ${pid}" | 
|  | if ! kill -TERM "${pid}" 2>/dev/null; then | 
|  | error "could not terminate caffeinate PID ${pid}" | 
|  | return 1 | 
|  | fi | 
|  | wait "${pid}" 2>/dev/null \|\| true | 
|  | log "Stopped caffeinate" | 
|  | return 0 | 
|  | } | 
|  | # ----------------------------------------------------------------------------- | 
|  | # Watcher cleanup / signals | 
|  | # ----------------------------------------------------------------------------- | 
|  | cleanup_watcher() { | 
|  | local original_status=$? | 
|  | debug "cleaning up" | 
|  | stop_caffeinate \|\| true | 
|  | if [[ "${IS_DAEMON}" == "1" ]]; then | 
|  | remove_pid_file_if_owned "$$" | 
|  | fi | 
|  | return "${original_status}" | 
|  | } | 
|  | handle_signal() { | 
|  | local signal_name="$1" | 
|  | local exit_code=1 | 
|  | case "${signal_name}" in | 
|  | INT) | 
|  | exit_code=130 | 
|  | ;; | 
|  | TERM) | 
|  | exit_code=143 | 
|  | ;; | 
|  | HUP) | 
|  | exit_code=129 | 
|  | ;; | 
|  | esac | 
|  | log "Received ${signal_name}; shutting down" | 
|  | exit "${exit_code}" | 
|  | } | 
|  | # ----------------------------------------------------------------------------- | 
|  | # Argument parsing | 
|  | # ----------------------------------------------------------------------------- | 
|  | parse_run_arguments() { | 
|  | local custom_apps=false | 
|  | local custom_caffeinate_args=false | 
|  | while (( $# > 0 )); do | 
|  | case "$1" in | 
|  | -a\|--app) | 
|  | if (( $# < 2 )); then | 
|  | die "$1 requires an application/process name" 2 | 
|  | fi | 
|  | if [[ "${custom_apps}" == false ]]; then | 
|  | TARGET_APPS=() | 
|  | custom_apps=true | 
|  | fi | 
|  | TARGET_APPS+=("$2") | 
|  | shift 2 | 
|  | ;; | 
|  | --app=*) | 
|  | if [[ "${custom_apps}" == false ]]; then | 
|  | TARGET_APPS=() | 
|  | custom_apps=true | 
|  | fi | 
|  | TARGET_APPS+=("${1#*=}") | 
|  | shift | 
|  | ;; | 
|  | -i\|--interval) | 
|  | if (( $# < 2 )); then | 
|  | die "$1 requires a number of seconds" 2 | 
|  | fi | 
|  | POLL_INTERVAL="$2" | 
|  | shift 2 | 
|  | ;; | 
|  | --interval=*) | 
|  | POLL_INTERVAL="${1#*=}" | 
|  | shift | 
|  | ;; | 
|  | -m\|--match) | 
|  | if (( $# < 2 )); then | 
|  | die "$1 requires either 'exact' or 'contains'" 2 | 
|  | fi | 
|  | MATCH_MODE="$2" | 
|  | shift 2 | 
|  | ;; | 
|  | --match=*) | 
|  | MATCH_MODE="${1#*=}" | 
|  | shift | 
|  | ;; | 
|  | -c\|--caffeinate) | 
|  | if (( $# < 2 )); then | 
|  | die "$1 requires a caffeinate argument" 2 | 
|  | fi | 
|  | if [[ "${custom_caffeinate_args}" == false ]]; then | 
|  | CAFFEINATE_ARGS=() | 
|  | custom_caffeinate_args=true | 
|  | fi | 
|  | CAFFEINATE_ARGS+=("$2") | 
|  | shift 2 | 
|  | ;; | 
|  | --caffeinate=*) | 
|  | if [[ "${custom_caffeinate_args}" == false ]]; then | 
|  | CAFFEINATE_ARGS=() | 
|  | custom_caffeinate_args=true | 
|  | fi | 
|  | CAFFEINATE_ARGS+=("${1#*=}") | 
|  | shift | 
|  | ;; | 
|  | -v\|--verbose) | 
|  | VERBOSE=true | 
|  | shift | 
|  | ;; | 
|  | --dry-run) | 
|  | DRY_RUN=true | 
|  | shift | 
|  | ;; | 
|  | -h\|--help) | 
|  | usage | 
|  | exit 0 | 
|  | ;; | 
|  | --) | 
|  | shift | 
|  | break | 
|  | ;; | 
|  | -*) | 
|  | die "unknown option: $1" 2 | 
|  | ;; | 
|  | *) | 
|  | die "unexpected argument: $1" 2 | 
|  | ;; | 
|  | esac | 
|  | done | 
|  | validate_positive_integer "${POLL_INTERVAL}" "poll interval" | 
|  | case "${MATCH_MODE}" in | 
|  | exact\|contains) | 
|  | ;; | 
|  | *) | 
|  | die \ | 
|  | "match mode must be 'exact' or 'contains'; received: ${MATCH_MODE}" \ | 
|  | 2 | 
|  | ;; | 
|  | esac | 
|  | if (( ${#TARGET_APPS[@]} == 0 )); then | 
|  | die "at least one application/process must be specified" 2 | 
|  | fi | 
|  | local app | 
|  | for app in "${TARGET_APPS[@]}"; do | 
|  | if [[ -z "${app}" ]]; then | 
|  | die "application/process names may not be empty" 2 | 
|  | fi | 
|  | done | 
|  | if (( ${#CAFFEINATE_ARGS[@]} == 0 )); then | 
|  | die "at least one caffeinate argument must be specified" 2 | 
|  | fi | 
|  | local caffeinate_arg | 
|  | for caffeinate_arg in "${CAFFEINATE_ARGS[@]}"; do | 
|  | if [[ -z "${caffeinate_arg}" ]]; then | 
|  | die "caffeinate arguments may not be empty" 2 | 
|  | fi | 
|  | done | 
|  | } | 
|  | # ----------------------------------------------------------------------------- | 
|  | # Foreground watcher | 
|  | # ----------------------------------------------------------------------------- | 
|  | run_monitor() { | 
|  | parse_run_arguments "$@" | 
|  | validate_runtime_dependencies | 
|  | trap cleanup_watcher EXIT | 
|  | trap 'handle_signal INT' INT | 
|  | trap 'handle_signal TERM' TERM | 
|  | trap 'handle_signal HUP' HUP | 
|  | if [[ "${IS_DAEMON}" == "1" ]]; then | 
|  | write_daemon_pid "$$" | 
|  | fi | 
|  | log "Watching for: ${(j:, :)TARGET_APPS}" | 
|  | log "Poll interval: ${POLL_INTERVAL}s" | 
|  | log "Process matching: ${MATCH_MODE}" | 
|  | log "Caffeinate arguments: ${(j: :)CAFFEINATE_ARGS}" | 
|  | if [[ "${DRY_RUN}" == true ]]; then | 
|  | print_status "Dry-run mode enabled" | 
|  | fi | 
|  | local previous_target="" | 
|  | while true; do | 
|  | local running_target="" | 
|  | if running_target="$(find_running_target)"; then | 
|  | debug "detected target process: ${running_target}" | 
|  | if [[ "${running_target}" != "${previous_target}" ]]; then | 
|  | if [[ "${DRY_RUN}" == true ]]; then | 
|  | print_status \ | 
|  | "Dry run: would start caffeinate for ${running_target}" | 
|  | else | 
|  | log "Detected ${running_target}" | 
|  | fi | 
|  | previous_target="${running_target}" | 
|  | fi | 
|  | if [[ "${DRY_RUN}" != true ]]; then | 
|  | if ! caffeinate_is_running; then | 
|  | # | 
|  | # If a PID was recorded but is no longer alive, caffeinate died | 
|  | # unexpectedly rather than being intentionally stopped. | 
|  | # | 
|  | if [[ -n "${CAFFEINATE_PID}" ]]; then | 
|  | log \ | 
|  | "caffeinate exited unexpectedly; restarting" | 
|  | fi | 
|  | if ! start_caffeinate; then | 
|  | error \ | 
|  | "will retry caffeinate on the next polling cycle" | 
|  | fi | 
|  | fi | 
|  | fi | 
|  | else | 
|  | if [[ -n "${previous_target}" ]]; then | 
|  | if [[ "${DRY_RUN}" == true ]]; then | 
|  | print_status "Dry run: would stop caffeinate" | 
|  | else | 
|  | log "No monitored applications are running" | 
|  | fi | 
|  | previous_target="" | 
|  | fi | 
|  | if [[ "${DRY_RUN}" != true ]]; then | 
|  | if caffeinate_is_running; then | 
|  | stop_caffeinate \|\| true | 
|  | elif [[ -n "${CAFFEINATE_PID}" ]]; then | 
|  | debug \ | 
|  | "clearing stale caffeinate PID ${CAFFEINATE_PID}" | 
|  | CAFFEINATE_PID="" | 
|  | fi | 
|  | fi | 
|  | fi | 
|  | sleep "${POLL_INTERVAL}" | 
|  | done | 
|  | } | 
|  | # ----------------------------------------------------------------------------- | 
|  | # Child argument construction | 
|  | # ----------------------------------------------------------------------------- | 
|  | build_child_arguments() { | 
|  | local app | 
|  | for app in "${TARGET_APPS[@]}"; do | 
|  | print -r -- "--app" | 
|  | print -r -- "${app}" | 
|  | done | 
|  | print -r -- "--interval" | 
|  | print -r -- "${POLL_INTERVAL}" | 
|  | print -r -- "--match" | 
|  | print -r -- "${MATCH_MODE}" | 
|  | local caffeinate_arg | 
|  | for caffeinate_arg in "${CAFFEINATE_ARGS[@]}"; do | 
|  | print -r -- "--caffeinate=${caffeinate_arg}" | 
|  | done | 
|  | if [[ "${VERBOSE}" == true ]]; then | 
|  | print -r -- "--verbose" | 
|  | fi | 
|  | } | 
|  | # ----------------------------------------------------------------------------- | 
|  | # Background daemon start | 
|  | # ----------------------------------------------------------------------------- | 
|  | start_daemon_current_config() { | 
|  | validate_runtime_dependencies | 
|  | require_command nohup | 
|  | if [[ "${DRY_RUN}" == true ]]; then | 
|  | die "--dry-run is only supported with the run command" 2 | 
|  | fi | 
|  | ensure_state_dir | 
|  | local existing_pid | 
|  | if daemon_is_running; then | 
|  | existing_pid="$(read_daemon_pid)" | 
|  | print_status \ | 
|  | "caffeinate-ai is already running (PID ${existing_pid})" | 
|  | return 0 | 
|  | fi | 
|  | rm -f "${PID_FILE}" | 
|  | local child_args=( | 
|  | "${(@f)$(build_child_arguments)}" | 
|  | ) | 
|  | debug "starting detached watcher" | 
|  | # | 
|  | # Verbose logs are deliberately session-scoped rather than append-only. | 
|  | # | 
|  | if [[ "${VERBOSE}" == true ]]; then | 
|  | if ! : >"${LOG_FILE}"; then | 
|  | die "could not initialize verbose log: ${LOG_FILE}" | 
|  | fi | 
|  | CAFFEINATE_AI_DAEMON=1 \ | 
|  | nohup "${SCRIPT_PATH}" run "${child_args[@]}" \ | 
|  | >>"${LOG_FILE}" \ | 
|  | 2>&1 \ | 
|  | </dev/null & | 
|  | else | 
|  | CAFFEINATE_AI_DAEMON=1 \ | 
|  | nohup "${SCRIPT_PATH}" run "${child_args[@]}" \ | 
|  | >/dev/null \ | 
|  | 2>&1 \ | 
|  | </dev/null & | 
|  | fi | 
|  | local pid=$! | 
|  | # | 
|  | # Wait for the child to finish initialization and write its PID file. | 
|  | # | 
|  | local attempts=30 | 
|  | local daemon_pid="" | 
|  | while (( attempts > 0 )); do | 
|  | if ! process_is_alive "${pid}"; then | 
|  | error "caffeinate-ai failed to start" | 
|  | if [[ "${VERBOSE}" == true ]]; then | 
|  | error "check log: ${LOG_FILE}" | 
|  | fi | 
|  | return 1 | 
|  | fi | 
|  | if daemon_pid="$(read_daemon_pid 2>/dev/null)"; then | 
|  | if [[ "${daemon_pid}" == "${pid}" ]] && | 
|  | process_matches_daemon "${pid}"; then | 
|  | print_status \ | 
|  | "Started caffeinate-ai (PID ${pid})" | 
|  | if [[ "${VERBOSE}" == true ]]; then | 
|  | print_status \ | 
|  | "Verbose log: ${LOG_FILE}" | 
|  | fi | 
|  | return 0 | 
|  | fi | 
|  | fi | 
|  | sleep 0.1 | 
|  | (( attempts-- )) | 
|  | done | 
|  | # | 
|  | # Never intentionally leave an unmanageable daemon behind. | 
|  | # | 
|  | if process_matches_daemon "${pid}"; then | 
|  | kill -TERM "${pid}" 2>/dev/null \|\| true | 
|  | fi | 
|  | rm -f "${PID_FILE}" | 
|  | error "timed out waiting for caffeinate-ai to initialize" | 
|  | if [[ "${VERBOSE}" == true ]]; then | 
|  | error "check log: ${LOG_FILE}" | 
|  | fi | 
|  | return 1 | 
|  | } | 
|  | # ----------------------------------------------------------------------------- | 
|  | # Background daemon stop | 
|  | # ----------------------------------------------------------------------------- | 
|  | stop_daemon_current() { | 
|  | validate_lifecycle_dependencies | 
|  | local pid | 
|  | if ! daemon_is_running; then | 
|  | rm -f "${PID_FILE}" | 
|  | print_status \ | 
|  | "caffeinate-ai background watcher is not running" | 
|  | return 0 | 
|  | fi | 
|  | pid="$(read_daemon_pid)" | 
|  | print_status \ | 
|  | "Stopping caffeinate-ai (PID ${pid})" | 
|  | if ! kill -TERM "${pid}" 2>/dev/null; then | 
|  | error "could not send TERM to PID ${pid}" | 
|  | return 1 | 
|  | fi | 
|  | # | 
|  | # Allow roughly five seconds for graceful shutdown. | 
|  | # | 
|  | local attempts=50 | 
|  | while (( attempts > 0 )); do | 
|  | if ! process_matches_daemon "${pid}"; then | 
|  | break | 
|  | fi | 
|  | sleep 0.1 | 
|  | (( attempts-- )) | 
|  | done | 
|  | if process_matches_daemon "${pid}"; then | 
|  | error \ | 
|  | "process did not stop gracefully; sending SIGKILL" | 
|  | kill -KILL "${pid}" 2>/dev/null \|\| true | 
|  | attempts=20 | 
|  | while (( attempts > 0 )); do | 
|  | if ! process_matches_daemon "${pid}"; then | 
|  | break | 
|  | fi | 
|  | sleep 0.1 | 
|  | (( attempts-- )) | 
|  | done | 
|  | fi | 
|  | if process_matches_daemon "${pid}"; then | 
|  | error "could not stop caffeinate-ai PID ${pid}" | 
|  | return 1 | 
|  | fi | 
|  | remove_pid_file_if_owned "${pid}" | 
|  | print_status "Stopped caffeinate-ai" | 
|  | return 0 | 
|  | } | 
|  | # ----------------------------------------------------------------------------- | 
|  | # Restart / toggle | 
|  | # ----------------------------------------------------------------------------- | 
|  | restart_daemon_current_config() { | 
|  | stop_daemon_current \|\| return $? | 
|  | start_daemon_current_config | 
|  | } | 
|  | toggle_daemon_current_config() { | 
|  | if daemon_is_running; then | 
|  | stop_daemon_current | 
|  | else | 
|  | start_daemon_current_config | 
|  | fi | 
|  | } | 
|  | # ----------------------------------------------------------------------------- | 
|  | # caffeinate child lookup | 
|  | # ----------------------------------------------------------------------------- | 
|  | get_caffeinate_child_pids() { | 
|  | local daemon_pid="$1" | 
|  | pgrep \ | 
|  | -U "${UID}" \ | 
|  | -P "${daemon_pid}" \ | 
|  | -x \ | 
|  | caffeinate \ | 
|  | 2>/dev/null \|\| | 
|  | true | 
|  | } | 
|  | get_first_caffeinate_child_pid() { | 
|  | local daemon_pid="$1" | 
|  | local raw_pids | 
|  | raw_pids="$(get_caffeinate_child_pids "${daemon_pid}")" | 
|  | if [[ -z "${raw_pids}" ]]; then | 
|  | return 1 | 
|  | fi | 
|  | local pids=( | 
|  | "${(@f)raw_pids}" | 
|  | ) | 
|  | if (( ${#pids[@]} == 0 )); then | 
|  | return 1 | 
|  | fi | 
|  | print -r -- "${pids[1]}" | 
|  | } | 
|  | # ----------------------------------------------------------------------------- | 
|  | # Status | 
|  | # ----------------------------------------------------------------------------- | 
|  | status_daemon() { | 
|  | validate_lifecycle_dependencies | 
|  | local pid | 
|  | if ! daemon_is_running; then | 
|  | rm -f "${PID_FILE}" | 
|  | print_status \ | 
|  | "caffeinate-ai background watcher is not running" | 
|  | return 1 | 
|  | fi | 
|  | pid="$(read_daemon_pid)" | 
|  | print_status \ | 
|  | "caffeinate-ai background watcher is running (PID ${pid})" | 
|  | local caffeinate_pid="" | 
|  | if caffeinate_pid="$( | 
|  | get_first_caffeinate_child_pid "${pid}" | 
|  | )"; then | 
|  | print_status \ | 
|  | "caffeinate is active (PID ${caffeinate_pid})" | 
|  | else | 
|  | print_status \ | 
|  | "caffeinate is currently inactive" | 
|  | fi | 
|  | return 0 | 
|  | } | 
|  | # ----------------------------------------------------------------------------- | 
|  | # Doctor | 
|  | # ----------------------------------------------------------------------------- | 
|  | doctor() { | 
|  | parse_run_arguments "$@" | 
|  | validate_doctor_dependencies | 
|  | if [[ "${DRY_RUN}" == true ]]; then | 
|  | die "--dry-run is only supported with the run command" 2 | 
|  | fi | 
|  | print_status "caffeinate-ai diagnostics" | 
|  | print_status "" | 
|  | # | 
|  | # Watcher | 
|  | # | 
|  | print_status "Watcher:" | 
|  | local daemon_pid="" | 
|  | if daemon_is_running; then | 
|  | daemon_pid="$(read_daemon_pid)" | 
|  | print_status \ | 
|  | " background: running (PID ${daemon_pid})" | 
|  | else | 
|  | print_status \ | 
|  | " background: not running" | 
|  | fi | 
|  | print_status "" | 
|  | # | 
|  | # Targets | 
|  | # | 
|  | print_status "Targets:" | 
|  | local app | 
|  | local raw_pids | 
|  | for app in "${TARGET_APPS[@]}"; do | 
|  | raw_pids="$(get_matching_pids "${app}" 2>/dev/null \|\| true)" | 
|  | if [[ -n "${raw_pids}" ]]; then | 
|  | local pids=( | 
|  | "${(@f)raw_pids}" | 
|  | ) | 
|  | print_status \ | 
|  | " ${app}: running (PIDs ${(j:, :)pids})" | 
|  | else | 
|  | print_status \ | 
|  | " ${app}: not running" | 
|  | fi | 
|  | done | 
|  | print_status "" | 
|  | # | 
|  | # caffeinate | 
|  | # | 
|  | print_status "Sleep prevention:" | 
|  | local caffeinate_pid="" | 
|  | if [[ -n "${daemon_pid}" ]] && | 
|  | caffeinate_pid="$( | 
|  | get_first_caffeinate_child_pid "${daemon_pid}" | 
|  | )"; then | 
|  | print_status \ | 
|  | " caffeinate: active (PID ${caffeinate_pid})" | 
|  | local assertions | 
|  | assertions="$(pmset -g assertions 2>/dev/null \|\| true)" | 
|  | if print -r -- "${assertions}" \| | 
|  | grep -E \ | 
|  | "pid[[:space:]]+${caffeinate_pid}\\(" \ | 
|  | >/dev/null 2>&1; then | 
|  | print_status \ | 
|  | " power assertion: present" | 
|  | else | 
|  | print_status \ | 
|  | " power assertion: not detected" | 
|  | fi | 
|  | else | 
|  | print_status \ | 
|  | " caffeinate: inactive" | 
|  | print_status \ | 
|  | " power assertion: none expected" | 
|  | fi | 
|  | print_status "" | 
|  | # | 
|  | # Effective diagnostic configuration | 
|  | # | 
|  | print_status "Configuration:" | 
|  | print_status \ | 
|  | " interval: ${POLL_INTERVAL}s" | 
|  | print_status \ | 
|  | " match: ${MATCH_MODE}" | 
|  | print_status \ | 
|  | " apps: ${(j:, :)TARGET_APPS}" | 
|  | print_status \ | 
|  | " caffeinate arguments: ${(j: :)CAFFEINATE_ARGS}" | 
|  | return 0 | 
|  | } | 
|  | # ----------------------------------------------------------------------------- | 
|  | # Background configuration preparation | 
|  | # ----------------------------------------------------------------------------- | 
|  | prepare_background_configuration() { | 
|  | parse_run_arguments "$@" | 
|  | if [[ "${DRY_RUN}" == true ]]; then | 
|  | die "--dry-run is only supported with the run command" 2 | 
|  | fi | 
|  | validate_runtime_dependencies | 
|  | require_command nohup | 
|  | } | 
|  | # ----------------------------------------------------------------------------- | 
|  | # Main | 
|  | # ----------------------------------------------------------------------------- | 
|  | main() { | 
|  | local subcommand="${1:-run}" | 
|  | case "${subcommand}" in | 
|  | start) | 
|  | shift | 
|  | prepare_background_configuration "$@" | 
|  | with_operation_lock \ | 
|  | start_daemon_current_config | 
|  | ;; | 
|  | stop) | 
|  | shift | 
|  | if (( $# > 0 )); then | 
|  | die "stop does not accept additional arguments" 2 | 
|  | fi | 
|  | validate_lifecycle_dependencies | 
|  | with_operation_lock \ | 
|  | stop_daemon_current | 
|  | ;; | 
|  | restart) | 
|  | shift | 
|  | # | 
|  | # Validate everything before stopping an existing daemon. That prevents | 
|  | # an invalid replacement configuration from unnecessarily taking down | 
|  | # a working watcher. | 
|  | # | 
|  | prepare_background_configuration "$@" | 
|  | with_operation_lock \ | 
|  | restart_daemon_current_config | 
|  | ;; | 
|  | toggle) | 
|  | shift | 
|  | prepare_background_configuration "$@" | 
|  | with_operation_lock \ | 
|  | toggle_daemon_current_config | 
|  | ;; | 
|  | status) | 
|  | shift | 
|  | if (( $# > 0 )); then | 
|  | die "status does not accept additional arguments" 2 | 
|  | fi | 
|  | status_daemon | 
|  | ;; | 
|  | doctor) | 
|  | shift | 
|  | doctor "$@" | 
|  | ;; | 
|  | run) | 
|  | # "run" may be implicit when no arguments were supplied. | 
|  | if (( $# > 0 )); then | 
|  | shift | 
|  | fi | 
|  | run_monitor "$@" | 
|  | ;; | 
|  | help) | 
|  | usage | 
|  | ;; | 
|  | -h\|--help) | 
|  | usage | 
|  | ;; | 
|  | -*) | 
|  | # | 
|  | # Shorthand: | 
|  | # | 
|  | # caffeinate-ai --verbose | 
|  | # | 
|  | # is equivalent to: | 
|  | # | 
|  | # caffeinate-ai run --verbose | 
|  | # | 
|  | run_monitor "$@" | 
|  | ;; | 
|  | *) | 
|  | error "unknown command: ${subcommand}" | 
|  | print >&2 | 
|  | usage >&2 | 
|  | exit 2 | 
|  | ;; | 
|  | esac | 
|  | } | 
|  | main "$@" |
