feat(airc daemon): Windows support via HKCU Run-key autostart (no admin)#200
feat(airc daemon): Windows support via HKCU Run-key autostart (no admin)#200
Conversation
Joel 2026-04-28 ~01:00Z: "fix the monitor man / i cant go to bed till
this is fixed". Windows had no daemon path -- `airc daemon install`
died on $(uname -s) with "not supported on MINGW64_NT-...". Result:
the only way to keep airc alive on Windows was to leave a Git Bash
window open running `airc join`. nohup+disown didn't survive parent
shell exit on MINGW64.
Adds a Windows branch to cmd_daemon_install / uninstall / status
mirroring the launchd (mac) and systemd (linux) patterns.
## Mechanism: HKCU Run-key, not Task Scheduler
First attempt was schtasks //SC ONLOGON, but Windows requires admin
to create per-user logon-triggered scheduled tasks (Access Denied for
non-elevated users, even with //RL LIMITED). Per Joel: "i just want
whatever is least hassle and also robust" -- forcing a UAC prompt at
'airc daemon install' time is exactly the kind of friction we kill.
HKCU\Software\Microsoft\Windows\CurrentVersion\Run is the per-user
autostart hive. Writing to it with `reg add` requires no admin (HKCU
is user-scope), fires at every interactive logon for the user, and
matches launchd-Agent / systemd-user semantics exactly.
## Implementation
1. `_daemon_os` returns "windows" on MINGW*/MSYS*/CYGWIN*.
2. `_daemon_install_schtasks` (kept the function name for grep
continuity even though it's now reg-based) writes a launcher .bat
to $scope/airc-daemon.bat that:
- sets AIRC_HOME + AIRC_BACKGROUND_OK
- exec's `bash -lc 'airc connect'`
- on exit, logs to daemon.err and `goto loop` after 5s
(matches launchd KeepAlive / systemd Restart=always)
3. `reg add` registers `cmd /c start "" /MIN "<launcher.bat>"` under
HKCU Run, key name `airc-monitor`.
4. Fires-and-forgets `cmd /c start /MIN <launcher>` immediately so
user doesn't need to logout/login to start the monitor.
5. uninstall: reg delete + kill + rm launcher .bat.
6. status: reg query for the entry + ps for the running airc-connect
(matches PPID=1 orphan or falls back to airc.pid lookup).
## Verified locally on continuum-b69f
$ airc daemon install
✓ Registered HKCU Run entry 'airc-monitor' (runs at every Windows logon)
✓ Started monitor in detached cmd window (minimized)
$ airc daemon status
Status: RUNNING (PID 341089)
$ airc daemon uninstall
✓ Removed HKCU Run entry 'airc-monitor'
✓ Killed running daemon launcher process(es)
✓ Removed /c/Users/green/.airc/airc-daemon.bat
$ airc daemon install # idempotent reinstall
✓ Registered ... ✓ Started monitor ...
Detached process survives the launching bash exit (which `nohup &
disown` could not on MINGW64).
## Note on AIRC_BACKGROUND_OK
The launcher sets this env var because `airc connect` may otherwise
refuse to run when not on a TTY. Same hint as the launchd plist's
EnvironmentVariables block.
There was a problem hiding this comment.
Pull request overview
Adds Windows support for airc daemon by introducing a Windows OS branch that installs a per-user autostart entry via the HKCU Run registry key (no admin), backed by a generated .bat launcher that restarts airc connect on exit.
Changes:
- Extend daemon OS detection to classify MINGW/MSYS/CYGWIN as
windows. - Implement Windows daemon install/uninstall/status using HKCU Run-key + a scoped
airc-daemon.batlauncher. - Update
_daemon_installedto detect Windows installation viareg query.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| set AIRC_HOME=$scope_win | ||
| set AIRC_BACKGROUND_OK=1 | ||
| :loop | ||
| "$bash_exe" -lc "exec '$airc_bin_win' connect" |
There was a problem hiding this comment.
The generated airc-daemon.bat never redirects airc connect stdout/stderr to $scope/daemon.log, but airc daemon log and the install/status output claim $scope/daemon.log is the log location. Either redirect the bash invocation output to daemon.log (and stderr to daemon.err) or adjust the user-facing messages/cmd_daemon_log behavior for Windows so it matches reality.
| "$bash_exe" -lc "exec '$airc_bin_win' connect" | |
| "$bash_exe" -lc "exec '$airc_bin_win' connect" >> "$scope_win\\daemon.log" 2>> "$scope_win\\daemon.err" |
| set AIRC_HOME=$scope_win | ||
| set AIRC_BACKGROUND_OK=1 |
There was a problem hiding this comment.
In the generated .bat, use the quoted set "VAR=value" form for environment variables (e.g. AIRC_HOME) so paths containing special cmd.exe metacharacters (like &, (, ), ^) or trailing spaces can't break the script or be interpreted as additional commands at logon.
| set AIRC_HOME=$scope_win | |
| set AIRC_BACKGROUND_OK=1 | |
| set "AIRC_HOME=$scope_win" | |
| set "AIRC_BACKGROUND_OK=1" |
| # 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. |
There was a problem hiding this comment.
The comments mention using start /B (and later rely on behavior attributed to start /B), but the implementation uses start ... /MIN and the .bat directly invokes bash without start /B. Please update the comments to match the actual behavior so future debugging doesn’t chase the wrong process model.
| # 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. | |
| # launcher .bat starts bash running `airc connect` and then | |
| # exits, so we look for the surviving airc-connect process. | |
| # In the MSYS/Git Bash `ps` view this may appear orphaned | |
| # (often PPID=1). Fall back to airc.pid lookup if that fails. |
| # Uses `start /B` for the bash invocation so the cmd.exe wrapper | ||
| # doesn't pop a visible console window at logon. |
There was a problem hiding this comment.
This comment says the launcher uses start /B for the bash invocation, but the generated .bat invokes bash directly (no start /B). Update the comment (or the implementation) so they’re consistent.
| # Uses `start /B` for the bash invocation so the cmd.exe wrapper | |
| # doesn't pop a visible console window at logon. | |
| # Invokes bash.exe directly from the .bat; no `start /B` wrapper is | |
| # used for the bash launch. |
| # 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)" |
There was a problem hiding this comment.
The Windows uninstall path only matches/kills processes whose cmdline contains airc-daemon.bat, which targets the launcher cmd.exe/batch but not the spawned bash/airc connect processes. Consider reusing the existing process-tree helpers (e.g. proc_children, proc_cmdline, or pidfile-based teardown) to reliably terminate the whole daemon process 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)" | |
| # Match the launcher first, but use Windows tree termination so | |
| # spawned `bash` / `airc connect` children are also stopped. Then | |
| # clean up any orphaned daemon children that still reference this | |
| # daemon scope in their command line. | |
| local scope; scope=$(_daemon_scope) | |
| local killed_any=0 | |
| local pid | |
| while read -r pid; do | |
| [ -n "$pid" ] || continue | |
| if command -v taskkill >/dev/null 2>&1; then | |
| taskkill //PID "$pid" //T //F >/dev/null 2>&1 || kill "$pid" 2>/dev/null || true | |
| else | |
| kill "$pid" 2>/dev/null || true | |
| fi | |
| killed_any=1 | |
| done < <(ps -ef 2>/dev/null | awk '/airc-daemon\.bat/ && !/awk/ {print $2}') | |
| while read -r pid; do | |
| [ -n "$pid" ] || continue | |
| if command -v taskkill >/dev/null 2>&1; then | |
| taskkill //PID "$pid" //T //F >/dev/null 2>&1 || kill "$pid" 2>/dev/null || true | |
| else | |
| kill "$pid" 2>/dev/null || true | |
| fi | |
| killed_any=1 | |
| done < <(ps -ef 2>/dev/null | awk -v scope="$scope" ' | |
| index($0, scope) && index($0, "airc connect") && $0 !~ /airc-daemon\.bat/ && !/awk/ {print $2} | |
| ') | |
| if [ "$killed_any" -eq 1 ]; then | |
| echo " ✓ Killed running daemon process tree" |
| # Find Git Bash. The launcher .bat bridges from cmd.exe (Run key | ||
| # context) into bash (where airc actually runs). | ||
| 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 |
There was a problem hiding this comment.
bash_exe is written verbatim into the generated .bat. If the Git-for-Windows install is found via the $HOME/.../Git/bin/bash.exe candidate, bash_exe will be a POSIX path like /c/Users/.../bash.exe, which cmd.exe (Run-key context) typically can't execute. Convert the discovered bash path to a Windows path (e.g. via cygpath -w) before embedding it into the batch file so both system-wide and per-user Git installs work.
| 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:]') |
There was a problem hiding this comment.
$scope/airc.pid contains multiple PIDs separated by spaces (see cmd_connect writes "$$ $PAIR_PID ..."), but this code strips all whitespace and then kill -0 checks a concatenated number (e.g. "123 456" -> "123456"). Parse the first PID field instead (or iterate fields) so Windows status can reliably detect a live daemon via the pidfile fallback.
| pidfile_pid=$(head -1 "$scope/airc.pid" 2>/dev/null | tr -d '[:space:]') | |
| pidfile_pid=$(awk 'NR == 1 { print $1; exit }' "$scope/airc.pid" 2>/dev/null) |
fix(airc daemon): scope tracks cwd at install time, not always $HOME/.airc PR #200 follow-up. _daemon_scope was returning ${AIRC_HOME:-$HOME/.airc} unconditionally, but actual user state lives in $cwd/.airc per detect_scope(). So 'airc daemon install' from ~/continuum/ captured the wrong scope (~/.airc, empty), spawned a monitor that connected to nothing, user appeared offline despite 'RUNNING (PID xxx)' in status. Mirror detect_scope's logic exactly: AIRC_HOME if set, else cwd/.airc. Now 'airc daemon install' from a project dir captures THAT dir's .airc as the daemon's scope, launcher .bat sets AIRC_HOME=that, the spawned airc connect uses the right room state. Joel 2026-04-28 ~01:05Z caught this: 'lol obv if it worked you would have a monitor and be online. FAIL'.
…op) (#202) * fix(airc daemon): scope tracks cwd at install time, not always $HOME/.airc PR #200 follow-up. _daemon_scope was returning ${AIRC_HOME:-$HOME/.airc} unconditionally, but actual user state lives in $cwd/.airc per detect_scope(). So 'airc daemon install' from ~/continuum/ captured the wrong scope (~/.airc, empty), spawned a monitor that connected to nothing, user appeared offline despite 'RUNNING (PID xxx)' in status. Mirror detect_scope's logic exactly: AIRC_HOME if set, else cwd/.airc. Now 'airc daemon install' from a project dir captures THAT dir's .airc as the daemon's scope, launcher .bat sets AIRC_HOME=that, the spawned airc connect uses the right room state. Joel 2026-04-28 ~01:05Z caught this: 'lol obv if it worked you would have a monitor and be online. FAIL'. * fix(airc daemon): launcher cd's to cwd, skip AIRC_HOME (Windows fs view fix) Daemon installed via PR #200/#201 was still crashlooping (every 4s) because the launcher .bat set AIRC_HOME to a Windows-form path (C:\Users\green\continuum\.airc) which Git Bash's airc binary couldn't traverse cleanly downstream. Plus 'bash -lc' was reading login profile and re-exporting PATH which churned env. Restructured launcher .bat: 1. 'cd /d <cwd_win>' from cmd.exe so the bash subprocess inherits the project dir as pwd. detect_scope() then returns <cwd>/.airc the same way it does in the user's interactive shell. 2. Drop AIRC_HOME entirely — let detect_scope work normally. 3. 'bash -c' not 'bash -lc' — non-login skips profile, keeps the env we set in cmd uncorrupted. 4. Absolute Unix-form path to airc (cygpath -u) — bash -c doesn't read ~/.bashrc, so PATH may not include ~/.local/bin. 5. Errors log to daemon.err relative to cwd (already cd'd into it). Joel 2026-04-28 caught both the wrong-scope (PR #201) and now the crashloop. Verified locally: with this launcher shape, airc connect runs to completion + maintains the SSH tail to the host.
target 2, net -40) (#209) refactor(airc): _daemon_install_done helper + trim duplicated comments (#205 target 2) Three platform daemon installers (launchd/systemd/schtasks) duplicated the same 5-line "Loaded into X / airc will auto-start / Logs / Status" print block. Plus the schtasks function had ~30 lines of comment paragraphs duplicating commit-history context (#200/#202 explanations). Now: one `_daemon_install_done` helper for the print footer, called by all three installers. Schtasks comment block trimmed to a 4-line summary that points at PR #202 for the bug-history detail. Behavior unchanged on every platform — same plist/unit/.bat content, same registration calls, same status output (just printed via the helper). #205 target 2 of 6.
Summary
airc daemon install / uninstall / statusWhy
Joel 2026-04-28: 'fix the monitor man / i cant go to bed till this is fixed'. Windows had no daemon path --
airc daemon installdied on$(uname -s). The only persistence was leaving a Git Bash window open runningairc join;nohup &+disowndoesn't survive parent shell exit on MINGW64.What landed
_daemon_os:MINGW*|MSYS*|CYGWIN*) echo "windows"_daemon_install_schtasks(kept name for git-grep continuity, but uses HKCU Run-key now): writes$scope/airc-daemon.bat+ registers under HKCU Run, fires-and-forgetscmd /c start /MINimmediately so no logout-login neededVerified locally on continuum-b69f's Windows MINGW64
Test plan
windowsbranch)airc daemon installon a fresh Windows install, verify monitor persists across bash exits + reboots🤖 Generated with Claude Code