diff --git a/airc b/airc
index 06b851b..11d5ddb 100755
--- a/airc
+++ b/airc
@@ -4714,438 +4714,18 @@ else:
fi
}
-# ── cmd_daemon: install / manage the OS auto-restart for `airc connect` ────
-# Issue followup to #39 substrate: the channel must auto-resume across machine
-# sleep/wake/crash so users walk away and come back to a live mesh. Without
-# this, every laptop sleep kills airc + the user must remember to restart it.
-#
-# Implementation: install a platform-native autostart that wraps `airc connect`
-# with KeepAlive/Restart=always. AIRC_BACKGROUND_OK=1 is set in the env so
-# airc's heartbeat-stdout-pipe-trap doesn't exit-3 under launchd/systemd
-# (which have no notification-consumer reading stdout).
-#
-# Subcommands:
-# airc daemon install Install + start the autostart entry
-# airc daemon uninstall Stop + remove the autostart entry
-# airc daemon status Show install state + running pid + log path
-# airc daemon log [N] Tail the daemon stdout log
-#
-# Scope: defaults to the GLOBAL scope ($HOME/.airc), since the daemon is the
-# user's "always-on" mesh presence — not tied to a specific project dir. If
-# the user wants a per-project always-on daemon, they pass AIRC_HOME=
-# in the environment when running install (and the generated unit/plist
-# will carry that scope).
-cmd_daemon() {
- local action="${1:-status}"
- shift 2>/dev/null || true
- case "$action" in
- install) cmd_daemon_install "$@" ;;
- uninstall|remove|stop) cmd_daemon_uninstall "$@" ;;
- status) cmd_daemon_status "$@" ;;
- log|logs) cmd_daemon_log "$@" ;;
- *) die "Usage: airc daemon [install|uninstall|status|log]" ;;
- esac
-}
-
-# Resolve the absolute path to airc binary that should run under the daemon.
-# install.sh symlinks $HOME/.local/bin/airc → $AIRC_DIR/airc; we want the
-# real path so a future `airc update` (which mutates $AIRC_DIR/airc in
-# place) is picked up by launchd/systemd without re-installing the unit.
-_daemon_airc_path() {
- local airc_link="${HOME}/.local/bin/airc"
- if [ -L "$airc_link" ] || [ -x "$airc_link" ]; then
- echo "$airc_link"
- elif [ -x "${AIRC_DIR:-$HOME/.airc-src}/airc" ]; then
- echo "${AIRC_DIR:-$HOME/.airc-src}/airc"
- else
- echo "/usr/local/bin/airc" # last-resort guess; install will fail loud if wrong
- fi
-}
-
-# The scope the daemon will run under. Mirrors detect_scope() (line 135)
-# so `airc daemon install` from a project dir captures THAT dir's
-# .airc as the daemon's scope -- otherwise the daemon spawns a monitor
-# pointed at $HOME/.airc (empty / wrong room) while the user's actual
-# join state lives at $cwd/.airc. Joel 2026-04-28: "lol obv if it
-# worked you would have a monitor and be online. FAIL" -- caught the
-# scope mismatch on continuum-b69f's box.
-_daemon_scope() {
- if [ -n "${AIRC_HOME:-}" ]; then
- echo "$AIRC_HOME"
- else
- echo "$(pwd -P)/.airc"
- fi
-}
-
-# Returns 0 if the autostart daemon (launchd / systemd unit) is installed
-# on this OS, 1 otherwise. Used by the monitor escalation banner (#184)
-# to tell the user whether the upcoming exit-99 will trigger self-heal
-# (daemon present) or just kill the relay silently (no daemon — they
-# need to `airc join` again).
-_daemon_installed() {
- local os; os=$(detect_platform)
- case "$os" in
- darwin)
- [ -f "$HOME/Library/LaunchAgents/com.cambriantech.airc.plist" ] && return 0 ;;
- linux|wsl)
- [ -f "$HOME/.config/systemd/user/airc.service" ] && return 0 ;;
- windows)
- reg query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" //v airc-monitor >/dev/null 2>&1 && return 0 ;;
- esac
- return 1
-}
-
-cmd_daemon_install() {
- local os; os=$(detect_platform)
- local airc_bin; airc_bin=$(_daemon_airc_path)
- local scope; scope=$(_daemon_scope)
- mkdir -p "$scope"
-
- case "$os" in
- darwin) _daemon_install_launchd "$airc_bin" "$scope" ;;
- linux|wsl) _daemon_install_systemd "$airc_bin" "$scope" "$os" ;;
- windows) _daemon_install_schtasks "$airc_bin" "$scope" ;;
- *) die "Daemon install not supported on $(uname -s). Manual workaround: run 'airc connect' under your platform's preferred autostart mechanism." ;;
- esac
-}
-
-# Print the common "daemon installed; here's where to look" footer.
-# Three platform installers used to duplicate this 5-line block; now
-# they call this helper. Pass the platform-specific lead line as $1 and
-# any optional trailing note as $2 (heredoc-style multi-line OK).
-_daemon_install_done() {
- local lead="$1" scope="$2" note="${3:-}"
- echo " ✓ $lead"
- echo " airc will now auto-start at login + restart on exit."
- echo " Logs: $scope/daemon.log"
- echo " Status: airc daemon status"
- if [ -n "$note" ]; then echo ""; printf ' %s\n' "$note"; fi
-}
-
-_daemon_install_launchd() {
- local airc_bin="$1" scope="$2"
- local plist_dir="$HOME/Library/LaunchAgents"
- local plist_path="$plist_dir/com.cambriantech.airc.plist"
- mkdir -p "$plist_dir"
- cat > "$plist_path" <
-
-
-
- Label
- com.cambriantech.airc
- ProgramArguments
-
- ${airc_bin}
- connect
-
- EnvironmentVariables
-
- AIRC_BACKGROUND_OK
- 1
- AIRC_HOME
- ${scope}
- HOME
- ${HOME}
- PATH
- /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${HOME}/.local/bin
-
- RunAtLoad
-
- KeepAlive
-
- StandardOutPath
- ${scope}/daemon.log
- StandardErrorPath
- ${scope}/daemon.err
- ProcessType
- Background
- ThrottleInterval
- 10
-
-
-PLIST
- echo " Wrote $plist_path"
- # Bootout first to reset any prior load (idempotent install).
- launchctl bootout "gui/$(id -u)/com.cambriantech.airc" 2>/dev/null || true
- launchctl bootstrap "gui/$(id -u)" "$plist_path" 2>&1 \
- || die "launchctl bootstrap failed. Plist written but not loaded; check Console.app for errors."
- launchctl enable "gui/$(id -u)/com.cambriantech.airc" 2>/dev/null || true
- _daemon_install_done "Loaded into launchd (gui/$(id -u)/com.cambriantech.airc)" "$scope" \
- "Note: if 'airc canary' / gist push fails under launchd, the gh keychain may not be unlocked at boot. Workaround: 'gh auth status' once after login to unlock; airc daemon picks it up on next restart."
-}
-
-_daemon_install_schtasks() {
- # Windows daemon via HKCU Run-key (no admin; HKCU\...\Run is user-
- # scope, so per-user autostart at logon without UAC). PRs #200/#202
- # for the why; this function for the how.
- local airc_bin="$1" scope="$2"
- local entry_name="airc-monitor"
-
- # Find Git Bash — the launcher .bat needs it to exec airc.
- local bash_exe=""
- for c in 'C:\Program Files\Git\bin\bash.exe' 'C:\Program Files (x86)\Git\bin\bash.exe' "$HOME/AppData/Local/Programs/Git/bin/bash.exe"; do
- local check_path; check_path=$(echo "$c" | sed 's|\\|/|g; s|^C:|/c|')
- if [ -f "$c" ] || [ -f "$check_path" ]; then bash_exe="$c"; break; fi
- done
- [ -z "$bash_exe" ] && die "bash.exe not found at any standard Git for Windows path. Install Git for Windows + re-run."
-
- # Convert paths to Windows form; cmd.exe can't read /c/Users/... .
- local airc_bin_win; airc_bin_win=$(_to_win_path "$airc_bin")
- local scope_win; scope_win=$(_to_win_path "$scope")
-
- # Launcher .bat: cd to cwd (so airc's detect_scope finds /.airc),
- # bash -c (not -lc, to keep cmd-set env), absolute unix airc path
- # (bash -c doesn't read .bashrc so PATH won't have ~/.local/bin).
- # Loop with 5s restart matches launchd KeepAlive / systemd Restart=always.
- # See PR #202 for the bug history that necessitated each of those choices.
- local cwd_win; cwd_win=$(_to_win_path "$(pwd -P)")
- local airc_bin_unix; airc_bin_unix=$(_to_bash_path "$airc_bin")
- [ -z "$airc_bin_unix" ] && airc_bin_unix="$airc_bin"
- # Marker path the .bat polls to distinguish intentional re-exec
- # (written by _reexec_into) from "actual crash" (#203/#204).
- local marker_win; marker_win=$(_to_win_path "$scope/airc.reexec-marker")
- local launcher_bash="$scope/airc-daemon.bat"
- cat > "$launcher_bash" <nul 2>&1
- if not errorlevel 1 (
- echo [%date% %time%] airc re-exec'd into different mode ^(host-takeover or rejoin^); new process is now daemon, launcher exiting. >> daemon.err
- del "$marker_win" >nul 2>&1
- exit /b 0
- )
-)
-echo [%date% %time%] airc connect exited. Restarting in 5s. >> daemon.err
-timeout /t 5 /nobreak >nul
-goto loop
-EOF
- local launcher_win; launcher_win=$(_to_win_path "$launcher_bash")
-
- # `cmd /c start "" /MIN ` launches detached + minimized; empty ""
- # is start's title slot. reg add /f is idempotent (overwrites prior).
- local run_cmd="cmd /c start \"\" /MIN \"$launcher_win\""
- reg add "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" //v "$entry_name" //t REG_SZ //d "$run_cmd" //f >/dev/null 2>&1 \
- || die "reg add failed for HKCU Run\\$entry_name"
- # Start now (no logout/login needed). Fires-and-forgets.
- cmd //c start "" //MIN "$launcher_win" >/dev/null 2>&1 || true
-
- echo " ✓ Started monitor in detached cmd window (minimized)"
- _daemon_install_done "Registered HKCU Run entry '$entry_name' (runs at every Windows logon)" "$scope"
-}
-
-_daemon_install_systemd() {
- local airc_bin="$1" scope="$2" os="$3"
- local unit_dir="$HOME/.config/systemd/user"
- local unit_path="$unit_dir/airc.service"
- if ! command -v systemctl >/dev/null 2>&1; then
- if [ "$os" = "wsl" ]; then
- die "systemctl not found. Enable systemd in WSL: edit /etc/wsl.conf to add [boot]\nsystemd=true, then 'wsl --shutdown' from PowerShell + restart your distro."
- else
- die "systemctl not found. Daemon install requires systemd."
- fi
- fi
- # Probe the user-level systemd bus BEFORE writing the unit. WSL2 ships
- # systemctl on PATH but typically has init (not systemd) as PID 1, so
- # `systemctl --user` returns "Failed to connect to bus" — we'd write
- # the unit then fail to load it, leaving cruft on disk. Detect early.
- if ! systemctl --user is-system-running >/dev/null 2>&1 \
- && ! systemctl --user list-units >/dev/null 2>&1; then
- if [ "$os" = "wsl" ]; then
- cat >&2 < "$unit_path" </dev/null \
- && echo " ✓ Unloaded from launchd" \
- || echo " (was not loaded)"
- [ -f "$plist_path" ] && rm "$plist_path" && echo " ✓ Removed $plist_path" \
- || echo " (no plist on disk)"
- ;;
- linux|wsl)
- systemctl --user disable --now airc.service 2>/dev/null \
- && echo " ✓ Stopped + disabled airc.service" \
- || echo " (was not enabled)"
- local unit_path="$HOME/.config/systemd/user/airc.service"
- [ -f "$unit_path" ] && rm "$unit_path" && systemctl --user daemon-reload && echo " ✓ Removed $unit_path" \
- || echo " (no unit on disk)"
- ;;
- windows)
- local entry_name="airc-monitor"
- if reg query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" //v "$entry_name" >/dev/null 2>&1; then
- reg delete "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" //v "$entry_name" //f >/dev/null 2>&1 \
- && echo " ✓ Removed HKCU Run entry '$entry_name'" \
- || echo " (reg delete failed — try 'reg delete' manually)"
- else
- echo " (no Run entry '$entry_name' registered)"
- fi
- # Kill any currently-running daemon-launched airc-connect tree.
- # Match on the launcher .bat path so we don't kill foreground
- # `airc join` running in the user's terminal.
- local scope; scope=$(_daemon_scope)
- if ps -ef 2>/dev/null | grep 'airc-daemon.bat' | grep -v grep >/dev/null; then
- ps -ef | grep 'airc-daemon.bat' | grep -v grep | awk '{print $2}' | while read pid; do
- kill "$pid" 2>/dev/null || true
- done
- echo " ✓ Killed running daemon launcher process(es)"
- fi
- [ -f "$scope/airc-daemon.bat" ] && rm "$scope/airc-daemon.bat" \
- && echo " ✓ Removed $scope/airc-daemon.bat"
- ;;
- *) echo " Daemon uninstall not supported on $(uname -s)."; return 1 ;;
- esac
-}
-
-cmd_daemon_status() {
- local os; os=$(detect_platform)
- case "$os" in
- darwin)
- local plist_path="$HOME/Library/LaunchAgents/com.cambriantech.airc.plist"
- if [ -f "$plist_path" ]; then
- echo " Plist: $plist_path"
- # launchctl print returns rich state; grep the key fields.
- local state; state=$(launchctl print "gui/$(id -u)/com.cambriantech.airc" 2>/dev/null \
- | grep -E 'state =|pid =|last exit code' | head -3)
- if [ -n "$state" ]; then
- echo " Loaded: yes"
- printf '%s\n' "$state" | sed 's/^[[:space:]]*/ /'
- else
- echo " Loaded: no (plist present but not bootstrapped — try 'airc daemon install' to reload)"
- fi
- local scope; scope=$(_daemon_scope)
- echo " Logs: $scope/daemon.log"
- else
- echo " No daemon installed. Run: airc daemon install"
- fi
- ;;
- linux|wsl)
- local unit_path="$HOME/.config/systemd/user/airc.service"
- if [ -f "$unit_path" ]; then
- echo " Unit: $unit_path"
- local active; active=$(systemctl --user is-active airc.service 2>/dev/null)
- local enabled; enabled=$(systemctl --user is-enabled airc.service 2>/dev/null)
- echo " Active: $active"
- echo " Enabled: $enabled"
- local scope; scope=$(_daemon_scope)
- echo " Logs: $scope/daemon.log (journalctl --user -u airc -f for live)"
- else
- echo " No daemon installed. Run: airc daemon install"
- fi
- ;;
- windows)
- local entry_name="airc-monitor"
- if reg query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" //v "$entry_name" >/dev/null 2>&1; then
- echo " Type: HKCU Run-key (per-user logon autostart, no admin)"
- echo " Entry: $entry_name"
- local scope; scope=$(_daemon_scope)
- echo " Logs: $scope/daemon.log"
- echo " Errors: $scope/daemon.err"
- echo " Launcher: $scope/airc-daemon.bat"
- # Is the daemon-launched airc actually running right now? The
- # launcher .bat spawns bash + airc-connect then exits, so we
- # look for the airc-connect process (PPID=1 = orphaned-into-
- # init, which is what `start /B` produces on Windows). Falling
- # back to airc.pid lookup if that fails.
- local live_pid
- live_pid=$(ps -ef 2>/dev/null | awk '$3 == 1 && /airc.*connect/ && !/grep/ {print $2; exit}')
- if [ -z "$live_pid" ] && [ -f "$scope/airc.pid" ]; then
- local pidfile_pid
- pidfile_pid=$(head -1 "$scope/airc.pid" 2>/dev/null | tr -d '[:space:]')
- if [ -n "$pidfile_pid" ] && kill -0 "$pidfile_pid" 2>/dev/null; then
- live_pid="$pidfile_pid (from airc.pid)"
- fi
- fi
- if [ -n "$live_pid" ]; then
- echo " Status: RUNNING (PID $live_pid)"
- else
- echo " Status: registered (will start at next logon — or 'airc daemon install' to start now)"
- fi
- else
- echo " No daemon installed. Run: airc daemon install"
- fi
- ;;
- *) echo " Daemon status not supported on $(uname -s)." ;;
- esac
-}
-
-cmd_daemon_log() {
- local n="${1:-50}"
- local scope; scope=$(_daemon_scope)
- local log="$scope/daemon.log"
- if [ ! -f "$log" ]; then
- echo " No log at $log. Daemon may not have started yet."
- return 1
- fi
- tail -"$n" "$log"
-}
+# cmd_daemon family extracted to lib/airc_bash/cmd_daemon.sh
+# (#152 Phase 3 file split, follow-up to cmd_doctor.sh / cmd_connect.sh).
+# The block holds cmd_daemon + cmd_daemon_install/uninstall/status/log
+# plus all _daemon_* private helpers.
+if [ -n "${_airc_lib_dir:-}" ] && [ -f "$_airc_lib_dir/airc_bash/cmd_daemon.sh" ]; then
+ # shellcheck source=lib/airc_bash/cmd_daemon.sh
+ source "$_airc_lib_dir/airc_bash/cmd_daemon.sh"
+else
+ echo "ERROR: airc_bash/cmd_daemon.sh not found via lib-dir resolver." >&2
+ echo " Resolved lib_dir: ${_airc_lib_dir:-}" >&2
+ exit 1
+fi
# cmd_doctor + helpers extracted to lib/airc_bash/cmd_doctor.sh
# (#152 Phase 3 file split). Sourced via the lib-dir resolver.
diff --git a/lib/airc_bash/cmd_daemon.sh b/lib/airc_bash/cmd_daemon.sh
new file mode 100644
index 0000000..8879715
--- /dev/null
+++ b/lib/airc_bash/cmd_daemon.sh
@@ -0,0 +1,461 @@
+# Sourced by airc. cmd_daemon family — install / status / uninstall /
+# log of the OS auto-restart for `airc connect`.
+#
+# Functions exported back to airc's dispatch:
+# cmd_daemon — verb router (install|status|uninstall|log)
+# cmd_daemon_install — top-level installer, branches per platform
+# cmd_daemon_uninstall — top-level uninstaller
+# cmd_daemon_status — dump platform-native unit/plist state + log tail
+# cmd_daemon_log — `tail` the daemon stdout log
+#
+# Private helpers (all `_daemon_*` named):
+# _daemon_airc_path — resolve the absolute path airc was invoked as
+# _daemon_scope — pick install scope (defaults to $HOME/.airc)
+# _daemon_installed — fast yes/no probe used by monitor self-heal
+# _daemon_install_done — shared post-install confirmation print
+# _daemon_install_launchd — macOS plist writer + launchctl bootstrap
+# _daemon_install_schtasks— Windows HKCU Run-key registration
+# _daemon_install_systemd — Linux/WSL systemd-user unit writer
+#
+# External cross-references (resolved at call time, defined inline in airc
+# top-level): die, detect_platform. Also called BY cmd_connect / monitor
+# (`_daemon_installed` for the no-claude-left-behind self-heal probe).
+#
+# Extracted from airc as part of #152 Phase 3 file split, after Joel
+# 2026-04-27 push: "shell scripts are like classes; the 5200-line bash
+# monolith was wrong." This is the cmd_daemon group — each command-family
+# becomes one .sh file, mirroring the cmd_doctor.sh / cmd_connect.sh
+# extraction pattern.
+
+# ── cmd_daemon: install / manage the OS auto-restart for `airc connect` ────
+# Issue followup to #39 substrate: the channel must auto-resume across machine
+# sleep/wake/crash so users walk away and come back to a live mesh. Without
+# this, every laptop sleep kills airc + the user must remember to restart it.
+#
+# Implementation: install a platform-native autostart that wraps `airc connect`
+# with KeepAlive/Restart=always. AIRC_BACKGROUND_OK=1 is set in the env so
+# airc's heartbeat-stdout-pipe-trap doesn't exit-3 under launchd/systemd
+# (which have no notification-consumer reading stdout).
+#
+# Subcommands:
+# airc daemon install Install + start the autostart entry
+# airc daemon uninstall Stop + remove the autostart entry
+# airc daemon status Show install state + running pid + log path
+# airc daemon log [N] Tail the daemon stdout log
+#
+# Scope: defaults to the GLOBAL scope ($HOME/.airc), since the daemon is the
+# user's "always-on" mesh presence — not tied to a specific project dir. If
+# the user wants a per-project always-on daemon, they pass AIRC_HOME=
+# in the environment when running install (and the generated unit/plist
+# will carry that scope).
+cmd_daemon() {
+ local action="${1:-status}"
+ shift 2>/dev/null || true
+ case "$action" in
+ install) cmd_daemon_install "$@" ;;
+ uninstall|remove|stop) cmd_daemon_uninstall "$@" ;;
+ status) cmd_daemon_status "$@" ;;
+ log|logs) cmd_daemon_log "$@" ;;
+ *) die "Usage: airc daemon [install|uninstall|status|log]" ;;
+ esac
+}
+
+# Resolve the absolute path to airc binary that should run under the daemon.
+# install.sh symlinks $HOME/.local/bin/airc → $AIRC_DIR/airc; we want the
+# real path so a future `airc update` (which mutates $AIRC_DIR/airc in
+# place) is picked up by launchd/systemd without re-installing the unit.
+_daemon_airc_path() {
+ local airc_link="${HOME}/.local/bin/airc"
+ if [ -L "$airc_link" ] || [ -x "$airc_link" ]; then
+ echo "$airc_link"
+ elif [ -x "${AIRC_DIR:-$HOME/.airc-src}/airc" ]; then
+ echo "${AIRC_DIR:-$HOME/.airc-src}/airc"
+ else
+ echo "/usr/local/bin/airc" # last-resort guess; install will fail loud if wrong
+ fi
+}
+
+# The scope the daemon will run under. Mirrors detect_scope() (line 135)
+# so `airc daemon install` from a project dir captures THAT dir's
+# .airc as the daemon's scope -- otherwise the daemon spawns a monitor
+# pointed at $HOME/.airc (empty / wrong room) while the user's actual
+# join state lives at $cwd/.airc. Joel 2026-04-28: "lol obv if it
+# worked you would have a monitor and be online. FAIL" -- caught the
+# scope mismatch on continuum-b69f's box.
+_daemon_scope() {
+ if [ -n "${AIRC_HOME:-}" ]; then
+ echo "$AIRC_HOME"
+ else
+ echo "$(pwd -P)/.airc"
+ fi
+}
+
+# Returns 0 if the autostart daemon (launchd / systemd unit) is installed
+# on this OS, 1 otherwise. Used by the monitor escalation banner (#184)
+# to tell the user whether the upcoming exit-99 will trigger self-heal
+# (daemon present) or just kill the relay silently (no daemon — they
+# need to `airc join` again).
+_daemon_installed() {
+ local os; os=$(detect_platform)
+ case "$os" in
+ darwin)
+ [ -f "$HOME/Library/LaunchAgents/com.cambriantech.airc.plist" ] && return 0 ;;
+ linux|wsl)
+ [ -f "$HOME/.config/systemd/user/airc.service" ] && return 0 ;;
+ windows)
+ reg query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" //v airc-monitor >/dev/null 2>&1 && return 0 ;;
+ esac
+ return 1
+}
+
+cmd_daemon_install() {
+ local os; os=$(detect_platform)
+ local airc_bin; airc_bin=$(_daemon_airc_path)
+ local scope; scope=$(_daemon_scope)
+ mkdir -p "$scope"
+
+ case "$os" in
+ darwin) _daemon_install_launchd "$airc_bin" "$scope" ;;
+ linux|wsl) _daemon_install_systemd "$airc_bin" "$scope" "$os" ;;
+ windows) _daemon_install_schtasks "$airc_bin" "$scope" ;;
+ *) die "Daemon install not supported on $(uname -s). Manual workaround: run 'airc connect' under your platform's preferred autostart mechanism." ;;
+ esac
+}
+
+# Print the common "daemon installed; here's where to look" footer.
+# Three platform installers used to duplicate this 5-line block; now
+# they call this helper. Pass the platform-specific lead line as $1 and
+# any optional trailing note as $2 (heredoc-style multi-line OK).
+_daemon_install_done() {
+ local lead="$1" scope="$2" note="${3:-}"
+ echo " ✓ $lead"
+ echo " airc will now auto-start at login + restart on exit."
+ echo " Logs: $scope/daemon.log"
+ echo " Status: airc daemon status"
+ if [ -n "$note" ]; then echo ""; printf ' %s\n' "$note"; fi
+}
+
+_daemon_install_launchd() {
+ local airc_bin="$1" scope="$2"
+ local plist_dir="$HOME/Library/LaunchAgents"
+ local plist_path="$plist_dir/com.cambriantech.airc.plist"
+ mkdir -p "$plist_dir"
+ cat > "$plist_path" <
+
+
+
+ Label
+ com.cambriantech.airc
+ ProgramArguments
+
+ ${airc_bin}
+ connect
+
+ EnvironmentVariables
+
+ AIRC_BACKGROUND_OK
+ 1
+ AIRC_HOME
+ ${scope}
+ HOME
+ ${HOME}
+ PATH
+ /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${HOME}/.local/bin
+
+ RunAtLoad
+
+ KeepAlive
+
+ StandardOutPath
+ ${scope}/daemon.log
+ StandardErrorPath
+ ${scope}/daemon.err
+ ProcessType
+ Background
+ ThrottleInterval
+ 10
+
+
+PLIST
+ echo " Wrote $plist_path"
+ # Bootout first to reset any prior load (idempotent install).
+ launchctl bootout "gui/$(id -u)/com.cambriantech.airc" 2>/dev/null || true
+ launchctl bootstrap "gui/$(id -u)" "$plist_path" 2>&1 \
+ || die "launchctl bootstrap failed. Plist written but not loaded; check Console.app for errors."
+ launchctl enable "gui/$(id -u)/com.cambriantech.airc" 2>/dev/null || true
+ _daemon_install_done "Loaded into launchd (gui/$(id -u)/com.cambriantech.airc)" "$scope" \
+ "Note: if 'airc canary' / gist push fails under launchd, the gh keychain may not be unlocked at boot. Workaround: 'gh auth status' once after login to unlock; airc daemon picks it up on next restart."
+}
+
+_daemon_install_schtasks() {
+ # Windows daemon via HKCU Run-key (no admin; HKCU\...\Run is user-
+ # scope, so per-user autostart at logon without UAC). PRs #200/#202
+ # for the why; this function for the how.
+ local airc_bin="$1" scope="$2"
+ local entry_name="airc-monitor"
+
+ # Find Git Bash — the launcher .bat needs it to exec airc.
+ local bash_exe=""
+ for c in 'C:\Program Files\Git\bin\bash.exe' 'C:\Program Files (x86)\Git\bin\bash.exe' "$HOME/AppData/Local/Programs/Git/bin/bash.exe"; do
+ local check_path; check_path=$(echo "$c" | sed 's|\\|/|g; s|^C:|/c|')
+ if [ -f "$c" ] || [ -f "$check_path" ]; then bash_exe="$c"; break; fi
+ done
+ [ -z "$bash_exe" ] && die "bash.exe not found at any standard Git for Windows path. Install Git for Windows + re-run."
+
+ # Convert paths to Windows form; cmd.exe can't read /c/Users/... .
+ local airc_bin_win; airc_bin_win=$(_to_win_path "$airc_bin")
+ local scope_win; scope_win=$(_to_win_path "$scope")
+
+ # Launcher .bat: cd to cwd (so airc's detect_scope finds /.airc),
+ # bash -c (not -lc, to keep cmd-set env), absolute unix airc path
+ # (bash -c doesn't read .bashrc so PATH won't have ~/.local/bin).
+ # Loop with 5s restart matches launchd KeepAlive / systemd Restart=always.
+ # See PR #202 for the bug history that necessitated each of those choices.
+ local cwd_win; cwd_win=$(_to_win_path "$(pwd -P)")
+ local airc_bin_unix; airc_bin_unix=$(_to_bash_path "$airc_bin")
+ [ -z "$airc_bin_unix" ] && airc_bin_unix="$airc_bin"
+ # Marker path the .bat polls to distinguish intentional re-exec
+ # (written by _reexec_into) from "actual crash" (#203/#204).
+ local marker_win; marker_win=$(_to_win_path "$scope/airc.reexec-marker")
+ local launcher_bash="$scope/airc-daemon.bat"
+ cat > "$launcher_bash" <nul 2>&1
+ if not errorlevel 1 (
+ echo [%date% %time%] airc re-exec'd into different mode ^(host-takeover or rejoin^); new process is now daemon, launcher exiting. >> daemon.err
+ del "$marker_win" >nul 2>&1
+ exit /b 0
+ )
+)
+echo [%date% %time%] airc connect exited. Restarting in 5s. >> daemon.err
+timeout /t 5 /nobreak >nul
+goto loop
+EOF
+ local launcher_win; launcher_win=$(_to_win_path "$launcher_bash")
+
+ # `cmd /c start "" /MIN ` launches detached + minimized; empty ""
+ # is start's title slot. reg add /f is idempotent (overwrites prior).
+ local run_cmd="cmd /c start \"\" /MIN \"$launcher_win\""
+ reg add "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" //v "$entry_name" //t REG_SZ //d "$run_cmd" //f >/dev/null 2>&1 \
+ || die "reg add failed for HKCU Run\\$entry_name"
+ # Start now (no logout/login needed). Fires-and-forgets.
+ cmd //c start "" //MIN "$launcher_win" >/dev/null 2>&1 || true
+
+ echo " ✓ Started monitor in detached cmd window (minimized)"
+ _daemon_install_done "Registered HKCU Run entry '$entry_name' (runs at every Windows logon)" "$scope"
+}
+
+_daemon_install_systemd() {
+ local airc_bin="$1" scope="$2" os="$3"
+ local unit_dir="$HOME/.config/systemd/user"
+ local unit_path="$unit_dir/airc.service"
+ if ! command -v systemctl >/dev/null 2>&1; then
+ if [ "$os" = "wsl" ]; then
+ die "systemctl not found. Enable systemd in WSL: edit /etc/wsl.conf to add [boot]\nsystemd=true, then 'wsl --shutdown' from PowerShell + restart your distro."
+ else
+ die "systemctl not found. Daemon install requires systemd."
+ fi
+ fi
+ # Probe the user-level systemd bus BEFORE writing the unit. WSL2 ships
+ # systemctl on PATH but typically has init (not systemd) as PID 1, so
+ # `systemctl --user` returns "Failed to connect to bus" — we'd write
+ # the unit then fail to load it, leaving cruft on disk. Detect early.
+ if ! systemctl --user is-system-running >/dev/null 2>&1 \
+ && ! systemctl --user list-units >/dev/null 2>&1; then
+ if [ "$os" = "wsl" ]; then
+ cat >&2 < "$unit_path" </dev/null \
+ && echo " ✓ Unloaded from launchd" \
+ || echo " (was not loaded)"
+ [ -f "$plist_path" ] && rm "$plist_path" && echo " ✓ Removed $plist_path" \
+ || echo " (no plist on disk)"
+ ;;
+ linux|wsl)
+ systemctl --user disable --now airc.service 2>/dev/null \
+ && echo " ✓ Stopped + disabled airc.service" \
+ || echo " (was not enabled)"
+ local unit_path="$HOME/.config/systemd/user/airc.service"
+ [ -f "$unit_path" ] && rm "$unit_path" && systemctl --user daemon-reload && echo " ✓ Removed $unit_path" \
+ || echo " (no unit on disk)"
+ ;;
+ windows)
+ local entry_name="airc-monitor"
+ if reg query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" //v "$entry_name" >/dev/null 2>&1; then
+ reg delete "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" //v "$entry_name" //f >/dev/null 2>&1 \
+ && echo " ✓ Removed HKCU Run entry '$entry_name'" \
+ || echo " (reg delete failed — try 'reg delete' manually)"
+ else
+ echo " (no Run entry '$entry_name' registered)"
+ fi
+ # Kill any currently-running daemon-launched airc-connect tree.
+ # Match on the launcher .bat path so we don't kill foreground
+ # `airc join` running in the user's terminal.
+ local scope; scope=$(_daemon_scope)
+ if ps -ef 2>/dev/null | grep 'airc-daemon.bat' | grep -v grep >/dev/null; then
+ ps -ef | grep 'airc-daemon.bat' | grep -v grep | awk '{print $2}' | while read pid; do
+ kill "$pid" 2>/dev/null || true
+ done
+ echo " ✓ Killed running daemon launcher process(es)"
+ fi
+ [ -f "$scope/airc-daemon.bat" ] && rm "$scope/airc-daemon.bat" \
+ && echo " ✓ Removed $scope/airc-daemon.bat"
+ ;;
+ *) echo " Daemon uninstall not supported on $(uname -s)."; return 1 ;;
+ esac
+}
+
+cmd_daemon_status() {
+ local os; os=$(detect_platform)
+ case "$os" in
+ darwin)
+ local plist_path="$HOME/Library/LaunchAgents/com.cambriantech.airc.plist"
+ if [ -f "$plist_path" ]; then
+ echo " Plist: $plist_path"
+ # launchctl print returns rich state; grep the key fields.
+ local state; state=$(launchctl print "gui/$(id -u)/com.cambriantech.airc" 2>/dev/null \
+ | grep -E 'state =|pid =|last exit code' | head -3)
+ if [ -n "$state" ]; then
+ echo " Loaded: yes"
+ printf '%s\n' "$state" | sed 's/^[[:space:]]*/ /'
+ else
+ echo " Loaded: no (plist present but not bootstrapped — try 'airc daemon install' to reload)"
+ fi
+ local scope; scope=$(_daemon_scope)
+ echo " Logs: $scope/daemon.log"
+ else
+ echo " No daemon installed. Run: airc daemon install"
+ fi
+ ;;
+ linux|wsl)
+ local unit_path="$HOME/.config/systemd/user/airc.service"
+ if [ -f "$unit_path" ]; then
+ echo " Unit: $unit_path"
+ local active; active=$(systemctl --user is-active airc.service 2>/dev/null)
+ local enabled; enabled=$(systemctl --user is-enabled airc.service 2>/dev/null)
+ echo " Active: $active"
+ echo " Enabled: $enabled"
+ local scope; scope=$(_daemon_scope)
+ echo " Logs: $scope/daemon.log (journalctl --user -u airc -f for live)"
+ else
+ echo " No daemon installed. Run: airc daemon install"
+ fi
+ ;;
+ windows)
+ local entry_name="airc-monitor"
+ if reg query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" //v "$entry_name" >/dev/null 2>&1; then
+ echo " Type: HKCU Run-key (per-user logon autostart, no admin)"
+ echo " Entry: $entry_name"
+ local scope; scope=$(_daemon_scope)
+ echo " Logs: $scope/daemon.log"
+ echo " Errors: $scope/daemon.err"
+ echo " Launcher: $scope/airc-daemon.bat"
+ # Is the daemon-launched airc actually running right now? The
+ # launcher .bat spawns bash + airc-connect then exits, so we
+ # look for the airc-connect process (PPID=1 = orphaned-into-
+ # init, which is what `start /B` produces on Windows). Falling
+ # back to airc.pid lookup if that fails.
+ local live_pid
+ live_pid=$(ps -ef 2>/dev/null | awk '$3 == 1 && /airc.*connect/ && !/grep/ {print $2; exit}')
+ if [ -z "$live_pid" ] && [ -f "$scope/airc.pid" ]; then
+ local pidfile_pid
+ pidfile_pid=$(head -1 "$scope/airc.pid" 2>/dev/null | tr -d '[:space:]')
+ if [ -n "$pidfile_pid" ] && kill -0 "$pidfile_pid" 2>/dev/null; then
+ live_pid="$pidfile_pid (from airc.pid)"
+ fi
+ fi
+ if [ -n "$live_pid" ]; then
+ echo " Status: RUNNING (PID $live_pid)"
+ else
+ echo " Status: registered (will start at next logon — or 'airc daemon install' to start now)"
+ fi
+ else
+ echo " No daemon installed. Run: airc daemon install"
+ fi
+ ;;
+ *) echo " Daemon status not supported on $(uname -s)." ;;
+ esac
+}
+
+cmd_daemon_log() {
+ local n="${1:-50}"
+ local scope; scope=$(_daemon_scope)
+ local log="$scope/daemon.log"
+ if [ ! -f "$log" ]; then
+ echo " No log at $log. Daemon may not have started yet."
+ return 1
+ fi
+ tail -"$n" "$log"
+}