| | #!/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}" =~ '[1]+$' ]] && | | | 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 "$@" |
0-9 ↩︎