diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a3ab964 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,221 @@ +name: ci + +# Three jobs on every PR + every push to canary/main: +# +# clean-install-linux ubuntu install.sh + airc doctor + smoke test +# clean-install-macos macos install.sh + airc doctor + smoke test +# clean-install-windows windows install.ps1 + airc doctor (PS-side) +# +# Plus on canary/main only (skipped on PR): integration-suite runs the +# full test/integration.sh on ubuntu — the most thorough gate, but +# expensive (real gh-gist scenarios + ~5min runtime). +# +# The clean-install jobs are the "guarantee installs work from zero" +# gate Joel asked for (2026-04-28). They run on a stock runner image +# with no airc preinstalled, exercise install.{sh,ps1} the way a real +# first-time user would, and validate the binary lands + doctor reports +# clean. Without these, every Windows install bug (#94, #96, #98, #99) +# slipped past every PR review and only surfaced when a user hit them. + +on: + pull_request: + branches: [canary, main] + push: + branches: [canary, main] + +# A previous push's CI gets cancelled if the same branch / PR pushes +# again. Prevents queue pileup when several PRs land in quick succession. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + clean-install-linux: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Stage install.sh in a temp dir (simulate first-time user) + # The shipped install.sh clones from the canonical github URL. + # In CI we want it to install FROM THIS COMMIT, not from main — + # otherwise we'd be testing the published install.sh against + # whatever's already on the canonical branch, not the PR. + # Override AIRC_DIR + skip the clone step by pre-populating + # the source tree, then run install.sh's PATH/skills wiring. + run: | + mkdir -p $HOME/.airc-src + cp -r . $HOME/.airc-src/ + # Real install — no AIRC_SKIP_PREREQS. install.sh must + # detect the package manager and install everything missing. + AIRC_DIR=$HOME/.airc-src bash install.sh + + - name: airc doctor (must report environment-clean) + run: | + export PATH="$HOME/.local/bin:$PATH" + which airc + airc doctor + + - name: Smoke — connect --no-room --no-gist + teardown + run: | + export PATH="$HOME/.local/bin:$PATH" + export AIRC_HOME=/tmp/ci-airc/state + export AIRC_NO_DISCOVERY=1 AIRC_NO_GENERAL=1 AIRC_NO_IDENTITY_PROMPT=1 + mkdir -p /tmp/ci-airc/state + # Spawn host in background. --no-gist keeps it offline. + airc connect --no-room --no-gist > /tmp/ci-airc/host.log 2>&1 & + # Wait up to 10s for airc.pid to appear (airc writes it once + # the host loop is up). Don't pgrep on argv — airc's actual + # process line is `bash /path/to/airc connect ...` and pgrep + # patterns are brittle across distros. + for i in 1 2 3 4 5 6 7 8 9 10; do + [ -f /tmp/ci-airc/state/airc.pid ] && break + sleep 1 + done + if [ ! -f /tmp/ci-airc/state/airc.pid ]; then + echo "FAIL: airc.pid never appeared — connect didn't reach host loop" + cat /tmp/ci-airc/host.log || true + exit 1 + fi + # Verify all PIDs in airc.pid are alive. + for p in $(cat /tmp/ci-airc/state/airc.pid); do + if ! kill -0 "$p" 2>/dev/null; then + echo "FAIL: PID $p in airc.pid is not alive" + cat /tmp/ci-airc/host.log || true + exit 1 + fi + done + echo "✓ airc connect stayed up (pids: $(cat /tmp/ci-airc/state/airc.pid))" + airc teardown + sleep 1 + # After teardown, airc.pid is removed AND no PID from it should + # still be alive. We saved the pids before teardown for the + # post-check. + if [ -f /tmp/ci-airc/state/airc.pid ]; then + echo "FAIL: airc teardown left airc.pid behind" + exit 1 + fi + echo "✓ airc teardown clean" + + clean-install-macos: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Stage install.sh + run (no skip-prereqs — real install path) + run: | + mkdir -p $HOME/.airc-src + cp -r . $HOME/.airc-src/ + AIRC_DIR=$HOME/.airc-src bash install.sh + + - name: airc doctor (must report environment-clean) + run: | + export PATH="$HOME/.local/bin:$PATH" + which airc + airc doctor + + - name: Smoke — same as linux (airc.pid based) + run: | + export PATH="$HOME/.local/bin:$PATH" + export AIRC_HOME=/tmp/ci-airc/state + export AIRC_NO_DISCOVERY=1 AIRC_NO_GENERAL=1 AIRC_NO_IDENTITY_PROMPT=1 + mkdir -p /tmp/ci-airc/state + airc connect --no-room --no-gist > /tmp/ci-airc/host.log 2>&1 & + for i in 1 2 3 4 5 6 7 8 9 10; do + [ -f /tmp/ci-airc/state/airc.pid ] && break + sleep 1 + done + if [ ! -f /tmp/ci-airc/state/airc.pid ]; then + echo "FAIL: airc.pid never appeared — connect didn't reach host loop" + cat /tmp/ci-airc/host.log || true + exit 1 + fi + for p in $(cat /tmp/ci-airc/state/airc.pid); do + if ! kill -0 "$p" 2>/dev/null; then + echo "FAIL: PID $p in airc.pid is not alive" + cat /tmp/ci-airc/host.log || true + exit 1 + fi + done + echo "✓ airc connect stayed up (pids: $(cat /tmp/ci-airc/state/airc.pid))" + airc teardown + sleep 1 + if [ -f /tmp/ci-airc/state/airc.pid ]; then + echo "FAIL: airc teardown left airc.pid behind" + exit 1 + fi + echo "✓ airc teardown clean" + + clean-install-windows: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run install.ps1 (no skip — real install path via winget) + shell: pwsh + run: | + $env:AIRC_DIR = "$env:USERPROFILE\.airc-src" + New-Item -ItemType Directory -Force -Path $env:AIRC_DIR | Out-Null + Copy-Item -Recurse -Force * $env:AIRC_DIR + # install.ps1 must work from default Windows PowerShell 5.1 + # too; we test 5.1 path in a separate job below. + & "$env:AIRC_DIR\install.ps1" + + - name: airc doctor (must report environment-clean) + shell: pwsh + run: | + $env:PATH = "$env:USERPROFILE\AppData\Local\Programs\airc;$env:PATH" + (Get-Command airc -ErrorAction SilentlyContinue) | Out-String + airc doctor + if ($LASTEXITCODE -ne 0) { + Write-Error "airc doctor failed with exit $LASTEXITCODE" + exit $LASTEXITCODE + } + + clean-install-windows-ps5: + # Validates the bootstrap path under Windows PowerShell 5.1 — the + # default that ships with Windows. install.ps1 must work from 5.1 + # to bootstrap pwsh itself (#91 — bootstrap-airc.ps1 fails under + # PS 5.1 because airc.ps1 has #Requires -Version 7.0). Splitting + # this into its own job means a 5.1 regression fails loudly without + # also failing the pwsh-based smoke. + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run install.ps1 under Windows PowerShell 5.1 (real install) + shell: powershell + run: | + $env:AIRC_DIR = "$env:USERPROFILE\.airc-src-ps5" + New-Item -ItemType Directory -Force -Path $env:AIRC_DIR | Out-Null + Copy-Item -Recurse -Force * $env:AIRC_DIR + & "$env:AIRC_DIR\install.ps1" + + integration-suite: + # Heavy gate: the full test/integration.sh, including scenarios that + # hit real gh-gists. Runs on canary/main pushes, NOT on PRs (rate + # limits + flaky network). When canary→main bundle PRs come up, this + # already-green status on the canary tip is the signal that cross- + # branch validation passed. + if: github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Stage + install (real install path) + run: | + mkdir -p $HOME/.airc-src + cp -r . $HOME/.airc-src/ + AIRC_DIR=$HOME/.airc-src bash install.sh + + - name: Run integration suite + run: | + export PATH="$HOME/.local/bin:$PATH" + # Tests that need real gists self-skip without gh auth. The + # remaining ~85% of the suite covers the local-only scenarios + # that catch the lion's share of regressions. + bash test/integration.sh diff --git a/.gitignore b/.gitignore index 6f35e7a..b9b91dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .airc/ +__pycache__/ +*.pyc diff --git a/README.md b/README.md index b03eac3..827af3a 100644 --- a/README.md +++ b/README.md @@ -14,17 +14,15 @@ ## Install -**macOS / Linux / WSL** (bash): +**Any platform** (bash — works from macOS / Linux / WSL / Windows Git Bash): ```bash curl -fsSL https://raw.githubusercontent.com/CambrianTech/airc/main/install.sh | bash ``` -**Windows** (PowerShell — works from the default Windows PowerShell 5.1; bootstraps pwsh 7 + every other prereq via winget): +This is the install command for everyone running Claude Code, Codex, Cursor, opencode, Windsurf, or openclaw — all of which use bash on every platform including Windows. On Windows, install.sh detects Git Bash, installs prereqs via winget, and self-elevates once for OpenSSH server + DefaultShell setup. You stay in your terminal — no PowerShell switch. -```powershell -iwr https://raw.githubusercontent.com/CambrianTech/airc/main/install.ps1 | iex -``` +> **Native-PowerShell users (rare):** if you specifically want `airc.ps1` (the PowerShell port, not the bash one), use `iwr https://raw.githubusercontent.com/CambrianTech/airc/main/install.ps1 | iex` instead. Most users don't need this — Claude Code / Codex / etc. on Windows run in Git Bash, where `install.sh` is the right entry. One command. Puts `airc` on your `PATH` and installs the Claude Code skills automatically. Other agents (Codex, Cursor, opencode, Windsurf, openclaw) get their integration files at [`integrations/`](integrations/). @@ -120,19 +118,15 @@ This isn't a knock on the federation protocols — they solve real enterprise fe ## Install -**macOS / Linux / WSL**: +**Every platform** (macOS / Linux / WSL / Windows Git Bash): ```bash curl -fsSL https://raw.githubusercontent.com/CambrianTech/airc/main/install.sh | bash ``` -**Windows** (PowerShell): - -```powershell -iwr https://raw.githubusercontent.com/CambrianTech/airc/main/install.ps1 | iex -``` +Puts `airc` on your `PATH` and installs Claude Code skills automatically. Auto-installs every prereq (gh, openssl, python3, openssh-client, optional tailscale) via the platform's package manager (brew / apt / dnf / pacman / apk / winget). On Windows it self-elevates once for OpenSSH Server + DefaultShell setup; you stay in your terminal. -Puts `airc` on your `PATH` and installs Claude Code skills automatically. Both installers auto-install every prereq (gh, openssl, python3, openssh-client, optional tailscale) via the platform's package manager (brew / apt / dnf / pacman / apk / winget). +> **Native-PowerShell users:** rare, but if you specifically want the PowerShell port `airc.ps1` instead of the bash binary, use `iwr https://raw.githubusercontent.com/CambrianTech/airc/main/install.ps1 | iex`. The bash install.sh is the right entry for everyone running Claude Code / Codex / Cursor on Windows (which all default to Git Bash). ## 30-Second Setup diff --git a/airc b/airc index 434cc0e..ef3d7d0 100755 --- a/airc +++ b/airc @@ -14,17 +14,102 @@ set -euo pipefail # downstream goes through this wrapper. Hard fail if neither is present # (we genuinely need Python — the inline heredocs for monitor formatting # and pair handshake are not yet ported to pure shell). -if ! command -v python3 >/dev/null 2>&1; then - if command -v python >/dev/null 2>&1; then - # Define a wrapper function that callers see as `python3`. - python3() { command python "$@"; } - export -f python3 2>/dev/null || true +# +# AIRC_PYTHON: resolve real Python interpreter once, propagate via env +# var. Pre-fix (PR #153) used a bash function shim named python3 +# exported via export -f. On Git Bash MINGW, export -f succeeds +# silently but the function does NOT reliably inherit into command- +# substitution subshells (the captured-via-dollar-paren pattern). +# Result: every site that captured python3 -c output through a +# subshell (45+ callsites) bypassed the shim, hit the Microsoft +# Store stub, exited 49 with empty stdout. The pipe-to-echo-empty +# fallbacks on those sites then silently set config values to empty +# strings — host_target, host_airc_home, name all became empty. +# cmd_send then took the HOST path (no host_target) and mirrored +# locally without ever attempting the SSH push. Net: every Win→Mac +# broadcast silently no-op'd while pretending success. Caught by +# continuum-b69f via cross-Mac/Windows substrate-bypass gist +# 2026-04-27. +# +# Fix: env-var holds the resolved interpreter path. Bash variables +# propagate to subshells unconditionally — no function-export quirks. +# Every callsite now invokes "$AIRC_PYTHON" -c instead of the +# function-shim name; sed replace across the file did the conversion. +if python3 --version >/dev/null 2>&1; then + AIRC_PYTHON=python3 +elif command -v python >/dev/null 2>&1 && python --version >/dev/null 2>&1; then + AIRC_PYTHON=python +else + echo "ERROR: airc requires a working python3 (or python on Windows/Git Bash)." >&2 + echo " macOS: brew install python3" >&2 + echo " Linux: apt install python3 / dnf install python3" >&2 + echo " Windows: install from https://www.python.org/downloads/" >&2 + echo "" >&2 + echo " Note for Windows: a 'python3.exe' Store-installer alias on PATH" >&2 + echo " is NOT a real Python — disable it under" >&2 + echo " Settings → Apps → Advanced app settings → App execution aliases" >&2 + echo " (toggle off python.exe and python3.exe), or PATH-prepend your real" >&2 + echo " install (e.g. C:\\Users\\\\AppData\\Local\\Programs\\Python\\Python312\\)." >&2 + exit 1 +fi +export AIRC_PYTHON + +# MSYS Git Bash on Windows translates argv VALUES that look like +# Unix-rooted paths when bash invokes a Windows-native binary. So +# `--host-airc-home /Users/joelteply/.airc` arrives at python.exe as +# `--host-airc-home C:/Program Files/Git/Users/joelteply/.airc` — +# silently corrupting paths that the joiner later sends back over SSH +# to a real Unix host. continuum-b69f traced + fixed this 2026-04-27; +# the targeted exclude covers macOS / Linux / root home prefixes +# without breaking `/tmp/` or `/c/` paths (which DO need translation +# for `--config "$CONFIG"` where $CONFIG is on the local Windows +# filesystem). Set once at airc startup, exported, every airc_core +# invocation inherits the same translation policy. +export MSYS2_ARG_CONV_EXCL="${MSYS2_ARG_CONV_EXCL:-/Users/;/home/;/root/}" + +# Force UTF-8 for stdin/stdout/stderr in every airc_core invocation. +# Windows Python defaults to the local code page (cp1252 on most US/EU +# installs) which can't encode common Unicode chars like → or em-dashes; +# write attempts raise UnicodeEncodeError, the formatter's per-line +# error handler catches it, and the message gets silently dropped from +# the user's view. PYTHONIOENCODING=utf-8 is the standard remedy — +# applies to every Python subprocess airc spawns. Honors user override. +# continuum-b69f's catch + verify 2026-04-27. +export PYTHONIOENCODING="${PYTHONIOENCODING:-utf-8}" + +# Resolve the airc install dir's lib/ path and prepend to PYTHONPATH so +# Python heredocs + module invocations can import airc_core (the +# Python truth-layer #152). Three resolution paths, first hit wins: +# 1. $AIRC_DIR (explicit override / install.sh's working dir) +# 2. dirname of this script (dev checkout — running from repo) +# 3. $HOME/.airc-src (install.sh default) +# When the lib/ dir doesn't exist (e.g. older install before this PR +# landed), PYTHONPATH stays unmodified — heredocs still work. +_airc_resolve_lib_dir() { + local _candidate _abs + for _candidate in \ + "${AIRC_DIR:-}/lib" \ + "$(dirname "$(readlink "$0" 2>/dev/null || echo "$0")")/lib" \ + "$(dirname "$0")/lib" \ + "$HOME/.airc-src/lib"; do + if [ -d "$_candidate/airc_core" ]; then + # Canonicalize to absolute path so PYTHONPATH stays valid even + # if cwd changes mid-script (heredocs that cd elsewhere). cd + + # pwd is the portable canonicalize idiom — `realpath` and + # `readlink -f` are not available everywhere (BSD readlink + # lacks -f, busybox lacks realpath). + _abs=$(cd "$_candidate" 2>/dev/null && pwd) || _abs="$_candidate" + printf '%s' "$_abs" + return 0 + fi + done +} +_airc_lib_dir=$(_airc_resolve_lib_dir) +if [ -n "${_airc_lib_dir:-}" ]; then + if [ -n "${PYTHONPATH:-}" ]; then + export PYTHONPATH="$_airc_lib_dir:$PYTHONPATH" else - echo "ERROR: airc requires python3 (or python on Windows/Git Bash)." >&2 - echo " macOS: brew install python3" >&2 - echo " Linux: apt install python3 / dnf install python3" >&2 - echo " Windows: install from python.org or Microsoft Store" >&2 - exit 1 + export PYTHONPATH="$_airc_lib_dir" fi fi @@ -206,6 +291,54 @@ fi unset _gh_resolved AIRC_WRITE_DIR="$(detect_scope)" + +# Re-exec airc connect into a different mode (rejoin into another tab's +# gist or take over as host). Centralizes (a) the sentinel marker for +# the Windows daemon launcher (#203/#204 — distinguishes intentional +# re-exec from "actual crash"), (b) AIRC_NAME preservation across the +# exec, and (c) AIRC_NO_DISCOVERY=1 for host-takeover so the new +# instance won't re-find the just-deleted gist via gh's list-cache. +# Replaces 5 duplicated 3-line call sites in cmd_connect (#205 target 1). +_reexec_into() { + local mode="$1"; shift # "rejoin" or "host" + printf '%d:%d\n' "$$" "$(date +%s)" > "$AIRC_WRITE_DIR/airc.reexec-marker" 2>/dev/null || true + local _name; _name=$(get_config_val name "") + if [ "$mode" = "host" ]; then + exec env AIRC_NO_DISCOVERY=1 ${_name:+AIRC_NAME="$_name"} "$0" connect "$@" + else + exec env ${_name:+AIRC_NAME="$_name"} "$0" connect "$@" + fi +} + +# Stale-host self-heal + race-loser detection. Args: $1=stale gist id, +# $2=room name. Random jitter, delete stale, re-list to see if another +# tab self-healed first; if yes rejoin theirs, else take over as host. +# Always exec's via _reexec_into; does not return. Wipes $CONFIG + +# room_name first since both pointed at the dead host. Replaces 2 +# duplicated 22-line blocks in cmd_connect (#205 target 4). +_self_heal_stale_host() { + local stale_id="$1" room_name="$2" + local jitter; jitter=$(awk -v r="$RANDOM" 'BEGIN{printf "%.3f", 0.1 + (r%1500)/1000}') + sleep "$jitter" + if gh gist delete "$stale_id" --yes 2>/dev/null; then + echo " ✓ Stale gist removed." + else + echo " ⚠ Stale gist already gone — another tab may have taken over first." + fi + local picked + picked=$(gh gist list --limit 50 2>/dev/null \ + | awk -F'\t' -v re="airc room: ${room_name}\$" -v skip="$stale_id" \ + '$2 ~ re && $1 != skip { print $1; exit }') + rm -f "$CONFIG" "$AIRC_WRITE_DIR/room_name" + if [ -n "$picked" ]; then + echo " ✓ Another tab beat us to it — joining their fresh gist ($picked)" + echo "" + _reexec_into rejoin "$picked" + fi + echo " Re-execing into host mode for #${room_name}..." + echo "" + _reexec_into host --room "$room_name" +} CONFIG="$AIRC_WRITE_DIR/config.json" IDENTITY_DIR="$AIRC_WRITE_DIR/identity" PEERS_DIR="$AIRC_WRITE_DIR/peers" @@ -240,12 +373,23 @@ ensure_init() { die "Not initialized ($AIRC_WRITE_DIR). Run: airc connect" } +# config CRUD via airc_core.config — proper argparse --flags so paths +# are per-arg-predictable across MSYS path-translation. Each call passes +# `--config ` explicitly. get_name() { - python3 -c "import json; print(json.load(open('$CONFIG'))['name'])" 2>/dev/null || echo "unknown" + "$AIRC_PYTHON" -m airc_core.config get_name --config "$CONFIG" 2>/dev/null || echo "unknown" } -get_config_val() { - python3 -c "import json; print(json.load(open('$CONFIG')).get('$1','$2'))" 2>/dev/null || echo "$2" +get_config_val() { "$AIRC_PYTHON" -m airc_core.config get --config "$CONFIG" "$1" "${2:-}" 2>/dev/null || echo "${2:-}"; } +set_config_val() { "$AIRC_PYTHON" -m airc_core.config set --config "$CONFIG" --key "$1" --value "$2"; } +unset_config_keys() { "$AIRC_PYTHON" -m airc_core.config unset_keys --config "$CONFIG" "$@"; } + +# Same as get_config_val but reads from an arbitrary config.json path. +# Used by _whois_in_scope (#134 cross-scope walk) and other places +# that need to read sibling-scope state without changing $CONFIG. +get_config_val_in() { + local cfg="$1" key="$2" default="${3:-}" + "$AIRC_PYTHON" -m airc_core.config get --config "$cfg" "$key" "$default" 2>/dev/null || echo "$default" } get_host() { @@ -277,9 +421,9 @@ get_host() { # Returns one of 192.168.*, 10.*, 172.16-31.* on a typical home/office # LAN. Returns 127.0.0.1 if no internet route is available — which we # treat as "no LAN" and fall through to hostname. - if command -v python3 >/dev/null 2>&1; then + if [ -n "${AIRC_PYTHON:-}" ]; then local lan_ip - lan_ip=$(python3 -c " + lan_ip=$("$AIRC_PYTHON" -c " import socket s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: @@ -327,17 +471,36 @@ resolve_tailscale_bin() { return 0 fi # Known-path fallbacks. Both common Windows install locations + the - # macOS .app bundle. Order matters: try the cheap PATH cases first. + # macOS .app bundle. Use [ -f ] not [ -x ] for the Windows .exe paths + # — Git Bash MSYS doesn't always reflect the executable bit on NTFS + # for files Windows considers runnable-by-extension. -f catches the + # file's existence; .exe is implicitly executable on Windows. local candidate for candidate in \ "/c/Program Files/Tailscale/tailscale.exe" \ - "/mnt/c/Program Files/Tailscale/tailscale.exe" \ - "/Applications/Tailscale.app/Contents/MacOS/Tailscale"; do - if [ -x "$candidate" ]; then + "/c/Program Files (x86)/Tailscale/tailscale.exe" \ + "/mnt/c/Program Files/Tailscale/tailscale.exe"; do + if [ -f "$candidate" ]; then echo "$candidate" return 0 fi done + if [ -x /Applications/Tailscale.app/Contents/MacOS/Tailscale ]; then + echo "/Applications/Tailscale.app/Contents/MacOS/Tailscale" + return 0 + fi + # Last resort: where.exe searches every PATH+PATHEXT location, catches + # winget user-scope installs (%LOCALAPPDATA%\...) that aren't in any + # of the hard-coded paths above (Joel 2026-04-28: install.sh's + # tailscale_present had the same blind spot). + if command -v where.exe >/dev/null 2>&1; then + local _wherewin + _wherewin=$(where.exe tailscale.exe 2>/dev/null | head -1 | tr -d '\r') + if [ -n "$_wherewin" ]; then + local _bash; _bash=$(_to_bash_path "$_wherewin") + [ -n "$_bash" ] && [ -f "$_bash" ] && { echo "$_bash"; return 0; } + fi + fi return 1 } @@ -556,7 +719,32 @@ tailscale_login_check_or_prompt() { # same. No URL extraction, no bg jobs, no browser auto-launch. The # `|| true` lets us continue if the user cancels (Ctrl-C); we just # proceed without Tailscale, same-machine + same-LAN paths still work. - "$ts_bin" up || true + "$ts_bin" up || _ts_up_rc=$? + # On Windows, `tailscale up` from non-admin Git Bash can exit silently + # if the user's shell isn't allowed to talk to the daemon's named + # pipe. Detect that case and fall back to launching the GUI (tailscale- + # ipn.exe lives next to tailscale.exe) so the user can click "Log in" + # from the tray. Without this fallback, the user sees nothing, has + # no obvious next step, and we silently proceed with logged-out + # Tailscale (Joel 2026-04-28). + if [ "${_ts_up_rc:-0}" != "0" ]; then + case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) + echo "" >&2 + echo " ⚠ 'tailscale up' didn't complete (non-admin shell limitation on Windows)." >&2 + local _ts_dir _ts_ipn + _ts_dir=$(dirname "$ts_bin") + _ts_ipn="$_ts_dir/tailscale-ipn.exe" + if [ -f "$_ts_ipn" ]; then + echo " Opening Tailscale GUI — click the tray icon → Log in to authorize." >&2 + "$_ts_ipn" >/dev/null 2>&1 & + else + echo " Click the Tailscale tray icon (system tray) → Log in to authorize." >&2 + fi + echo "" >&2 + ;; + esac + fi echo "" >&2 return 0 } @@ -772,147 +960,21 @@ sign_message() { } # ── Platform adapters ─────────────────────────────────────────────────── -# -# Single-purpose helpers that hide platform-specific differences in the -# process / port / filesystem APIs. Every callsite that needs "find -# children of PID X" or "find PIDs listening on port Y" goes through -# these helpers, NOT inline pgrep/lsof. That way: -# -# 1. The platform-specific implementation lives in ONE place per -# capability — adding a Windows fallback for `lsof` (e.g. via -# `netstat -ano`) means editing one helper, not 4+ callsites. -# 2. The business logic above the adapter line stays platform- -# agnostic. Refactor risk drops. -# 3. We hold the line on Joel's "fixing one platform shouldn't -# degrade another" rule (2026-04-26): without adapters, a Mac -# AI's tweak to a pgrep callsite easily diverges from the Linux -# AI's tweak. With adapters, both AIs touch the same helper. -# -# Each adapter takes simple inputs and emits a one-thing-per-line -# stream, suitable for `while IFS= read -r` consumption. Callers can -# `tr '\n' ' '` if they want space-separated, but the canonical -# representation is newline-delimited (POSIX-friendly). -# -# Conventions: -# - `proc_*` — process / PID introspection -# - `port_*` — TCP port introspection -# - `file_*` — filesystem metadata -# - `detect_*` — environment classification - -# Return PIDs of direct children of $1, one per line. -# Implementations: pgrep -P (POSIX/macOS/Linux), ps fallback for -# environments without pgrep (Git Bash for Windows ships only msys -# coreutils — no pgrep by default; the fallback uses `ps -axo pid,ppid` -# which msys2 ps DOES support). Empty output if no children or pid is -# already gone. -proc_children() { - local pid="$1" - [ -z "$pid" ] && return 0 - if command -v pgrep >/dev/null 2>&1; then - pgrep -P "$pid" 2>/dev/null - else - # POSIX-portable fallback. Works on Git Bash (msys ps), Linux ps, - # macOS ps. Awk filters by ppid column. - ps -axo pid,ppid 2>/dev/null | awk -v p="$pid" '$2 == p { print $1 }' - fi -} - -# Return parent PID of $1. Empty if $1 is gone. -proc_parent() { - local pid="$1" - [ -z "$pid" ] && return 0 - ps -p "$pid" -o ppid= 2>/dev/null | tr -d ' ' -} - -# Return the command line of $1 (full argv, space-joined). Empty if gone. -proc_cmdline() { - local pid="$1" - [ -z "$pid" ] && return 0 - ps -p "$pid" -o command= 2>/dev/null -} - -# Find airc-related PIDs owned by the current user matching a pattern. -# Used by `airc teardown --all` to nuke every airc process. -# Pattern is a regex passed to pgrep -f or to awk's =~. -proc_airc_pids_matching() { - local pattern="$1" - [ -z "$pattern" ] && return 0 - if command -v pgrep >/dev/null 2>&1; then - pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null - else - # Fallback: ps + awk. Less precise than pgrep -f (no anchored regex) - # but covers the same shape. Filter by user since msys ps -u option - # may not match POSIX semantics. - local me; me=$(whoami 2>/dev/null) - ps -axo pid,user,command 2>/dev/null \ - | awk -v u="$me" -v p="$pattern" 'NR>1 && $2 == u && $0 ~ p { print $1 }' - fi -} - -# Return PIDs listening on TCP port $1 (LISTEN state), one per line. -# Implementations: -# 1. lsof -tiTCP: -sTCP:LISTEN — macOS, most BSDs, modern Linux -# with lsof installed. -# 2. ss -tlnp — modern Linux distros (iproute2 default since ~2017), -# replaces deprecated netstat. Output post-processing extracts pid. -# 3. netstat -ano — Windows native (cmd / PowerShell), and also a -# fallback on minimal Linux containers without lsof or ss. Output -# shape differs per platform; awk parses the LISTENING column. -# Empty output = nobody listening. -port_listeners() { - local port="$1" - [ -z "$port" ] && return 0 - if command -v lsof >/dev/null 2>&1; then - lsof -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null - elif command -v ss >/dev/null 2>&1; then - # ss output: 'LISTEN 0 ... users:(("python",pid=12345,fd=4))' - # Awk extracts pid= number. - ss -tlnp "( sport = :$port )" 2>/dev/null \ - | awk 'NR>1 { match($0, /pid=[0-9]+/); if (RSTART) print substr($0, RSTART+4, RLENGTH-4) }' - elif command -v netstat >/dev/null 2>&1; then - # netstat -ano output (Windows + some Linux): - # TCP 0.0.0.0:7547 0.0.0.0:0 LISTENING 12345 - # Trailing column is PID. Match $port at end of local-address column. - netstat -ano 2>/dev/null \ - | awk -v p=":$port" '$2 ~ p"$" && /LISTEN/ { print $NF }' - fi -} - -# Return file size in bytes. Empty / 0 on failure. -# stat is not POSIX (different flags on BSD vs GNU); chain both with -# fallback to wc -c which IS POSIX. -file_size() { - local path="$1" - [ -f "$path" ] || { echo 0; return 0; } - stat -f%z "$path" 2>/dev/null \ - || stat -c%s "$path" 2>/dev/null \ - || wc -c < "$path" 2>/dev/null \ - || echo 0 -} - -# Detect platform: emits one of macos, linux, wsl, windows-bash (Git Bash -# on Windows native), unknown. Most callers don't need this — they -# should use the proc_/port_/file_ adapters, which handle platform -# differences internally. detect_platform is for the rare case where -# a top-level decision genuinely depends on platform (e.g. Tailscale.app -# launching on macOS). -detect_platform() { - local s; s=$(uname -s 2>/dev/null) - case "$s" in - Darwin) echo macos ;; - Linux) - # Detect WSL via /proc/version content (kernel string contains - # 'microsoft' or 'WSL'). Bare Linux otherwise. - if grep -qiE 'microsoft|wsl' /proc/version 2>/dev/null; then - echo wsl - else - echo linux - fi ;; - MINGW*|MSYS*|CYGWIN*) echo windows-bash ;; - *) echo unknown ;; - esac -} - +# Decomposed into lib/airc_bash/platform_adapters.sh (#152 Phase 3 — file +# split). Sourced via the lib-dir resolver set at the top of airc. The +# resolver's lib_dir already covers airc_core/ (Python truth-layer); +# airc_bash/ is the bash-side companion that holds extracted adapters +# and command files. Same precedence: AIRC_DIR / readlink / dirname / +# $HOME/.airc-src. +if [ -n "${_airc_lib_dir:-}" ] && [ -f "$_airc_lib_dir/airc_bash/platform_adapters.sh" ]; then + # shellcheck source=lib/airc_bash/platform_adapters.sh + source "$_airc_lib_dir/airc_bash/platform_adapters.sh" +else + echo "ERROR: airc_bash/platform_adapters.sh not found via lib-dir resolver." >&2 + echo " Resolved lib_dir: ${_airc_lib_dir:-}" >&2 + echo " Re-run install.sh or check AIRC_DIR." >&2 + exit 1 +fi # ── End platform adapters ─────────────────────────────────────────────── relay_ssh() { @@ -965,70 +1027,11 @@ _primary_scope_for() { fi } -# Read the parted_rooms list (issue #136) from a primary scope's -# config.json. Echoes one room per line (empty if unset). Caller can -# pipe to grep -Fxq "" to test membership without subshell. -_read_parted_rooms() { - local primary="$1" - local cfg="$primary/config.json" - [ -f "$cfg" ] || return 0 - CONFIG="$cfg" python3 -c ' -import json, os -try: - c = json.load(open(os.environ["CONFIG"])) - for r in c.get("parted_rooms", []) or []: - print(r) -except Exception: - pass -' 2>/dev/null -} - -# Mark a room as parted in the primary scope's config (issue #136). -# Idempotent — re-parting the same room does not create duplicates. -# Persists across teardown/reboot so /part is sticky, not session-only. -_record_parted_room() { - local primary="$1" room="$2" - local cfg="$primary/config.json" - [ -f "$cfg" ] || return 0 - CONFIG="$cfg" ROOM="$room" python3 -c ' -import json, os, sys -cfg = os.environ["CONFIG"] -room = os.environ["ROOM"] -try: - c = json.load(open(cfg)) -except Exception: - # Better to no-op than corrupt config; the missing persist surfaces - # as auto-resubscribe on next bootstrap, not silent state corruption. - sys.exit(0) -parted = list(c.get("parted_rooms", []) or []) -if room not in parted: - parted.append(room) - c["parted_rooms"] = parted - json.dump(c, open(cfg, "w"), indent=2) -' 2>/dev/null || true -} - -# Remove a room from the primary scope's parted_rooms (issue #136). -# Used by `airc join --general` (and similar explicit re-opt-in flows) -# to undo a prior /part. -_clear_parted_room() { - local primary="$1" room="$2" - local cfg="$primary/config.json" - [ -f "$cfg" ] || return 0 - CONFIG="$cfg" ROOM="$room" python3 -c ' -import json, os, sys -cfg = os.environ["CONFIG"] -room = os.environ["ROOM"] -try: - c = json.load(open(cfg)) -except Exception: - sys.exit(0) -parted = [r for r in (c.get("parted_rooms", []) or []) if r != room] -if parted != (c.get("parted_rooms", []) or []): - c["parted_rooms"] = parted - json.dump(c, open(cfg, "w"), indent=2) -' 2>/dev/null || true -} +# parted_rooms helpers (#136 sticky /part) — thin wrappers; mutation +# logic lives in airc_core.config (#205 target 6). +_read_parted_rooms() { [ -f "$1/config.json" ] && "$AIRC_PYTHON" -m airc_core.config read_parted --config "$1/config.json" 2>/dev/null; } +_record_parted_room() { [ -f "$1/config.json" ] && "$AIRC_PYTHON" -m airc_core.config record_parted --config "$1/config.json" --room "$2" 2>/dev/null || true; } +_clear_parted_room() { [ -f "$1/config.json" ] && "$AIRC_PYTHON" -m airc_core.config clear_parted --config "$1/config.json" --room "$2" 2>/dev/null || true; } # Spawn the #general sidecar (issue #121) — a parallel `airc connect` # in a sibling scope (.general suffix) so the primary tab is in BOTH @@ -1093,11 +1096,32 @@ spawn_general_sidecar_if_wanted() { # helper on 2026-04-26. local _env_args=(AIRC_HOME="$_sidecar_scope" AIRC_GENERAL_SIDECAR=1 AIRC_NO_AUTO_ROOM=1) [ -n "$_primary_name" ] && _env_args+=("AIRC_NAME=$_primary_name") + # Inherit primary's --no-gist flag so test fixtures don't leak a real + # #general gist into the live joelteply gh namespace via the sidecar. + # Bug found by continuum-b69f 2026-04-27 across the cross-Mac/Windows + # substrate-bypass channel: scenario_part_persists et al spawn the + # primary with `--no-gist --no-discovery`, but those flags do not + # propagate to the sidecar's spawn here. The sidecar then publishes + # an `airc room: general` gist with the test fixture's host info + # (e.g. host=alpha, port=7556). Test exits via cleanup_all's `kill -9` + # which bypasses the on-exit gist-delete trap, leaving the gist + # orphaned. Real users (continuum-b69f's Windows tab) discover it, + # try to TCP-connect, get RST. Two layers of test isolation hole; + # this fix patches the upstream half. (The downstream half — kill -9 + # bypassing the trap — is harder to fix; tracked separately.) + # + # AIRC_NO_DISCOVERY=1 propagates automatically via the subshell env; + # only --no-gist needs explicit forwarding because it's a flag, not + # an env var. + local _sidecar_args=(connect --room general) + if [ "${use_gist:-1}" = "0" ]; then + _sidecar_args+=(--no-gist) + fi # Unset primary's AIRC_PORT so sidecar doesn't fight for the same port — # primary has it bound already, sidecar's auto-bump-loop would land on # +1, but better to start the sidecar from the canonical default and # let it find its own free port without the conflict-detect dance. - ( env -u AIRC_PORT "${_env_args[@]}" "$0" connect --room general ) & + ( env -u AIRC_PORT "${_env_args[@]}" "$0" "${_sidecar_args[@]}" ) & local _sidecar_pid=$! # Sidecar's own scope writes its own airc.pid for its bash + descendants. @@ -1120,7 +1144,7 @@ resolve_name() { if [ -n "${AIRC_NAME:-}" ]; then name="$AIRC_NAME" elif [ -f "$CONFIG" ]; then - name=$(python3 -c "import json; print(json.load(open('$CONFIG')).get('name',''))" 2>/dev/null) + name=$(get_config_val name "") fi # Reject flag-shaped names that may have leaked in from a buggy prior rename. case "$name" in -*) name="" ;; esac @@ -1253,16 +1277,42 @@ monitor() { local saved_room="" [ -f "$AIRC_WRITE_DIR/room_name" ] && saved_room=$(cat "$AIRC_WRITE_DIR/room_name" 2>/dev/null) if [ -n "$saved_room" ]; then + # Surface to STDOUT (not stderr-only) so Monitor-style consumers + # that watch stdout (Claude Code Monitor tool, simple `airc join + # | tee log`, integration tests) actually see WHY the mesh just + # went dark. The pre-fix behavior printed to stderr only and + # consumers got a silent disconnect — Joel's #184 (high severity, + # violates CLAUDE.md "never swallow errors"). + # + # Daemon-aware: detect whether `airc daemon install` has been + # run on this OS; if yes, the exit-99 will trigger self-heal + # via launchd/systemd. If NOT, exit 99 is just death — tell + # the user explicitly so they can `airc join` again or install + # the daemon for auto-recovery. + local _daemon_present=0 + if _daemon_installed >/dev/null 2>&1; then + _daemon_present=1 + fi echo "" - echo " ⚠ Host of #${saved_room} dead for $consecutive_timeouts consecutive cycles" >&2 - echo " ⚠ Exiting airc connect — daemon restart will trigger self-heal" >&2 - echo " ⚠ (per the no-claude-left-behind protocol — first agent back becomes new host)" >&2 + if [ "$_daemon_present" = "1" ]; then + echo "airc: mesh disconnected — host of #${saved_room} dead $consecutive_timeouts cycles; daemon restart will self-heal" + echo " ⚠ Host of #${saved_room} dead for $consecutive_timeouts consecutive cycles" >&2 + echo " ⚠ Exiting airc connect — daemon restart will trigger self-heal" >&2 + echo " ⚠ (per the no-claude-left-behind protocol — first agent back becomes new host)" >&2 + else + echo "airc: mesh disconnected — host of #${saved_room} dead $consecutive_timeouts cycles; NO DAEMON installed, restart with: airc join" + echo "airc: (for auto-recovery on disconnect: airc daemon install)" + echo " ⚠ Host of #${saved_room} dead for $consecutive_timeouts consecutive cycles" >&2 + echo " ⚠ Exiting airc connect — NO DAEMON installed; rerun 'airc join' to reconnect" >&2 + echo " ⚠ Install daemon for auto-recovery: airc daemon install" >&2 + fi # Specific exit code so postmortems can tell why we left. launchd / # systemd Restart=always treat any non-zero exit as restart-worthy. exit 99 else # Legacy 1:1 invite scope. Don't auto-promote, but warn the user # so they can manually re-pair if the host is genuinely gone. + echo "airc: $consecutive_timeouts consecutive watchdog timeouts on legacy invite scope — host may be down" echo " ⚠ $consecutive_timeouts consecutive watchdog timeouts on legacy invite scope — host may be down" >&2 consecutive_timeouts=0 # reset to avoid spamming fi @@ -1275,278 +1325,32 @@ monitor() { tail_pos="-n +$(($(cat "$offset_file" 2>/dev/null || echo 0) + 1))" sleep 1 done + # `while true` should be unreachable here — the body has no break / + # exit / return, the pipeline is `|| true`-guarded, and there's no + # signal trap in this scope that returns from the loop. Yet Joel + # observed the host monitor silently disappearing on canary dee3b6c + # (#184 part 2). If we ever fall through, leave a loud diagnostic + # on both stdout (Monitor-visible) and stderr (log-visible) so the + # next person debugging has something to grep — silent exit was + # the original sin per CLAUDE.md "never swallow errors". + echo "airc: host monitor loop exited unexpectedly — restart with: airc join" + echo " ⚠ host monitor while-true loop fell through; this should be impossible." >&2 + echo " ⚠ If you see this, capture the airc connect stdout/stderr + report on #184." >&2 + exit 99 fi } # Read JSONL from stdin, emit one human-readable line per message. # Handles [rename] protocol by updating peer records on disk. +# Read JSONL from stdin, emit one human-readable line per message. +# Migrated to airc_core.monitor_formatter (#152 Phase 1) — same +# stdin/stdout contract, but the python lives in a real .py file +# (no shell-escape gymnastics, no bash-into-python heredoc fragility). +# Bash function is a thin wrapper that invokes the module with the +# same env vars (PEERS_DIR) and argv (my_name). monitor_formatter() { local my_name="$1" - PEERS_DIR="$PEERS_DIR" python3 -u -c ' -import sys, json, os, re, time, signal - -# Inactivity watchdog: if no inbound line arrives in WATCHDOG_SEC, -# exit with a distinct code so the caller'\''s while-loop reconnects. -# Why: the outer SSH tail can hang silently — middleboxes drop idle -# TCP while still ACK'\''ing SSH ServerAlive keepalives, so SSH does -# not notice the channel is dead, and tail -F never returns EOF. The -# Python read just blocks forever. With an application-level watchdog, -# a truly dead channel forces the formatter out and the reconnect loop -# restarts the ssh. Normal chat traffic keeps resetting the alarm so -# there is no penalty when the channel is healthy. -# -# Joel 2026-04-24: heartbeat is OFF by default (canary 95d9907), so -# every fmt_exit=2 used to look like "host went quiet" and spam restart -# notifications on healthy idle. Fix is in the bash retry loop: it -# probes the host on fmt_exit=2 BEFORE counting/notifying. Probe -# success = healthy idle (silent reset); probe failure = real death -# (notify + count toward escalation). -# -# With the probe, WATCHDOG_SEC is just the polling cadence at which -# we re-check the channel. 150s × ESCALATE_AFTER=2 = 5 minutes total -# dead-host detection per Joel'\''s spec. The watchdog itself only fires -# the python exit; the bash probe is what decides whether the user -# sees a notification. -WATCHDOG_SEC = 150 -def _watchdog_exit(signum=None, frame=None): - # Diagnostic to stderr only. The bash retry loop owns the - # user-visible notification — it probes the host on fmt_exit=2 - # to decide whether silence means "healthy idle" (silent reset) - # or "host actually unreachable" (notify + count). Emitting from - # python here would notify on every healthy-idle cycle. - sys.stderr.write(f"[airc:monitor] no inbound in {WATCHDOG_SEC}s — exiting for probe\\n") - sys.stderr.flush() - os._exit(2) - -# Cross-platform watchdog. POSIX (mac/linux/WSL) gets signal.SIGALRM -# which is cheaper (single-thread, kernel-armed). Windows Python has -# no SIGALRM so we fall back to threading.Timer — same exit semantics, -# slight overhead from the timer thread. Either way the fmt_exit=2 -# contract is preserved. -try: - signal.signal(signal.SIGALRM, _watchdog_exit) - signal.alarm(WATCHDOG_SEC) - def _arm_watchdog(): - signal.alarm(WATCHDOG_SEC) -except (AttributeError, ValueError): - import threading - _wd_timer_holder = [None] - def _arm_watchdog(): - if _wd_timer_holder[0] is not None: - _wd_timer_holder[0].cancel() - t = threading.Timer(WATCHDOG_SEC, _watchdog_exit) - t.daemon = True - t.start() - _wd_timer_holder[0] = t - _arm_watchdog() - -peers_dir = os.environ.get("PEERS_DIR", "") -scope_dir = os.path.dirname(peers_dir) -config_path = os.path.join(scope_dir, "config.json") -local_log = os.path.join(scope_dir, "messages.jsonl") -offset_path = os.path.join(scope_dir, "monitor_offset") -# Only mirror inbound to the local log when we are a joiner (tailing a -# REMOTE host over SSH). For a HOST, the local log IS the source the -# tail reads from — mirroring creates an infinite feedback loop: tail -# sees new line, we append that line back to the file, tail sees it -# again, append, etc. Scary fast log pollution. -is_joiner = False -try: - is_joiner = bool(json.load(open(config_path)).get("host_target", "")) -except Exception: - pass - -# Room name for the chat-line prefix. Read once at startup; a rename -# of the room would require a fresh airc connect to pick up. Default -# is "general"; legacy single-pair invite scope shows "1:1" as the -# visual marker. -room_path = os.path.join(scope_dir, "room_name") -try: - room_name = open(room_path).read().strip() or "general" -except Exception: - room_name = "1:1" - -def current_name(): - """Read identity name fresh from config.json each time so a rename - during the session immediately takes effect for own-send filtering. - Without this the monitor keeps the name it saw at startup and fails - to filter our own outbound rename markers, which can trigger the - host-fallback chain-repair against other peers sharing our host.""" - try: - return json.load(open(config_path)).get("name", "") - except Exception: - return "" -# Marker may carry an optional `host=user@ip` so receivers can find the -# sender via stable host field even when name-keyed lookup would miss -# (chain break from a dropped rename, stale records, etc). -RENAME_RE = re.compile(r"^\[rename\] old=([a-z0-9-]+) new=([a-z0-9-]+)(?:\s+host=(\S+))?") - -def _rename_files(old, new): - old_json = os.path.join(peers_dir, f"{old}.json") - new_json = os.path.join(peers_dir, f"{new}.json") - if not os.path.isfile(old_json): - return False - try: - os.rename(old_json, new_json) - d = json.load(open(new_json)) - d["name"] = new - json.dump(d, open(new_json, "w"), indent=2) - except Exception: - pass - old_pub = os.path.join(peers_dir, f"{old}.pub") - new_pub = os.path.join(peers_dir, f"{new}.pub") - if os.path.isfile(old_pub): - try: os.rename(old_pub, new_pub) - except Exception: pass - return True - -def _find_peer_by_host(host): - """Return current name of the peer record whose host matches, or None.""" - if not host or not os.path.isdir(peers_dir): - return None - for entry in os.listdir(peers_dir): - if not entry.endswith(".json"): continue - try: - d = json.load(open(os.path.join(peers_dir, entry))) - except Exception: - continue - if d.get("host") == host: - return d.get("name") or entry[:-5] - return None - -def handle_rename(msg, ts): - m = RENAME_RE.match(msg) - if not m: return False - old, new, host = m.group(1), m.group(2), m.group(3) - # Primary path: name-keyed rename. - if _rename_files(old, new): - print(f"airc: nick {old} → {new}", flush=True) - return True - # Fallback: peer file sits under a different (older) name due to a - # previous chain break. Resolve via stable host field. - if host: - current = _find_peer_by_host(host) - if current and current != new and _rename_files(current, new): - print(f"airc: nick (chain-repair) {current} → {new}", flush=True) - return True - return False - -offset_counter = 0 -try: - with open(offset_path) as f: - offset_counter = int(f.read().strip() or 0) -except Exception: - pass - -for line in sys.stdin: - # Any inbound line — real message, heartbeat, whatever — means the - # channel is alive. Reset the watchdog (POSIX: re-arms SIGALRM; - # Windows: cancels + restarts threading.Timer). - _arm_watchdog() - line = line.strip() - if not line: continue - offset_counter += 1 - try: - with open(offset_path, "w") as f: - f.write(str(offset_counter)) - except Exception: - pass - try: - m = json.loads(line) - except Exception: - continue - ts = m.get("ts", "") - fr = m.get("from", "?") - to = m.get("to", "") - msg = m.get("msg", "") - # Filter own sends early, including our own [rename] markers. Read - # the name fresh so a mid-session rename takes effect immediately. - if fr == current_name(): - continue - # Mirror inbound to the local messages.jsonl ONLY when we are a - # joiner (tailing the remote host). For a host the local log is - # already the source of truth; mirroring would create a feedback - # loop (tail sees line -> we append line -> tail sees it again). - if is_joiner: - try: - with open(local_log, "a") as f: - f.write(line + "\n") - except Exception: - pass - if handle_rename(msg, ts): - continue - # Ping/pong monitor-liveness probe. Prefix marker on a normal - # message so non-implementing clients (older airc, Codex, etc) - # just see a weird message. Auto-pong here is opportunistic; - # cmd_ping tails the log for PONG with matching uuid + timeout, - # which distinguishes wire-dead vs monitor-dead vs peer-no-support. - ping_match = re.match(r"^\[PING:([a-f0-9-]+)\]", msg or "") - pong_match = re.match(r"^\[PONG:([a-f0-9-]+)\]", msg or "") - if ping_match: - ping_id = ping_match.group(1) - # Only auto-pong when the ping is addressed to US specifically. - # Without this check every peer on the mesh auto-replies to - # every ping they see in the log (monitor tails are shared - # across the whole host), so a single ping fans out to N - # PONGs and makes liveness diagnosis meaningless. Broadcast - # pings (to=all) also skip here a broadcast ping is a - # discovery message the operator reads, not a round-trip. - my_current = current_name() - if to == my_current: - # Auto-reply pong via subprocess. Fire-and-forget. Uses - # airc send so the reply rides the same signed-message - # path as normal traffic (no protocol divergence). - import subprocess - try: - subprocess.Popen( - ["airc", "send", f"@{fr}", f"[PONG:{ping_id}]"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except Exception: - pass - # Suppress from user-visible output (control traffic), - # regardless of whether we auto-ponged. - continue - if pong_match: - # cmd_ping picks PONG up by tailing messages.jsonl directly. - # Suppress to keep the chat surface clean. - continue - # One-liner per event. Every line starts with `airc:` so the source - # is unambiguous when other Monitor tasks (continuum, tests, etc.) - # are also firing notifications. - # - # No length cap any more -- consumers (Claude Code Monitor, Codex, - # log tailers, etc.) decide their own display truncation. Truncating - # in the substrate forced everyone downstream to fall back to - # `airc logs` to see anything past the cap, which is exactly the - # polling-vs-substrate anti-pattern Joel called out 2026-04-24. - # Newlines collapsed to spaces so each emitted event is still a - # single line, but the full body always reaches the consumer. - msg_one_line = (msg or "").replace("\\n", " ").replace("\\r", " ").strip() - try: - if fr in ("airc", "sys"): - # System events (joins, parts, drain, auth, watchdog). - # Example: airc: [#general] alice joined - print(f"airc: [#{room_name}] {msg_one_line}", flush=True) - elif to and to not in ("all", ""): - # DM with addressed recipient. - # Example: airc: [#general] bigmama → alice: quick question - print(f"airc: [#{room_name}] {fr} → {to}: {msg_one_line}", flush=True) - else: - # Broadcast. - # Example: airc: [#general] bigmama: hello everyone - print(f"airc: [#{room_name}] {fr}: {msg_one_line}", flush=True) - except Exception as e: - # Belt-and-suspenders -- one bad message must never take the - # whole monitor down. Surface to stderr (which the bash retry - # loop captures) and keep going. - try: - sys.stderr.write(f"[airc:formatter] skipped one line: {e}\\n") - sys.stderr.flush() - except Exception: - pass -' + "$AIRC_PYTHON" -u -m airc_core.monitor_formatter --peers-dir "$PEERS_DIR" --my-name "$my_name" } # Drain pending.jsonl when the host is reachable again. Runs in background @@ -1647,3897 +1451,133 @@ reminder_timer_loop() { done } -cmd_reminder() { - ensure_init - local arg="${1:-status}" - local reminder_file="$AIRC_WRITE_DIR/reminder" - - case "$arg" in - off|0) - rm -f "$reminder_file" - echo " Reminders off." - ;; - pause) - echo "0" > "$reminder_file" - echo " Reminders paused. 'airc reminder ' to resume." - ;; - status) - if [ -f "$reminder_file" ]; then - local val; val=$(cat "$reminder_file") - if [ "$val" = "0" ]; then - echo " Reminders paused." - else - echo " Reminder every ${val}s." - fi - else - echo " Reminders off." - fi - ;; - *) - echo "$arg" > "$reminder_file" - echo " Reminder every ${arg}s if no messages." - ;; - esac -} +# cmd_reminder extracted to lib/airc_bash/cmd_reminder.sh +# (#152 Phase 3 file split — final structural sweep). +if [ -n "${_airc_lib_dir:-}" ] && [ -f "$_airc_lib_dir/airc_bash/cmd_reminder.sh" ]; then + # shellcheck source=lib/airc_bash/cmd_reminder.sh + source "$_airc_lib_dir/airc_bash/cmd_reminder.sh" +else + echo "ERROR: airc_bash/cmd_reminder.sh not found via lib-dir resolver." >&2 + exit 1 +fi # ── Commands ──────────────────────────────────────────────────────────── -cmd_connect() { - # Flag parsing. Issue #37 — host display shapes: - # default (gh installed + authed): gist ID + humanhash mnemonic + long invite - # default (no gh OR gh not authed): long invite only (today's behavior) - # --no-gist : long invite only, even if gh works - # - # `--gist` and `-gist` accepted for explicitness/back-compat; both no-ops - # because gist is now the default when gh is available. Gist push silently - # falls through to long-invite-only when gh is missing or unauthed, so - # the host command never fails just because GitHub isn't reachable. - # - # Room flags (issue #39 + #121): - # --room : join (or host) a named room (default: auto-scope - # from git org, falling back to 'general') - # --no-room : disable the substrate entirely; legacy 1:1 - # invite-string flow (use_room=0). Inherits #38 - # single-pair behavior. Aliased --no-general was - # removed for this — those have different meanings. - # --no-general : keep the project room, but DON'T also subscribe - # to the #general lobby. Project-only focus mode. - # (NEW; previously this was an alias for --no-room.) - # --room-only : explicit project room + no general sidecar. - # Equivalent to `--room --no-general`. - # - # Default behavior (issue #121): every `airc join` lands in BOTH the - # auto-scoped project room AND #general. The general sidecar runs in a - # sibling scope (.general suffix) under the same visible identity, so - # AIs cross-pollinate between projects via the lobby while keeping - # focused work in their project room. Set AIRC_GENERAL_SIDECAR=1 to - # signal "this IS the sidecar, don't recurse" — internal-only. - local use_gist=1 # default ON; runtime probe later checks gh availability - local room_name="general" - local room_explicit=0 # set to 1 when user passes --room explicitly - local use_room=1 # default ON — auto-#general substrate - local general_sidecar=1 # default ON (issue #121) — also subscribe to #general - local _force_general_sidecar=0 # set by --general flag (issue #136 re-opt-in) - # Recursion guard: when WE are the sidecar (spawned by another airc - # connect), don't spawn our own sidecar. Otherwise: turtles all the way. - [ "${AIRC_GENERAL_SIDECAR:-0}" = "1" ] && general_sidecar=0 - # User-facing env opt-out, equivalent to --no-general flag. Useful - # for test harnesses that don't care about sidecar behavior, and - # for one-off scoped scripts that want to set it once and forget. - [ "${AIRC_NO_GENERAL:-0}" = "1" ] && general_sidecar=0 - # Declared at function scope so set -u doesn't bite when JOIN MODE runs - # without a prior gist parser (inline-invite path skips the parser - # entirely; resolved_room_name only gets a value when we resolved a - # kind:room gist envelope). - local resolved_room_name="" - # _resolved_gist_id is captured by the gist resolver when discovery resolves - # a kind:"room" gist. Used by JOIN MODE's self-heal path: if the pair - # handshake fails because the host listed in the room gist is unreachable - # (sleep/crash/network), the joiner deletes the stale gist and re-execs - # itself in host mode — first-agent-back-in becomes the new host. - local _resolved_gist_id="" - # Heartbeat freshness vars - parsed by gist resolver in the room - # case-arm. Must be defaulted here so the JOIN MODE early-takeover - # check (which runs unconditionally if a target has '@') doesn't trip - # 'unbound variable' when target came in inline (no gist resolved). - local _resolved_heartbeat_stale=0 - local _resolved_heartbeat_age="" - # Multi-address fields parsed from host.addresses[] in the room - # gist envelope. _resolved_addresses_json is the raw JSON array - # (or empty if the host published a legacy envelope with only - # host.address/host.port). _resolved_host_machine_id lets the - # joiner detect "we're on the same machine" and dial 127.0.0.1. - local _resolved_addresses_json="" - local _resolved_host_machine_id="" - local positional=() - while [ $# -gt 0 ]; do - case "$1" in - --gist|-gist) use_gist=1; shift ;; - --no-gist|-no-gist) use_gist=0; shift ;; - --room|-room) room_name="${2:-general}"; use_room=1; room_explicit=1; shift 2 ;; - --no-room|-no-room) use_room=0; shift ;; - --no-general|-no-general) - # NEW semantic (issue #121): keep the project room substrate, - # just don't ALSO subscribe to the #general lobby sidecar. This - # used to alias --no-room (disable substrate entirely); the - # behaviors are now distinct because dual-room presence is - # default and users need a way to opt out of just the lobby - # part without dropping back to legacy 1:1 invites. - general_sidecar=0; shift ;; - --general|-general) - # Issue #136: explicit re-opt-in to #general after a prior - # /part. Clears the room from primary scope's parted_rooms so - # the sidecar resubscribes. Force general_sidecar=1 too in case - # AIRC_GENERAL_SIDECAR=1 was set (recursion guard) — the user - # is explicitly asking for the sidecar, override session env. - # Symmetric inverse of --no-general. - _force_general_sidecar=1; shift ;; - --room-only|-room-only) - # Combo: explicit project room + skip general sidecar. For - # focused work where lobby noise would distract. - room_name="${2:-general}"; use_room=1; room_explicit=1; general_sidecar=0 - shift 2 ;; - --no-tailscale|-no-tailscale) - # Opt out of Tailscale entirely: skips the login prompt AND - # drops the tailscale entry from host_address_set so the - # gist envelope advertises only localhost+LAN. The flag is - # the primary user-facing API; AIRC_NO_TAILSCALE=1 stays as - # an internal toggle for code that already reads it. - export AIRC_NO_TAILSCALE=1 - shift ;; - *) positional+=("$1"); shift ;; - esac - done - set -- "${positional[@]+"${positional[@]}"}" - - # Issue #136: --general re-opt-in. Clear parted state on primary - # scope and force the sidecar back on. Done after arg parsing so we - # know AIRC_WRITE_DIR (set by ensure_init below) is meaningful — but - # we have to wait for ensure_init to run, since --general can be - # called before any prior init. The cleanup happens via a deferred - # check in spawn_general_sidecar_if_wanted: since _clear_parted_room - # is idempotent, we can call it eagerly here when config exists, and - # also force general_sidecar=1 to override any session env opt-out. - if [ "$_force_general_sidecar" = "1" ]; then - general_sidecar=1 - if [ -f "$AIRC_WRITE_DIR/config.json" ]; then - local _primary_now; _primary_now=$(_primary_scope_for "$AIRC_WRITE_DIR") - _clear_parted_room "$_primary_now" "general" - fi - fi - - # Tailscale-installed-but-logged-out nudge. Runs AFTER flag parsing - # so --no-tailscale takes effect. Default behavior: if Tailscale is - # installed, "just works" — prompt the user to sign in (Mac: opens - # Tailscale.app). The 90% case is "I have it and want it on"; - # --no-tailscale is the explicit opt-out for the few who don't. - tailscale_login_check_or_prompt - - # `airc join` (no args) auto-scopes to the room matching the current cwd. - # Resolution: git remote org first ('useideem/authenticator' → #useideem), - # parent-dir basename second (local-only repos). Falls back to #general - # only when neither signal fires (non-git dir, no remote). The skill - # /join contract documents this as the default. - # - # The trade-off: two tabs in DIFFERENT projects on the same gh account - # land in different rooms (a #cambriantech tab can't see a #useideem - # tab). That's intentional — project work shouldn't mix with unrelated - # project chatter. Cross-project agents who need a shared lobby: - # `AIRC_NO_AUTO_ROOM=1 airc join` or `airc join --room general`. - # - # Two tabs in the SAME project converge automatically: both useideem - # tabs auto-scope to #useideem, both find each other. That's the case - # this default optimizes for. - # - # History: this was rolled back in PR #104 over the cross-project - # concern, then re-enabled here after dogfooding showed the converse - # bug (two same-project tabs both defaulting to #general and never - # converging on the project room) was the more painful failure mode. - if [ "$use_room" = "1" ] && [ "$room_explicit" = "0" ] \ - && [ "${AIRC_NO_AUTO_ROOM:-0}" != "1" ]; then - # Saved room_name (#130): the one piece of cross-restart state worth - # trusting. If a prior connect landed us in #foo, the next bare - # `airc connect` should target #foo too — not the auto-scope or the - # "general" fallback. This replaces the resume code's room-tracking - # with a single read of the saved file. Cached host_target is still - # NOT trusted (discovery re-derives that from the gist). - local _saved_room="" - [ -f "$AIRC_WRITE_DIR/room_name" ] && _saved_room=$(cat "$AIRC_WRITE_DIR/room_name" 2>/dev/null) - if [ -n "$_saved_room" ]; then - room_name="$_saved_room" - echo " Resuming saved room: #${room_name} (override with --room or 'airc part' first)" - else - local _inferred - _inferred=$(infer_default_room 2>/dev/null || true) - if [ -n "$_inferred" ]; then - room_name="${_inferred%|*}" - local _source="${_inferred#*|}" - echo " Auto-scoped: #${room_name} (from git ${_source}; override with --room or AIRC_NO_AUTO_ROOM=1)" - fi - fi - fi - - local target="${1:-}" - local reminder_interval="${AIRC_REMINDER:-${2:-300}}" # env > positional > 5min default - - # ── Notification-sink liveness ───────────────────────────────────── - # `airc connect` is only useful when a CONSUMER is reading our stdout — - # that's how inbound peer messages reach the AI agent or human. The - # canonical launcher is Claude Code's Monitor (persistent=true, command= - # "airc connect ...") which streams every stdout line as a notification. - # - # Failure mode this catches: someone runs `airc connect ` via a - # one-shot Bash tool / nohup / background `&` / detached shell. The - # python formatter + ssh tail get spawned, the pairing succeeds, the - # local messages.jsonl fills correctly — but stdout has no reader (the - # bash that exec'd us already exited and closed the pipe), so inbound - # NEVER reaches the agent's notification surface. Looks paired, is - # functionally deaf. Cost a session of debugging on 2026-04-23. - # - # Approach: install a SIGPIPE handler that exits LOUDLY (to stderr, - # which usually survives) the moment any write to stdout fails. Plus a - # periodic heartbeat line every 60s so SIGPIPE actually fires if there's - # no reader. With both: - # - Monitor reading: heartbeats succeed silently (Monitor surfaces - # them as benign notifications, but they're harmless) - # - One-shot bash / nohup / background: first heartbeat triggers - # SIGPIPE → airc exits with a clear error pointing at the right - # launch pattern → no silent deafness - # - # Opt out: AIRC_BACKGROUND_OK=1 disables the heartbeat for legitimate - # background launches (systemd unit + dedicated tail consumer, tests). - trap ' - { - echo "" - echo "❌ airc connect: stdout pipe closed — no notification consumer." - echo "" - echo " Inbound peer messages would have been silently lost. Most" - echo " common cause: airc was launched as a one-shot bash exec," - echo " nohup, background \"&\", or detached shell. The pairing" - echo " succeeds and messages.jsonl fills, but the AI agent never" - echo " sees inbound notifications. That is the worst kind of" - echo " silent failure — looks fine, is broken." - echo "" - echo " Right launchers:" - echo " • Claude Code skill: /airc:connect " - echo " • Monitor tool: Monitor(persistent=true, command=\"airc connect \")" - echo " • Interactive shell: just type \`airc connect \` at a TTY" - echo "" - echo " Bypass for legitimate background use (systemd + log tail," - echo " tests): export AIRC_BACKGROUND_OK=1" - echo "" - } >&2 - exit 3 - ' PIPE - # Heartbeat to stdout for SIGPIPE-pipe-death detection. OFF BY DEFAULT - # as of 2026-04-24 — at 60s it was filling Claude Code chat history - # with a notification per minute per peer, drowning real peer events. - # Joel: "I'd rather only see the messages." - # - # Real peer traffic still triggers SIGPIPE on pipe death, so we lose - # detection only when the channel is genuinely silent for a long time. - # That tradeoff is worth it for the cleaner Monitor surface. - # - # Set AIRC_HEARTBEAT_SEC= to opt back in (tests, diagnostic - # sessions, one-shot-bash launchers that need the safety net). 0 or - # unset = no heartbeat. - if [ -z "${AIRC_BACKGROUND_OK:-}" ] && [ -n "${AIRC_HEARTBEAT_SEC:-}" ] && [ "$AIRC_HEARTBEAT_SEC" -gt 0 ] 2>/dev/null; then - ( - while sleep "$AIRC_HEARTBEAT_SEC"; do - echo " [airc heartbeat $(date -u +%H:%M:%SZ)]" - done - ) & - fi - - # Auto-teardown any stale airc process in this scope before starting fresh. - # Previously users had to run `airc teardown` manually before `airc connect` - # if a prior monitor was still around — easy to forget, often resulted in - # duplicate monitors or port collisions. Now a single `airc connect` or - # `airc resume` does the right thing. - local stale_pidfile="$AIRC_WRITE_DIR/airc.pid" - if [ -f "$stale_pidfile" ]; then - local stale_pids; stale_pids=$(cat "$stale_pidfile" 2>/dev/null | tr '\n' ' ') - local all_stale="$stale_pids" - for p in $stale_pids; do - # `|| true` — pgrep returns 1 when the parent PID is already dead (no - # children to find). With `set -euo pipefail` at the top of the script, - # that would abort this block *before* reaching the rm on line 442 that - # self-heals the stale pidfile. Result: joiner wedged forever after a - # parent crash / laptop sleep until someone manually rm'd the pidfile. - all_stale="$all_stale $(proc_children "$p" | tr '\n' ' ' || true)" - done - # Quiet kill — don't warn unless there was actually a live process. - if [ -n "$all_stale" ]; then - local any_alive=0 - for p in $all_stale; do kill -0 "$p" 2>/dev/null && any_alive=1; done - if [ "$any_alive" = "1" ]; then - kill -9 $all_stale 2>/dev/null || true - sleep 1 - fi - fi - rm -f "$stale_pidfile" - fi - - # No resume code path. (#130, 2026-04-26.) - # - # The gist is the source of truth for who's hosting which room and at - # what address. Local state we trust across restarts is identity (ssh - # key, signing key, name, identity blob) and peer records. We do NOT - # trust cached host_target / host_port / host_ssh_pub — those describe - # external substrate that can change behind us (host crashed, port - # auto-bumped, gist regenerated, ssh key rotated, machine restarted). - # - # Every `airc connect` runs discovery. Cost: one `gh gist list` - # (~200ms). Benefit: every "saved pairing diverged from gist" failure - # mode is structurally impossible — there's no saved pairing to - # diverge. Discovery + JOIN MODE below already handle stale-heartbeat - # takeover, TCP-unreachable self-heal, race-loser detection, multi- - # address pick, Tailscale-down advisory, and host_target overwrite on - # successful pair. Removing the parallel resume implementation deletes - # ~250 lines and an entire bug class: - # - "(SSH verified)" printed against an unreachable cached host - # - silent-success on stale pair after machine restart - # - --room flag silently ignored if it differed from saved pairing - # - 404 self-heal gated on a separate code path with its own bugs - # Cached CONFIG fields like host_target are still WRITTEN by JOIN MODE - # for monitor() to read at runtime ("am I joiner or host?"), but never - # READ at connect-time to skip discovery. - - # ── Zero-arg discovery: rooms first, then legacy invites (#38, #39) - # If we got here with no target AND no saved config, the user just ran - # `airc connect` cold. The IRC substrate (#39) makes this simple: - # - # 1. Look for the named room gist (default `airc room: general`). - # Found → auto-join it. - # 2. Fall back to legacy `airc invite for ...` single-pair gists. - # Found 1 → auto-join. Found N → list + exit. - # 3. Found nothing → become the host and create the room (the - # auto-#general default — first agent in is the channel host). - # - # Skipped if `gh` isn't available (degraded → host invite-only) or - # AIRC_NO_DISCOVERY=1 (explicit opt-out). With `--no-general` the room - # path is skipped and we go straight to single-pair invite host mode. - # - # Discovery gate: run only when the user didn't pass an explicit target - # and gh is available. We deliberately do NOT short-circuit when CONFIG - # has a saved host_target — that's exactly the cached-pairing path the - # resume-deletion (#130) is killing. Always discover, always consult - # the gist; the gist is the truth. - local _did_room_discovery=0 - if [ -z "$target" ] && \ - [ "${AIRC_NO_DISCOVERY:-0}" != "1" ] && \ - command -v gh >/dev/null 2>&1; then - - # ── Room discovery (the substrate path) ────────────────────── - # Match exact room name to avoid `airc room: general-test` colliding - # with `airc room: general`. Pick the most-recent if duplicates exist - # (stale hosts get re-elected on next reconnect when SSH fails). - if [ "$use_room" = "1" ]; then - _did_room_discovery=1 - local _room_filter="airc room: ${room_name}\$" - local _room_candidates; _room_candidates=$(gh gist list --limit 50 2>/dev/null \ - | awk -F'\t' -v re="$_room_filter" '$2 ~ re { print $1 "\t" $2 "\t" $4 }') - local _room_count; _room_count=$(printf '%s' "$_room_candidates" | grep -c . || true) - if [ "$_room_count" -ge 1 ]; then - # Most recent wins (gh gist list is reverse-chrono by update). - local _picked_id; _picked_id=$(printf '%s' "$_room_candidates" | head -1 | awk -F'\t' '{print $1}') - echo " Found #${room_name} on your gh account → joining ($_picked_id)" - target="$_picked_id" - # fall through to gist resolver below — kind:room → invite handshake - else - echo " No #${room_name} found on your gh account → becoming the host." - # Race against a concurrent host attempt is handled POST-publish - # (see "race-loser detection" near host_gist_id write below). - # Pre-publish recheck doesn't help — neither tab's gist is - # globally visible yet at this point. - fi - fi - - # ── Legacy single-pair invite discovery (only if no room flow) ── - # Preserves the #38 behavior for users running with --no-general - # OR for room-mode users whose room discovery missed (we already - # set target in that case, so this block won't fire). - if [ -z "$target" ] && [ "$use_room" = "0" ]; then - local _candidates; _candidates=$(gh gist list --limit 30 2>/dev/null \ - | awk -F'\t' '/airc invite for/ { print $1 "\t" $2 }') - local _count; _count=$(printf '%s' "$_candidates" | grep -c . || true) - if [ "$_count" = "1" ]; then - local _picked_id; _picked_id=$(printf '%s' "$_candidates" | awk -F'\t' '{print $1}') - local _picked_desc; _picked_desc=$(printf '%s' "$_candidates" | awk -F'\t' '{print $2}') - echo " Found 1 open airc invite on your gh account: $_picked_desc" - echo " → auto-joining $_picked_id" - target="$_picked_id" - elif [ "$_count" -ge 2 ]; then - echo "" - echo " $_count open airc invite(s) on your gh account:" - echo "" - printf '%s\n' "$_candidates" | while IFS=$'\t' read -r _id _desc; do - local _hh; _hh=$(humanhash "$_id" 2>/dev/null) - printf ' %s %s\n mnemonic: %s\n' "$_id" "$_desc" "$_hh" - done - echo "" - echo " Pick one to join: airc connect " - echo " Host a new mesh: AIRC_NO_DISCOVERY=1 airc connect --no-general" - exit 0 - fi - fi - fi - - # ── Mnemonic resolver (humanhash → gist id, same gh account) ───── - # Joel's UX target: a friend (or your own other tab) can type - # airc connect oregon-uncle-bravo-eleven - # instead of pasting a 32-char hex gist id. Humanhash is one-way - # (XOR-fold of the gist id bytes), so we can't reverse it directly — - # but we CAN walk gh's gist list, hash each id, and pick the match. - # - # Detection: target looks like a hyphen-separated 3+ word phrase of - # lowercase alphabetic tokens (matches the humanhash dictionary - # convention — no digits, no underscores). Example acceptable form: - # `oregon-uncle-bravo-eleven`. Reject `2f6a907224f4...` (it's a hex id), - # `gist:abc123` (handled below), inline invites with `@`, etc. - # - # Scope: same-gh-account only (we list OUR own gists). Cross-account - # (Friend on a different gh) requires the `user/mnemonic` form which - # is roadmap. For now the friend pastes the gist id directly when - # accounts differ. - if [ -n "$target" ] && echo "$target" | grep -qE '^[a-z]+(-[a-z]+){2,}$'; then - if ! command -v gh >/dev/null 2>&1; then - die "Mnemonic '$target' lookup needs the 'gh' CLI. Install gh + 'gh auth login', or use the gist id directly: airc connect " - fi - local _matched_gist_id="" - while IFS=$'\t' read -r _gid _; do - [ -z "$_gid" ] && continue - local _hh; _hh=$(humanhash "$_gid" 2>/dev/null) - if [ "$_hh" = "$target" ]; then - _matched_gist_id="$_gid" - break - fi - done < <(gh gist list --limit 50 2>/dev/null | awk -F'\t' '/airc room:|airc invite for/ { print $1 "\t" $2 }') - if [ -n "$_matched_gist_id" ]; then - echo " Resolved mnemonic '$target' → gist $_matched_gist_id" - target="$_matched_gist_id" - else - die "Mnemonic '$target' didn't match any airc gist on this gh account. If your friend's gist is on a different gh, paste the gist id directly: airc connect " - fi - fi - - # ── Gist transport (issue #37) ─────────────────────────────────── - # If the target doesn't look like an inline invite (no `@`), treat it - # as a gist ID and fetch the real invite content from there. Three - # accepted shapes: - # gist: — explicit, unambiguous - # — bare alphanumeric, auto-detected as a gist ID - # foo@bar@... — today's inline invite, untouched - # - # The whole point: an inline invite is ~200 chars of base64 that gets - # mangled by chat clients (line wraps, auto-linkification, smart - # quotes). A 7-char gist ID survives every transport. Host pushes the - # invite to a secret gist (see `airc connect --gist` below); receiver - # pastes just the ID. Also: gist works as a coordination layer for - # cross-tailnet pairing where the two peers don't share a VPN - # initially. - # - # Gist payload format: a versioned JSON envelope (see host-side push - # below for shape). Receiver parses `{ airc: 1, kind: "invite", invite: "..." }` - # and dispatches on `kind`. Today only `kind: "invite"` is recognized. - # Future kinds (cross-tailnet relay, bootstrap, webrtc-mesh) slot in - # by adding a case below — old peers reject the kind cleanly with a - # version-mismatch message instead of silently misinterpreting bytes. - # - # Backward compat: a gist that contains a raw invite string (no JSON - # envelope) still parses — we fall through to the raw-string branch - # if JSON parse fails. Lets pre-envelope gists keep working. - if [ -n "$target" ] && ! echo "$target" | grep -q '@'; then - local gist_id="${target#gist:}" - # Capture for self-heal in JOIN MODE: if the host in this gist turns - # out to be unreachable, JOIN MODE deletes the gist by this id + takes - # over as the new host of the same room. - _resolved_gist_id="$gist_id" - # Gist IDs are hex strings, typically 20-32 chars but accept any - # plausible length so future GH ID schemes don't break us. - if echo "$gist_id" | grep -qE '^[a-zA-Z0-9]{6,40}$'; then - echo " Resolving gist $gist_id ..." - local raw_content="" - # Prefer `gh api` over `gh gist view --raw` — the latter prepends - # the gist description as a header line ("airc room: general\n\n{...}") - # which breaks JSON parse downstream. `gh api` returns the file - # content cleanly. This bug bit hard during daemon-install dogfood: - # parser fell through to the @.*@ regex fallback which captured the - # malformed JSON `"invite": "..."` line (quotes and all), pair - # handshake failed on garbage host info, and self-heal didn't fire - # because resolved_room_name was never extracted via the jq path. - if command -v gh >/dev/null 2>&1 && command -v jq >/dev/null 2>&1; then - raw_content=$(gh api "gists/$gist_id" 2>/dev/null \ - | jq -r '.files | to_entries[0].value.content // empty' 2>/dev/null) - fi - # Fallback path 1: gh without jq → degraded gh gist view --raw, with - # a description-strip in the consumer below. - if [ -z "$raw_content" ] && command -v gh >/dev/null 2>&1; then - raw_content=$(gh gist view "$gist_id" --raw 2>/dev/null) - fi - # Fallback path 2: anonymous curl + jq for environments without gh. - if [ -z "$raw_content" ] && command -v curl >/dev/null 2>&1 && command -v jq >/dev/null 2>&1; then - raw_content=$(curl -fsSL "https://api.github.com/gists/$gist_id" 2>/dev/null \ - | jq -r '.files | to_entries[0].value.content // empty' 2>/dev/null) - fi - # Last-resort cleanup: if raw_content still has the description-header - # leak from a degraded gh-view path, strip lines before the first '{' - # (room/invite envelopes are JSON, always start with '{'). - if [ -n "$raw_content" ] && ! printf '%s' "$raw_content" | head -c 1 | grep -q '{'; then - raw_content=$(printf '%s' "$raw_content" | awk '/^\{/{flag=1} flag') - fi - if [ -z "$raw_content" ]; then - die "Failed to fetch gist '$gist_id'. Check the ID, network, and (if private) 'gh auth login'." - fi - - # Try parse as airc JSON envelope first. If it parses + has airc - # field, dispatch on `kind`. Otherwise, treat raw_content as the - # legacy raw-invite-string format (backward compat). - # _resolved_heartbeat_stale + _resolved_heartbeat_age are declared - # at function-scope above so the JOIN MODE check sees them on the - # inline-invite path too (where this gist block doesn't run). - local resolved="" - if command -v jq >/dev/null 2>&1; then - local airc_ver kind - airc_ver=$(printf '%s' "$raw_content" | jq -r '.airc // empty' 2>/dev/null) - kind=$(printf '%s' "$raw_content" | jq -r '.kind // empty' 2>/dev/null) - if [ -n "$airc_ver" ]; then - # Versioned envelope — dispatch on kind. - case "$kind" in - invite) - # Single-pair invite (legacy + --no-general flow). Gist is - # ephemeral; host deletes after pair. - resolved=$(printf '%s' "$raw_content" | jq -r '.invite // empty' 2>/dev/null \ - | head -1 | tr -d '\r\n ') - ;; - room) - # Persistent IRC-style channel (issue #39, the substrate). - # Same SSH-pair handshake as invite, but the gist persists - # so additional joiners can keep arriving. The room.invite - # field carries today's name@user@host:port#pubkey string. - resolved=$(printf '%s' "$raw_content" | jq -r '.invite // empty' 2>/dev/null \ - | head -1 | tr -d '\r\n ') - resolved_room_name=$(printf '%s' "$raw_content" | jq -r '.name // empty' 2>/dev/null) - # Multi-address: capture host.addresses[] + host.machine_id - # for the joiner's address-picker (peer_pick_address). Empty - # if the host published a pre-multi-address envelope; in - # that case JOIN MODE falls back to the parsed-from-invite - # host:port (legacy single-address path). - _resolved_addresses_json=$(printf '%s' "$raw_content" | jq -c '.host.addresses // empty' 2>/dev/null) - _resolved_host_machine_id=$(printf '%s' "$raw_content" | jq -r '.host.machine_id // empty' 2>/dev/null) - - # Heartbeat freshness check — the structural fix for - # orphan-gist class. Hosts update last_heartbeat every - # AIRC_HEARTBEAT_SEC (default 30s); if it's older than - # AIRC_HEARTBEAT_STALE (default 90s = 3 missed beats), - # the host is dead. We short-circuit the SSH attempt and - # take over directly — no minute-long timeout, no peer - # confusion about "is this thing on?". Pre-heartbeat - # gists (no field) are treated as fresh for backward - # compat; their hosts will get caught by the existing - # SSH-failure self-heal path at line ~1850. - local _hb_iso _hb_ts _now_ts _hb_stale_sec - _hb_iso=$(printf '%s' "$raw_content" | jq -r '.last_heartbeat // empty' 2>/dev/null) - _hb_stale_sec="${AIRC_HEARTBEAT_STALE:-90}" - if [ -n "$_hb_iso" ]; then - # Convert ISO-8601 UTC to epoch. GNU date and BSD date - # have incompatible flags; try GNU first (linux + git-bash), - # fall back to BSD (mac default). If both fail (busybox?), - # skip the check rather than mis-classify. - _hb_ts=$(date -u -d "$_hb_iso" +%s 2>/dev/null \ - || date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$_hb_iso" +%s 2>/dev/null \ - || echo "") - if [ -n "$_hb_ts" ]; then - _now_ts=$(date -u +%s) - _resolved_heartbeat_age=$(( _now_ts - _hb_ts )) - if [ "$_resolved_heartbeat_age" -gt "$_hb_stale_sec" ]; then - _resolved_heartbeat_stale=1 - fi - fi - fi - ;; - "") - die "Gist has airc envelope (v$airc_ver) but no 'kind' field — malformed." - ;; - *) - # Unknown kind — fail loud. Old peers should reject - # rather than silently misinterpret a future kind. - die "Gist uses unknown kind '$kind' (airc v$airc_ver). This receiver only supports 'invite' and 'room'. Update airc: 'airc update'." - ;; - esac - fi - fi - if [ -z "$resolved" ]; then - # Legacy raw-string format OR jq missing — take the first - # non-empty line that looks like an invite. - resolved=$(printf '%s' "$raw_content" | grep -E '@.*@' | head -1 | tr -d '\r\n ') - fi - if [ -z "$resolved" ] || ! echo "$resolved" | grep -q '@'; then - die "Failed to resolve gist '$gist_id' to a valid invite (got: $(printf '%s' "$raw_content" | head -c 80)...)" - fi - echo " ✓ Resolved invite from gist." - target="$resolved" - fi - fi - - if [ -n "$target" ] && echo "$target" | grep -q '@'; then - # ── JOIN MODE ────────────────────────────────────────────────── - - # Stale-heartbeat fast-path takeover. If the gist we resolved had a - # last_heartbeat older than AIRC_HEARTBEAT_STALE (parsed above), the - # host is dead. Skip the SSH attempt entirely — no minute-long TCP - # timeout, no peer wondering "is this thing on" — go straight to - # take-over. Same operations as the SSH-failure self-heal at the - # bottom of JOIN MODE (delete stale gist, re-exec as host with - # AIRC_NO_DISCOVERY=1) but triggered from positive evidence (stale - # presence signal) rather than negative evidence (TCP timeout). - # - # Backward compat: pre-heartbeat gists have no last_heartbeat field, - # _resolved_heartbeat_stale stays 0, this block is a no-op, and the - # SSH-failure self-heal still catches the dead host (slower, but - # correct). - if [ "$_resolved_heartbeat_stale" = "1" ] && [ -n "$resolved_room_name" ] \ - && [ -n "$_resolved_gist_id" ] && command -v gh >/dev/null 2>&1; then - echo "" - echo " ⚠ Host of #${resolved_room_name} is stale (last heartbeat ${_resolved_heartbeat_age}s ago) — taking over..." - echo " (prior host's gist: $_resolved_gist_id)" - - # Same race-loser detection as the SSH-failure self-heal path - # below. Two tabs concurrently deciding "host is stale" both - # delete + publish, end up with split-brain — caught only by - # running two tabs together. - local _race_jitter_s; _race_jitter_s=$(awk -v r="$RANDOM" 'BEGIN{printf "%.3f", 0.1 + (r%1500)/1000}') - sleep "$_race_jitter_s" - - if gh gist delete "$_resolved_gist_id" --yes 2>/dev/null; then - echo " ✓ Stale gist removed." - else - echo " ⚠ Stale gist already gone — another tab may have taken over first." - fi - - local _new_picked; _new_picked=$(gh gist list --limit 50 2>/dev/null \ - | awk -F'\t' -v re="airc room: ${resolved_room_name}\$" -v skip="$_resolved_gist_id" \ - '$2 ~ re && $1 != skip { print $1; exit }') - - local _preserved_name; _preserved_name=$(get_config_val name "") - rm -f "$CONFIG" - rm -f "$AIRC_WRITE_DIR/room_name" - - if [ -n "$_new_picked" ]; then - echo " ✓ Another tab beat us to it — joining their fresh gist ($_new_picked)" - echo "" - exec env ${_preserved_name:+AIRC_NAME="$_preserved_name"} "$0" connect "$_new_picked" - fi - - echo " Re-execing into host mode for #${resolved_room_name}..." - echo "" - exec env AIRC_NO_DISCOVERY=1 ${_preserved_name:+AIRC_NAME="$_preserved_name"} "$0" connect --room "$resolved_room_name" - fi - - # Parse name@user@host[:port]#pubkey - local host_ssh_pubkey_b64="" - if echo "$target" | grep -q '#'; then - host_ssh_pubkey_b64="${target##*#}" - target="${target%%#*}" - fi - - local peer_name ssh_target peer_port="7547" - peer_name="${target%%@*}" - ssh_target="${target#*@}" - # Extract :port if present at the end of the host part - if echo "$ssh_target" | grep -qE ':[0-9]+$'; then - peer_port="${ssh_target##*:}" - ssh_target="${ssh_target%:*}" - fi - - [ -z "$peer_name" ] || [ -z "$ssh_target" ] && die "Format: airc connect name@user@host" - - # Multi-address override: if the gist envelope carried host.addresses[] - # and host.machine_id, use peer_pick_address to choose the cheapest - # reachable scope (same-machine localhost > same-LAN > tailscale). - # This is what makes Tailscale truly optional — same-machine and - # same-LAN peers connect via 127.0.0.1 / LAN IP regardless of the - # invite string's host:port (which historically advertised one IP). - if [ -n "$_resolved_addresses_json" ] && [ "$_resolved_addresses_json" != "null" ]; then - local _picked; _picked=$(peer_pick_address "$_resolved_addresses_json" "$_resolved_host_machine_id") - if [ -n "$_picked" ]; then - local _picked_addr="${_picked%|*}" - local _picked_port="${_picked#*|}" - # Reconstruct ssh_target with the user@addr form. Original - # ssh_target was user@invite-string-host; preserve the user. - local _ssh_user="${ssh_target%@*}" - if [ "$_ssh_user" = "$ssh_target" ]; then _ssh_user=""; fi - ssh_target="${_ssh_user:+${_ssh_user}@}${_picked_addr}" - peer_port="$_picked_port" - echo " ✓ Multi-address pick: ${_picked_addr}:${_picked_port} (from host.addresses)" - fi - fi - - local my_name - my_name=$(resolve_name) - init_identity "$my_name" - - # Merge into existing config.json instead of clobbering — preserves - # the `identity` block (issue #34) across re-pairs so a teardown + - # rejoin keeps pronouns/role/bio/status without requiring users to - # re-run airc identity set every time. - MY_NAME="$my_name" MY_HOST="$(get_host)" SSH_TARGET="$ssh_target" CREATED="$(timestamp)" CONFIG="$CONFIG" python3 -c ' -import json, os -try: - c = json.load(open(os.environ["CONFIG"])) -except Exception: - c = {} -c["name"] = os.environ["MY_NAME"] -c["host"] = os.environ["MY_HOST"] -c["host_target"] = os.environ["SSH_TARGET"] -c["created"] = os.environ["CREATED"] -json.dump(c, open(os.environ["CONFIG"], "w"), indent=2) -' - - # Remember which room we joined (issue #39). Lets `airc rooms` and - # status/diagnostics report channel context, and gives the joiner - # something to hand to a friend ("airc connect "). We don't - # need the gist_id for cmd_part on joiner side — only the host owns - # the gist lifecycle — but we save the room name for display. - if [ -n "$resolved_room_name" ]; then - echo "$resolved_room_name" > "$AIRC_WRITE_DIR/room_name" - echo " Joined #${resolved_room_name}" - fi - - # Exchange keys with host via TCP (port 7547) — public keys only - # Pre-authorize host's pubkey if in join string - if [ -n "$host_ssh_pubkey_b64" ]; then - local host_ssh_pubkey - host_ssh_pubkey=$(echo "$host_ssh_pubkey_b64" | base64 -d 2>/dev/null || echo "$host_ssh_pubkey_b64" | base64 -D 2>/dev/null || true) - if [ -n "$host_ssh_pubkey" ]; then - mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh" - grep -qF "$host_ssh_pubkey" "$HOME/.ssh/authorized_keys" 2>/dev/null || { - echo "$host_ssh_pubkey" >> "$HOME/.ssh/authorized_keys" - chmod 600 "$HOME/.ssh/authorized_keys" - } - fi - fi - - # Exchange keys with host via TCP - local peer_host_only="${ssh_target##*@}" - - # Tailscale-down pre-flight on fresh-pair / gist-discovery paths. - # Resume path (line ~1241) already calls advise_tailscale_if_down, but - # that gate doesn't cover (a) cold-start `airc join ` from a - # fresh scope or (b) the gist-discovery resolution that lands here - # with a tailnet host_target. Without this check, a logged-out - # Tailscale produced a silent unreachable-host + self-heal cascade - # (issue #78, Memento's case 2026-04-25). Same call site shape as the - # resume path: detect-and-instruct, do not auto-tailscale-up. - if ! advise_tailscale_if_down "$peer_host_only"; then - die "Re-run airc join after starting Tailscale." - fi - - echo " Connecting to $peer_host_only:$peer_port..." - local my_ssh_pub my_sign_pub - my_ssh_pub=$(cat "$IDENTITY_DIR/ssh_key.pub" 2>/dev/null) - my_sign_pub=$(cat "$IDENTITY_DIR/public.pem" 2>/dev/null) - - # Read own identity blob to send in handshake (issue #34 v2 — peers - # cache each other's identity at pair-time so airc whois works fast). - local my_identity_json; my_identity_json=$(CONFIG="$CONFIG" python3 -c ' -import json, os -try: - c = json.load(open(os.environ["CONFIG"])) - print(json.dumps(c.get("identity", {}))) -except Exception: - print("{}") -' 2>/dev/null) - [ -z "$my_identity_json" ] && my_identity_json="{}" - - local response - local _pair_ok=1 - response=$(MY_IDENTITY="$my_identity_json" python3 -c " -import socket, json, sys, os -payload = json.dumps({ - 'name': '$my_name', - 'host': '$(whoami)@$(get_host)', - 'ssh_pub': '''$my_ssh_pub''', - 'sign_pub': '''$my_sign_pub''', - 'airc_home': '$AIRC_WRITE_DIR', - 'identity': json.loads(os.environ.get('MY_IDENTITY', '{}')) -}) -sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) -sock.settimeout(30) -sock.connect(('$peer_host_only', $peer_port)) -sock.sendall((payload + '\n').encode()) -sock.shutdown(socket.SHUT_WR) -data = b'' -while True: - chunk = sock.recv(4096) - if not chunk: break - data += chunk -sock.close() -print(data.decode().strip()) -" 2>&1) || _pair_ok=0 - - if [ "$_pair_ok" = "0" ]; then - # ── Self-heal: stale-host takeover ───────────────────────────── - # If discovery handed us a kind:room gist AND the host listed in it - # is unreachable, the most likely cause is the prior host went away - # (laptop sleep, crash, network blip). Per Joel: "no claude left - # behind" — first agent back in becomes the new host of #general. - # - # Mechanics: - # 1. Delete the stale gist (we have gh perms because it's on our - # own gh account, same auth as the discovery that found it). - # 2. Tear down the half-written CONFIG that pointed at the dead - # host (else resume on next start would loop into the same - # stale pair). - # 3. exec into a fresh airc connect in HOST mode for the same - # room name. AIRC_NO_DISCOVERY=1 so we don't re-find the gist - # we just deleted (gh propagation lag). - # - # Only fires when ALL three are true: - # - We resolved a kind:room gist (resolved_room_name + _resolved_gist_id non-empty) - # - gh CLI is available (to delete the stale gist) - # - Pair handshake failed (TCP unreachable / timeout) - # If any condition isn't met, fall through to the original die(). - if [ -n "$resolved_room_name" ] && [ -n "$_resolved_gist_id" ] \ - && command -v gh >/dev/null 2>&1; then - echo "" - echo " ⚠ Host of #${resolved_room_name} unreachable — self-healing as new host..." - echo " (prior host's gist: $_resolved_gist_id)" - - # Jittered backoff before takeover. Without this, two tabs that - # hit the same dead gist concurrently both delete + publish - # within the same gh API window and you end up with two - # competing gists for the same room name (split-brain race — - # caught only by running two tabs against a stale gist - # simultaneously, NOT by the integration test). - local _race_jitter_s; _race_jitter_s=$(awk -v r="$RANDOM" 'BEGIN{printf "%.3f", 0.1 + (r%1500)/1000}') - sleep "$_race_jitter_s" - - if gh gist delete "$_resolved_gist_id" --yes 2>/dev/null; then - echo " ✓ Stale gist removed." - else - echo " ⚠ Stale gist already gone — another tab may have taken over first." - fi - - # Race-loser detection: re-scan for any OTHER fresh gist with - # this room name. If a concurrent self-heal already published - # one, JOIN their fresh gist instead of publishing a duplicate. - local _new_picked; _new_picked=$(gh gist list --limit 50 2>/dev/null \ - | awk -F'\t' -v re="airc room: ${resolved_room_name}\$" -v skip="$_resolved_gist_id" \ - '$2 ~ re && $1 != skip { print $1; exit }') - - # Preserve identity name across re-exec (same reason as resume - # path: derive_name re-runs from cwd and can drift on case- - # aliasing, peers see a "new" peer). - local _preserved_name; _preserved_name=$(get_config_val name "") - # Wipe the CONFIG we just wrote — it points at the dead host - # and would trigger 'resume joiner' on next airc connect. - rm -f "$CONFIG" - rm -f "$AIRC_WRITE_DIR/room_name" - - if [ -n "$_new_picked" ]; then - echo " ✓ Another tab beat us to it — joining their fresh gist ($_new_picked)" - echo "" - # Re-exec as joiner pointing at the winner's gist. - exec env ${_preserved_name:+AIRC_NAME="$_preserved_name"} "$0" connect "$_new_picked" - fi - - echo " Re-execing into host mode for #${resolved_room_name}..." - echo "" - # exec replaces the current bash process. AIRC_NO_DISCOVERY=1 - # prevents the new instance from re-finding the just-deleted gist - # (gh's gist-list cache might still show it for a few seconds). - exec env AIRC_NO_DISCOVERY=1 ${_preserved_name:+AIRC_NAME="$_preserved_name"} "$0" connect --room "$resolved_room_name" - fi - # Either not a room flow, or no gh, or no resolved_room_name → original die. - die "Can't reach $peer_host_only:$peer_port. Is the host running 'airc connect'?" - fi - - # Authorize host's SSH pubkey (for the joiner->host auth direction). - # NOTE: the handshake's ssh_pub is airc's USER identity key — not the - # sshd server host key used for known_hosts verification. Proper - # host-key handling relies on ssh's own accept-new mode, plus a - # targeted ssh-keygen -R when a PRIOR real-sshd host key in known_hosts - # is known stale (e.g. the server rotated sshd host keys). - local host_ssh_pub - host_ssh_pub=$(echo "$response" | python3 -c "import sys,json; print(json.load(sys.stdin).get('ssh_pub',''))" 2>/dev/null || true) - if [ -n "$host_ssh_pub" ]; then - mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh" - grep -qF "$host_ssh_pub" "$HOME/.ssh/authorized_keys" 2>/dev/null || { - echo "$host_ssh_pub" >> "$HOME/.ssh/authorized_keys" - chmod 600 "$HOME/.ssh/authorized_keys" - } - fi - # Clear any stale sshd host key for this address before first SSH. - # Cheap insurance against "REMOTE HOST IDENTIFICATION HAS CHANGED" - # when the target was a different sshd host some time ago. - local host_addr="${ssh_target##*@}" - touch "$HOME/.ssh/known_hosts" 2>/dev/null && chmod 600 "$HOME/.ssh/known_hosts" 2>/dev/null - ssh-keygen -R "$host_addr" -f "$HOME/.ssh/known_hosts" >/dev/null 2>&1 || true - - # Save host as a peer (with their airc_home so wire paths are correct). - # Drop any existing peer records with the same host first — stale names - # from a prior rename chain must not linger alongside the current one. - local host_airc_home - host_airc_home=$(echo "$response" | python3 -c "import sys,json; print(json.load(sys.stdin).get('airc_home',''))" 2>/dev/null || true) - python3 -c " -import json, os -peers_dir = os.path.expanduser('$PEERS_DIR') -os.makedirs(peers_dir, exist_ok=True) -peer_name = '$peer_name' -ssh_target = '$ssh_target' -if os.path.isdir(peers_dir): - for entry in os.listdir(peers_dir): - if not entry.endswith('.json'): continue - if entry == peer_name + '.json': continue - try: - d = json.load(open(os.path.join(peers_dir, entry))) - except Exception: - continue - if d.get('host') == ssh_target: - for ext in ('.json', '.pub'): - p = os.path.join(peers_dir, entry[:-5] + ext) - if os.path.isfile(p): - try: os.remove(p) - except Exception: pass -record = { - 'name': peer_name, - 'host': ssh_target, - 'airc_home': '$host_airc_home', - 'paired': '$(timestamp)' -} -with open(os.path.join(peers_dir, peer_name + '.json'), 'w') as f: - json.dump(record, f, indent=2) -" 2>/dev/null || true - - # If we resolved this pair via gist discovery (vs. inline-invite), - # persist the gist id so resume-time freshness checks can detect a - # gist-deletion / replacement before re-pairing against a stale host - # (issue #83). Cleared by cmd_part on graceful leave. - if [ -n "$_resolved_gist_id" ]; then - echo "$_resolved_gist_id" > "$AIRC_WRITE_DIR/room_gist_id" - fi - - # Persist host details in own config so `airc invite` can reconstruct - # the join string for onward sharing without a fresh handshake. Also - # cache the host's identity blob from the handshake response so - # `airc whois ` works locally (issue #34 v2). - local host_identity_json; host_identity_json=$(echo "$response" | python3 -c ' -import sys, json -try: - print(json.dumps(json.load(sys.stdin).get("identity", {}) or {})) -except Exception: - print("{}") -' 2>/dev/null) - [ -z "$host_identity_json" ] && host_identity_json="{}" - HOST_IDENTITY="$host_identity_json" python3 -c " -import json, os -c = json.load(open('$CONFIG')) -c['host_airc_home'] = '$host_airc_home' -c['host_name'] = '$peer_name' -c['host_port'] = ${peer_port:-7547} -c['host_ssh_pub'] = '''$host_ssh_pub''' -c['host_identity'] = json.loads(os.environ.get('HOST_IDENTITY', '{}')) -json.dump(c, open('$CONFIG', 'w'), indent=2) -" 2>/dev/null || true +# cmd_connect extracted to lib/airc_bash/cmd_connect.sh +# (#152 Phase 3 file split, follow-up to cmd_doctor.sh / platform_adapters.sh). +# Sourced via the lib-dir resolver. The 1355-line connect orchestrator was +# the single largest block in airc; pulling it out brings the top-level +# script back under the 4000-line bar so future structural work has room. +if [ -n "${_airc_lib_dir:-}" ] && [ -f "$_airc_lib_dir/airc_bash/cmd_connect.sh" ]; then + # shellcheck source=lib/airc_bash/cmd_connect.sh + source "$_airc_lib_dir/airc_bash/cmd_connect.sh" +else + echo "ERROR: airc_bash/cmd_connect.sh not found via lib-dir resolver." >&2 + echo " Resolved lib_dir: ${_airc_lib_dir:-}" >&2 + echo " Re-run install.sh or check AIRC_DIR." >&2 + exit 1 +fi - # Pick up reminder setting from host - local host_reminder - host_reminder=$(echo "$response" | python3 -c "import sys,json; print(json.load(sys.stdin).get('reminder',300))" 2>/dev/null || echo "300") - if [ "$host_reminder" -gt 0 ] 2>/dev/null; then - echo "$host_reminder" > "$AIRC_WRITE_DIR/reminder" - date +%s > "$AIRC_WRITE_DIR/last_sent" - fi +# cmd_rename extracted to lib/airc_bash/cmd_rename.sh +# (#152 Phase 3 file split — final structural sweep). +if [ -n "${_airc_lib_dir:-}" ] && [ -f "$_airc_lib_dir/airc_bash/cmd_rename.sh" ]; then + # shellcheck source=lib/airc_bash/cmd_rename.sh + source "$_airc_lib_dir/airc_bash/cmd_rename.sh" +else + echo "ERROR: airc_bash/cmd_rename.sh not found via lib-dir resolver." >&2 + exit 1 +fi - # Verify SSH works - if relay_ssh "$ssh_target" "echo ok" 2>/dev/null; then - echo " Connected to '$peer_name' (SSH verified, reminder: ${host_reminder}s)" - else - echo " Connected to '$peer_name' (SSH not verified — messages may need retry)" - fi +# Identity bundle (cmd_away + cmd_identity + cmd_whois + _identity_* +# helpers) extracted to lib/airc_bash/cmd_identity.sh (#152 Phase 3 file +# split). The bundle was already cohesive — every helper is _identity_*, +# every public verb is about presence/persona — so it goes to ONE file. +if [ -n "${_airc_lib_dir:-}" ] && [ -f "$_airc_lib_dir/airc_bash/cmd_identity.sh" ]; then + # shellcheck source=lib/airc_bash/cmd_identity.sh + source "$_airc_lib_dir/airc_bash/cmd_identity.sh" +else + echo "ERROR: airc_bash/cmd_identity.sh not found via lib-dir resolver." >&2 + exit 1 +fi - # Write PID file so `airc teardown` can find us later. - echo $$ > "$AIRC_WRITE_DIR/airc.pid" - # Clean exit on tab close / signal: reap the ssh tail subprocess so the - # remote doesn't see an orphaned session and the port doesn't linger. - trap ' - rm -f "$AIRC_WRITE_DIR/airc.pid" 2>/dev/null - for p in $(proc_children $$); do kill $p 2>/dev/null; done - ' EXIT INT TERM +# cmd_send + cmd_ping extracted to lib/airc_bash/cmd_send.sh +# (#152 Phase 3 file split, follow-up to cmd_connect / cmd_daemon / +# cmd_doctor extractions). +if [ -n "${_airc_lib_dir:-}" ] && [ -f "$_airc_lib_dir/airc_bash/cmd_send.sh" ]; then + # shellcheck source=lib/airc_bash/cmd_send.sh + source "$_airc_lib_dir/airc_bash/cmd_send.sh" +else + echo "ERROR: airc_bash/cmd_send.sh not found via lib-dir resolver." >&2 + exit 1 +fi - spawn_general_sidecar_if_wanted - echo " Monitoring for messages..." - monitor +# Channel/peer cluster (cmd_rooms + cmd_part + cmd_send_file + cmd_invite + +# cmd_peers) extracted to lib/airc_bash/cmd_rooms.sh (#152 Phase 3 file +# split). Bundled because in IRC mental model these are all the same +# conceptual surface — channel/peer ops belong together. +if [ -n "${_airc_lib_dir:-}" ] && [ -f "$_airc_lib_dir/airc_bash/cmd_rooms.sh" ]; then + # shellcheck source=lib/airc_bash/cmd_rooms.sh + source "$_airc_lib_dir/airc_bash/cmd_rooms.sh" +else + echo "ERROR: airc_bash/cmd_rooms.sh not found via lib-dir resolver." >&2 + exit 1 +fi - else - # ── HOST MODE ───────────────────────────────────────────────── - local name="${target:-}" - [ -z "$name" ] && name=$(resolve_name) +# cmd_teardown + cmd_disconnect extracted to lib/airc_bash/cmd_teardown.sh +# (#152 Phase 3 file split). +if [ -n "${_airc_lib_dir:-}" ] && [ -f "$_airc_lib_dir/airc_bash/cmd_teardown.sh" ]; then + # shellcheck source=lib/airc_bash/cmd_teardown.sh + source "$_airc_lib_dir/airc_bash/cmd_teardown.sh" +else + echo "ERROR: airc_bash/cmd_teardown.sh not found via lib-dir resolver." >&2 + exit 1 +fi - init_identity "$name" +# Release-info cluster (cmd_update + cmd_channel + cmd_version) +# extracted to lib/airc_bash/cmd_update.sh (#152 Phase 3 file split — +# final structural sweep). +if [ -n "${_airc_lib_dir:-}" ] && [ -f "$_airc_lib_dir/airc_bash/cmd_update.sh" ]; then + # shellcheck source=lib/airc_bash/cmd_update.sh + source "$_airc_lib_dir/airc_bash/cmd_update.sh" +else + echo "ERROR: airc_bash/cmd_update.sh not found via lib-dir resolver." >&2 + exit 1 +fi - # Merge into existing config.json (preserve identity across re-spawns - # — same rationale as the joiner branch above). - MY_NAME="$name" MY_HOST="$(get_host)" CREATED="$(timestamp)" CONFIG="$CONFIG" python3 -c ' -import json, os -try: - c = json.load(open(os.environ["CONFIG"])) -except Exception: - c = {} -c["name"] = os.environ["MY_NAME"] -c["host"] = os.environ["MY_HOST"] -c["created"] = os.environ["CREATED"] -# Host mode: clear any leftover host_target/host_name from a prior -# joiner run in this scope (avoid mis-reading ourselves as a joiner). -for k in ("host_target", "host_name", "host_port", "host_airc_home", "host_ssh_pub", "host_identity"): - c.pop(k, None) -json.dump(c, open(os.environ["CONFIG"], "w"), indent=2) -' +# cmd_status + cmd_logs extracted to lib/airc_bash/cmd_status.sh +# (#152 Phase 3 file split). cmd_logs lived ~30 lines below the cmd_doctor +# source line; this single source-block provides BOTH functions. +if [ -n "${_airc_lib_dir:-}" ] && [ -f "$_airc_lib_dir/airc_bash/cmd_status.sh" ]; then + # shellcheck source=lib/airc_bash/cmd_status.sh + source "$_airc_lib_dir/airc_bash/cmd_status.sh" +else + echo "ERROR: airc_bash/cmd_status.sh not found via lib-dir resolver." >&2 + exit 1 +fi - local host; host=$(get_host) - local user; user=$(whoami) - local ssh_pubkey_b64; ssh_pubkey_b64=$(base64 < "$IDENTITY_DIR/ssh_key.pub" | tr -d '\n') - # Port selection: start at AIRC_PORT (or 7547) and walk up if already - # taken. Happens on machines with stale/zombie airc hosts or multiple - # concurrent scopes. Users don't need to pick a port manually. - local host_port="${AIRC_PORT:-7547}" - local original_port="$host_port" - local tried=0 - while [ -n "$(port_listeners "$host_port")" ]; do - host_port=$((host_port + 1)) - tried=$((tried + 1)) - if [ "$tried" -ge 20 ]; then - die "No free port in range ${original_port}-$((original_port + 20)). Close other airc hosts or set AIRC_PORT explicitly." - fi - done - # Only include :port in the join string when non-default, keeping strings compact. - local port_suffix="" - [ "$host_port" != "7547" ] && port_suffix=":$host_port" +# 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 - # Persist the actual listen port so `airc invite` can reconstruct the - # join string later without needing to parse the startup banner. - echo "$host_port" > "$AIRC_WRITE_DIR/host_port" +# cmd_doctor + helpers extracted to lib/airc_bash/cmd_doctor.sh +# (#152 Phase 3 file split). Sourced via the lib-dir resolver. +if [ -n "${_airc_lib_dir:-}" ] && [ -f "$_airc_lib_dir/airc_bash/cmd_doctor.sh" ]; then + # shellcheck source=lib/airc_bash/cmd_doctor.sh + source "$_airc_lib_dir/airc_bash/cmd_doctor.sh" +else + echo "ERROR: airc_bash/cmd_doctor.sh not found via lib-dir resolver." >&2 + exit 1 +fi - # Set reminder interval from host - if [ "$reminder_interval" -gt 0 ] 2>/dev/null; then - echo "$reminder_interval" > "$AIRC_WRITE_DIR/reminder" - date +%s > "$AIRC_WRITE_DIR/last_sent" - fi - - echo "" - [ "$host_port" != "$original_port" ] && echo " Port $original_port was taken; using $host_port." - echo " Hosting as '$name' (reminder: ${reminder_interval}s)" - echo "" - local _invite_long="${name}@${user}@${host}${port_suffix}#${ssh_pubkey_b64}" - # When --gist is requested AND succeeds, the short gist ID becomes - # the primary handoff and the long invite is demoted to a footnote - # ("if the gist channel fails, fall back to this"). When --gist is - # NOT requested, we print the long invite as the primary as today. - local _printed_long=0 - if [ "$use_gist" != "1" ]; then - echo " On the other machine:" - echo " airc connect $_invite_long" - _printed_long=1 - fi - - # Record room name + print substrate banner BEFORE the gist push - # attempt so cmd_part / status / diagnostics know the channel name - # even when the gist push is skipped (--no-gist) or fails (gh - # missing/unauthed). The gist_id is recorded only when an actual - # gist is created (see below). The "Hosting #" banner is the - # signal both humans and the integration test use to confirm - # substrate framing took effect — emit unconditionally for room mode. - if [ "$use_room" = "1" ]; then - echo "$room_name" > "$AIRC_WRITE_DIR/room_name" - echo " Hosting #${room_name} — no existing room on your gh account, fresh start." - echo " Other agents on your gh account who run 'airc join' will auto-join." - fi - - # ── Gist transport (--gist flag, issue #37) ──────────────────── - # Push the long invite to a secret gist + print the short ID. The - # short ID is robust across chat clients (sms, slack, paste-buffer - # cross-machine) where the 200-char base64 invite gets line-wrapped - # or auto-formatted into uselessness. It's also a coordination - # layer for cross-tailnet pairing where the two peers don't share - # a VPN initially — the gist is the shared rendezvous point. - # - # Payload is a versioned JSON envelope, NOT a raw invite string. - # Same shape as image file headers: magic + version + typed body. - # `airc: 1` marks it as ours; `kind` is the dispatch field for - # future connection kinds (cross-tailnet relay, bootstrap-tailnet, - # webrtc-mesh, etc.). Receiver reads kind → calls the matching - # handler; new kinds added without breaking old peers because the - # version field gates compat. - if [ "$use_gist" = "1" ]; then - if ! command -v gh >/dev/null 2>&1; then - echo "" - echo " ⚠ --gist requested but 'gh' CLI not installed." - echo " Install: https://cli.github.com (or: brew install gh)" - echo " Skipping gist push; long invite above is the only handoff." - else - local _gist_tmp; _gist_tmp=$(mktemp -t airc-invite.XXXXXX) - local _now; _now=$(date -u +%Y-%m-%dT%H:%M:%SZ) - local _gist_kind="invite" - local _gist_desc="airc invite for $name (delete after pair)" - local _gist_payload="" - - if [ "$use_room" = "1" ]; then - # Room mode (#39 substrate): persistent gist, not deleted after - # pair. Lets additional joiners discover + auto-join the same - # channel. Same SSH-pair handshake under the hood — only the - # gist lifecycle + envelope kind differ. - _gist_kind="room" - _gist_desc="airc room: ${room_name}" - # last_heartbeat: host's presence signal, refreshed every - # AIRC_HEARTBEAT_SEC (default 30s) by the bg loop spawned - # below. Joiners detect stale → take over deterministically. - # - # machine_id + host.addresses[]: multi-address redundancy. - # Same machine, two tabs → joiner sees machine_id match, - # uses 127.0.0.1 regardless of network state. Same LAN → - # joiner picks the LAN entry. Tailscale → joiner picks - # tailscale ONLY when nothing closer works AND the host is - # actually signed in (host_address_set drops tailscale from - # the list when not authed). Tailscale becomes truly - # optional: if it's down or you're logged out, the gist's - # localhost+LAN entries still let same-machine and - # same-LAN peers connect. - local _addrs_json; _addrs_json=$(host_addresses_json "$host_port") - local _machine_id; _machine_id=$(host_machine_id) - _gist_payload=$(cat < "$_gist_tmp" - # Secret gist: URL-only-discoverable, not searchable. The gist - # ID itself is the secret. Same threat model as the long invite: - # whoever holds the string can pair. Room gists persist; invite - # gists should be deleted by the host after the first joiner. - local _gist_url; _gist_url=$(gh gist create -d "$_gist_desc" "$_gist_tmp" 2>/dev/null | tail -1) - rm -f "$_gist_tmp" - if [ -n "$_gist_url" ]; then - local _gist_id="${_gist_url##*/}" - local _hh; _hh=$(humanhash "$_gist_id" 2>/dev/null) - # Persist the gist id locally so cmd_part can delete the room - # gist on graceful host exit (room mode only — invite mode is - # one-shot and the joiner-pair flow already prompts cleanup). - if [ "$_gist_kind" = "room" ]; then - echo "$_gist_id" > "$AIRC_WRITE_DIR/room_gist_id" - echo "$room_name" > "$AIRC_WRITE_DIR/room_name" - - # Heartbeat loop: keep last_heartbeat fresh in the gist so - # joiners can deterministically detect a dead host. Without - # this, a host that dies ungracefully (sleep, kill -9, OOM, - # crashed bash) leaves a gist pointing at a corpse forever. - # Every messy state cascade today (memento, my own - # bash-bg-and-die orphan, the manual gist-delete I had to - # run by hand) traces to this missing presence signal. - # - # Loop runs every AIRC_HEARTBEAT_SEC (default 30s) and dies - # automatically when its parent (the host airc connect bash) - # exits — so kill -9 on the host stops heartbeats within one - # interval. Joiners treat last_heartbeat older than - # AIRC_HEARTBEAT_STALE (default 90s = 3 missed beats) as - # stale and self-heal as new host. - local _heartbeat_sec="${AIRC_HEARTBEAT_SEC:-30}" - local _hb_parent_pid=$$ - local _hb_invite="$_invite_long" - local _hb_name="$name" - local _hb_user="$user" - local _hb_host="$host" - local _hb_port="$host_port" - local _hb_room="$room_name" - local _hb_created="$_now" - local _hb_machine_id="$_machine_id" - ( - # Detach from job control so a parent SIGINT kills the - # whole tree but normal exit lets us race the trap to - # delete the gist first. - while sleep "$_heartbeat_sec"; do - # Parent died (PID gone) → exit. This is the kill -9 - # / OOM / sleep recovery path. - if ! kill -0 "$_hb_parent_pid" 2>/dev/null; then - exit 0 - fi - local _hb_now; _hb_now=$(date -u +%Y-%m-%dT%H:%M:%SZ) - # Refresh addresses each tick. Captures network changes - # mid-session: laptop moves to a different LAN, Tailscale - # comes up / goes down / re-auths, interface flapping. - # The next gist write reflects current reachability; - # joiners that lose connection re-discover and try the - # new address set. - local _hb_addrs; _hb_addrs=$(host_addresses_json "${_hb_port}") - local _hb_payload; _hb_payload=$(cat < "$_hb_tmp" - gh gist edit "$_gist_id" "$_hb_tmp" >/dev/null 2>&1 || true - rm -f "$_hb_tmp" - done - ) & - local _hb_pid=$! - # Stash heartbeat-loop PID + gist-id in scope-local files so - # the canonical exit-trap (set later in cmd_connect, around - # line 2498) can reap them. We don't set our own EXIT trap - # here because bash traps are last-set-wins per shell — the - # later trap would clobber us, leaving the gist orphaned on - # graceful Ctrl-C. Instead, the canonical trap reads these - # state files and cleans everything up in one place. - echo "$_hb_pid" > "$AIRC_WRITE_DIR/heartbeat.pid" - echo "$_gist_id" > "$AIRC_WRITE_DIR/host_gist_id" - - # Post-publish race-loser detection. Two tabs that ran - # `airc join --room X` simultaneously can BOTH see empty - # gist list (gh propagation lag) and BOTH publish — pre- - # publish recheck doesn't help because neither's gist is - # globally visible yet. Solution: after publishing, look - # for OTHER gists with the same room name. Deterministic - # tiebreaker (lowest gist id alphabetically) picks the - # winner; loser deletes its gist + re-execs as joiner - # targeting the winner. Light jitter spreads the listing - # so we both see the same set. - local _race_jit; _race_jit=$(awk -v r="$RANDOM" 'BEGIN{printf "%.3f", 0.5 + (r%1000)/1000}') - sleep "$_race_jit" - local _peer_rooms; _peer_rooms=$(gh gist list --limit 50 2>/dev/null \ - | awk -F'\t' -v re="airc room: ${room_name}\$" '$2 ~ re {print $1}' \ - | sort) - local _peer_count; _peer_count=$(printf '%s\n' "$_peer_rooms" | grep -c . || true) - if [ "$_peer_count" -gt 1 ]; then - local _winner_id; _winner_id=$(printf '%s\n' "$_peer_rooms" | head -1) - if [ "$_winner_id" != "$_gist_id" ]; then - echo "" - echo " ⚠ Concurrent host detected for #${room_name} — yielding to winner ($_winner_id)." - # Stop our heartbeat, delete our gist, clear state, re-exec as joiner. - kill "$_hb_pid" 2>/dev/null || true - gh gist delete "$_gist_id" --yes >/dev/null 2>&1 || true - rm -f "$AIRC_WRITE_DIR/heartbeat.pid" \ - "$AIRC_WRITE_DIR/host_gist_id" \ - "$AIRC_WRITE_DIR/room_gist_id" \ - "$AIRC_WRITE_DIR/room_name" - local _preserved_name; _preserved_name=$(get_config_val name "") - exec env ${_preserved_name:+AIRC_NAME="$_preserved_name"} "$0" connect "$_winner_id" - fi - fi - - echo " Hosting #${room_name} (gh-account substrate)." - echo " Other agents on your gh account auto-join via: airc connect" - echo " Cross-account share (rare):" - echo " airc connect $_gist_id" - [ -n "$_hh" ] && echo " # mnemonic: $_hh" - echo " airc connect $_invite_long" - echo "" - echo " (Room gist: $_gist_url — persistent; deleted on 'airc part'.)" - else - echo " On the other machine (pick whichever is easiest to share):" - echo "" - echo " airc connect $_gist_id" - [ -n "$_hh" ] && echo " # mnemonic: $_hh" - echo " airc connect $_invite_long" - echo "" - echo " (Gist: $_gist_url — secret, single-use; delete after pairing.)" - fi - else - echo "" - echo " ⚠ Gist push failed (gh auth?). Falling back to long invite:" - if [ "$_printed_long" = "0" ]; then - echo " airc connect $_invite_long" - fi - fi - fi - fi - echo "" - echo " Waiting for peers on port $host_port..." - # Background: accept peer registrations via TCP (public keys only) - while true; do - python3 -c " -import socket, json, sys, os - -sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) -sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) -sock.bind(('0.0.0.0', $host_port)) -sock.listen(1) -# Short accept timeout + parent-death check means if the outer bash dies -# between pairings, this python exits cleanly on the next timeout instead -# of orphaning and holding the port forever. -sock.settimeout(10) -while True: - try: - conn, addr = sock.accept() - break - except socket.timeout: - if os.getppid() == 1: - sock.close() - sys.exit(0) -data = b'' -while True: - chunk = conn.recv(4096) - if not chunk: break - data += chunk - if b'\n' in data: break - -joiner = json.loads(data.decode().strip()) - -# Authorize joiner's SSH key -ssh_dir = os.path.expanduser('~/.ssh') -os.makedirs(ssh_dir, mode=0o700, exist_ok=True) -ak = os.path.join(ssh_dir, 'authorized_keys') -ssh_key = joiner.get('ssh_pub', '') -if ssh_key: - existing = open(ak).read() if os.path.exists(ak) else '' - if ssh_key not in existing: - with open(ak, 'a') as f: - f.write(ssh_key.strip() + '\n') - os.chmod(ak, 0o600) - -# Save joiner as peer — but first drop any existing records that share -# this joiner's host (stable identity across renames). Otherwise a -# rename chain leaves stale '.json' alongside the new one. -peers_dir = os.path.expanduser('$PEERS_DIR') -os.makedirs(peers_dir, exist_ok=True) -jname = joiner['name'] -jhost = joiner.get('host','') -if jhost and os.path.isdir(peers_dir): - for entry in os.listdir(peers_dir): - if not entry.endswith('.json'): continue - if entry == jname + '.json': continue - try: - d = json.load(open(os.path.join(peers_dir, entry))) - except Exception: - continue - if d.get('host') == jhost: - # Same machine+user pairing under a different name — stale. - for ext in ('.json', '.pub'): - p = os.path.join(peers_dir, entry[:-5] + ext) - if os.path.isfile(p): - try: os.remove(p) - except Exception: pass -with open(os.path.join(peers_dir, jname + '.json'), 'w') as f: - json.dump({ - 'name': jname, - 'host': joiner.get('host',''), - 'airc_home': joiner.get('airc_home', ''), - 'paired': '$(timestamp)', - # Cache joiner's SSH pubkey so airc kick can remove it from - # authorized_keys later. Without this, kick has no way to find - # the right line in authorized_keys and the kicked peer keeps - # SSH access — Copilot caught this on PR #73 review. - 'ssh_pub': joiner.get('ssh_pub', ''), - # Cache joiner's identity blob (issue #34 v2). Empty on legacy - # peers that don't send the field — airc whois prints the - # 'not exchanged yet' fallback gracefully. - 'identity': joiner.get('identity', {}) - }, f, indent=2) -if joiner.get('sign_pub'): - with open(os.path.join(peers_dir, jname + '.pub'), 'w') as f: - f.write(joiner['sign_pub']) - -# Send back host's SSH pubkey + airc_home + own identity blob (issue #34 -# v2). Joiner caches under host_identity so 'airc whois ' -# works locally without a round-trip. -host_pub = open(os.path.expanduser('$IDENTITY_DIR/ssh_key.pub')).read().strip() -host_identity = {} -try: - host_config = json.load(open('$CONFIG')) - host_identity = host_config.get('identity', {}) or {} -except Exception: - pass -response = json.dumps({ - 'ssh_pub': host_pub, - 'name': '$name', - 'reminder': $reminder_interval, - 'airc_home': '$AIRC_WRITE_DIR', - 'identity': host_identity -}) -conn.sendall((response + '\n').encode()) -conn.close() -sock.close() -print(f' Peer joined: {jname}') -# Surface the join as a system event in messages.jsonl so the monitor -# formatter (and downstream Monitor task summaries on every paired peer) -# render a one-liner like '[#general] airc: joined' instead of -# silence. Without this, peer-joined is invisible to anyone reading -# notifications — they only learn about the new peer when chat traffic -# starts flowing. Joel 2026-04-24: 'preview of the message or the -# connection or whatever happened, Anvil joined instead of generic'. -import datetime -try: - room_name_path = '$AIRC_WRITE_DIR/room_name' - room_name = open(room_name_path).read().strip() if os.path.isfile(room_name_path) else 'general' - event = { - 'ts': datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'), - 'from': 'airc', - 'to': 'all', - 'msg': f'{jname} joined #{room_name}', - } - with open('$MESSAGES', 'a') as f: - f.write(json.dumps(event) + '\n') -except Exception: - # Don't fail the pair on event-emit error — pairing already - # succeeded by this point; the missing event line is cosmetic. - pass -" 2>/dev/null || true - done & - PAIR_PID=$! - - # Write PID file so `airc teardown` can find us later. Record us, the - # PAIR_PID (TCP-accept loop), and the heartbeat-loop PID (if hosting a - # room with a gist) so teardown can reap all three. - _hb_pid_persisted="" - [ -f "$AIRC_WRITE_DIR/heartbeat.pid" ] && _hb_pid_persisted=$(cat "$AIRC_WRITE_DIR/heartbeat.pid" 2>/dev/null) - echo "$$ $PAIR_PID $_hb_pid_persisted" > "$AIRC_WRITE_DIR/airc.pid" - # Clean exit on tab close (SIGTERM/SIGINT from Claude Code's Monitor tool - # going away, or any other signal): reap the accept loop, its python - # listener, the heartbeat loop, AND delete our hosted gist if any — - # don't leave orphans holding the port, the SSH session, or a stale - # gist pointing at a corpse. Single canonical trap (was previously - # split between this site + the gist-publish site, but bash traps are - # last-set-wins per shell so the split lost the gist-cleanup half). - trap ' - _exit_hb_pid="" - _exit_gist_id="" - [ -f "$AIRC_WRITE_DIR/heartbeat.pid" ] && _exit_hb_pid=$(cat "$AIRC_WRITE_DIR/heartbeat.pid" 2>/dev/null) - [ -f "$AIRC_WRITE_DIR/host_gist_id" ] && _exit_gist_id=$(cat "$AIRC_WRITE_DIR/host_gist_id" 2>/dev/null) - [ -n "$_exit_hb_pid" ] && kill $_exit_hb_pid 2>/dev/null - if [ -n "$_exit_gist_id" ] && command -v gh >/dev/null 2>&1; then - gh gist delete "$_exit_gist_id" --yes >/dev/null 2>&1 - fi - rm -f "$AIRC_WRITE_DIR/airc.pid" "$AIRC_WRITE_DIR/heartbeat.pid" "$AIRC_WRITE_DIR/host_gist_id" 2>/dev/null - for p in $PAIR_PID $(proc_children $PAIR_PID) $(proc_children $$); do - kill $p 2>/dev/null - done - ' EXIT INT TERM - - spawn_general_sidecar_if_wanted - echo " Monitoring for messages..." - monitor - kill $PAIR_PID 2>/dev/null - fi -} - -cmd_rename() { - local new_name="${1:-}" - # Intercept help flags BEFORE sanitization — otherwise `--help` looks like a - # valid name (all chars are in [a-z0-9-]) and gets written into config.json. - case "$new_name" in - ""|-h|--help) - echo "Usage: airc rename " - echo " Renames this identity and broadcasts [rename] to paired peers." - [ -z "$new_name" ] && exit 1 || exit 0 - ;; - esac - # Reject leading dash so no flag-shaped string can ever become an identity. - case "$new_name" in -*) die "Name must not start with '-' (got '$new_name')" ;; esac - # Sanitize: lowercase, replace non-[a-z0-9-] with '-', collapse runs of - # dashes, strip leading/trailing dashes, then cap. The post-sanitization - # leading-dash strip matters because input like `.foo` becomes `-foo` - # after the `[^a-z0-9-]` replacement and would slip past the case check - # above — making the resulting name unreachable by `airc whois` / - # `airc kick` (both reject leading-dash). Caught by Copilot review on - # PR #75 follow-up. - new_name=$(echo "$new_name" \ - | tr '[:upper:]' '[:lower:]' \ - | sed 's/[^a-z0-9-]/-/g' \ - | sed 's/--*/-/g; s/^-*//; s/-*$//' \ - | cut -c1-24 \ - | sed 's/-*$//') - [ -z "$new_name" ] && die "Invalid name (must be a-z 0-9 -)" - [ ! -f "$CONFIG" ] && die "Not initialized — run 'airc connect' first" - - local old_name - old_name=$(python3 -c "import json; print(json.load(open('$CONFIG')).get('name',''))" 2>/dev/null) - if [ "$old_name" = "$new_name" ]; then - echo " Already named '$new_name'." - return - fi - - python3 -c " -import json -c = json.load(open('$CONFIG')) -c['name'] = '$new_name' -json.dump(c, open('$CONFIG', 'w'), indent=2) -" - echo " Renamed: $old_name → $new_name" - - # Broadcast the rename. Include a stable `host` field so receivers can - # find THIS peer's record even if their name-keyed lookup would miss - # (e.g. a prior rename marker got dropped; their peer file for us - # still sits under an older name). host is immutable per machine+user. - local my_host; my_host="$(whoami)@$(get_host)" - cmd_send "[rename] old=$old_name new=$new_name host=$my_host" >/dev/null 2>&1 || true -} - -# ── Identity (issue #34) ──────────────────────────────────────────────── -# -# Structured agent persona, layered on top of the bootstrap name from -# derive_name. Stored under config.json's `identity` key (single-file -# scope: `name` already lives in config.json, identity fields sit -# alongside). Five fields: -# -# pronouns — she/they/he/it; used by skill narrators for grammar -# role — short hyphenated tag, e.g. "device-link-orchestrator" -# bio — one-line free-form, IRC-realname analog -# status — mutable "what I'm working on now" (Slack-like) -# integrations — { platform: handle } mappings to other platforms -# (continuum, slack, telegram) so airc identity can -# adopt or be adopted by canonical persona elsewhere -# -# Skill-side bootstrap prompts the agent to fill these on first /join -# (set AIRC_NO_IDENTITY_PROMPT=1 to skip — used by integration tests). -# v1: airc identity show/set/link locally; airc whois on self. -# v2 (deferred): peer WHOIS over SSH; live continuum/slack import/push. - -# IRC /away: short alias for `airc identity set --status ...`. With a -# message, marks the agent as away. Without args, clears the status -# (back from away). Adheres to IRC convention; the longer form -# (airc identity set --status) still works for scripted state changes. -cmd_away() { - ensure_init - if [ $# -eq 0 ]; then - _identity_set --status "" >/dev/null - echo " back — away cleared." - else - local msg="$*" - _identity_set --status "$msg" >/dev/null - echo " away: $msg" - fi -} - -cmd_identity() { - ensure_init - local sub="${1:-show}" - shift 2>/dev/null || true - case "$sub" in - show|"") _identity_show ;; - set) _identity_set "$@" ;; - link) _identity_link "$@" ;; - import) _identity_import "$@" ;; - push) _identity_push "$@" ;; - -h|--help|help) - echo "Usage:" - echo " airc identity show Print own identity" - echo " airc identity set [--pronouns X] [--role Y] [--bio \"…\"] [--status \"…\"]" - echo " airc identity link [handle] Map this identity to a platform persona (omit handle to unlink)" - echo " airc identity import : Pull persona from platform (continuum)" - echo " airc identity push Send local fields to platform (continuum)" - ;; - *) die "Unknown identity subcommand: $sub (try: show, set, link, import, push)" ;; - esac -} - -_identity_show() { - CONFIG="$CONFIG" python3 -c ' -import json, os -try: - c = json.load(open(os.environ["CONFIG"])) -except Exception: - print(" (no config — run airc connect)"); raise SystemExit(0) -ident = c.get("identity", {}) or {} -fields = [ - ("name", c.get("name", "?"), ""), - ("pronouns", ident.get("pronouns", ""), "(unset)"), - ("role", ident.get("role", ""), "(unset)"), - ("bio", ident.get("bio", ""), "(unset)"), - # status field is the IRC /away analog. Surface the airc away - # command in the unset case so QA users (continuum-b741 2026-04-27) - # do not see a half-baked empty field with no obvious setter. - ("status", ident.get("status", ""), "(unset; airc away to set)"), -] -for k, v, fallback in fields: - label = k + ":" - value = v if v else fallback - print(f" {label:<11} {value}") -ints = ident.get("integrations", {}) or {} -if ints: - print(" integrations:") - for k, v in ints.items(): - print(f" {k}: {v}") -else: - print(" integrations: (none)") -' -} - -_identity_set() { - local pronouns="" role="" bio="" status="" - local set_pronouns=0 set_role=0 set_bio=0 set_status=0 - while [ $# -gt 0 ]; do - case "$1" in - --pronouns) pronouns="${2:-}"; set_pronouns=1; shift 2 ;; - --role) role="${2:-}"; set_role=1; shift 2 ;; - --bio) bio="${2:-}"; set_bio=1; shift 2 ;; - --status) status="${2:-}"; set_status=1; shift 2 ;; - *) die "Unknown flag: $1 (use --pronouns/--role/--bio/--status)" ;; - esac - done - if [ "$set_pronouns" = 0 ] && [ "$set_role" = 0 ] && [ "$set_bio" = 0 ] && [ "$set_status" = 0 ]; then - die "Pass at least one of --pronouns / --role / --bio / --status" - fi - CONFIG="$CONFIG" \ - SET_PRONOUNS="$set_pronouns" PRONOUNS="$pronouns" \ - SET_ROLE="$set_role" ROLE="$role" \ - SET_BIO="$set_bio" BIO="$bio" \ - SET_STATUS="$set_status" STATUS="$status" \ - python3 -c ' -import json, os -c = json.load(open(os.environ["CONFIG"])) -ident = c.setdefault("identity", {}) -for key, env_set, env_val in [ - ("pronouns", "SET_PRONOUNS", "PRONOUNS"), - ("role", "SET_ROLE", "ROLE"), - ("bio", "SET_BIO", "BIO"), - ("status", "SET_STATUS", "STATUS"), -]: - if os.environ.get(env_set) == "1": - v = os.environ.get(env_val, "").strip() - if v: - ident[key] = v - else: - ident.pop(key, None) -json.dump(c, open(os.environ["CONFIG"], "w"), indent=2) -print(" identity updated.") -' -} - -_identity_link() { - local platform="${1:-}" handle="${2:-}" - [ -z "$platform" ] && die "Usage: airc identity link [handle] (omit/blank handle to unlink)" - CONFIG="$CONFIG" PLATFORM="$platform" HANDLE="$handle" python3 -c ' -import json, os -c = json.load(open(os.environ["CONFIG"])) -ints = c.setdefault("identity", {}).setdefault("integrations", {}) -platform = os.environ["PLATFORM"] -handle = os.environ.get("HANDLE", "").strip() -if handle: - ints[platform] = handle - print(f" linked: {platform} -> {handle}") -else: - ints.pop(platform, None) - print(f" unlinked: {platform}") -json.dump(c, open(os.environ["CONFIG"], "w"), indent=2) -' -} - -# WHOIS: prints identity for self, host, paired peer, or other peer of -# our host. Identity blobs are exchanged at pair-handshake time and -# cached locally — no round-trip needed for self/host/local-peer. Cross- -# peer (we're a joiner asking about another joiner of our host) falls -# back to a single SSH read of the host's peer file. -# -# Cross-scope (issue #134): walks sibling scopes (.airc + .airc.) -# so a project-tab whois can find a peer who's only in the #general -# sidecar's host. Without this, JOIN events in the sidecar room emit -# names that whois can't resolve, breaking the IRC mental model where -# every room member is reachable. -cmd_whois() { - ensure_init - local target="${1:-}" - local my_name; my_name=$(get_name) - - # Self — same identity across all scopes, no walk needed. - if [ -z "$target" ] || [ "$target" = "$my_name" ]; then - _identity_show - return 0 - fi - - # Reject path-traversal / shell-injection in target before it touches - # filesystem paths (local /peers/.json) or remote SSH - # cmds (cat $host_airc_home/peers/.json) in any scope. - _validate_peer_name "$target" - - # Try primary scope first, then walk sibling sidecar scopes. First - # hit wins. The order matters: primary scope's host/peer-file lookups - # are local-only (cheap); sibling scopes may add an SSH round-trip - # per scope for the cross-peer-via-host path. - if _whois_in_scope "$AIRC_WRITE_DIR" "$target"; then - return 0 - fi - - local parent self_base prefix sibling - parent=$(dirname "$AIRC_WRITE_DIR") - self_base=$(basename "$AIRC_WRITE_DIR") - # Strip a trailing . to recover the primary prefix. Mirrors the - # detection in cmd_peers (#124) so .airc / .airc.general both resolve - # to .airc as the prefix; in tests we see state / state.general → state. - prefix=$(printf '%s' "$self_base" | sed -E 's/\.[a-z0-9-]+$//') - if [ -d "$parent" ]; then - for sibling in "$parent/$prefix" "$parent/$prefix".*; do - [ -d "$sibling" ] || continue - [ "$sibling" = "$AIRC_WRITE_DIR" ] && continue - [ -f "$sibling/config.json" ] || continue - if _whois_in_scope "$sibling" "$target"; then - return 0 - fi - done - fi - - echo " whois: no record for '$target' (try airc peers to list paired peers)" - return 1 -} - -# Per-scope whois lookup. Returns 0 + prints if found; non-zero if not. -# Args: scope-dir, target-name. Caller has already validated target. -_whois_in_scope() { - local scope="$1" target="$2" - local scope_config="$scope/config.json" - local scope_peers="$scope/peers" - [ -f "$scope_config" ] || return 1 - - # Host of this scope (we're a joiner, target is the host we paired with). - local host_name - host_name=$(SCOPE_CONFIG="$scope_config" python3 -c ' -import json, os -try: print(json.load(open(os.environ["SCOPE_CONFIG"])).get("host_name", "") or "") -except Exception: pass -' 2>/dev/null || echo "") - if [ -n "$host_name" ] && [ "$target" = "$host_name" ]; then - local host_id_blob host_target_addr - host_id_blob=$(SCOPE_CONFIG="$scope_config" python3 -c ' -import json, os -try: print(json.dumps(json.load(open(os.environ["SCOPE_CONFIG"])).get("host_identity", {}) or {})) -except Exception: print("{}") -' 2>/dev/null || echo "{}") - host_target_addr=$(SCOPE_CONFIG="$scope_config" python3 -c ' -import json, os -try: print(json.load(open(os.environ["SCOPE_CONFIG"])).get("host_target", "") or "") -except Exception: pass -' 2>/dev/null || echo "") - _whois_pretty "$target" "$host_id_blob" "$host_target_addr" - return 0 - fi - - # Local peer file under this scope. - local peer_file="$scope_peers/$target.json" - if [ -f "$peer_file" ]; then - local blob host - blob=$(PEER_FILE="$peer_file" python3 -c ' -import json, os -try: print(json.dumps(json.load(open(os.environ["PEER_FILE"])).get("identity", {}) or {})) -except Exception: print("{}") -' 2>/dev/null) - host=$(PEER_FILE="$peer_file" python3 -c ' -import json, os -try: print(json.load(open(os.environ["PEER_FILE"])).get("host", "") or "") -except Exception: pass -' 2>/dev/null) - _whois_pretty "$target" "$blob" "$host" - return 0 - fi - - # Cross-peer via this scope's host (we're a joiner; query host's peer - # file remotely). Skipped when we're the host of this scope (no - # host_target). The SSH key for this scope is at $scope/identity/ssh_key - # — relay_ssh picks up IDENTITY_DIR from the env, so we set it for the - # subprocess. - local host_target_addr host_airc_home - host_target_addr=$(SCOPE_CONFIG="$scope_config" python3 -c ' -import json, os -try: print(json.load(open(os.environ["SCOPE_CONFIG"])).get("host_target", "") or "") -except Exception: pass -' 2>/dev/null || echo "") - host_airc_home=$(SCOPE_CONFIG="$scope_config" python3 -c ' -import json, os -try: print(json.load(open(os.environ["SCOPE_CONFIG"])).get("host_airc_home", "") or "") -except Exception: pass -' 2>/dev/null || echo "") - if [ -n "$host_target_addr" ] && [ -n "$host_airc_home" ]; then - local remote_blob - remote_blob=$(IDENTITY_DIR="$scope/identity" relay_ssh "$host_target_addr" "cat $host_airc_home/peers/$target.json 2>/dev/null" 2>/dev/null || true) - if [ -n "$remote_blob" ]; then - local peer_id peer_host - peer_id=$(printf '%s' "$remote_blob" | python3 -c ' -import sys, json -try: print(json.dumps(json.load(sys.stdin).get("identity", {}) or {})) -except Exception: print("{}") -' 2>/dev/null) - peer_host=$(printf '%s' "$remote_blob" | python3 -c ' -import sys, json -try: print(json.load(sys.stdin).get("host", "") or "") -except Exception: pass -' 2>/dev/null) - _whois_pretty "$target" "$peer_id" "$peer_host" - return 0 - fi - fi - - return 1 -} - -# Pretty-print an identity blob (JSON string) for a named peer. -# Args: name, identity-json, host (any may be empty). -_whois_pretty() { - local name="$1" blob="${2:-{\}}" host="${3:-}" - NAME="$name" BLOB="$blob" HOST="$host" python3 <<'PYEOF' -import json, os -name = os.environ["NAME"] -host = os.environ.get("HOST", "") -try: - ident = json.loads(os.environ.get("BLOB", "{}") or "{}") -except Exception: - ident = {} -print(f" name: {name}") -fields = [("pronouns", ident.get("pronouns", "")), - ("role", ident.get("role", "")), - ("bio", ident.get("bio", "")), - ("status", ident.get("status", ""))] -for k, v in fields: - label = k + ":" - fallback = "(unset)" - print(f" {label:<11} {v if v else fallback}") -ints = ident.get("integrations", {}) or {} -if ints: - print(" integrations:") - for k, v in ints.items(): - print(f" {k}: {v}") -else: - print(" integrations: (none)") -if host: - print(f" host: {host}") -PYEOF -} - -cmd_kick() { - # Host-only: forcibly remove a paired peer. IRC analog: /kick . - # Steps: emit a system event, drop their SSH pubkey from authorized_keys, - # remove the peer file. The kicked peer's tail loop dies on the closed - # pipe AND any future auth attempts fail because their key is gone from - # authorized_keys — they can't silently keep operating after a kick. - # They can re-pair via airc connect (no ban yet) — for that, see future - # `airc ban`. - ensure_init - local target="${1:-}" - [ -z "$target" ] && die "Usage: airc kick [reason]" - _validate_peer_name "$target" - shift || true - local reason="${*:-no reason given}" - - # Joiner role check — kicking only makes sense as host. - local host_target; host_target=$(get_config_val host_target "") - if [ -n "$host_target" ]; then - die "kick: only the room host can kick. You are a joiner of $host_target — talk to the host." - fi - - local peer_file="$PEERS_DIR/$target.json" - if [ ! -f "$peer_file" ]; then - die "kick: '$target' not in peers list (try: airc peers)" - fi - - # Read the joiner's SSH pubkey from the peer JSON record (the host - # handshake stores it there — `.pub` holds the SIGNING pubkey, - # not the SSH auth key, so we can't use that file). Without this, - # kick would leave the joiner's SSH key in authorized_keys and the - # peer could keep authenticating despite the "kick" — caught by - # Copilot review on PR #73. - local peer_ssh_pub - peer_ssh_pub=$(PEER_FILE="$peer_file" python3 -c ' -import json, os -try: - p = json.load(open(os.environ["PEER_FILE"])) - print((p.get("ssh_pub") or "").strip()) -except Exception: - pass -' 2>/dev/null || echo "") - - if [ -n "$peer_ssh_pub" ] && [ -f "$HOME/.ssh/authorized_keys" ]; then - # grep -v returns 1 when every line matches (or the file is empty); - # both are fine outcomes here, so eat the exit code. - grep -vF "$peer_ssh_pub" "$HOME/.ssh/authorized_keys" > "$HOME/.ssh/authorized_keys.tmp" 2>/dev/null || true - [ -f "$HOME/.ssh/authorized_keys.tmp" ] && mv "$HOME/.ssh/authorized_keys.tmp" "$HOME/.ssh/authorized_keys" - chmod 600 "$HOME/.ssh/authorized_keys" 2>/dev/null || true - fi - - # Remove peer files (rm -f is set-e-safe). The .pub here is the - # signing key file, separate from authorized_keys. - rm -f "$peer_file" "$PEERS_DIR/$target.pub" - - # Emit a system event so the kicked peer (and others) see it in the - # tail stream. Reuse cmd_send's plumbing. - cmd_send "[kick] $target ($reason)" >/dev/null 2>&1 || true - - if [ -n "$peer_ssh_pub" ]; then - echo " Kicked $target ($reason). SSH key removed from authorized_keys; peer file gone." - else - echo " Kicked $target ($reason). Peer file gone, but no SSH key recorded for this peer — they were paired before #34's handshake update; their authorized_keys entry survived. Run airc peers to confirm." - fi - echo " They can re-pair via airc connect; for permanent ban, see future 'airc ban'." -} - -# ── Identity import/push (issue #34 v2) ───────────────────────────────── -# -# Cross-platform persona linking. The basic shape: airc has an opt-in -# tool wrapper for each known platform. If the platform's CLI is on PATH -# AND a matching profile is found, pull/push fields. Otherwise: clear -# error pointing at the manual `airc identity link `. -# -# v1 supports: continuum (the high-leverage internal case). slack/ -# telegram/discord are stubs that error with platform-install hints — -# they're scaffolding for future PRs, not productionized integrations. - -_identity_import() { - local spec="${1:-}" - [ -z "$spec" ] && die "Usage: airc identity import :" - local platform="${spec%%:*}" - local id="${spec#*:}" - if [ "$platform" = "$spec" ] || [ -z "$id" ]; then - die "Usage: airc identity import : (got '$spec' — missing colon?)" - fi - case "$platform" in - continuum) - _identity_import_continuum "$id" ;; - slack|telegram|discord) - die "import from $platform not yet implemented. For now, run: airc identity link $platform " - ;; - *) - die "Unknown platform '$platform'. Supported: continuum (v1). slack/telegram/discord stubbed." - ;; - esac -} - -_identity_push() { - local platform="${1:-}" - [ -z "$platform" ] && die "Usage: airc identity push " - case "$platform" in - continuum) - _identity_push_continuum ;; - slack|telegram|discord) - die "push to $platform not yet implemented. For now, run: airc identity link $platform " - ;; - *) - die "Unknown platform '$platform'. Supported: continuum (v1). slack/telegram/discord stubbed." - ;; - esac -} - -# Continuum integration: shells out to a `continuum` binary if it's on -# PATH. Expected interface (best-effort — we degrade gracefully if the -# binary doesn't support these subcommands yet): -# continuum persona show → prints JSON {pronouns, role, bio, ...} -# continuum persona update --bio ... → updates the persona -# If continuum isn't installed, link() the handle anyway so the mapping -# is recorded for future syncs. -_identity_import_continuum() { - local id="$1" - if ! command -v continuum >/dev/null 2>&1; then - echo " continuum CLI not on PATH — recording link only." - echo " Once you install continuum, re-run: airc identity import continuum:$id" - _identity_link continuum "$id" - return 0 - fi - local blob; blob=$(continuum persona show "$id" 2>/dev/null || true) - if [ -z "$blob" ]; then - echo " continuum persona '$id' not found — recording link only." - _identity_link continuum "$id" - return 0 - fi - # Parse the JSON; merge into our identity. Empty fields skip; existing - # fields get overwritten (the user's intent: "I want to BE this persona"). - BLOB="$blob" CONFIG="$CONFIG" python3 -c ' -import json, os -try: - src = json.loads(os.environ["BLOB"]) -except Exception: - src = {} -c = json.load(open(os.environ["CONFIG"])) -ident = c.setdefault("identity", {}) -for k in ("pronouns", "role", "bio"): - v = src.get(k) - if v: - ident[k] = v -ints = ident.setdefault("integrations", {}) -ints["continuum"] = src.get("name", "") -json.dump(c, open(os.environ["CONFIG"], "w"), indent=2) -print(f" imported continuum:{src.get(\"name\", \"?\")} → pronouns={src.get(\"pronouns\", \"\")} role={src.get(\"role\", \"\")} bio set={bool(src.get(\"bio\"))}") -' -} - -_identity_push_continuum() { - if ! command -v continuum >/dev/null 2>&1; then - die "continuum CLI not on PATH — install continuum before pushing." - fi - local handle; handle=$(CONFIG="$CONFIG" python3 -c ' -import json, os -c = json.load(open(os.environ["CONFIG"])) -print(c.get("identity", {}).get("integrations", {}).get("continuum", "")) -' 2>/dev/null) - [ -z "$handle" ] && die "No continuum handle linked. Run: airc identity link continuum " - CONFIG="$CONFIG" HANDLE="$handle" python3 -c ' -import json, os, subprocess -c = json.load(open(os.environ["CONFIG"])) -ident = c.get("identity", {}) -handle = os.environ["HANDLE"] -args = ["continuum", "persona", "update", handle] -for k in ("pronouns", "role", "bio"): - v = ident.get(k) - if v: - args += [f"--{k}", v] -res = subprocess.run(args, capture_output=True, text=True) -if res.returncode != 0: - print(f" continuum push failed: {res.stderr.strip() or res.stdout.strip()}") - raise SystemExit(1) -print(f" pushed local identity to continuum:{handle}") -' -} - -cmd_send() { - # Chat-room semantics. Default: broadcast to everyone in the current - # scope's room. Prefix the first arg with '@' to DM a specific peer. - # airc send "hello everyone" → broadcast to current room - # airc send @alice "hey" → DM alice in current room - # airc send --room general "hi lobby" → broadcast to a SIBLING room - # airc send --room general @alice "..."→ DM alice via the sibling room - # - # --room route (issue #122 follow-up): the multi-room sidecar - # model means a tab is in #project-room AND #general simultaneously, - # but each room has its own scope. Without --room support here, sending - # to a non-current room required `AIRC_HOME=$cwd/.airc. airc msg`, - # which is nonobvious (vhsm-Claude attempted `airc msg --room general` - # on 2026-04-26, the unrecognized flag silently became part of the - # message body — exactly the evidence-eating shape the project rejects). - # - # Implementation: parse --room here. If it names a sibling sidecar scope - # (e.g. ${AIRC_WRITE_DIR}.), re-exec ourselves with AIRC_HOME - # pointed at that scope so the rest of the function runs there. Errors - # loudly when the requested room isn't in the user's subscription set - # — never silently broadcasts to the wrong place. - local target_room="" - local positional=() - while [ $# -gt 0 ]; do - case "$1" in - --room|-room) - target_room="${2:-}" - [ -z "$target_room" ] && die "Usage: airc send --room " - shift 2 ;; - *) positional+=("$1"); shift ;; - esac - done - set -- "${positional[@]+"${positional[@]}"}" - - if [ -n "$target_room" ]; then - # Resolve target_room to a scope dir. Two cases: - # 1. We ARE in target_room already (current scope's room_name file - # matches) → just continue here, no re-exec. - # 2. A sibling scope `${primary_scope}.${target_room}` exists → - # re-exec with AIRC_HOME there. Recursion guard via - # AIRC_SEND_REROUTED=1 — without it, a misconfigured sibling - # scope could loop. - # - # Determining "primary scope" is the awkward bit because we may - # ALREADY be in a sidecar scope (AIRC_WRITE_DIR ends in `.X`). Strip - # any trailing `.` to find the project scope, then append - # `.` for the requested sibling. If target_room IS the - # project room name (read from primary's room_name file), point at - # the project scope itself, not a sibling. - local _here_room="" - [ -f "$AIRC_WRITE_DIR/room_name" ] && _here_room=$(cat "$AIRC_WRITE_DIR/room_name" 2>/dev/null) - if [ "$_here_room" = "$target_room" ]; then - : # already in the right scope, fall through to normal send - else - [ "${AIRC_SEND_REROUTED:-0}" = "1" ] \ - && die "send: --room re-route loop detected (scope $AIRC_WRITE_DIR room=$_here_room target=$target_room)" - # Strip any sibling suffix from current scope to get the project - # scope path. e.g. /path/.airc.general → /path/.airc - local _project_scope="$AIRC_WRITE_DIR" - case "$_project_scope" in - *.airc.*) - _project_scope="${_project_scope%.*}" ;; - esac - # Read the project scope's room_name to compare with target. - local _project_room="" - [ -f "$_project_scope/room_name" ] && _project_room=$(cat "$_project_scope/room_name" 2>/dev/null) - local _target_scope="" - if [ "$_project_room" = "$target_room" ]; then - _target_scope="$_project_scope" - else - # Sibling sidecar scope under the project scope's parent. - # Convention: primary scope is `/.airc`, sidecar scope is - # `/.airc.` (e.g. `.airc.general`). - _target_scope="${_project_scope}.${target_room}" - fi - if [ ! -d "$_target_scope" ] || [ ! -f "$_target_scope/room_name" ]; then - echo " send --room #${target_room}: not subscribed in this scope." >&2 - echo " looked at: $_target_scope" >&2 - echo " rooms you ARE in:" >&2 - for _d in "$_project_scope" "$_project_scope".*; do - [ -f "$_d/room_name" ] && echo " - #$(cat "$_d/room_name" 2>/dev/null) (scope: $_d)" >&2 - done - echo " Fix: 'airc join --room ${target_room}' (in a separate scope), or drop the --room flag." >&2 - die "send: not subscribed to #${target_room}" - fi - # Re-exec with AIRC_HOME pointed at the target scope. Pass the - # remaining positional args (peer/message) through. The recursion - # guard prevents infinite re-routing if the target scope is itself - # misconfigured. - exec env AIRC_HOME="$_target_scope" AIRC_SEND_REROUTED=1 "$0" send "$@" - fi - fi - - local first="${1:-}" - [ -z "$first" ] && die "Usage: airc send or airc send @peer " - - local peer_name msg - case "$first" in - @*) - peer_name="${first#@}" - shift - msg="$*" - [ -z "$msg" ] && die "Usage: airc send @peer " - ;; - *) - peer_name="all" - msg="$*" - ;; - esac - ensure_init - - local my_name ts_val - my_name=$(get_name) - ts_val=$(timestamp) - - local escaped_msg - escaped_msg=$(printf '%s' "$msg" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read())[1:-1])") - - local payload="{\"from\":\"$my_name\",\"to\":\"$peer_name\",\"ts\":\"$ts_val\",\"msg\":\"$escaped_msg\"}" - local sig; sig=$(sign_message "$payload") - local full_msg="{\"from\":\"$my_name\",\"to\":\"$peer_name\",\"ts\":\"$ts_val\",\"msg\":\"$escaped_msg\",\"sig\":\"$sig\"}" - - local host_target - host_target=$(get_config_val host_target "") - - if [ -n "$host_target" ]; then - local rhome; rhome=$(remote_home) - # Always mirror locally FIRST so we have an audit trail regardless of - # what the wire does. If send succeeds: local + remote both have it. - # If send fails: local has it (user can see it + retry), remote doesn't. - # This prevents silent loss where both sides forget a message that - # never arrived. - echo "$full_msg" >> "$MESSAGES" - - # Fast-path: when tailscale status already reports this peer offline, - # don't burn 10s on the ssh ConnectTimeout — queue immediately with a - # cleaner "peer offline in tailnet" marker. flush_pending_loop + - # monitor reconnect handle the drain automatically when the peer - # wakes. Skipped entirely for non-CGNAT targets, LAN peers, or when - # tailscale CLI is unavailable (falls through to normal ssh attempt). - if is_peer_offline_in_tailnet "$host_target"; then - echo "$full_msg" >> "$AIRC_WRITE_DIR/pending.jsonl" - local queue_marker; queue_marker=$(printf '{"from":"airc","ts":"%s","msg":"[QUEUED to %s — peer offline in tailnet, auto-delivers on wake]"}' \ - "$(timestamp)" "$peer_name") - echo "$queue_marker" >> "$MESSAGES" - date +%s > "$AIRC_WRITE_DIR/last_sent" 2>/dev/null - rm -f "$AIRC_WRITE_DIR/reminded" 2>/dev/null - return 0 - fi - - # Attempt the wire. Trust the remote's __APPENDED__ marker — some shells - # bubble benign ssh stderr warnings up as non-zero exit, but the append - # itself succeeded. We check stdout for the marker, not the exit code. - # `|| true` prevents set -e from aborting when ssh itself fails (exit 255 - # on unreachable host); we want to reach the failure-marker branch below. - # Pipe message via stdin so apostrophes (or any shell metachar) in the - # payload cannot break the single-quoted remote echo. - local out err - err=$(mktemp -t airc-send-err.XXXXXX) - out=$(printf '%s\n' "$full_msg" | relay_ssh "$host_target" "cat >> $rhome/messages.jsonl && echo __APPENDED__" 2>"$err" || true) - if ! echo "$out" | grep -q '^__APPENDED__$'; then - # Wire failed. Queue the payload for automatic retry by flush_pending_loop - # in the monitor, then annotate the local log with a [QUEUED] marker so - # `airc logs` makes the state obvious. Don't die() — queued is a form of - # success. The user's shell scripts can still check pending.jsonl if - # they need to block on delivery. - # Distinguish auth failures (user must re-pair — retrying won't help) - # from network failures (queue + retry makes sense). Prior behavior - # silently queued both the same way, hiding auth errors behind a - # misleading "Host unreachable" message. This bit the cross-mesh - # coordination: fresh-install joiner's SSH key wasn't in host's - # authorized_keys, cmd_send queued + returned 0, the joiner thought - # their send succeeded when the host never saw anything. - local stderr_raw; stderr_raw=$(cat "$err" 2>/dev/null) - local stderr; stderr=$(printf '%s' "$stderr_raw" | tr '\n' ' ' | sed 's/"/\\"/g' | cut -c1-300) - rm -f "$err" - - local is_auth_fail=0 - if echo "$stderr_raw" | grep -qiE 'permission denied|publickey|host key verification|authentication fail|identification has changed|no supported authentication'; then - is_auth_fail=1 - fi - - if [ "$is_auth_fail" = "1" ]; then - local fail_marker; fail_marker=$(printf '{"from":"airc","ts":"%s","msg":"[AUTH FAILED to %s — repair required, NOT queued] %s"}' \ - "$(timestamp)" "$peer_name" "${stderr:-no stderr}") - echo "$fail_marker" >> "$MESSAGES" - echo " SSH auth to host FAILED. Message NOT queued — every retry would fail identically." >&2 - echo " SSH stderr: ${stderr}" >&2 - echo " Fix: airc teardown --flush && airc connect " >&2 - die "Authentication failure — re-pair required" - fi - - # Network-class wire failure: legitimately transient, queue for retry. - echo "$full_msg" >> "$AIRC_WRITE_DIR/pending.jsonl" - local queue_marker; queue_marker=$(printf '{"from":"airc","ts":"%s","msg":"[QUEUED to %s — network error, will retry] %s"}' \ - "$(timestamp)" "$peer_name" "${stderr:-no stderr}") - echo "$queue_marker" >> "$MESSAGES" - echo " Network error reaching host — message queued for retry. Monitor will flush when host returns." >&2 - # Surface the actual stderr so the user understands WHY — the old - # generic "host unreachable" was hiding real errors. - echo " SSH stderr: ${stderr:-}" >&2 - else - rm -f "$err" - fi - else - # Host path: append to OUR messages.jsonl. Joiners' SSH tails will - # pick it up and route to their monitors. BUT — if our monitor isn't - # actually running, no joiner is connected (the SSH tail rides on the - # monitor process tree), and this append goes to a log nobody reads. - # The send returns 0 and the user thinks it succeeded. - # - # That's exactly how Joel hit "I see no communication going on" on - # 2026-04-26: shell auto-cd'd into a different scope mid-session, that - # scope's monitor was dead, every `airc msg` returned 0 with zero - # delivery, and the peer in the actual room waited forever for a - # reply that never landed. - # - # Detect: pidfile exists AND every PID in it is alive. Anything else - # = monitor dead = broadcasting into a void. Die loudly so the user - # immediately knows their cwd / scope / monitor state is wrong. - local _pidfile="$AIRC_WRITE_DIR/airc.pid" - local _monitor_alive=0 - if [ -f "$_pidfile" ]; then - local _pids; _pids=$(cat "$_pidfile" 2>/dev/null) - if [ -n "$_pids" ]; then - local _all_alive=1 _p - for _p in $_pids; do - kill -0 "$_p" 2>/dev/null || { _all_alive=0; break; } - done - [ "$_all_alive" = "1" ] && _monitor_alive=1 - fi - fi - if [ "$_monitor_alive" = "0" ]; then - echo " Send NOT delivered — this scope's monitor isn't running." >&2 - echo " scope: $AIRC_WRITE_DIR" >&2 - echo " identity: $my_name (host)" >&2 - if [ -f "$_pidfile" ]; then - echo " pidfile: $_pidfile (stale — process not alive)" >&2 - else - echo " pidfile: absent (monitor never started in this scope)" >&2 - fi - echo " Joiners ride on the monitor's SSH tail; with the monitor down, your message reaches no one." >&2 - echo " Fix: run 'airc connect' to start (or resume) this scope's monitor, then retry." >&2 - echo " OR cd into the scope you actually meant to send from." >&2 - die "monitor down — refusing to silently broadcast into a void" - fi - echo "$full_msg" >> "$MESSAGES" - fi - - # Reset reminder — you sent something, clock restarts - date +%s > "$AIRC_WRITE_DIR/last_sent" 2>/dev/null - rm -f "$AIRC_WRITE_DIR/reminded" 2>/dev/null -} - -# Ping a peer to verify their monitor is alive AND processing traffic. -# -# Sends [PING:] to the peer via cmd_send, then tails the local -# messages.jsonl for a [PONG:] response from that peer with a -# timeout. Three outcomes the caller can distinguish: -# -# - PONG arrives within timeout → peer's monitor is alive + running -# a compatible airc version (one with the auto-pong handler in -# monitor_formatter). -# - Timeout, but [PING:] IS visible in local log → the ping -# landed on the wire (SSH append succeeded) but no response. Either -# (a) peer's monitor is dead, or (b) peer is running an older airc -# without the auto-pong handler, or (c) peer is a non-airc agent -# (e.g., Codex) that reads the log but doesn't respond. -# - Timeout, [PING:] NOT visible → the send itself failed or -# queued (see cmd_send's wire-failure branch). Wire is broken. -# -# Design: ping is a regular signed message with a prefix marker. Clients -# that don't implement auto-pong see it as "a message starting with -# [PING:]" — harmless, logs it, life continues. Forward-compatible + -# gracefully-degrading across airc versions AND across agent types. -# -# Usage: -# airc ping @peer # default 10s timeout -# airc ping @peer 30 # 30s timeout -cmd_ping() { - local first="${1:-}" - [ -z "$first" ] && die "Usage: airc ping @peer [timeout_secs]" - case "$first" in - @*) ;; - *) die "Usage: airc ping @peer — ping requires an @peer target (broadcast ping not supported)" ;; - esac - local peer_name="${first#@}" - local timeout="${2:-10}" - # Basic sanity: timeout must be a positive integer. Guards against - # typos that would make the wait-loop spin forever or exit early. - case "$timeout" in - ''|*[!0-9]*) die "timeout must be a positive integer (got '$timeout')" ;; - esac - ensure_init - - # uuid from python for format consistency with the regex in monitor_formatter. - local ping_id - ping_id=$(python3 -c "import uuid; print(uuid.uuid4())") - - local start_time - start_time=$(date +%s) - - # Use cmd_send so the ping rides the same signed-message path as - # normal traffic — guaranteed shape parity with what the receiver's - # monitor_formatter reads. - cmd_send "@$peer_name" "[PING:$ping_id]" >/dev/null || die "ping send failed — check SSH/auth state (airc status)" - - echo "ping sent to $peer_name (id=$ping_id) — waiting up to ${timeout}s for pong..." - - # Poll local messages.jsonl for the matching pong. We check the FULL - # log since the ping was written (cmd_send mirrors locally first). - # 0.5s poll is responsive without spinning. - while true; do - local now elapsed - now=$(date +%s) - elapsed=$((now - start_time)) - if grep -q "\[PONG:$ping_id\]" "$MESSAGES" 2>/dev/null; then - echo "PONG received from $peer_name after ${elapsed}s — monitor alive + auto-responder working." - return 0 - fi - if [ "$elapsed" -ge "$timeout" ]; then - echo "TIMEOUT after ${timeout}s — no pong from $peer_name." - # Secondary diagnosis: did the ping land on the wire at all? - if grep -q "\[PING:$ping_id\]" "$MESSAGES" 2>/dev/null; then - echo " Ping IS visible in local log (cmd_send mirrored it). That proves our outbound works." - echo " No pong likely means: (a) peer's monitor is dead, (b) peer runs older airc without auto-pong, or (c) peer is a non-airc agent." - else - echo " Ping is NOT in local log — cmd_send's mirror may have failed. Check: airc status, airc logs." - fi - return 1 - fi - sleep 0.5 - done -} - -# ── cmd_rooms: list open airc invite gists on this gh account ──────── -# Issue #38. The gist namespace IS the room registry — every airc invite -# pushed via the default gist transport (#37) shows up here. Filter is -# the description prefix `"airc invite for "` that push-image side writes. -# -# The Claude Code skill (/list, /rooms) calls this and lets the AI use -# conversation context to pick. The CLI itself stays orthogonal — it -# emits the menu, doesn't decide. -cmd_rooms() { - if ! command -v gh >/dev/null 2>&1; then - echo " airc rooms requires the 'gh' CLI: https://cli.github.com" >&2 - echo " airc IS aIRC — github gist is the coordination layer; gh is mandatory." >&2 - return 1 - fi - # Match BOTH the persistent IRC-style rooms (#39, prefix `airc room:`) - # and the legacy single-pair invites (#37/#38, prefix `airc invite for`). - # Show kind explicitly so the AI / human can tell them apart. - # gh gist list columns: id description files visibility updated_at - # Use $5 (timestamp) for the updated field — pre-#82 we were using - # $4 (visibility, "secret") under the "updated:" label, which is a - # display bug fixed here on the way to adding stale markers. - local raw; raw=$(gh gist list --limit 50 2>/dev/null \ - | awk -F'\t' ' - /airc room:/ { print "room\t" $1 "\t" $2 "\t" $5 } - /airc invite for/ { print "invite\t" $1 "\t" $2 "\t" $5 } - ') - local count; count=$(printf '%s' "$raw" | grep -c . || true) - if [ "$count" = "0" ]; then - echo " No open airc rooms or invites on your gh account." - echo " Host the default room: airc connect" - echo " Host a named room: airc connect --room " - return 0 - fi - echo "" - echo " $count open on your gh account:" - echo "" - printf '%s\n' "$raw" | while IFS=$'\t' read -r kind id desc updated; do - local hh; hh=$(humanhash "$id" 2>/dev/null) - local marker - case "$kind" in - room) marker="#" ;; # persistent channel - invite) marker="(1:1)" ;; # ephemeral pairing - esac - local age_str; age_str=$(_format_relative_time "$updated") - local stale_marker="" - if _is_stale "$updated"; then - stale_marker=" (stale)" - fi - printf ' %s %s%s\n id: %s\n mnemonic: %s\n updated: %s\n\n' \ - "$marker" "$desc" "$stale_marker" "$id" "$hh" "$age_str" - done - echo " Join (auto-resolves on same gh account): airc connect" - echo " Join by id (cross-account share): airc connect " - echo "" -} - -# Convert an ISO 8601 timestamp into a relative-time string ("12m ago", -# "3h ago", "2d ago"). Handles both macOS BSD date and GNU/Linux date -# syntax differences. Falls back to the raw timestamp on parse failure. -# Used by cmd_rooms to display gist activity (#82). -_format_relative_time() { - local ts="${1:-}" - [ -z "$ts" ] && { echo "(unknown)"; return; } - local epoch - if epoch=$(date -j -u -f "%Y-%m-%dT%H:%M:%SZ" "$ts" +%s 2>/dev/null); then - : # BSD/macOS - elif epoch=$(date -u -d "$ts" +%s 2>/dev/null); then - : # GNU/Linux/WSL - else - echo "$ts" - return - fi - local now; now=$(date -u +%s) - local diff=$((now - epoch)) - if [ "$diff" -lt 0 ]; then echo "$ts"; return; fi - if [ "$diff" -lt 60 ]; then echo "${diff}s ago" - elif [ "$diff" -lt 3600 ]; then echo "$((diff / 60))m ago" - elif [ "$diff" -lt 86400 ]; then echo "$((diff / 3600))h ago" - else echo "$((diff / 86400))d ago" - fi -} - -# Return 0 if the given ISO timestamp is older than AIRC_STALE_HOURS -# (default 24h). Used to mark abandoned rooms in cmd_rooms output (#82). -_is_stale() { - local ts="${1:-}" - local threshold_hours="${AIRC_STALE_HOURS:-24}" - [ -z "$ts" ] && return 1 - local epoch - if epoch=$(date -j -u -f "%Y-%m-%dT%H:%M:%SZ" "$ts" +%s 2>/dev/null); then - : - elif epoch=$(date -u -d "$ts" +%s 2>/dev/null); then - : - else - return 1 - fi - local now; now=$(date -u +%s) - local diff=$((now - epoch)) - [ "$diff" -gt $((threshold_hours * 3600)) ] -} - -# ── cmd_part: leave the current room ────────────────────────────────── -# Issue #39. Two paths, distinguished by config.json's host_target: -# - Host (no host_target): delete the room gist if we created one, then -# teardown. Joiners watching us will see SSH die — IRC's "ircd -# restart" — and the next reconnect re-elects a new host. -# - Joiner (host_target set): just teardown local processes; host's -# gist stays open for other joiners (we're one of N). -# Either way, local config + identity + peer records persist (use -# `airc teardown --flush` for nuclear). -# -# Detection note: we use config.json::host_target as the host-vs-joiner -# signal, NOT presence of room_gist_id. The gist file may be absent for -# a legitimate host case (`--no-gist`, or gh push failed) — falling back -# to "you're a joiner" would be wrong. -cmd_part() { - ensure_init - - local gist_id_file="$AIRC_WRITE_DIR/room_gist_id" - local room_name_file="$AIRC_WRITE_DIR/room_name" - local room_name="(unnamed)" - [ -f "$room_name_file" ] && room_name=$(cat "$room_name_file") - - local host_target; host_target=$(get_config_val host_target "") - - if [ -z "$host_target" ]; then - # ── Host path ── - if [ -f "$gist_id_file" ]; then - local gid; gid=$(cat "$gist_id_file") - if command -v gh >/dev/null 2>&1; then - echo " Host of #${room_name} parting — deleting room gist ${gid}..." - gh gist delete "$gid" --yes 2>/dev/null \ - && echo " ✓ Room gist deleted." \ - || echo " ⚠ Couldn't delete gist ${gid} (already gone? gh auth?). Continuing teardown." - else - echo " ⚠ gh CLI not available — can't delete room gist ${gid} automatically." - echo " Delete it manually: gh gist delete ${gid} --yes" - fi - else - # Host but no gist (--no-gist or gh-push failed). Nothing to delete - # in the gh namespace; just clean local state. - echo " Host of #${room_name} parting (no gist was published; nothing to clean up in gh)." - fi - rm -f "$gist_id_file" "$room_name_file" - else - # ── Joiner path ── - echo " Joiner of #${room_name} parting — host's gist stays open for others." - # Clear our cached gist_id too, matching the comment on the joiner- - # side cache write site (PR #92 Copilot feedback). Without this, a - # parted joiner that later reconnects via the same scope would - # incorrectly trigger the stale-pairing-detect path on the next - # resume even though they parted intentionally. - rm -f "$room_name_file" "$gist_id_file" - fi - - # Issue #136: persist the /part. Record the room into the PRIMARY - # scope's parted_rooms list so a later `airc join` won't auto- - # resubscribe. Only meaningful for sidecar rooms (general, future - # opt-in #repo etc.) — parting your project's primary scope means - # the whole scope is gone, so persistence there is moot. - local _primary_scope; _primary_scope=$(_primary_scope_for "$AIRC_WRITE_DIR") - if [ "$_primary_scope" != "$AIRC_WRITE_DIR" ] && [ "$room_name" != "(unnamed)" ]; then - _record_parted_room "$_primary_scope" "$room_name" - echo " /part persisted — #${room_name} won't auto-resubscribe. Rejoin with: airc join --${room_name}" - fi - - # IRC `/part` semantics — leave THIS room only; the #general sidecar - # (or any other sibling subscription) keeps running. cmd_teardown - # respects AIRC_TEARDOWN_PART_ONLY=1 by skipping its sidecar block, - # so the kill is scope-local. cmd_teardown without this guard remains - # the "kill everything in this scope tree" command. - local AIRC_TEARDOWN_PART_ONLY=1 - cmd_teardown -} - -cmd_send_file() { - local peer_name="${1:-}" filepath="${2:-}" - [ -z "$peer_name" ] || [ -z "$filepath" ] && die "Usage: airc send-file " - [ -f "$filepath" ] || die "File not found: $filepath" - ensure_init - - local host_target my_name - host_target=$(get_config_val host_target "") - my_name=$(get_name) - - local filename; filename=$(basename "$filepath") - local target_host="$host_target" - [ -z "$target_host" ] && target_host="localhost" - - local rhome; rhome=$(remote_home) - relay_ssh "$target_host" "mkdir -p $rhome/files/${my_name}" 2>/dev/null - # Use the airc identity key for scp — same key relay_ssh uses. Without -i, - # scp falls back to system ssh_config (~/.ssh/id_* etc), which doesn't know - # about isolated AIRC_HOME identities. Surfaced by m5-test's send-file test. - local ssh_key="$IDENTITY_DIR/ssh_key" - local scp_out - if [ -f "$ssh_key" ]; then - scp_out=$(scp -i "$ssh_key" -o StrictHostKeyChecking=accept-new -q "$filepath" "${target_host}:${rhome}/files/${my_name}/${filename}" 2>&1) - else - scp_out=$(scp -o StrictHostKeyChecking=accept-new -q "$filepath" "${target_host}:${rhome}/files/${my_name}/${filename}" 2>&1) - fi - if [ $? -ne 0 ]; then - die "Failed to transfer $filename: $scp_out" - fi - - local filesize; filesize=$(file_size "$filepath") - cmd_send "$peer_name" "Sent file: $filename ($filesize bytes)" - echo "Sent $filename ($filesize bytes)" -} - -cmd_invite() { - ensure_init - local host_target pubkey_b64 join_string - host_target=$(get_config_val host_target "") - - if [ -n "$host_target" ]; then - # Joiner: reconstruct the HOST's join string from stored pairing info. - # Any connected peer can share the same join string — everyone converges - # on the same host. - local host_name host_port host_ssh_pub - host_name=$(get_config_val host_name "") - host_port=$(get_config_val host_port 7547) - host_ssh_pub=$(get_config_val host_ssh_pub "") - if [ -z "$host_name" ] || [ -z "$host_ssh_pub" ]; then - die "Host info missing from config. Re-pair with 'airc teardown' then 'airc connect '." - fi - pubkey_b64=$(printf '%s\n' "$host_ssh_pub" | base64 | tr -d '\n') - local port_suffix="" - [ "$host_port" != "7547" ] && port_suffix=":$host_port" - join_string="${host_name}@${host_target}${port_suffix}#${pubkey_b64}" - else - # Host: build own join string from live state. - local my_name user host port - my_name=$(get_name) - user=$(whoami) - host=$(get_host) - port=$(cat "$AIRC_WRITE_DIR/host_port" 2>/dev/null || echo 7547) - local port_suffix="" - [ "$port" != "7547" ] && port_suffix=":$port" - pubkey_b64=$(base64 < "$IDENTITY_DIR/ssh_key.pub" | tr -d '\n') - join_string="${my_name}@${user}@${host}${port_suffix}#${pubkey_b64}" - fi - - echo "$join_string" -} - -cmd_peers() { - ensure_init - # `airc peers --prune` — remove stale records that share a host with a - # newer record (cruft left from rename chain-breaks before the stable-host - # matching logic landed). - if [ "${1:-}" = "--prune" ]; then - python3 -c " -import json, os, sys -peers_dir = os.path.expanduser('$PEERS_DIR') -if not os.path.isdir(peers_dir): - sys.exit(0) -# Group records by host; keep the most-recently-paired, remove the rest. -by_host = {} -for entry in sorted(os.listdir(peers_dir)): - if not entry.endswith('.json'): continue - p = os.path.join(peers_dir, entry) - try: - d = json.load(open(p)) - except Exception: - continue - host = d.get('host', '') - if not host: continue - by_host.setdefault(host, []).append((d.get('paired', ''), entry, d.get('name', entry[:-5]))) -removed = [] -for host, records in by_host.items(): - if len(records) < 2: continue - records.sort(reverse=True) # newest paired first - for _, entry, name in records[1:]: - for ext in ('.json', '.pub'): - f = os.path.join(peers_dir, entry[:-5] + ext) - if os.path.isfile(f): - try: os.remove(f) - except Exception: pass - removed.append((name, host)) -if removed: - for name, host in removed: - print(f' pruned: {name} -> {host}') -else: - print(' No stale records to prune.') -" - return - fi - - # Walk scopes that count as "subscribed rooms" for this tab: primary - # (current AIRC_WRITE_DIR) plus any sibling sidecar scopes (.airc. - # pattern under the project scope's parent). For each, read peers/ - # records and annotate with the scope's room_name. Same peer in both - # scopes folds into one line with both room tags. - # - # Intent (issue #121 follow-up): multi-room presence shouldn't fragment - # the operator's view of "who am I connected to" into separate per-scope - # listings. From the user's perspective they're in N rooms; airc peers - # should reflect that as one unified roster with room context per peer. - python3 -c " -import json, os, sys, re - -primary_scope = os.path.expanduser('$AIRC_WRITE_DIR') -parent = os.path.dirname(primary_scope) -self_basename = os.path.basename(primary_scope) - -# Prefix detection: a sidecar scope is named like \`.\` -# (e.g. .airc.general). Strip a trailing . to recover the -# primary scope's basename. Works for both production layout -# (.airc / .airc.general) and test ad-hoc paths (state / state.general) -# without baking in the .airc literal. -prefix_match = re.match(r'(.+?)\.[a-z0-9-]+\$', self_basename) -prefix = prefix_match.group(1) if prefix_match else self_basename - -# Collect: the primary scope itself, plus every sibling whose name is -# .. We additionally require room_name + peers/ on -# each candidate so unrelated dirs in the same parent (e.g. .airc-old, -# .airc.bak) don't pollute the listing. -candidates = [] -if os.path.isdir(parent): - for entry in sorted(os.listdir(parent)): - if entry == prefix or entry.startswith(prefix + '.'): - candidates.append(os.path.join(parent, entry)) -scopes = [s for s in candidates - if os.path.isfile(os.path.join(s, 'room_name')) - and os.path.isdir(os.path.join(s, 'peers'))] -# Always include primary even if it doesn't have room_name yet — that's -# the legacy 1:1 invite mode case (use_room=0). -if primary_scope not in scopes and os.path.isdir(os.path.join(primary_scope, 'peers')): - scopes.insert(0, primary_scope) - -# Build {(name, host): [room1, room2, ...]} by walking each scope's peers/. -peers_by_id = {} -for scope in scopes: - peers_dir = os.path.join(scope, 'peers') - if not os.path.isdir(peers_dir): - continue - rn_file = os.path.join(scope, 'room_name') - room = '(?)' - if os.path.isfile(rn_file): - try: room = open(rn_file).read().strip() - except Exception: pass - for f in sorted(os.listdir(peers_dir)): - if not f.endswith('.json'): continue - try: - d = json.load(open(os.path.join(peers_dir, f))) - except Exception: - continue - key = (d.get('name', f[:-5]), d.get('host', '')) - peers_by_id.setdefault(key, []).append(room) - -if not peers_by_id: - print(' No peers yet.') - sys.exit(0) - -# Render. Each peer once, with room annotations sorted + deduped. -for (name, host), rooms in sorted(peers_by_id.items()): - seen = set(); ordered = [] - for r in rooms: - if r not in seen: - ordered.append(r); seen.add(r) - tags = ', '.join('#' + r for r in ordered) - print(f' {name} → {host} [{tags}]') -" -} - -cmd_teardown() { - # Kill all airc processes for this user and free any ports they hold. - # Add --flush to also wipe the state dir (identity, peers, messages) — nuclear. - # Add --all to nuke EVERY airc-looking process on this machine, ignoring - # scope/PID file — for the "I just want it all dead" case after stale - # zombies survive across sessions (verified 2026-04-21: /tmp/airc-prefix - # connect processes from a previous session were still alive 2 days later - # because teardown's PID file no longer existed for them). - local flush=0 all=0 - while [ $# -gt 0 ]; do - case "$1" in - --flush) flush=1 ;; - --all) all=1 ;; - *) echo " unknown teardown flag: $1" >&2; return 2 ;; - esac - shift - done - - # ── --all: nuclear, scope-blind ─────────────────────────────────── - # Find every airc-related process for THIS user and kill it. Targets: - # - bash processes running `airc connect` (any scope) - # - bash processes running `/airc connect` or `/tmp/airc-prefix connect` - # - python processes spawned by airc (the inline -u -c monitor with - # the `WATCHDOG_SEC` heredoc) — identified by ppid pointing at one - # of the bash processes we're killing - # - python listeners holding any TCP port in the airc range (7547-7559) - # Then proceeds to the scope-aware path below to clean up our own pidfile - # + reap any orphaned listener on our specific port. - if [ "$all" = "1" ]; then - local nuked=0 - # Bash airc-connect processes (any path that ends in /airc connect or - # the /tmp/airc-prefix bootstrap variant the curl|bash installer uses). - local bash_pids - bash_pids=$(proc_airc_pids_matching '(airc|airc-prefix)[[:space:]]+connect' || true) - if [ -n "$bash_pids" ]; then - echo " --all: killing airc bash processes: $(echo $bash_pids | tr '\n' ' ')" - kill -9 $bash_pids 2>/dev/null || true - nuked=1 - fi - # Python listeners on airc port range (7547-7559). Don't touch python - # outside that range — could be unrelated processes. - local port - for port in 7547 7548 7549 7550 7551 7552 7553 7554 7555 7556 7557 7558 7559; do - local lpids - lpids=$(port_listeners "$port" || true) - for lpid in $lpids; do - local cmd - cmd=$(proc_cmdline "$lpid" || true) - if echo "$cmd" | grep -q "socket.SOCK_STREAM\|socket.AF_INET"; then - echo " --all: freeing port $port (python pid $lpid)" - kill -9 "$lpid" 2>/dev/null || true - nuked=1 - fi - done - done - # Stale tail/ssh subprocesses that look like airc message tails - # (ssh ... tail -F .../.airc/messages.jsonl). - local tail_pids - tail_pids=$(proc_airc_pids_matching '\.airc/messages\.jsonl' || true) - if [ -n "$tail_pids" ]; then - echo " --all: killing stale airc message tails: $(echo $tail_pids | tr '\n' ' ')" - kill -9 $tail_pids 2>/dev/null || true - nuked=1 - fi - [ "$nuked" = "0" ] && echo " --all: no machine-wide airc processes to kill." - # Fall through to scope-aware path below to also clean up THIS scope's - # pidfile + flush if requested. (--all is additive, not exclusive.) - fi - - - local killed=0 - # Hosted gist cleanup BEFORE process kill. The cmd_connect EXIT trap - # would normally delete our hosted gist on graceful shutdown, but the - # kill -9 below skips traps entirely. Without this explicit step, - # every `airc teardown` of a host left an orphan gist on the gh - # account that joiners couldn't tell apart from a live host until - # heartbeat went stale (~90s later). Caught by Joel's other tab - # bouncing repeatedly and accumulating fresh #general gists each - # cycle. - if [ -f "$AIRC_WRITE_DIR/host_gist_id" ] && command -v gh >/dev/null 2>&1; then - local _td_gist; _td_gist=$(cat "$AIRC_WRITE_DIR/host_gist_id" 2>/dev/null) - if [ -n "$_td_gist" ]; then - if gh gist delete "$_td_gist" --yes >/dev/null 2>&1; then - echo " deleted hosted gist: $_td_gist" - fi - rm -f "$AIRC_WRITE_DIR/host_gist_id" - fi - fi - - # Sidecar scope cleanup (issue #121 — multi-room presence). - # When the primary tab spawned a #general sidecar, that sidecar runs - # in a sibling .general scope with its own pidfile + (if hosting) - # its own host_gist_id. Mirror the primary's gist cleanup + pidfile - # kill there. Without this, killing the primary leaves an orphan - # #general gist on the gh account AND an orphan sidecar process that - # the primary's pidfile descendant-walk wouldn't catch (sidecar's - # bash isn't a child of cmd_teardown — it was forked detached). - # - # Guard: AIRC_TEARDOWN_PART_ONLY=1 (set by cmd_part) skips the sidecar - # block. IRC `/part` should leave only the current channel; the - # sidecar (#general lobby) should keep running. cmd_teardown without - # this flag is the "kill everything in this scope tree" semantic. - local _sidecar_scope="${AIRC_WRITE_DIR}.general" - if [ "${AIRC_TEARDOWN_PART_ONLY:-0}" = "1" ]; then - : # cmd_part path — skip sidecar - elif [ -d "$_sidecar_scope" ]; then - if [ -f "$_sidecar_scope/host_gist_id" ] && command -v gh >/dev/null 2>&1; then - local _td_sc_gist; _td_sc_gist=$(cat "$_sidecar_scope/host_gist_id" 2>/dev/null) - if [ -n "$_td_sc_gist" ]; then - if gh gist delete "$_td_sc_gist" --yes >/dev/null 2>&1; then - echo " deleted sidecar #general gist: $_td_sc_gist" - fi - rm -f "$_sidecar_scope/host_gist_id" - fi - fi - if [ -f "$_sidecar_scope/airc.pid" ]; then - local _sc_pids; _sc_pids=$(cat "$_sidecar_scope/airc.pid" 2>/dev/null | tr '\n' ' ') - if [ -n "$_sc_pids" ]; then - local _all_sc="$_sc_pids" - for _p in $_sc_pids; do - local _kids; _kids=$(proc_children "$_p" | tr '\n' ' ' || true) - [ -n "$_kids" ] && _all_sc="$_all_sc $_kids" - done - _all_sc=$(echo "$_all_sc" | tr ' ' '\n' | sort -u | grep -v '^$' || true) - if [ -n "$_all_sc" ]; then - echo " killing sidecar scope $_sidecar_scope: $(echo $_all_sc | tr '\n' ' ')" - kill -9 $_all_sc 2>/dev/null || true - killed=1 - fi - fi - rm -f "$_sidecar_scope/airc.pid" - fi - if [ "$flush" = "1" ]; then - rm -rf "$_sidecar_scope" - fi - fi - - # Scope-aware via PID file: cmd_connect wrote its PID(s) to $AIRC_WRITE_DIR/airc.pid. - # We kill ONLY those PIDs + their descendants. Never touches other scopes. - local pidfile="$AIRC_WRITE_DIR/airc.pid" - if [ -f "$pidfile" ]; then - local main_pids - # `|| true` — same class as #6: if $pidfile is racily removed between the - # `-f` test and this read, cat+pipefail would abort cmd_teardown before we - # reach `rm -f` below. Empty main_pids → we fall through cleanly. - main_pids=$(cat "$pidfile" 2>/dev/null | tr '\n' ' ' || true) - if [ -n "$main_pids" ]; then - # Collect descendants (Python listener etc) before killing the parent. - local all_pids="$main_pids" - for pid in $main_pids; do - local kids - kids=$(proc_children "$pid" | tr '\n' ' ' || true) - [ -n "$kids" ] && all_pids="$all_pids $kids" - done - all_pids=$(echo "$all_pids" | tr ' ' '\n' | sort -u | grep -v '^$' || true) - # Part-only path: exclude the sidecar's bash + its descendants so - # `airc part` doesn't sweep them via the primary's child-tree. - # The sidecar's bash is forked from primary, so pgrep -P picks it - # up here; without exclusion we'd kill the sidecar in violation - # of IRC /part semantics (leave one channel, keep others alive). - if [ "${AIRC_TEARDOWN_PART_ONLY:-0}" = "1" ] && [ -n "$all_pids" ]; then - local _exclude_pids="" - local _sc_pidfile="${AIRC_WRITE_DIR}.general/airc.pid" - if [ -f "$_sc_pidfile" ]; then - local _sc_pids; _sc_pids=$(cat "$_sc_pidfile" 2>/dev/null | tr '\n' ' ') - for _scp in $_sc_pids; do - _exclude_pids="$_exclude_pids $_scp" - local _scp_kids; _scp_kids=$(proc_children "$_scp" | tr '\n' ' ' || true) - [ -n "$_scp_kids" ] && _exclude_pids="$_exclude_pids $_scp_kids" - done - fi - if [ -n "$_exclude_pids" ]; then - local _filtered="" - for _p in $all_pids; do - local _skip=0 - for _ex in $_exclude_pids; do - [ "$_p" = "$_ex" ] && { _skip=1; break; } - done - [ "$_skip" = "0" ] && _filtered="$_filtered $_p" - done - all_pids=$(echo "$_filtered" | tr ' ' '\n' | grep -v '^$' || true) - fi - fi - if [ -n "$all_pids" ]; then - echo " killing scope $AIRC_WRITE_DIR: $(echo $all_pids | tr '\n' ' ')" - kill -9 $all_pids 2>/dev/null || true - killed=1 - fi - fi - rm -f "$pidfile" 2>/dev/null - fi - - # Brief pause to let the kernel reparent any airc python listener children - # to init (PID 1) after we killed their bash parent. Then reap orphans. - [ "$killed" = "1" ] && sleep 0.5 - - # Free the TCP port we were listening on. Kill any python socket listener - # that's now orphaned (parent=1). Don't touch anything else. - local ports="${AIRC_PORT:-7547}" - [ "$ports" != "7547" ] && ports="$ports 7547" - for port in $ports; do - local lpids - lpids=$(port_listeners "$port" || true) - for lpid in $lpids; do - # `|| true` on both — $lpid came from lsof a moment ago; if the process - # exited in the interim, `ps -p` returns 1 and pipefail/errexit would - # abort the port-reap loop mid-scan, leaving later ports unchecked. - # Empty parent/cmd → the `if` below falls through, which is correct. - local parent; parent=$(proc_parent "$lpid" || true) - local cmd; cmd=$(proc_cmdline "$lpid" || true) - # Reap if orphaned AND is a python socket listener. - if [ "$parent" = "1" ] && echo "$cmd" | grep -q "socket.SOCK_STREAM"; then - echo " freeing orphaned port $port (pid $lpid)" - kill -9 "$lpid" 2>/dev/null || true - killed=1 - fi - done - done - - if [ "$flush" = "1" ]; then - # Wipe current tier's state. Leaves the other tier alone. - local dir="$AIRC_WRITE_DIR" - if [ -n "$dir" ] && [ -d "$dir" ]; then - echo " flushing state: $dir" - rm -rf "$dir" - fi - fi - - [ "$killed" = "0" ] && echo " No airc processes running." || echo " Teardown complete." -} - -cmd_disconnect() { - # "Leave the room" — kill running processes in scope, then clear only the - # host-pairing fields from config.json. Your identity (name + keys), peers - # list, and message history are all preserved. Next `airc connect` (no - # args) starts fresh host mode instead of auto-resuming the prior pairing. - # Use when you want to switch to a different mesh or host a new one, but - # keep your agent identity stable. - cmd_teardown >/dev/null 2>&1 || true - if [ -f "$CONFIG" ]; then - python3 -c " -import json -try: - c = json.load(open('$CONFIG')) - for k in ('host_target', 'host_name', 'host_airc_home', 'host_port', 'host_ssh_pub'): - c.pop(k, None) - json.dump(c, open('$CONFIG', 'w'), indent=2) -except Exception: - pass -" 2>/dev/null || true - fi - echo " Disconnected. Identity preserved. Next 'airc connect' starts fresh (not a resume)." -} - -cmd_update() { - # Refresh install dir AND re-run install.sh so new skills get symlinked - # into ~/.claude/skills/ and old ones get cleaned up. install.sh is - # idempotent — it handles the pull, the binary symlink, and the skill - # directory refresh in one pass. Does NOT teardown or reconnect. - # - # Channels (#40 followup): airc supports release channels for opt-in - # pre-merge testing. main = stable; canary = features-not-yet-promoted. - # The chosen channel persists in $AIRC_DIR/.channel so subsequent - # `airc update` (no args) keeps the user on their chosen track. - # airc update # stay on current channel (default: main) - # airc update --channel canary # switch to canary + update - # airc update --channel main # switch back to main + update - # airc channel # show current channel without updating - local dir="${AIRC_DIR:-$HOME/.airc-src}" - local channel_file="$dir/.channel" - local requested_channel="" - while [ $# -gt 0 ]; do - case "$1" in - --channel|-c) - requested_channel="${2:-}" - [ -z "$requested_channel" ] && die "Usage: airc update --channel " - shift 2 - ;; - --canary) requested_channel="canary"; shift ;; - --main) requested_channel="main"; shift ;; - *) shift ;; - esac - done - - if [ ! -d "$dir/.git" ]; then - die "No git checkout at $dir. Reinstall: curl -fsSL https://raw.githubusercontent.com/CambrianTech/airc/main/install.sh | bash" - fi - - # Determine target channel: explicit request > saved preference > main. - local channel - if [ -n "$requested_channel" ]; then - channel="$requested_channel" - elif [ -f "$channel_file" ]; then - channel=$(cat "$channel_file" 2>/dev/null | tr -d '[:space:]') - [ -z "$channel" ] && channel="main" - else - channel="main" - fi - - # Switch to the target branch BEFORE pulling. install.sh will then ff-pull - # whatever branch is checked out. Fail loud if the channel doesn't exist - # on origin — silently falling back to main would defeat the opt-in test - # purpose. - local before; before=$(git -C "$dir" rev-parse --short HEAD 2>/dev/null) - local current_branch; current_branch=$(git -C "$dir" rev-parse --abbrev-ref HEAD 2>/dev/null) - if [ "$current_branch" != "$channel" ]; then - git -C "$dir" fetch --quiet origin "$channel" 2>/dev/null \ - || die "Channel '$channel' not found on origin. Try: airc channel (to see options)." - git -C "$dir" checkout -q "$channel" 2>/dev/null \ - || git -C "$dir" checkout -q -B "$channel" "origin/$channel" 2>/dev/null \ - || die "Failed to checkout '$channel'. Resolve manually in $dir." - fi - - if [ ! -x "$dir/install.sh" ]; then - die "install.sh missing at $dir. Reinstall via curl|bash." - fi - AIRC_DIR="$dir" bash "$dir/install.sh" || die "install.sh failed." - - # Persist channel choice AFTER successful update so a failed switch - # doesn't leave a dangling preference for a broken state. - echo "$channel" > "$channel_file" - - local after; after=$(git -C "$dir" rev-parse --short HEAD 2>/dev/null) - if [ "$before" = "$after" ]; then - echo " Already at ${after} on channel '${channel}'. Skills refreshed." - else - echo " Updated: ${before} -> ${after} on channel '${channel}'. Skills refreshed." - echo " Running monitor still uses the old code. To pick up: airc teardown && airc connect" - fi -} - -# ── cmd_channel: show or set the release channel without pulling ────── -# `airc channel` → print current channel + how to switch -# `airc channel canary` → set preferred channel; doesn't pull (use -# `airc update` after to actually switch) -# Allows the AI / human to inspect + decide before the heavier update. -cmd_channel() { - local dir="${AIRC_DIR:-$HOME/.airc-src}" - local channel_file="$dir/.channel" - local current="main" - [ -f "$channel_file" ] && current=$(cat "$channel_file" 2>/dev/null | tr -d '[:space:]') - [ -z "$current" ] && current="main" - - local target="${1:-}" - if [ -z "$target" ]; then - echo " Channel: $current" - echo " Available channels (any branch on origin can be a channel):" - echo " main — stable, what most users run" - echo " canary — features queued for the next main merge; opt-in testing" - echo " Switch:" - echo " airc channel # set preference (run 'airc update' after)" - echo " airc update --channel # set + pull in one step" - return 0 - fi - - echo "$target" > "$channel_file" - echo " Channel preference set: '$target'. Run 'airc update' to actually switch + pull." -} - -cmd_version() { - # Report git state for whichever airc actually ran. Prefer the binary's - # own directory so a dev-checkout run doesn't lie about AIRC_DIR. - local self; self="$(realpath "$0" 2>/dev/null || echo "$0")" - local here; here="$(dirname "$self")" - local dir="" - if [ -d "$here/.git" ]; then - dir="$here" - elif [ -d "${AIRC_DIR:-$HOME/.airc-src}/.git" ]; then - dir="${AIRC_DIR:-$HOME/.airc-src}" - fi - if [ -z "$dir" ]; then - echo " unknown (no git metadata found)" - return - fi - local sha subject branch dirty - sha=$(git -C "$dir" rev-parse --short HEAD 2>/dev/null) - subject=$(git -C "$dir" log -1 --format=%s 2>/dev/null) - branch=$(git -C "$dir" rev-parse --abbrev-ref HEAD 2>/dev/null) - dirty="" - [ -n "$(git -C "$dir" status --porcelain 2>/dev/null)" ] && dirty=" (dirty)" - echo " airc ${sha}${dirty} on ${branch}" - [ -n "$subject" ] && echo " ${subject}" - echo " install: $dir" -} - -cmd_status() { - # Human-readable liveness view. Fast — no network calls by default; `--probe` - # opts into a 3s SSH reachability check. - ensure_init - local probe=0 - [ "${1:-}" = "--probe" ] && probe=1 - - local my_name host_target host_name host_port - my_name=$(get_name) - host_target=$(get_config_val host_target "") - host_name=$(get_config_val host_name "") - host_port=$(get_config_val host_port 7547) - - echo " airc status — scope $AIRC_WRITE_DIR" - - # Identity + role line. - if [ -n "$host_target" ]; then - echo " identity: $my_name (joiner of ${host_name:-?} @ ${host_target}:${host_port})" - else - local my_port; my_port="${AIRC_PORT:-7547}" - [ -f "$AIRC_WRITE_DIR/host_port" ] && my_port=$(cat "$AIRC_WRITE_DIR/host_port" 2>/dev/null) - echo " identity: $my_name (hosting on port ${my_port})" - fi - - # Monitor alive? Read the scope's pidfile — cmd_connect writes its own PID - # there. pgrep'd descendants (python listener, tail loop) should be children - # of that PID. If the main PID is gone, the monitor is down. - local monitor_state="not running" - local pidfile="$AIRC_WRITE_DIR/airc.pid" - if [ -f "$pidfile" ]; then - # cmd_connect writes multiple space-separated PIDs on one line (parent + - # python listener). Monitor is "running" if ANY of them is alive. - local pids_raw; pids_raw=$(cat "$pidfile" 2>/dev/null | tr '\n' ' ' || true) - local any_alive="" - for p in $pids_raw; do - if kill -0 "$p" 2>/dev/null; then any_alive="$p"; break; fi - done - if [ -n "$any_alive" ]; then - monitor_state="running (PID $any_alive)" - else - monitor_state="stale pidfile (PIDs $pids_raw not alive — run 'airc connect' to self-heal)" - fi - fi - echo " monitor: $monitor_state" - - # Host reachability. Only meaningful for joiners; opt-in via --probe to keep - # `airc status` fast by default (SSH connect can hang for seconds). - if [ -n "$host_target" ] && [ "$probe" = "1" ]; then - local ssh_key="$IDENTITY_DIR/ssh_key" - local probe_out - probe_out=$(ssh -i "$ssh_key" -o StrictHostKeyChecking=accept-new \ - -o ConnectTimeout=3 -o BatchMode=yes \ - "$host_target" "echo __REACHABLE__" 2>/dev/null || true) - if echo "$probe_out" | grep -q '^__REACHABLE__$'; then - echo " host: reachable" - else - echo " host: UNREACHABLE (ssh timeout or auth failure)" - fi - fi - - # Last send / receive timestamps. last_sent is a unix epoch written by - # cmd_send. last receive: tail the local messages.jsonl for the most recent - # inbound line (from != $my_name). - local now; now=$(date +%s) - if [ -f "$AIRC_WRITE_DIR/last_sent" ]; then - local ls; ls=$(cat "$AIRC_WRITE_DIR/last_sent" 2>/dev/null) - if [ -n "$ls" ] && [ "$ls" -gt 0 ] 2>/dev/null; then - echo " last send: $(( now - ls ))s ago" - else - echo " last send: never" - fi - else - echo " last send: never" - fi - - if [ -s "$MESSAGES" ]; then - local last_rx_ts - last_rx_ts=$(PEERS_DIR="$PEERS_DIR" MY_NAME="$my_name" python3 -c " -import sys, json, os, calendar, time -name = os.environ.get('MY_NAME', '') -last_ts = None -try: - with open('$MESSAGES') as f: - for line in f: - try: - m = json.loads(line) - if m.get('from') and m.get('from') != name and m.get('from') != 'airc': - last_ts = m.get('ts') - except: pass -except: pass -if last_ts: - # ts is ISO8601 UTC (Z-suffix). Convert to epoch. - try: - t = time.strptime(last_ts.replace('Z',''), '%Y-%m-%dT%H:%M:%S') - print(int(calendar.timegm(t))) - except: print('') -else: - print('') -" 2>/dev/null) - if [ -n "$last_rx_ts" ]; then - echo " last recv: $(( now - last_rx_ts ))s ago" - else - echo " last recv: never" - fi - else - echo " last recv: never" - fi - - # Pending queue — how many sends are waiting for a drain. Populated by - # cmd_send's wire-failure branch; drained by flush_pending_loop. - local pending="$AIRC_WRITE_DIR/pending.jsonl" - local pending_count=0 - [ -f "$pending" ] && pending_count=$(grep -c '^.' "$pending" 2>/dev/null || echo 0) - if [ "$pending_count" -gt 0 ]; then - echo " queue: ${pending_count} pending (auto-retries every ~5s)" - else - echo " queue: empty" - fi - - # Reminder state - local reminder_file="$AIRC_WRITE_DIR/reminder" - if [ -f "$reminder_file" ]; then - local rv; rv=$(cat "$reminder_file" 2>/dev/null) - if [ "$rv" = "0" ]; then - echo " reminder: paused" - elif [ -n "$rv" ] && [ "$rv" -gt 0 ] 2>/dev/null; then - echo " reminder: every ${rv}s" - fi - else - echo " reminder: off" - 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 -} - -# Detect the OS: darwin / linux / wsl / unknown. -_daemon_os() { - case "$(uname -s)" in - Darwin) echo "darwin" ;; - Linux) - # WSL2 detection — systemd may or may not be enabled; we still treat - # it as linux (user must have [boot] systemd=true in wsl.conf). - if grep -qi 'microsoft\|wsl' /proc/version 2>/dev/null; then - echo "wsl" - else - echo "linux" - fi - ;; - *) echo "unknown" ;; - 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. If AIRC_HOME is set at install time, -# that's recorded in the unit/plist so future starts use the same scope. -_daemon_scope() { - echo "${AIRC_HOME:-$HOME/.airc}" -} - -cmd_daemon_install() { - local os; os=$(_daemon_os) - 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" ;; - *) die "Daemon install not supported on $(uname -s). Manual workaround: run 'airc connect' under your platform's preferred autostart mechanism." ;; - esac -} - -_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 - echo " ✓ Loaded into launchd (gui/$(id -u)/com.cambriantech.airc)" - echo " airc will now auto-start at login + restart on crash + survive sleep/wake." - echo " Logs: $scope/daemon.log" - echo " Status: airc daemon status" - echo "" - echo " Note: gh keychain access — if 'airc canary' / gist push fails under" - echo " launchd, the gh keychain may not be unlocked at boot. Workaround:" - echo " run 'gh auth status' once after login to unlock, then airc daemon" - echo " will pick up gh credentials on next restart." -} - -_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)" - ;; - *) echo " Daemon uninstall not supported on $(uname -s)."; return 1 ;; - esac -} - -cmd_daemon_status() { - local os; os=$(_daemon_os) - 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 - ;; - *) 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_doctor() { - # Three modes: - # airc doctor -- environment health check (default). - # Probes each prereq and prints the exact - # install command for whichever package - # manager this platform uses, so any AI - # reading the output can `proactively fix - # recoverable issues` (per /doctor SKILL.md). - # airc doctor --connect -- pre-flight before `airc connect`. Runs - # the default health probes PLUS connect- - # specific checks (tailscale UP not just - # installed, gist API reachable, port free, - # cached host_target reachable). Issue #80. - # Use case: airc doctor --connect && airc connect - # airc doctor --tests -- run the integration test suite (the - # airc doctor tests prior default behavior; aliased on the - # dispatch via `tests|test`). - case "${1:-}" in - --tests|-t|tests|test|run|suite) shift; _doctor_run_tests "$@"; return ;; - --connect|-c|connect) shift; _doctor_connect_preflight "$@"; return ;; - esac - - echo "" - echo " airc doctor -- environment health" - echo " --------------------------------" - echo "" - local issues=0 - - # Detect the platform's package manager so we can emit concrete fix - # commands. Same shape as install.sh's ensure_prereqs. - local mgr; mgr=$(_doctor_detect_pkgmgr) - - _doctor_probe "git" "$mgr" "VCS for clone/update" || issues=$((issues+1)) - _doctor_probe "gh" "$mgr" "Gist substrate (room discovery)" || issues=$((issues+1)) - _doctor_probe_gh_auth || issues=$((issues+1)) - _doctor_probe "openssl" "$mgr" "Ed25519 sign keys + signing" || issues=$((issues+1)) - _doctor_probe "ssh" "$mgr" "OpenSSH client for the wire" || issues=$((issues+1)) - _doctor_probe "ssh-keygen" "$mgr" "Identity keypair generation" || issues=$((issues+1)) - _doctor_probe "python3" "$mgr" "Monitor formatter + heredocs" || issues=$((issues+1)) - _doctor_probe_tailscale "$mgr" # optional, never increments issues - - echo "" - echo " Scope:" - echo " AIRC_HOME = $AIRC_WRITE_DIR" - if [ -f "$CONFIG" ]; then - local _name; _name=$(get_name) - local _ht; _ht=$(get_config_val host_target "") - if [ -n "$_ht" ]; then - echo " Identity: $_name (joiner of $_ht)" - else - echo " Identity: $_name (host or unconnected)" - fi - else - echo " Identity: not initialized (run 'airc join' to set up)" - fi - - echo "" - if [ "$issues" -eq 0 ]; then - echo " All required prereqs present. Behavioral suite: airc doctor --tests" - else - echo " $issues prereq(s) missing -- see fix lines above." - echo " Fastest path: re-run install.sh (auto-installs via brew/apt/dnf/pacman/apk):" - echo " curl -fsSL https://raw.githubusercontent.com/CambrianTech/airc/main/install.sh | bash" - fi - echo "" -} - -_doctor_detect_pkgmgr() { - case "$(uname -s 2>/dev/null)" in - Darwin) - command -v brew >/dev/null 2>&1 && { echo "brew"; return; } - echo "brew-missing"; return ;; - Linux) - command -v apt-get >/dev/null 2>&1 && { echo "apt"; return; } - command -v dnf >/dev/null 2>&1 && { echo "dnf"; return; } - command -v pacman >/dev/null 2>&1 && { echo "pacman"; return; } - command -v apk >/dev/null 2>&1 && { echo "apk"; return; } - ;; - esac - echo "unknown" -} - -# Map a generic prereq to the install command for the detected pkgmgr. -# Empty string = we don't have a one-liner to suggest; emits a generic -# pointer instead. Mirrors install.sh:pkgname_for + install_with_pkgmgr. -_doctor_install_cmd_for() { - local mgr="$1" prereq="$2" - local pkg - case "$prereq" in - ssh|ssh-keygen) - case "$mgr" in - brew) pkg="openssh" ;; - apt) pkg="openssh-client" ;; - dnf) pkg="openssh-clients" ;; - pacman) pkg="openssh" ;; - apk) pkg="openssh-client" ;; - esac ;; - python3) - case "$mgr" in - pacman) pkg="python" ;; - *) pkg="python3" ;; - esac ;; - *) pkg="$prereq" ;; - esac - case "$mgr" in - brew) echo "brew install $pkg" ;; - apt) echo "sudo apt-get install -y $pkg" ;; - dnf) echo "sudo dnf install -y $pkg" ;; - pacman) echo "sudo pacman -S --needed $pkg" ;; - apk) echo "sudo apk add $pkg" ;; - brew-missing) - echo "Install Homebrew first: /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\", then: brew install $pkg" ;; - *) echo "Install '$pkg' via your platform's package manager" ;; - esac -} - -_doctor_probe() { - local cmd="$1" mgr="$2" purpose="$3" - if command -v "$cmd" >/dev/null 2>&1; then - printf " [ok] %s\n" "$cmd" - return 0 - fi - local fix; fix=$(_doctor_install_cmd_for "$mgr" "$cmd") - printf " [MISSING] %s -- %s\n" "$cmd" "$purpose" - printf " Fix: %s\n" "$fix" - return 1 -} - -_doctor_probe_gh_auth() { - if ! command -v gh >/dev/null 2>&1; then - return 0 # already reported missing by the gh probe - fi - if gh auth status >/dev/null 2>&1; then - printf " [ok] gh authenticated\n" - return 0 - fi - printf " [MISSING] gh authenticated (gist scope)\n" - printf " Fix: gh auth login -s gist\n" - return 1 -} - -_doctor_probe_tailscale() { - local mgr="$1" - if command -v tailscale >/dev/null 2>&1; then - if tailscale status >/dev/null 2>&1; then - printf " [ok] tailscale (optional) -- daemon up\n" - else - printf " [info] tailscale (optional) -- installed but daemon not up\n" - printf " Bring up: tailscale up (or skip; LAN mesh works without it)\n" - fi - return 0 - fi - # Optional -- print the install hint but don't count toward issues. - local fix - case "$mgr" in - brew) fix="brew install --cask tailscale" ;; - apt|dnf|pacman|apk) fix="curl -fsSL https://tailscale.com/install.sh | sh" ;; - *) fix="https://tailscale.com/download" ;; - esac - printf " [info] tailscale (optional) -- not installed; only needed for cross-LAN mesh\n" - printf " Install: %s\n" "$fix" - return 0 -} - -_doctor_connect_preflight() { - # Pre-flight check before `airc connect`. Issue #80. Runs the default - # prereq probes PLUS connect-specific checks. Output is a checklist - # with fix commands; exit non-zero if any blocking issue. Use case: - # - # airc doctor --connect && airc connect - # - # Catches the silent-fail classes that produced #78 / #85 / #79 - # cascades for first-time users and surfaced as detective-work bugs. - echo "" - echo " airc doctor --connect -- pre-flight checks" - echo " ------------------------------------------" - echo "" - local issues=0 - local mgr; mgr=$(_doctor_detect_pkgmgr) - - # ── Required prereqs (same as default doctor) ── - _doctor_probe "git" "$mgr" "VCS for clone/update" || issues=$((issues+1)) - _doctor_probe "openssl" "$mgr" "Ed25519 sign keys + signing" || issues=$((issues+1)) - _doctor_probe "ssh" "$mgr" "OpenSSH client for the wire" || issues=$((issues+1)) - _doctor_probe "ssh-keygen" "$mgr" "Identity keypair generation" || issues=$((issues+1)) - _doctor_probe "python3" "$mgr" "Monitor formatter + heredocs" || issues=$((issues+1)) - - # ── gh chain: installed → authed → gist scope → gists API reachable. - # Single chain (early-return on first failure) so a missing gh isn't - # counted 3-4x as a separate issue per dependent probe. Gist scope is - # checked explicitly because `gh auth status` alone passes for a - # gist-scope-less token (Copilot caught this on #87 review). - if ! _doctor_probe "gh" "$mgr" "Gist substrate (room discovery)"; then - issues=$((issues+1)) - elif ! gh auth status >/dev/null 2>&1; then - printf " [BLOCKED] gh authenticated\n" - printf " Fix: gh auth login -s gist\n" - issues=$((issues+1)) - elif ! gh auth status 2>&1 | grep -qiE '(scopes|token scopes):.*\bgist\b'; then - printf " [BLOCKED] gh authed but missing 'gist' scope (room substrate needs it)\n" - printf " Fix: gh auth refresh -s gist\n" - issues=$((issues+1)) - elif ! gh api 'gists?per_page=1' >/dev/null 2>&1; then - printf " [BLOCKED] gist API not reachable -- network outage or rate-limit\n" - printf " Fix: check internet; if persistent, run 'gh auth refresh'\n" - issues=$((issues+1)) - else - printf " [ok] gh authed with gist scope, gists API reachable\n" - fi - - # ── Connect-specific: tailscale state. The default doctor only marks - # tailscale as "info" since it's optional for LAN-only mesh. In - # --connect mode, if there's a saved host_target in tailnet CGNAT - # range, Tailscale being UP is a HARD requirement. - local prior_host_target="" - [ -f "$CONFIG" ] && prior_host_target=$(get_config_val host_target "") - local prior_host_only="${prior_host_target##*@}" - local target_is_cgnat=0 - case "$prior_host_only" in - 100.6[4-9].*|100.[7-9][0-9].*|100.1[01][0-9].*|100.12[0-7].*) target_is_cgnat=1 ;; - esac - if [ "$target_is_cgnat" = "1" ]; then - # Use resolve_tailscale_bin so the .app-bundle / Program Files paths - # are checked, not just PATH (consistency with the rest of airc). - local ts_bin; ts_bin=$(resolve_tailscale_bin 2>/dev/null || true) - if [ -n "$ts_bin" ]; then - if "$ts_bin" status >/dev/null 2>&1; then - printf " [ok] tailscale UP (cached host_target is tailnet CGNAT)\n" - else - printf " [BLOCKED] tailscale CLI installed but DOWN -- cached host is tailnet, can't reach\n" - printf " Fix: tailscale up\n" - issues=$((issues+1)) - fi - else - printf " [BLOCKED] tailscale CLI missing -- cached host is tailnet, can't reach\n" - printf " Fix: install tailscale (https://tailscale.com/download), then 'tailscale up'\n" - issues=$((issues+1)) - fi - else - _doctor_probe_tailscale "$mgr" # optional, info-only - fi - - # ── Connect-specific: AIRC_PORT free or auto-shift available ── - local target_port="${AIRC_PORT:-7547}" - if [ -n "$(port_listeners "$target_port")" ]; then - printf " [info] port %s busy -- airc will auto-shift to next free port\n" "$target_port" - else - printf " [ok] port %s available for hosting\n" "$target_port" - fi - - # ── Connect-specific: cached host_target reachable (resume scenario) ── - if [ -n "$prior_host_target" ]; then - local probe_key="$IDENTITY_DIR/ssh_key" - if [ -f "$probe_key" ]; then - if ssh -i "$probe_key" -o StrictHostKeyChecking=accept-new \ - -o ConnectTimeout=3 -o BatchMode=yes \ - "$prior_host_target" "echo __PROBE_OK__" 2>/dev/null | grep -q __PROBE_OK__; then - printf " [ok] cached host %s reachable + auth works\n" "$prior_host_target" - else - printf " [warn] cached host %s not reachable -- may need re-pair\n" "$prior_host_target" - printf " Fix: airc teardown --flush && airc join (fresh pairing)\n" - # Not blocking — fresh-pair flow handles this - fi - fi - fi - - echo "" - if [ "$issues" -eq 0 ]; then - echo " ✓ READY -- airc connect should work." - return 0 - else - echo " ✗ BLOCKED on $issues issue(s) -- fix the items above before 'airc connect'." - return 1 - fi -} - -_doctor_run_tests() { - # Behavioral suite -- the prior cmd_doctor entry point. Kept reachable - # via `airc doctor --tests` (or the `tests`/`test` aliases in dispatch) - # so existing CI / muscle memory still works. - local script="${AIRC_DIR:-$HOME/.airc-src}/test/integration.sh" - if [ ! -x "$script" ]; then - local self; self="$(realpath "$0" 2>/dev/null || echo "$0")" - local here; here="$(dirname "$self")" - [ -x "$here/test/integration.sh" ] && script="$here/test/integration.sh" - fi - [ -x "$script" ] || die "Can't find test script. Expected at \$AIRC_DIR/test/integration.sh" - exec bash "$script" "$@" -} - -cmd_logs() { - ensure_init - local count="${1:-20}" - local host_target - host_target=$(get_config_val host_target "") - - local raw - if [ -n "$host_target" ]; then - local rhome; rhome=$(remote_home) - raw=$(relay_ssh "$host_target" "tail -${count} $rhome/messages.jsonl 2>/dev/null" 2>/dev/null) || true - else - raw=$(tail -"$count" "$MESSAGES" 2>/dev/null) || true - fi - echo "$raw" | python3 -c " -import sys, json -for line in sys.stdin: - try: - m = json.loads(line.strip()) - print(f\"[{m.get('ts','')}] {m.get('from','?')}: {m.get('msg','')}\") - except: pass -" -} # ── Dispatch ──────────────────────────────────────────────────────────── @@ -5572,6 +1612,11 @@ case "${1:-help}" in debug-scope) echo "$AIRC_WRITE_DIR" ;; debug-name) resolve_name ;; debug-host) get_host ;; + debug-pythonpath) + echo "AIRC_PYTHON=$AIRC_PYTHON" + echo "lib_dir=${_airc_lib_dir:-}" + echo "PYTHONPATH=${PYTHONPATH:-}" + "$AIRC_PYTHON" -c "import airc_core; print(f'airc_core import ok: v{airc_core.__version__}')" 2>&1 ;; help|--help|-h) echo "AIRC — Agentic Internet Relay Chat for AI peers" echo "(IRC verbs work as primary; airc-classic names also accepted)" diff --git a/airc.ps1 b/airc.ps1 index 39b2e4c..b4d8165 100644 --- a/airc.ps1 +++ b/airc.ps1 @@ -325,7 +325,7 @@ function Advise-TailscaleIfDown { if (-not $ts) { Write-Host ' Tailscale is not installed. airc needs it only for cross-machine mesh.' Write-Host ' Install:' - Write-Host ' winget install --id tailscale.tailscale' + Write-Host ' winget install --id Tailscale.Tailscale' Write-Host ' (or https://tailscale.com/download/windows)' Write-Host '' Write-Host ' After install, bring the tailnet up and re-run airc join.' @@ -534,7 +534,22 @@ function Invoke-AircSsh { function Get-RemoteHome { $h = Get-ConfigVal -Key 'host_airc_home' -Default '' if (-not $h) { $h = '$HOME/.airc' } - return $h + # Windows host paths come from Get-AircHome as backslash form + # (e.g. 'C:\Users\Administrator\Documents\Cambrian\.airc'). When + # this gets interpolated into an SSH remote command and the remote + # DefaultShell is bash (Git for Windows — what install.ps1 sets), + # bash interprets the backslashes as escape characters and strips + # them, producing 'C:UsersAdministratorDocumentsCambrian.airc'. + # The redirect target then becomes garbage and `airc msg` silently + # fails (#99 — RebelTechPro 2026-04-25). + # + # Forward-slash form ('C:/Users/.../.airc') is interpreted correctly + # by bash as an absolute path, by Git for Windows' POSIX layer, and + # by the airc bash runtime on the receiving end. Windows itself + # accepts forward slashes in file paths everywhere it accepts + # backslashes (kernel32 normalizes), so this is a one-way safe + # conversion. + return ($h -replace '\\','/') } # -- Identity init: Ed25519 sign keypair + SSH keypair ------------------ @@ -1290,7 +1305,7 @@ function Invoke-Doctor { Probe 'tailscale (optional)' { Get-Command tailscale -ErrorAction SilentlyContinue - } 'winget install --id tailscale.tailscale (then: tailscale up) - LAN-only mode works without it' + } 'winget install --id Tailscale.Tailscale (then: tailscale up) - LAN-only mode works without it' # State-dir + identity Write-Host '' @@ -1317,6 +1332,15 @@ function Invoke-Doctor { Write-Host ' iwr https://raw.githubusercontent.com/CambrianTech/airc/canary/install.ps1 | iex' } Write-Host '' + # Always exit 0 from the default `airc doctor` — informational, like + # `git status`. Probes use `& gh auth status` etc which leak + # $LASTEXITCODE; without an explicit reset the script's natural-end + # exit picks up whatever the last external returned (typically + # gh-not-authed → 1 in CI / fresh installs). Match the bash doctor's + # behavior (cmd_doctor.sh — issues counter, no exit). For hard-fail + # semantics the user should run `airc doctor --connect`, which is + # the documented preflight gate that does exit non-zero on issues. + $global:LASTEXITCODE = 0 } # -- airc doctor --connect --------------------------------------------- @@ -1411,13 +1435,13 @@ function Invoke-DoctorConnectPreflight { } } else { Write-Host " [BLOCKED] tailscale CLI missing -- cached host is tailnet, can't reach" - Write-Host ' Fix: winget install --id tailscale.tailscale (then: tailscale up)' + Write-Host ' Fix: winget install --id Tailscale.Tailscale (then: tailscale up)' $script:DoctorIssues += 'tailscale-missing' } } else { Probe 'tailscale (optional)' { $null -ne (Resolve-TailscaleBin) - } 'winget install --id tailscale.tailscale (LAN-only mode works without it)' + } 'winget install --id Tailscale.Tailscale (LAN-only mode works without it)' } # Connect-specific: AIRC_PORT free diff --git a/install.ps1 b/install.ps1 index 6ca8c03..74bdbe8 100644 --- a/install.ps1 +++ b/install.ps1 @@ -149,6 +149,154 @@ function Install-OpenSSHClient { } } +# -- OpenSSH server (Windows Optional Feature) --------------------------- +# Required when this Windows host serves airc rooms -- joiners ssh-tail +# the host's messages.jsonl. Pre-fix the installer covered the CLIENT +# only. Post-fix (Joel 2026-04-27 "this needs to be in the install dude"): +# install.ps1 now installs+starts the server too, with auto-start on +# boot so the mesh survives reboots without manual intervention. +# Workaround for Windows HNS (Host Network Service) randomly reserving +# port 22 at boot. HNS dynamically reserves port ranges to support +# Hyper-V / WSL2 / Docker Desktop networking; the reservations rotate +# per-boot and are NOT visible in `netsh int ipv4 show excludedportrange` +# (that command shows static admin reservations only). When port 22 +# happens to fall inside a dynamic HNS range, sshd bind() returns EPERM +# even with admin. Diagnosis credit: continuum-b69f via cross-Mac/Windows +# coord gist 2026-04-27. Two-step persistent fix: +# +# 1. Disable HNS auto-exclusion via registry -- survives reboots. +# 2. Explicitly reserve port 22 in the static excluded-port-range so +# HNS can't grab it on subsequent boots. +# +# References: +# keasigmadelta.com/blog/how-to-solve-cannot-bind-to-port-due-to-permission-denied-on-windows +# github.com/docker/for-win/issues/3171 +function Set-HnsPortFreedomFor22 { + # Idempotent -- both checks before writing so re-runs of install + # don't double-write or noisy on a healthy system. + $regPath = 'HKLM:\SYSTEM\CurrentControlSet\Services\hns\State' + $regName = 'EnableExcludedPortRange' + $needRegWrite = $true + try { + $cur = (Get-ItemProperty -Path $regPath -Name $regName -ErrorAction SilentlyContinue).$regName + if ($cur -eq 0) { $needRegWrite = $false } + } catch { } + if ($needRegWrite) { + Write-Host ' Disabling HNS auto-exclusion (HKLM\...\hns\State EnableExcludedPortRange = 0) ...' + & reg add 'HKLM\SYSTEM\CurrentControlSet\Services\hns\State' /v 'EnableExcludedPortRange' /d 0 /f 2>$null | Out-Null + } + + # Check if port 22 is already in the static excluded-port-range. + $existing = & netsh int ipv4 show excludedportrange protocol=tcp 2>$null | Out-String + if ($existing -match '(?m)^\s*22\s+22\b') { + # Already reserved. + return + } + Write-Host ' Reserving port 22 in static excluded-port-range (netsh) ...' + & netsh int ipv4 add excludedportrange protocol=tcp startport=22 numberofports=1 2>$null | Out-Null +} + +# -- DefaultShell -- bash, not cmd.exe (#98) ---------------------------- +# Windows OpenSSH defaults DefaultShell to cmd.exe, which lacks `cat`, +# heredoc redirection, the rest of the POSIX shell vocabulary that airc +# remote commands rely on (`cat >> $rhome/messages.jsonl`, etc.). Result +# without this fix: every Windows airc HOST fails the moment a peer +# tries to send a message -- the remote `cat` command is "not recognized +# as an internal or external command", airc records [QUEUED] forever, +# and the user sees no errors locally. +# +# Set DefaultShell to Git for Windows bash. Bash is what airc.ps1's +# remote commands assume (POSIX paths, redirects). Git for Windows is +# already a hard prereq for Windows users (we install it above), so +# its bash.exe is a stable target. +function Set-OpenSSHDefaultShellBash { + $regPath = 'HKLM:\SOFTWARE\OpenSSH' + # Locate Git for Windows bash.exe. Standard install paths first, + # fall through to PATH lookup. Without bash.exe we can't set it, + # so warn loudly -- every airc host on this machine will break + # silently otherwise. + $bashCandidates = @( + 'C:\Program Files\Git\bin\bash.exe', + 'C:\Program Files (x86)\Git\bin\bash.exe', + "$env:USERPROFILE\AppData\Local\Programs\Git\bin\bash.exe" + ) + $bashPath = $null + foreach ($c in $bashCandidates) { + if (Test-Path $c) { $bashPath = $c; break } + } + if (-not $bashPath) { + $cmd = Get-Command bash.exe -ErrorAction SilentlyContinue + if ($cmd) { $bashPath = $cmd.Source } + } + if (-not $bashPath) { + Write-Warn2 "Could not locate Git for Windows bash.exe -- leaving OpenSSH DefaultShell at OS default (cmd.exe)." + Write-Host ' Without bash, this Windows machine cannot HOST an airc room -- joiners will see [QUEUED] forever.' + Write-Host ' Fix: install Git for Windows, then re-run install.ps1.' + return + } + # Idempotent -- read current, only write if different. + try { + $cur = (Get-ItemProperty -Path $regPath -Name DefaultShell -ErrorAction SilentlyContinue).DefaultShell + } catch { $cur = $null } + if ($cur -eq $bashPath) { + Write-Ok "OpenSSH DefaultShell already set to $bashPath" + return + } + try { + if (-not (Test-Path $regPath)) { + New-Item -Path $regPath -Force | Out-Null + } + New-ItemProperty -Path $regPath -Name DefaultShell -Value $bashPath -PropertyType String -Force | Out-Null + Write-Ok "OpenSSH DefaultShell set to $bashPath (was: $cur)" + } catch { + Write-Warn2 "Could not set DefaultShell registry value (admin required): $_" + Write-Host ' Manual fix (admin PowerShell):' + Write-Host " New-ItemProperty -Path '$regPath' -Name DefaultShell -Value '$bashPath' -PropertyType String -Force" + } +} + +function Install-OpenSSHServer { + $svc = Get-Service sshd -ErrorAction SilentlyContinue + if ($svc -and $svc.Status -eq 'Running') { + Write-Ok 'OpenSSH server already installed + running' + return + } + Write-Step 'Installing + starting OpenSSH Server (admin required) ...' + try { + # 1. Capability install (if not already). + $cap = Get-WindowsCapability -Online -Name 'OpenSSH.Server*' -ErrorAction Stop + if ($cap.State -ne 'Installed') { + Add-WindowsCapability -Online -Name $cap.Name -ErrorAction Stop | Out-Null + Write-Host ' OpenSSH.Server capability installed.' + } + # 2. HNS port-22 reservation (Hyper-V quirk -- see Set-HnsPortFreedomFor22). + Set-HnsPortFreedomFor22 + # 3. Firewall rule for inbound TCP/22. The capability install + # usually creates 'OpenSSH-Server-In-TCP' but it may be disabled + # or missing on some systems. Idempotent. + if (-not (Get-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -ErrorAction SilentlyContinue)) { + Write-Host ' Creating firewall rule for inbound SSH (TCP/22) ...' + New-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' ` + -DisplayName 'OpenSSH Server (sshd)' ` + -Enabled True -Direction Inbound -Protocol TCP ` + -Action Allow -LocalPort 22 -ErrorAction SilentlyContinue | Out-Null + } + # 4. Start + persist. + Start-Service sshd -ErrorAction Stop + Set-Service -Name sshd -StartupType Automatic -ErrorAction Stop + Write-Ok 'OpenSSH server installed + started + auto-start on boot' + } catch { + Write-Warn2 "Could not auto-install OpenSSH Server (run install.ps1 in admin PowerShell): $_" + Write-Host ' Manual fix (admin PowerShell):' + Write-Host ' Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0' + Write-Host ' reg add HKLM\SYSTEM\CurrentControlSet\Services\hns\State /v EnableExcludedPortRange /d 0 /f' + Write-Host ' netsh int ipv4 add excludedportrange protocol=tcp startport=22 numberofports=1' + Write-Host ' Start-Service sshd' + Write-Host ' Set-Service -Name sshd -StartupType Automatic' + Write-Host ' (The reg+netsh lines work around Windows HNS holding port 22 randomly per boot.)' + } +} + # -- Banner -------------------------------------------------------------- Write-Host '' Write-Host ' AIRC installer (Windows native)' @@ -174,9 +322,12 @@ Install-IfMissing -Name 'Python 3' -WingetId 'Python.Python.3.12' -Te return [bool](Get-Command py -ErrorAction SilentlyContinue) } Install-IfMissing -Name 'GitHub CLI (gh)' -WingetId 'GitHub.cli' -TestCmd { Get-Command gh -ErrorAction SilentlyContinue } -Install-IfMissing -Name 'Tailscale' -WingetId 'tailscale.tailscale' -TestCmd { Get-Command tailscale -ErrorAction SilentlyContinue } +Install-IfMissing -Name 'jq' -WingetId 'jqlang.jq' -TestCmd { Get-Command jq -ErrorAction SilentlyContinue } +Install-IfMissing -Name 'Tailscale' -WingetId 'Tailscale.Tailscale' -TestCmd { Get-Command tailscale -ErrorAction SilentlyContinue } Install-OpenSSHClient +Install-OpenSSHServer +Set-OpenSSHDefaultShellBash Write-Host '' @@ -357,3 +508,14 @@ Write-Host ' 4. Join the mesh: airc join' Write-Host '' Write-Host ' Diagnose anytime: airc doctor' Write-Host '' + +# Explicit successful exit. Earlier external probes (winget, tailscale +# status, etc.) leak their $LASTEXITCODE through to the script's +# natural-end exit -- most notably `tailscale status` returns non-zero +# when the user hasn't logged in yet (a perfectly normal post-install +# state we already report via Write-Warn2 above). Without this, every +# fresh install on a runner / VM with not-yet-signed-in tailscale exits +# 1 from install.ps1 even though the install fully succeeded. CI sees +# the install as failed, despite the binary being correctly placed. +$global:LASTEXITCODE = 0 +exit 0 diff --git a/install.sh b/install.sh index 441b7dd..4bf4970 100755 --- a/install.sh +++ b/install.sh @@ -23,6 +23,26 @@ info() { printf ' \033[1;34m->\033[0m %s\n' "$*"; } ok() { printf ' \033[1;32m->\033[0m %s\n' "$*"; } warn() { printf ' \033[1;33m!\033[0m %s\n' "$*" >&2; } +# MSYS / Git Bash path conversion. Three callsites in this file used the +# same `if command -v cygpath ... else sed ...` block; #205 Target #3 +# collapsed them. Mirrors lib/airc_bash/platform_adapters.sh's helpers +# (defined twice on purpose: install.sh runs pre-clone so it can't +# source from $CLONE_DIR, and the helper bodies are tiny). +_to_win_path() { + if command -v cygpath >/dev/null 2>&1; then + cygpath -w "$1" 2>/dev/null + else + printf '%s' "$1" | sed 's|^/\([a-z]\)/|\U\1:\\\\|; s|/|\\\\|g' + fi +} +_to_bash_path() { + if command -v cygpath >/dev/null 2>&1; then + cygpath -u "$1" 2>/dev/null + else + printf '%s' "$1" | sed 's|\\|/|g; s|^\([A-Za-z]\):|/\L\1|' + fi +} + # ── Prereq auto-install ───────────────────────────────────────────────── # Mirrors the Windows install.ps1 winget path: detect what's missing, # install via the platform's package manager, then verify. Designed for @@ -97,6 +117,11 @@ pkgname_for() { winget) echo "GitHub.cli" ;; *) echo "gh" ;; esac ;; + jq) + case "$mgr" in + winget) echo "jqlang.jq" ;; + *) echo "jq" ;; + esac ;; *) echo "$prereq" ;; esac } @@ -127,19 +152,468 @@ install_with_pkgmgr() { esac } +# Ensure sshd is installed AND running. Per-platform with one sudo / UAC +# prompt at most. Idempotent — if already running, no-op. +_ensure_sshd_running() { + case "$(uname -s 2>/dev/null)" in + Darwin) + # macOS: sshd is launchd-managed via "Remote Login". Detection + # without sudo: `launchctl print system` shows system services + # including com.openssh.sshd when Remote Login is on. Bare + # `launchctl list` is user-scope and never shows it. + if launchctl print system 2>/dev/null | grep -qE 'com\.openssh\.sshd($|[[:space:]])' \ + || systemsetup -getremotelogin 2>/dev/null | grep -qi "Remote Login: On"; then + ok "sshd running (Remote Login enabled)" + return 0 + fi + info "Enabling Remote Login (sshd) — admin password prompt incoming." + info " airc joiners need this to ssh-tail your messages.jsonl when you host." + # Two paths: terminal sudo (if a TTY is attached) or osascript GUI + # admin prompt (when called from non-terminal context — e.g. a + # Monitor-spawned shell, or via curl|bash piping). The osascript + # path uses macOS native admin dialog with a branded prompt + # explaining what airc is doing — Joel 2026-04-27 (continuum + # relay): "if we can prompt the user, we do NOT have them do + # annoying setup shit we automate into install." + if [ -t 0 ] && [ -t 1 ]; then + # Interactive shell — sudo can read the password. + if sudo systemsetup -setremotelogin on 2>&1; then + ok "Remote Login enabled." + else + warn "systemsetup failed. Manual: System Settings -> General -> Sharing -> Remote Login." + fi + else + # Non-interactive (Monitor/pipe/script) — use osascript GUI prompt. + if osascript -e 'do shell script "systemsetup -setremotelogin on" with administrator privileges with prompt "AIRC needs admin to enable Remote Login (sshd) — one-time setup so peers can ssh-tail your messages when you host an airc room."' 2>&1; then + ok "Remote Login enabled." + else + warn "osascript admin dialog cancelled or failed." + warn " Manual: System Settings -> General -> Sharing -> Remote Login." + fi + fi + ;; + Linux) + # Already running? + if systemctl is-active --quiet ssh 2>/dev/null || systemctl is-active --quiet sshd 2>/dev/null; then + ok "sshd running" + return 0 + fi + # Install (if missing) + enable. Try Debian/Ubuntu unit name first + # (ssh) then RHEL/Fedora (sshd). Guarded by detect_pkgmgr — if the + # package is missing we use install_with_pkgmgr which already + # handles sudo + the per-distro install command. + info "Installing + enabling sshd — needed for hosting airc rooms." + local _pkgmgr; _pkgmgr=$(detect_pkgmgr) + case "$_pkgmgr" in + apt|dnf|pacman|apk) + install_with_pkgmgr "$_pkgmgr" "openssh-server" 2>&1 || \ + warn "openssh-server install failed (already present? Try: airc doctor)." + # After install, enable + start the right unit. + if systemctl list-unit-files 2>/dev/null | grep -q "^ssh\.service"; then + sudo systemctl enable --now ssh 2>&1 \ + && ok "ssh.service enabled + running" \ + || warn "Failed to start ssh.service. Manual: sudo systemctl enable --now ssh" + elif systemctl list-unit-files 2>/dev/null | grep -q "^sshd\.service"; then + sudo systemctl enable --now sshd 2>&1 \ + && ok "sshd.service enabled + running" \ + || warn "Failed to start sshd.service. Manual: sudo systemctl enable --now sshd" + else + warn "Neither ssh.service nor sshd.service found. Check distro docs." + fi + ;; + *) + warn "Linux without recognized package manager — install + enable sshd manually." + ;; + esac + ;; + MINGW*|MSYS*|CYGWIN*) + # Windows Git Bash: probe via powershell.exe; install via UAC-elevated + # PowerShell (Start-Process -Verb RunAs). + # + # HNS port-22 reservation: Windows HNS (Host Network Service) + # randomly reserves dynamic port ranges per boot to support + # Hyper-V/WSL2/Docker. When port 22 falls inside an HNS range, + # sshd bind() returns EPERM even with admin. Persistent fix: + # (a) reg-disable HNS auto-exclusion + (b) reserve port 22 in the + # static excluded-port-range. Both run inside the elevated payload + # so user clicks UAC once for the whole sshd setup. + # Diagnosis: continuum-b69f via cross-Mac/Windows coord gist + # 2026-04-27. Refs: + # keasigmadelta.com/blog/how-to-solve-cannot-bind-to-port-... + # github.com/docker/for-win/issues/3171 + if ! command -v powershell.exe >/dev/null 2>&1; then + warn "powershell.exe not on PATH; can't auto-configure sshd." + return 0 + fi + local _state + _state=$(powershell.exe -NoProfile -Command "(Get-Service sshd -ErrorAction SilentlyContinue).Status" 2>/dev/null | tr -d '\r\n ') + # Single elevated payload: capability + HNS workaround + firewall + # rule + start + persist. Idempotent — the inner commands check + # state before writing, so re-running install on a healthy box + # doesn't re-prompt or duplicate state. + # DefaultShell = Git for Windows bash (#98). Without this, every + # Windows airc HOST silently fails inbound `airc msg` from peers + # because the OpenSSH default shell is cmd.exe, which lacks `cat`, + # `>>`, and the rest of the POSIX vocabulary airc remote commands + # rely on. Locate bash.exe; idempotent registry write. + # Payload wraps work in Start-Transcript so we ALWAYS get a log + # file we can show the user — the elevated window auto-closes when + # the script ends and any red errors flash too fast to read (Joel + # 2026-04-28: "your powershell crashes. It has red all over but + # blinks for a half second so i have no idea"). Log lives at + # $env:TEMP\airc-install-elevated.log; bash side surfaces it + # below regardless of success/failure. + # Stage payload as a .ps1 file in $CLONE_DIR (Joel + continuum-b69f + # 2026-04-28). Pre-fix: payload was inlined as + # ... -ArgumentList '-NoProfile -Command "$_elevated_payload"' + # but the payload itself contains many "" (PowerShell strings) and + # \\ (registry paths). Four layers of escaping (bash-double, ps1- + # outer-Command, Start-Process-ArgumentList-single, inner-Command- + # double) silently mangled the payload — PowerShell never parsed it, + # the elevated window opened, ran nothing, exited silently, no + # transcript ever written. continuum verified the .ps1 file approach + # writes a clean transcript every time. + local _elevated_ps1="$CLONE_DIR/install-elevated.ps1" + mkdir -p "$CLONE_DIR" + # NOTE: keep this heredoc ASCII-only. PowerShell 5.1 reads BOMless + # .ps1 files as the system codepage (cp1252 on most Windows). A + # UTF-8 em-dash (0xE2 0x80 0x94) ends in byte 0x94, which in + # cp1252 is RIGHT-DOUBLE-QUOTATION-MARK -- the parser sees it as + # a closing string quote and the rest of the file fails to parse. + # We also add a UTF-8 BOM below as defense-in-depth, AND the bash + # side runs a parse-check pass before invoking elevation so any + # parser error fails loud (no silent .ps1 launch). + cat > "$_elevated_ps1" <<'PSPAYLOAD' +$logPath = Join-Path ([System.IO.Path]::GetTempPath()) "airc-install-elevated.log"; +Start-Transcript -Path $logPath -Force | Out-Null; + +# No global try/catch, no $ErrorActionPreference = "Stop". Each step +# runs plainly; if a cmdlet errors, PowerShell prints the error to the +# transcript and execution continues. Bash side detects success/failure +# from Get-Service sshd post-check, not from this script's exit code. +# Anything wrapped in try/catch below is wrapped because the failure is +# *expected* and *recoverable* (e.g. ssh-keygen missing -> warn + skip). + +Write-Host "==> OpenSSH.Server capability"; +$cap = Get-WindowsCapability -Online -Name "OpenSSH.Server*"; +if ($cap.State -ne "Installed") { + Add-WindowsCapability -Online -Name $cap.Name | Out-Null; + Write-Host " installed: $($cap.Name)" +} else { Write-Host " already installed" } + +Write-Host "==> SSH host keys (regenerate so ACLs are clean from birth)"; +# Why "delete + regenerate" instead of "fix ACLs on existing": +# +# Verified on continuum-b69f's box (2026-04-28): even after icacls reset +# to SYSTEM + Administrators only, sshd still refused with error:5 +# (ACCESS_DENIED) and error:13 (ACL fails OpenSSH secure_permission_check). +# Apparently icacls /grant alone isn't enough -- the file owner and the +# combination of explicit + inherited ACEs has to match what OpenSSH's +# secure_permission_check expects, which is fragile. +# +# Cleaner approach: nuke any existing host keys, then run ssh-keygen -A +# from this elevated SYSTEM-context process. ssh-keygen -A sets the +# right ACLs at creation time (owner = SYSTEM, ACEs = SYSTEM + Admins). +# Since this is install-time setup and the host hasn't published any +# fingerprint yet, regenerating is safe -- nobody is trusting these +# keys yet from a client. +$sshKeygen = Join-Path $env:WINDIR "System32\OpenSSH\ssh-keygen.exe"; +if (-not (Test-Path $sshKeygen)) { + Write-Host " WARN: ssh-keygen.exe not found at $sshKeygen -- sshd will fail to start" +} else { + $sshDir = 'C:\ProgramData\ssh'; + if (-not (Test-Path $sshDir)) { New-Item -Path $sshDir -ItemType Directory -Force | Out-Null } + $existing = Get-ChildItem (Join-Path $sshDir 'ssh_host_*') -ErrorAction SilentlyContinue + if ($existing) { + Write-Host " removing $($existing.Count) existing host key file(s)" + $existing | Remove-Item -Force -ErrorAction SilentlyContinue + } + & $sshKeygen -A 2>&1 | ForEach-Object { Write-Host " ssh-keygen: $_" } + # ssh-keygen -A on Windows leaves an ACE for the user who ran it + # (e.g. BIGMAMA\green:(M) for an admin elevation), even though that + # user is just the file creator. OpenSSH's secure_permission_check + # rejects any ACE that isn't owner / SYSTEM / Administrators -- so + # we strip the creator's ACE explicitly. Verified on continuum-b69f + # 2026-04-28: with regenerate alone, sshd kept failing with error 13 + # (ACL secure_permission_check); with this strip, the ACL is just + # SYSTEM + Administrators and sshd accepts it. + # ssh-keygen -A leaves the file owner as the user who ran it + # (BIGMAMA\green even when running elevated). OpenSSH's + # secure_permission_check requires owner in {SYSTEM, Administrators, + # running sshd user}. Setting owner to SYSTEM is the safe default. + $me = (whoami).Trim() + $newKeys = Get-ChildItem (Join-Path $sshDir 'ssh_host_*_key') -ErrorAction SilentlyContinue + foreach ($k in $newKeys) { + icacls $k.FullName /setowner 'NT AUTHORITY\SYSTEM' 2>&1 | Out-Null + icacls $k.FullName /inheritance:r 2>&1 | Out-Null + icacls $k.FullName /grant 'NT AUTHORITY\SYSTEM:(F)' 'BUILTIN\Administrators:(F)' 2>&1 | Out-Null + icacls $k.FullName /remove:g $me 2>&1 | Out-Null + } + # Dump the post-fix ACL + OWNER on the rsa key so we can see in the + # transcript whether the result matches what sshd expects: owner must + # be SYSTEM or Administrators, ACEs must be only owner + SYSTEM + Admins. + $rsa = Join-Path $sshDir 'ssh_host_rsa_key' + if (Test-Path $rsa) { + Write-Host " post-fix ACL on ssh_host_rsa_key:" + icacls $rsa 2>&1 | ForEach-Object { Write-Host " $_" } + Write-Host " post-fix OWNER on ssh_host_rsa_key: $((Get-Acl $rsa).Owner)" + } +} + +Write-Host "==> SSH directory ACLs (C:\ProgramData\ssh + logs/)"; +# Per Microsoft KB on Error 1067 / Event 7034 (Oct 2024 Windows update +# regression that became permanent in newer builds): +# "This issue occurs if the C:\ProgramData\ssh and C:\ProgramData\ssh\logs +# folders have incorrect permissions. The permissions might be too limited +# or too open. For example, the SYSTEM account or the Administrators group +# might not have write permissions. For a second example, regular users +# might have write or full control permissions." +# https://learn.microsoft.com/en-us/troubleshoot/windows-server/system-management-components/error-1053-1067-7034-after-update-openssh-doesnt-start +# +# Required ACL on each folder: +# SYSTEM : Full Control +# Administrators : Full Control +# Authenticated Users : Read & execute (read-only, no write) +# Owner: SYSTEM (not the user who created the folder). +$sshDir = 'C:\ProgramData\ssh' +$logsDir = Join-Path $sshDir 'logs' +foreach ($d in @($sshDir, $logsDir)) { + if (-not (Test-Path $d)) { New-Item -Path $d -ItemType Directory -Force | Out-Null } + icacls $d /setowner 'NT AUTHORITY\SYSTEM' 2>&1 | Out-Null + icacls $d /inheritance:r 2>&1 | Out-Null + icacls $d /grant 'NT AUTHORITY\SYSTEM:(OI)(CI)(F)' 'BUILTIN\Administrators:(OI)(CI)(F)' 'NT AUTHORITY\Authenticated Users:(OI)(CI)(RX)' 2>&1 | Out-Null + Write-Host " $d :" + icacls $d 2>&1 | Select-Object -First 5 | ForEach-Object { Write-Host " $_" } +} + +Write-Host "==> sshd dry-run (config + key load test)"; +# Run sshd -t from elevated context to surface the *real* reason sshd +# is failing -- Start-Service sshd hides the underlying error behind a +# generic "Failed to start service" message. -t exits non-zero with a +# specific error message ("no hostkeys available", config syntax, +# privilege separation user missing, etc.). Captures stderr too. +$sshdExe = Join-Path $env:WINDIR "System32\OpenSSH\sshd.exe" +if (Test-Path $sshdExe) { + $sshdTest = & $sshdExe -t 2>&1 + $sshdTestExit = $LASTEXITCODE + if ($sshdTestExit -eq 0) { + Write-Host " sshd -t: OK (exit 0)" + } else { + Write-Host " sshd -t: FAILED (exit $sshdTestExit)"; + $sshdTest | ForEach-Object { Write-Host " $_" } + } +} + +Write-Host "==> HNS port-22 reservation"; +$reg = (Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\hns\State" -Name "EnableExcludedPortRange" -ErrorAction SilentlyContinue).EnableExcludedPortRange; +$regChanged = $false +if ($reg -ne 0) { + reg add "HKLM\SYSTEM\CurrentControlSet\Services\hns\State" /v "EnableExcludedPortRange" /d 0 /f | Out-Null; + Write-Host " HNS auto-exclusion disabled" + $regChanged = $true +} else { Write-Host " HNS auto-exclusion already off" } +$excl = netsh int ipv4 show excludedportrange protocol=tcp | Out-String; +if ($excl -notmatch "(?m)^\s*22\s+22\b") { + netsh int ipv4 add excludedportrange protocol=tcp startport=22 numberofports=1 | Out-Null; + Write-Host " port 22 reserved in static excluded-port-range" +} else { Write-Host " port 22 already reserved" } + +# Verify port 22 is actually claimable. If HNS has it reserved at a +# layer below netsh-visible (Hyper-V/WSL2/Docker share dynamic port +# ranges via HNS), a restart of the HNS service is the only way to +# re-evaluate the reservation. Without this, netsh shows port 22 +# excluded but sshd-as-LocalSystem still gets EACCES on bind: +# sshd: error: Bind to port 22 on 0.0.0.0 failed: Permission denied. +# sshd: fatal: Cannot bind any address. +# Verified on continuum-b69f 2026-04-28 in OpenSSH/Admin event log. +$hns = Get-Service hns -ErrorAction SilentlyContinue +if ($hns -and $hns.Status -eq 'Running') { + Write-Host " restarting HNS service so port-22 reservation takes effect" + Restart-Service hns -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 2 + Write-Host " HNS state: $((Get-Service hns).Status)" +} + +Write-Host "==> Firewall rule (TCP/22 inbound)"; +if (-not (Get-NetFirewallRule -Name "OpenSSH-Server-In-TCP" -ErrorAction SilentlyContinue)) { + New-NetFirewallRule -Name "OpenSSH-Server-In-TCP" -DisplayName "OpenSSH Server (sshd)" -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 | Out-Null; + Write-Host " inbound TCP/22 rule created" +} else { Write-Host " inbound TCP/22 rule already exists" } + +Write-Host "==> sshd service (start + auto-start on boot)"; +Start-Service sshd; +Set-Service -Name sshd -StartupType Automatic; +Write-Host " Get-Service sshd: $((Get-Service sshd).Status)"; + +Write-Host "==> DefaultShell registry (bash for joiners)"; +$bashCandidates = @("C:\Program Files\Git\bin\bash.exe", "C:\Program Files (x86)\Git\bin\bash.exe", "$env:USERPROFILE\AppData\Local\Programs\Git\bin\bash.exe"); +$bashPath = $null; +foreach ($c in $bashCandidates) { if (Test-Path $c) { $bashPath = $c; break } } +if (-not $bashPath) { $cmd = Get-Command bash.exe -ErrorAction SilentlyContinue; if ($cmd) { $bashPath = $cmd.Source } } +if (-not $bashPath) { + Write-Host " WARN: bash.exe not found; DefaultShell left at OS default. Install Git for Windows + re-run." +} else { + $cur = (Get-ItemProperty -Path "HKLM:\SOFTWARE\OpenSSH" -Name DefaultShell -ErrorAction SilentlyContinue).DefaultShell; + if ($cur -eq $bashPath) { + Write-Host " DefaultShell already $bashPath" + } else { + if (-not (Test-Path "HKLM:\SOFTWARE\OpenSSH")) { New-Item -Path "HKLM:\SOFTWARE\OpenSSH" -Force | Out-Null } + New-ItemProperty -Path "HKLM:\SOFTWARE\OpenSSH" -Name DefaultShell -Value $bashPath -PropertyType String -Force | Out-Null; + Write-Host " DefaultShell -> $bashPath" + } +} + +Write-Host ""; +Write-Host "airc: elevated install steps complete"; +Stop-Transcript | Out-Null; +exit 0; +PSPAYLOAD + + # Defense-in-depth: prepend a UTF-8 BOM so PowerShell 5.1 reads + # the .ps1 as UTF-8 (not cp1252). Heredoc is ASCII-only so this + # is just insurance for future edits. + if [ -f "$_elevated_ps1" ]; then + local _tmp_bom="$_elevated_ps1.bom" + printf '\xEF\xBB\xBF' > "$_tmp_bom" + cat "$_elevated_ps1" >> "$_tmp_bom" + mv "$_tmp_bom" "$_elevated_ps1" + fi + + # Translate the .ps1 path to Windows form for Start-Process -File + # and the parse-check below. + local _elevated_ps1_win; _elevated_ps1_win=$(_to_win_path "$_elevated_ps1") + + # Pre-flight parse-check: catch syntax errors in the staged .ps1 + # BEFORE we trigger UAC. Without this, a parser error means the + # elevated window opens, fails to parse, blinks closed, no log + # is written, bash side reports "transcript not written" and the + # user has no idea what went wrong (Joel 2026-04-28: "we prefer + # parser issues to actually error" -- this is how we make them + # actually error). Parser errors here abort the install loud. + local _parse_errs + _parse_errs=$(powershell.exe -NoProfile -Command " + \$tokens = \$null; \$errors = \$null; + [System.Management.Automation.Language.Parser]::ParseFile('$_elevated_ps1_win', [ref]\$tokens, [ref]\$errors) | Out-Null; + if (\$errors) { \$errors | ForEach-Object { Write-Output \$_.ToString() } } + " 2>&1 | tr -d '\r') + if [ -n "$_parse_errs" ]; then + warn "Staged elevated payload has PARSE ERRORS -- aborting before UAC." + warn " This is a bug in install.sh. File a bug w/ this output:" + printf '%s\n' "$_parse_errs" | sed 's/^/ /' + warn " staged file: $_elevated_ps1_win" + return 1 + fi + case "$_state" in + Running) + ok "sshd running (Windows OpenSSH.Server)" + return 0 + ;; + Stopped|StopPending|StartPending|Paused|"") + info "Configuring OpenSSH.Server + HNS port-22 reservation (UAC prompt incoming)." + info " airc joiners need this to ssh-tail your messages.jsonl when you host." + # Log path lives at %LOCALAPPDATA%\Temp\airc-install-elevated.log + # on Windows. Use [System.IO.Path]::GetTempPath() not $env:TEMP + # — Git Bash's inherited TEMP=/tmp leaks into powershell.exe and + # would resolve to /tmp instead of the real Windows user temp, + # making us look for the log at the wrong path (Joel 2026-04-28 + # — \"Elevated transcript not written\" but the log was written; + # we just looked at /tmp/airc-install-elevated.log instead of + # C:\\Users\\green\\AppData\\Local\\Temp\\airc-install-elevated.log). + local _ps_log_win _ps_log_bash _elev_rc=0 + _ps_log_win=$(powershell.exe -NoProfile -Command "Join-Path ([System.IO.Path]::GetTempPath()) 'airc-install-elevated.log'" 2>/dev/null | tr -d '\r') + _ps_log_bash=$(_to_bash_path "$_ps_log_win") + info " elevated payload: $_elevated_ps1_win" + info " elevated log: $_ps_log_win" + info " (bash log path: $_ps_log_bash)" + # Run the elevated payload via -File (no quoting hell). Start- + # Process -Wait propagates the elevated process's exit code. + # -ExecutionPolicy Bypass so the elevated PS doesn't refuse + # the unsigned .ps1. + powershell.exe -NoProfile -Command "Start-Process powershell -Verb RunAs -Wait -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','$_elevated_ps1_win')" 2>&1 \ + || _elev_rc=$? + # Always dump the transcript — success or failure, the user + # sees what happened. If transcript file is missing, the + # payload didn't even start (UAC denied / Start-Process + # itself failed). + if [ -n "$_ps_log_bash" ] && [ -f "$_ps_log_bash" ]; then + echo "" + info "─── elevated PowerShell output ───" + sed 's/^/ /' "$_ps_log_bash" + info "─── (end log; full file: $_ps_log_win) ───" + echo "" + # Detect failure inside the transcript even if Start-Process + # itself returned 0 (the elevated PS process could exit + # non-zero; Start-Process -Wait still propagates that, but + # check airc-elevated-error pattern as belt-and-suspenders). + if grep -q "airc-elevated-error:" "$_ps_log_bash"; then + _elev_rc=1 + fi + else + warn " Elevated transcript not written — UAC denied, or Start-Process failed." + fi + # Belt-and-suspenders: re-query sshd state from non-elevated PS + # (continuum-b69f 2026-04-28). If the elevated payload claimed + # exit 0 but sshd isn't actually Running, surface that — the + # silent-success-while-broken path was the worst version of + # this bug. The Get-Service call is cheap; doing it always + # is fine. + local _post_state + _post_state=$(powershell.exe -NoProfile -Command "(Get-Service sshd -ErrorAction SilentlyContinue).Status" 2>/dev/null | tr -d '\r ') + if [ "$_elev_rc" = "0" ] && [ "$_post_state" = "Running" ]; then + ok "OpenSSH.Server installed + sshd Running + HNS port-22 reserved + auto-start + DefaultShell=bash." + elif [ "$_elev_rc" = "0" ]; then + warn "Elevated payload exit 0 but sshd state is '$_post_state' — partial install." + warn " Re-run install or check elevated log: $_ps_log_win" + _elev_rc=1 + else + warn "Elevated payload failed (exit $_elev_rc, sshd state '$_post_state'). See log above." + warn "Manual fix (admin PowerShell):" + warn " Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0" + warn " reg add HKLM\\SYSTEM\\CurrentControlSet\\Services\\hns\\State /v EnableExcludedPortRange /d 0 /f" + warn " netsh int ipv4 add excludedportrange protocol=tcp startport=22 numberofports=1" + warn " Start-Service sshd" + warn " Set-Service -Name sshd -StartupType Automatic" + warn " New-ItemProperty -Path 'HKLM:\\SOFTWARE\\OpenSSH' -Name DefaultShell -Value 'C:\\Program Files\\Git\\bin\\bash.exe' -PropertyType String -Force" + fi + ;; + *) + warn "sshd state unknown (Get-Service returned: '$_state'). Run airc doctor to diagnose." + ;; + esac + ;; + *) + info "sshd auto-config skipped (unsupported platform: $(uname -s))" + ;; + esac +} + tailscale_present() { # macOS GUI install puts Tailscale.app at /Applications without putting - # `tailscale` on PATH — `command -v tailscale` then lies about a missing - # install and we'd brew-cask over the user's working Tailscale (sudo - # prompt + kernel extension churn). Check the GUI bundle path too. + # `tailscale` on PATH; Windows winget can install to Program Files OR + # LocalAppData (user scope) depending on package metadata. Probe many + # locations cheap-to-thorough. command -v tailscale >/dev/null 2>&1 && return 0 + command -v tailscale.exe >/dev/null 2>&1 && return 0 [ -d /Applications/Tailscale.app ] && return 0 [ -x /Applications/Tailscale.app/Contents/MacOS/Tailscale ] && return 0 + [ -x "/c/Program Files/Tailscale/tailscale.exe" ] && return 0 + [ -x "/c/Program Files (x86)/Tailscale/tailscale.exe" ] && return 0 + # Last-resort Windows probe: `where.exe` searches every PATH+PATHEXT + # location and catches winget user-scope installs (%LOCALAPPDATA%\...) + # that aren't in any of the hard-coded paths above. Joel's catch + # 2026-04-28: post-install said "Tailscale is optional but recommended" + # even though winget had just installed it to user scope; bash's + # `command -v tailscale` didn't honor PATHEXT, none of the hard-coded + # paths matched, so we lied to the user. + if command -v where.exe >/dev/null 2>&1; then + where.exe tailscale.exe >/dev/null 2>&1 && return 0 + fi return 1 } install_tailscale() { # Optional. macOS: brew cask. Linux: tailscale's official installer. + # Windows Git Bash: winget (case-sensitive id, see #94). tailscale_present && return 0 case "$(uname -s)" in Darwin) @@ -155,6 +629,17 @@ install_tailscale() { else warn "curl missing; install Tailscale manually: https://tailscale.com/download/linux" fi ;; + MINGW*|MSYS*|CYGWIN*) + # Windows Git Bash: winget. Package id is case-sensitive (#94 — + # 'tailscale.tailscale' lowercase silently fails; 'Tailscale.Tailscale' + # is the actual id). Mirrors install.ps1's Install-IfMissing line. + local wbin; wbin=$(command -v winget.exe 2>/dev/null || command -v winget 2>/dev/null || true) + if [ -n "$wbin" ]; then + "$wbin" install --id Tailscale.Tailscale --silent --accept-source-agreements --accept-package-agreements 2>&1 \ + || warn "Tailscale install via winget failed; install manually: https://tailscale.com/download/windows" + else + warn "winget not present; install Tailscale manually: https://tailscale.com/download/windows" + fi ;; *) warn "Don't know how to install Tailscale on $(uname -s); see https://tailscale.com/download" ;; esac @@ -177,8 +662,30 @@ ensure_prereqs() { fi local missing=() pkgs=() unmappable=() - for cmd in git gh openssl ssh-keygen python3; do + # jq added 2026-04-27: airc's gist envelope parser uses jq for the + # canonical path; bash bare-grep fallback handles JSON-key-prefix + # leak now (PR fix), but jq is the right tool — without it the + # fallback can't extract host.addresses[] for multi-address pick. + # On Git Bash, jq is winget-installable as 'jqlang.jq'. + for cmd in git gh jq openssl ssh-keygen python3; do + # Strict probe: presence on PATH AND a successful --version invocation. + # Used selectively: python3 needs the strict variant because Windows + # Store's python3.exe alias is on PATH but exits 49 with a Store- + # redirect (continuum-b69f, 2026-04-27). git/gh/jq/openssl all + # support --version cleanly. ssh-keygen does NOT have a version + # flag at all (different from `ssh -V`); calling `ssh-keygen + # --version` exits non-zero on every install, so the strict probe + # produces false positives — Joel 2026-04-28 saw "ssh-keygen needs + # manual install on winget" on a perfectly good Git for Windows + # install. Skip the strict variant for ssh-keygen; presence-on-PATH + # is sufficient since Git for Windows bundles a working binary. + local _missing=0 if ! command -v "$cmd" >/dev/null 2>&1; then + _missing=1 + elif [ "$cmd" != "ssh-keygen" ] && ! "$cmd" --version >/dev/null 2>&1; then + _missing=1 + fi + if [ "$_missing" = "1" ]; then missing+=("$cmd") local pkg; pkg=$(pkgname_for "$mgr" "$cmd") if [ -z "$pkg" ]; then @@ -215,9 +722,40 @@ ensure_prereqs() { ok "All required prereqs present" fi + # sshd: airc joiners ssh into the host's airc_home to tail messages. + # Every airc user who'll host a room (which is most users — first to + # discover becomes the host) needs sshd RUNNING. install.sh actually + # turns it on instead of just warning, since "warn + leave it to the + # user" was Joel's "this needs to be in the install dude" pushback + # 2026-04-27. ONE sudo / UAC prompt during install (same shape as + # install_with_pkgmgr already uses for apt/dnf/etc); after that + # airc just works for hosting. + # + # AIRC_SKIP_SSHD=1 short-circuits the whole block — for headless CI + # boxes that genuinely don't host, or environments that manage sshd + # via their own config-management (Ansible, Chef). + # + # Auto-detect: GitHub Actions sets CI=true; so does almost every CI + # system (Travis, CircleCI, GitLab, BuildKite, Jenkins). On macOS + # specifically, the osascript admin-prompt path hangs forever in CI + # because there's no Touch ID / password input — the runner job + # silently runs for the full 6-hour timeout. Skip when CI=true so + # the install completes cleanly and CI tests the rest of the path. + if [ "${CI:-}" = "true" ] || [ "${CI:-}" = "1" ]; then + info "CI=true — skipping sshd setup (no host-capability test in CI)" + elif [ "${AIRC_SKIP_SSHD:-0}" != "1" ]; then + _ensure_sshd_running + fi + # Tailscale is optional -- only needed for cross-LAN mesh. LAN-only # works fine without it, so we attempt install but don't fail loud. - if ! tailscale_present; then + # Skip in CI: brew install --cask tailscale on macOS runners is slow + # (multi-minute download + GUI app install) and there's no tailnet + # behind the runner anyway. The install itself is what we're gating + # on — Tailscale-as-optional is documented; CI doesn't need it. + if [ "${CI:-}" = "true" ] || [ "${CI:-}" = "1" ]; then + info "CI=true — skipping Tailscale install (optional, no tailnet in CI)" + elif ! tailscale_present; then info "Tailscale not present (optional -- LAN mesh works without it). Attempting install ..." install_tailscale fi @@ -229,6 +767,21 @@ ensure_prereqs() { if ! gh auth status >/dev/null 2>&1; then warn "gh CLI is not authenticated. Run once before 'airc join':" warn " gh auth login -s gist" + else + # Wire gh's token into git's credential helper. Without this, + # every git-over-HTTPS op (gist fetch/push -- airc's substrate + # hot path) prompts the user for a password, repeatedly. gh ships + # with `gh auth git-credential` for exactly this purpose; the + # `gh auth setup-git` one-liner registers it in ~/.gitconfig. + # Idempotent (no-op if already configured), safe to always run. + # Joel hit this on 2026-04-28 — Windows install where gh was + # auth'd-in-keyring but git itself didn't know. Resulted in a + # GUI password popup every airc operation that touched a gist. + if ! git config --global --get-all credential.https://github.com.helper 2>/dev/null | grep -q 'gh auth git-credential'; then + if gh auth setup-git 2>/dev/null; then + info " gh token wired into git credential helper (no more password popups for gist ops)" + fi + fi fi fi } @@ -301,8 +854,25 @@ EOF exit 1 fi else - info "Installing AIRC" - git clone --quiet "$REPO_URL" "$CLONE_DIR" + # First install. Honor AIRC_CHANNEL if set so users can land on canary + # directly via `AIRC_CHANNEL=canary curl|bash` without a follow-up + # `airc canary && airc update` dance. Default to main (the release + # branch) when AIRC_CHANNEL is unset. Caught by vhsm-d1f4 2026-04-28 + # during the #191 release-gate fresh-install verification: env var was + # silently ignored, install landed on main. + CHANNEL_TARGET="${AIRC_CHANNEL:-main}" + case "$CHANNEL_TARGET" in + main|canary) ;; + *) + warn "AIRC_CHANNEL='$CHANNEL_TARGET' is not a known channel (main, canary). Defaulting to main." + CHANNEL_TARGET="main" + ;; + esac + info "Installing AIRC (channel: $CHANNEL_TARGET)" + git clone --quiet --branch "$CHANNEL_TARGET" "$REPO_URL" "$CLONE_DIR" + # Persist the channel choice so future `airc update` follows the same + # branch. Mirrors what `airc canary` / `airc main` write. + echo "$CHANNEL_TARGET" > "$CLONE_DIR/.channel" fi # ── airc on PATH ─────────────────────────────────────────────────────── @@ -365,8 +935,24 @@ ts_post_check() { local ts_bin="" if command -v tailscale >/dev/null 2>&1; then ts_bin="tailscale" + elif command -v tailscale.exe >/dev/null 2>&1; then + ts_bin="tailscale.exe" elif [ -x /Applications/Tailscale.app/Contents/MacOS/Tailscale ]; then ts_bin="/Applications/Tailscale.app/Contents/MacOS/Tailscale" + elif [ -x "/c/Program Files/Tailscale/tailscale.exe" ]; then + # Windows Git Bash: winget installs Tailscale to Program Files; + # PATH may not yet include it in the current shell. Mirror + # airc.ps1's resolve_tailscale_bin candidates. + ts_bin="/c/Program Files/Tailscale/tailscale.exe" + elif [ -x "/c/Program Files (x86)/Tailscale/tailscale.exe" ]; then + ts_bin="/c/Program Files (x86)/Tailscale/tailscale.exe" + elif command -v where.exe >/dev/null 2>&1; then + # Last resort: where.exe searches every PATH+PATHEXT location. + # Catches winget user-scope installs (%LOCALAPPDATA%\...). Translate + # the returned Windows path to MSYS form for [ -x ]. + local _wherewin + _wherewin=$(where.exe tailscale.exe 2>/dev/null | head -1 | tr -d '\r') + [ -n "$_wherewin" ] && ts_bin=$(_to_bash_path "$_wherewin") fi [ -z "$ts_bin" ] && return 0 # not installed, nothing to nag about @@ -384,10 +970,16 @@ ts_post_check() { else info "Sign in: tailscale up" fi ;; + MINGW*|MSYS*|CYGWIN*) + info "Click the Tailscale tray icon to sign in, or run: tailscale up" + info "Do this BEFORE 'airc join', or cross-machine joins will hang." ;; *) info "Sign in: tailscale up (follow the printed URL)" ;; esac ;; + *) + # Logged in / running normally — silent (good UX, nothing to nag). + ;; esac } @@ -398,10 +990,20 @@ ts_post_check echo "" ok "Installed." echo "" -echo " Cross-LAN mesh? Tailscale is optional but recommended:" -echo " https://tailscale.com (then: tailscale up)" -echo " Same-LAN mesh works without it; gist orchestration handles either." -echo "" +# Tailscale post-install message — be honest about installed state. The +# pre-fix text always read "Tailscale is optional but recommended: +# https://tailscale.com" even when winget had just installed it 30s ago, +# which (per Joel 2026-04-28) reads as a fail. ts_post_check above +# already nudges sign-in if installed-but-logged-out, so here we only +# print the "go install it" line when tailscale really isn't present. +if tailscale_present; then + : # ts_post_check handled the messaging if relevant +else + echo " Cross-LAN mesh? Tailscale is optional (not installed):" + echo " https://tailscale.com (then: tailscale up)" + echo " Same-LAN mesh works without it; gist orchestration handles either." + echo "" +fi echo " Next:" echo " 1. gh auth login -s gist # one-time, browser flow" echo " 2. airc join # auto-#general (joins existing or hosts)" diff --git a/lib/airc_bash/cmd_connect.sh b/lib/airc_bash/cmd_connect.sh new file mode 100644 index 0000000..65e75bb --- /dev/null +++ b/lib/airc_bash/cmd_connect.sh @@ -0,0 +1,1379 @@ +# Sourced by airc. cmd_connect — the join/pair/host orchestrator. +# +# Single huge command function (1355 lines) covering all of: +# * argv flag parsing (~60 flags) +# * `airc join ` joiner path +# * `airc join` host bootstrap (gh gist publish, ssh keygen, sshd start) +# * connect-time doctor preflight + Tailscale start +# * heartbeat thread (15s gist update) +# * #general sidecar spawn + room gating +# * monitor loop entry +# +# Self-contained — calls airc top-level helpers (die, ensure_init, +# get_config_val, set_config_val, relay_ssh, _reexec_into, +# _self_heal_stale_host, spawn_general_sidecar_if_wanted, monitor, +# detect_platform, port_listeners, …) but defines no functions +# referenced from outside the connect surface. +# +# 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. cmd_connect was the single largest block. +# Future passes will further decompose this file (host vs joiner vs +# heartbeat are clearly separable), but step 1 is splitting it out of +# the top-level monolith without changing behavior. + +cmd_connect() { + # Flag parsing. Issue #37 — host display shapes: + # default (gh installed + authed): gist ID + humanhash mnemonic + long invite + # default (no gh OR gh not authed): long invite only (today's behavior) + # --no-gist : long invite only, even if gh works + # + # `--gist` and `-gist` accepted for explicitness/back-compat; both no-ops + # because gist is now the default when gh is available. Gist push silently + # falls through to long-invite-only when gh is missing or unauthed, so + # the host command never fails just because GitHub isn't reachable. + # + # Room flags (issue #39 + #121): + # --room : join (or host) a named room (default: auto-scope + # from git org, falling back to 'general') + # --no-room : disable the substrate entirely; legacy 1:1 + # invite-string flow (use_room=0). Inherits #38 + # single-pair behavior. Aliased --no-general was + # removed for this — those have different meanings. + # --no-general : keep the project room, but DON'T also subscribe + # to the #general lobby. Project-only focus mode. + # (NEW; previously this was an alias for --no-room.) + # --room-only : explicit project room + no general sidecar. + # Equivalent to `--room --no-general`. + # + # Default behavior (issue #121): every `airc join` lands in BOTH the + # auto-scoped project room AND #general. The general sidecar runs in a + # sibling scope (.general suffix) under the same visible identity, so + # AIs cross-pollinate between projects via the lobby while keeping + # focused work in their project room. Set AIRC_GENERAL_SIDECAR=1 to + # signal "this IS the sidecar, don't recurse" — internal-only. + local use_gist=1 # default ON; runtime probe later checks gh availability + local room_name="general" + local room_explicit=0 # set to 1 when user passes --room explicitly + local use_room=1 # default ON — auto-#general substrate + local general_sidecar=1 # default ON (issue #121) — also subscribe to #general + local _force_general_sidecar=0 # set by --general flag (issue #136 re-opt-in) + # Recursion guard: when WE are the sidecar (spawned by another airc + # connect), don't spawn our own sidecar. Otherwise: turtles all the way. + [ "${AIRC_GENERAL_SIDECAR:-0}" = "1" ] && general_sidecar=0 + # User-facing env opt-out, equivalent to --no-general flag. Useful + # for test harnesses that don't care about sidecar behavior, and + # for one-off scoped scripts that want to set it once and forget. + [ "${AIRC_NO_GENERAL:-0}" = "1" ] && general_sidecar=0 + # Declared at function scope so set -u doesn't bite when JOIN MODE runs + # without a prior gist parser (inline-invite path skips the parser + # entirely; resolved_room_name only gets a value when we resolved a + # kind:room gist envelope). + local resolved_room_name="" + # _resolved_gist_id is captured by the gist resolver when discovery resolves + # a kind:"room" gist. Used by JOIN MODE's self-heal path: if the pair + # handshake fails because the host listed in the room gist is unreachable + # (sleep/crash/network), the joiner deletes the stale gist and re-execs + # itself in host mode — first-agent-back-in becomes the new host. + local _resolved_gist_id="" + # Heartbeat freshness vars - parsed by gist resolver in the room + # case-arm. Must be defaulted here so the JOIN MODE early-takeover + # check (which runs unconditionally if a target has '@') doesn't trip + # 'unbound variable' when target came in inline (no gist resolved). + local _resolved_heartbeat_stale=0 + local _resolved_heartbeat_age="" + # Multi-address fields parsed from host.addresses[] in the room + # gist envelope. _resolved_addresses_json is the raw JSON array + # (or empty if the host published a legacy envelope with only + # host.address/host.port). _resolved_host_machine_id lets the + # joiner detect "we're on the same machine" and dial 127.0.0.1. + local _resolved_addresses_json="" + local _resolved_host_machine_id="" + local positional=() + while [ $# -gt 0 ]; do + case "$1" in + --gist|-gist) use_gist=1; shift ;; + --no-gist|-no-gist) use_gist=0; shift ;; + --room|-room) room_name="${2:-general}"; use_room=1; room_explicit=1; shift 2 ;; + --no-room|-no-room) use_room=0; shift ;; + --no-general|-no-general) + # NEW semantic (issue #121): keep the project room substrate, + # just don't ALSO subscribe to the #general lobby sidecar. This + # used to alias --no-room (disable substrate entirely); the + # behaviors are now distinct because dual-room presence is + # default and users need a way to opt out of just the lobby + # part without dropping back to legacy 1:1 invites. + general_sidecar=0; shift ;; + --general|-general) + # Issue #136: explicit re-opt-in to #general after a prior + # /part. Clears the room from primary scope's parted_rooms so + # the sidecar resubscribes. Force general_sidecar=1 too in case + # AIRC_GENERAL_SIDECAR=1 was set (recursion guard) — the user + # is explicitly asking for the sidecar, override session env. + # Symmetric inverse of --no-general. + _force_general_sidecar=1; shift ;; + --room-only|-room-only) + # Combo: explicit project room + skip general sidecar. For + # focused work where lobby noise would distract. + room_name="${2:-general}"; use_room=1; room_explicit=1; general_sidecar=0 + shift 2 ;; + --no-tailscale|-no-tailscale) + # Opt out of Tailscale entirely: skips the login prompt AND + # drops the tailscale entry from host_address_set so the + # gist envelope advertises only localhost+LAN. The flag is + # the primary user-facing API; AIRC_NO_TAILSCALE=1 stays as + # an internal toggle for code that already reads it. + export AIRC_NO_TAILSCALE=1 + shift ;; + *) positional+=("$1"); shift ;; + esac + done + set -- "${positional[@]+"${positional[@]}"}" + + # Issue #136: --general re-opt-in. Clear parted state on primary + # scope and force the sidecar back on. Done after arg parsing so we + # know AIRC_WRITE_DIR (set by ensure_init below) is meaningful — but + # we have to wait for ensure_init to run, since --general can be + # called before any prior init. The cleanup happens via a deferred + # check in spawn_general_sidecar_if_wanted: since _clear_parted_room + # is idempotent, we can call it eagerly here when config exists, and + # also force general_sidecar=1 to override any session env opt-out. + if [ "$_force_general_sidecar" = "1" ]; then + general_sidecar=1 + if [ -f "$AIRC_WRITE_DIR/config.json" ]; then + local _primary_now; _primary_now=$(_primary_scope_for "$AIRC_WRITE_DIR") + _clear_parted_room "$_primary_now" "general" + fi + fi + + # Tailscale-installed-but-logged-out nudge. Runs AFTER flag parsing + # so --no-tailscale takes effect. Default behavior: if Tailscale is + # installed, "just works" — prompt the user to sign in (Mac: opens + # Tailscale.app). The 90% case is "I have it and want it on"; + # --no-tailscale is the explicit opt-out for the few who don't. + tailscale_login_check_or_prompt + + # `airc join` (no args) auto-scopes to the room matching the current cwd. + # Resolution: git remote org first ('useideem/authenticator' → #useideem), + # parent-dir basename second (local-only repos). Falls back to #general + # only when neither signal fires (non-git dir, no remote). The skill + # /join contract documents this as the default. + # + # The trade-off: two tabs in DIFFERENT projects on the same gh account + # land in different rooms (a #cambriantech tab can't see a #useideem + # tab). That's intentional — project work shouldn't mix with unrelated + # project chatter. Cross-project agents who need a shared lobby: + # `AIRC_NO_AUTO_ROOM=1 airc join` or `airc join --room general`. + # + # Two tabs in the SAME project converge automatically: both useideem + # tabs auto-scope to #useideem, both find each other. That's the case + # this default optimizes for. + # + # History: this was rolled back in PR #104 over the cross-project + # concern, then re-enabled here after dogfooding showed the converse + # bug (two same-project tabs both defaulting to #general and never + # converging on the project room) was the more painful failure mode. + if [ "$use_room" = "1" ] && [ "$room_explicit" = "0" ] \ + && [ "${AIRC_NO_AUTO_ROOM:-0}" != "1" ]; then + # Saved room_name (#130): the one piece of cross-restart state worth + # trusting. If a prior connect landed us in #foo, the next bare + # `airc connect` should target #foo too — not the auto-scope or the + # "general" fallback. This replaces the resume code's room-tracking + # with a single read of the saved file. Cached host_target is still + # NOT trusted (discovery re-derives that from the gist). + local _saved_room="" + [ -f "$AIRC_WRITE_DIR/room_name" ] && _saved_room=$(cat "$AIRC_WRITE_DIR/room_name" 2>/dev/null) + if [ -n "$_saved_room" ]; then + room_name="$_saved_room" + echo " Resuming saved room: #${room_name} (override with --room or 'airc part' first)" + else + local _inferred + _inferred=$(infer_default_room 2>/dev/null || true) + if [ -n "$_inferred" ]; then + room_name="${_inferred%|*}" + local _source="${_inferred#*|}" + echo " Auto-scoped: #${room_name} (from git ${_source}; override with --room or AIRC_NO_AUTO_ROOM=1)" + fi + fi + fi + + local target="${1:-}" + local reminder_interval="${AIRC_REMINDER:-${2:-300}}" # env > positional > 5min default + + # ── Notification-sink liveness ───────────────────────────────────── + # `airc connect` is only useful when a CONSUMER is reading our stdout — + # that's how inbound peer messages reach the AI agent or human. The + # canonical launcher is Claude Code's Monitor (persistent=true, command= + # "airc connect ...") which streams every stdout line as a notification. + # + # Failure mode this catches: someone runs `airc connect ` via a + # one-shot Bash tool / nohup / background `&` / detached shell. The + # python formatter + ssh tail get spawned, the pairing succeeds, the + # local messages.jsonl fills correctly — but stdout has no reader (the + # bash that exec'd us already exited and closed the pipe), so inbound + # NEVER reaches the agent's notification surface. Looks paired, is + # functionally deaf. Cost a session of debugging on 2026-04-23. + # + # Approach: install a SIGPIPE handler that exits LOUDLY (to stderr, + # which usually survives) the moment any write to stdout fails. Plus a + # periodic heartbeat line every 60s so SIGPIPE actually fires if there's + # no reader. With both: + # - Monitor reading: heartbeats succeed silently (Monitor surfaces + # them as benign notifications, but they're harmless) + # - One-shot bash / nohup / background: first heartbeat triggers + # SIGPIPE → airc exits with a clear error pointing at the right + # launch pattern → no silent deafness + # + # Opt out: AIRC_BACKGROUND_OK=1 disables the heartbeat for legitimate + # background launches (systemd unit + dedicated tail consumer, tests). + trap ' + { + echo "" + echo "❌ airc connect: stdout pipe closed — no notification consumer." + echo "" + echo " Inbound peer messages would have been silently lost. Most" + echo " common cause: airc was launched as a one-shot bash exec," + echo " nohup, background \"&\", or detached shell. The pairing" + echo " succeeds and messages.jsonl fills, but the AI agent never" + echo " sees inbound notifications. That is the worst kind of" + echo " silent failure — looks fine, is broken." + echo "" + echo " Right launchers:" + echo " • Claude Code skill: /airc:connect " + echo " • Monitor tool: Monitor(persistent=true, command=\"airc connect \")" + echo " • Interactive shell: just type \`airc connect \` at a TTY" + echo "" + echo " Bypass for legitimate background use (systemd + log tail," + echo " tests): export AIRC_BACKGROUND_OK=1" + echo "" + } >&2 + exit 3 + ' PIPE + # Heartbeat to stdout for SIGPIPE-pipe-death detection. OFF BY DEFAULT + # as of 2026-04-24 — at 60s it was filling Claude Code chat history + # with a notification per minute per peer, drowning real peer events. + # Joel: "I'd rather only see the messages." + # + # Real peer traffic still triggers SIGPIPE on pipe death, so we lose + # detection only when the channel is genuinely silent for a long time. + # That tradeoff is worth it for the cleaner Monitor surface. + # + # Set AIRC_HEARTBEAT_SEC= to opt back in (tests, diagnostic + # sessions, one-shot-bash launchers that need the safety net). 0 or + # unset = no heartbeat. + if [ -z "${AIRC_BACKGROUND_OK:-}" ] && [ -n "${AIRC_HEARTBEAT_SEC:-}" ] && [ "$AIRC_HEARTBEAT_SEC" -gt 0 ] 2>/dev/null; then + ( + while sleep "$AIRC_HEARTBEAT_SEC"; do + echo " [airc heartbeat $(date -u +%H:%M:%SZ)]" + done + ) & + fi + + # Auto-teardown any stale airc process in this scope before starting fresh. + # Previously users had to run `airc teardown` manually before `airc connect` + # if a prior monitor was still around — easy to forget, often resulted in + # duplicate monitors or port collisions. Now a single `airc connect` or + # `airc resume` does the right thing. + local stale_pidfile="$AIRC_WRITE_DIR/airc.pid" + if [ -f "$stale_pidfile" ]; then + local stale_pids; stale_pids=$(cat "$stale_pidfile" 2>/dev/null | tr '\n' ' ') + local all_stale="$stale_pids" + for p in $stale_pids; do + # `|| true` — pgrep returns 1 when the parent PID is already dead (no + # children to find). With `set -euo pipefail` at the top of the script, + # that would abort this block *before* reaching the rm on line 442 that + # self-heals the stale pidfile. Result: joiner wedged forever after a + # parent crash / laptop sleep until someone manually rm'd the pidfile. + all_stale="$all_stale $(proc_children "$p" | tr '\n' ' ' || true)" + done + # Quiet kill — don't warn unless there was actually a live process. + if [ -n "$all_stale" ]; then + local any_alive=0 + for p in $all_stale; do kill -0 "$p" 2>/dev/null && any_alive=1; done + if [ "$any_alive" = "1" ]; then + kill -9 $all_stale 2>/dev/null || true + sleep 1 + fi + fi + rm -f "$stale_pidfile" + fi + + # No resume code path. (#130, 2026-04-26.) + # + # The gist is the source of truth for who's hosting which room and at + # what address. Local state we trust across restarts is identity (ssh + # key, signing key, name, identity blob) and peer records. We do NOT + # trust cached host_target / host_port / host_ssh_pub — those describe + # external substrate that can change behind us (host crashed, port + # auto-bumped, gist regenerated, ssh key rotated, machine restarted). + # + # Every `airc connect` runs discovery. Cost: one `gh gist list` + # (~200ms). Benefit: every "saved pairing diverged from gist" failure + # mode is structurally impossible — there's no saved pairing to + # diverge. Discovery + JOIN MODE below already handle stale-heartbeat + # takeover, TCP-unreachable self-heal, race-loser detection, multi- + # address pick, Tailscale-down advisory, and host_target overwrite on + # successful pair. Removing the parallel resume implementation deletes + # ~250 lines and an entire bug class: + # - "(SSH verified)" printed against an unreachable cached host + # - silent-success on stale pair after machine restart + # - --room flag silently ignored if it differed from saved pairing + # - 404 self-heal gated on a separate code path with its own bugs + # Cached CONFIG fields like host_target are still WRITTEN by JOIN MODE + # for monitor() to read at runtime ("am I joiner or host?"), but never + # READ at connect-time to skip discovery. + + # ── Zero-arg discovery: rooms first, then legacy invites (#38, #39) + # If we got here with no target AND no saved config, the user just ran + # `airc connect` cold. The IRC substrate (#39) makes this simple: + # + # 1. Look for the named room gist (default `airc room: general`). + # Found → auto-join it. + # 2. Fall back to legacy `airc invite for ...` single-pair gists. + # Found 1 → auto-join. Found N → list + exit. + # 3. Found nothing → become the host and create the room (the + # auto-#general default — first agent in is the channel host). + # + # Skipped if `gh` isn't available (degraded → host invite-only) or + # AIRC_NO_DISCOVERY=1 (explicit opt-out). With `--no-general` the room + # path is skipped and we go straight to single-pair invite host mode. + # + # Discovery gate: run only when the user didn't pass an explicit target + # and gh is available. We deliberately do NOT short-circuit when CONFIG + # has a saved host_target — that's exactly the cached-pairing path the + # resume-deletion (#130) is killing. Always discover, always consult + # the gist; the gist is the truth. + local _did_room_discovery=0 + if [ -z "$target" ] && \ + [ "${AIRC_NO_DISCOVERY:-0}" != "1" ] && \ + command -v gh >/dev/null 2>&1; then + + # ── Room discovery (the substrate path) ────────────────────── + # Match exact room name to avoid `airc room: general-test` colliding + # with `airc room: general`. Pick the most-recent if duplicates exist + # (stale hosts get re-elected on next reconnect when SSH fails). + if [ "$use_room" = "1" ]; then + _did_room_discovery=1 + local _room_filter="airc room: ${room_name}\$" + local _room_candidates; _room_candidates=$(gh gist list --limit 50 2>/dev/null \ + | awk -F'\t' -v re="$_room_filter" '$2 ~ re { print $1 "\t" $2 "\t" $4 }') + local _room_count; _room_count=$(printf '%s' "$_room_candidates" | grep -c . || true) + if [ "$_room_count" -ge 1 ]; then + # Most recent wins (gh gist list is reverse-chrono by update). + local _picked_id; _picked_id=$(printf '%s' "$_room_candidates" | head -1 | awk -F'\t' '{print $1}') + echo " Found #${room_name} on your gh account → joining ($_picked_id)" + target="$_picked_id" + # fall through to gist resolver below — kind:room → invite handshake + else + echo " No #${room_name} found on your gh account → becoming the host." + # Race against a concurrent host attempt is handled POST-publish + # (see "race-loser detection" near host_gist_id write below). + # Pre-publish recheck doesn't help — neither tab's gist is + # globally visible yet at this point. + fi + fi + + # ── Legacy single-pair invite discovery (only if no room flow) ── + # Preserves the #38 behavior for users running with --no-general + # OR for room-mode users whose room discovery missed (we already + # set target in that case, so this block won't fire). + if [ -z "$target" ] && [ "$use_room" = "0" ]; then + local _candidates; _candidates=$(gh gist list --limit 30 2>/dev/null \ + | awk -F'\t' '/airc invite for/ { print $1 "\t" $2 }') + local _count; _count=$(printf '%s' "$_candidates" | grep -c . || true) + if [ "$_count" = "1" ]; then + local _picked_id; _picked_id=$(printf '%s' "$_candidates" | awk -F'\t' '{print $1}') + local _picked_desc; _picked_desc=$(printf '%s' "$_candidates" | awk -F'\t' '{print $2}') + echo " Found 1 open airc invite on your gh account: $_picked_desc" + echo " → auto-joining $_picked_id" + target="$_picked_id" + elif [ "$_count" -ge 2 ]; then + echo "" + echo " $_count open airc invite(s) on your gh account:" + echo "" + printf '%s\n' "$_candidates" | while IFS=$'\t' read -r _id _desc; do + local _hh; _hh=$(humanhash "$_id" 2>/dev/null) + printf ' %s %s\n mnemonic: %s\n' "$_id" "$_desc" "$_hh" + done + echo "" + echo " Pick one to join: airc connect " + echo " Host a new mesh: AIRC_NO_DISCOVERY=1 airc connect --no-general" + exit 0 + fi + fi + fi + + # ── Mnemonic resolver (humanhash → gist id, same gh account) ───── + # Joel's UX target: a friend (or your own other tab) can type + # airc connect oregon-uncle-bravo-eleven + # instead of pasting a 32-char hex gist id. Humanhash is one-way + # (XOR-fold of the gist id bytes), so we can't reverse it directly — + # but we CAN walk gh's gist list, hash each id, and pick the match. + # + # Detection: target looks like a hyphen-separated 3+ word phrase of + # lowercase alphabetic tokens (matches the humanhash dictionary + # convention — no digits, no underscores). Example acceptable form: + # `oregon-uncle-bravo-eleven`. Reject `2f6a907224f4...` (it's a hex id), + # `gist:abc123` (handled below), inline invites with `@`, etc. + # + # Scope: same-gh-account only (we list OUR own gists). Cross-account + # (Friend on a different gh) requires the `user/mnemonic` form which + # is roadmap. For now the friend pastes the gist id directly when + # accounts differ. + if [ -n "$target" ] && echo "$target" | grep -qE '^[a-z]+(-[a-z]+){2,}$'; then + if ! command -v gh >/dev/null 2>&1; then + die "Mnemonic '$target' lookup needs the 'gh' CLI. Install gh + 'gh auth login', or use the gist id directly: airc connect " + fi + local _matched_gist_id="" + while IFS=$'\t' read -r _gid _; do + [ -z "$_gid" ] && continue + local _hh; _hh=$(humanhash "$_gid" 2>/dev/null) + if [ "$_hh" = "$target" ]; then + _matched_gist_id="$_gid" + break + fi + done < <(gh gist list --limit 50 2>/dev/null | awk -F'\t' '/airc room:|airc invite for/ { print $1 "\t" $2 }') + if [ -n "$_matched_gist_id" ]; then + echo " Resolved mnemonic '$target' → gist $_matched_gist_id" + target="$_matched_gist_id" + else + die "Mnemonic '$target' didn't match any airc gist on this gh account. If your friend's gist is on a different gh, paste the gist id directly: airc connect " + fi + fi + + # ── Gist transport (issue #37) ─────────────────────────────────── + # If the target doesn't look like an inline invite (no `@`), treat it + # as a gist ID and fetch the real invite content from there. Three + # accepted shapes: + # gist: — explicit, unambiguous + # — bare alphanumeric, auto-detected as a gist ID + # foo@bar@... — today's inline invite, untouched + # + # The whole point: an inline invite is ~200 chars of base64 that gets + # mangled by chat clients (line wraps, auto-linkification, smart + # quotes). A 7-char gist ID survives every transport. Host pushes the + # invite to a secret gist (see `airc connect --gist` below); receiver + # pastes just the ID. Also: gist works as a coordination layer for + # cross-tailnet pairing where the two peers don't share a VPN + # initially. + # + # Gist payload format: a versioned JSON envelope (see host-side push + # below for shape). Receiver parses `{ airc: 1, kind: "invite", invite: "..." }` + # and dispatches on `kind`. Today only `kind: "invite"` is recognized. + # Future kinds (cross-tailnet relay, bootstrap, webrtc-mesh) slot in + # by adding a case below — old peers reject the kind cleanly with a + # version-mismatch message instead of silently misinterpreting bytes. + # + # Backward compat: a gist that contains a raw invite string (no JSON + # envelope) still parses — we fall through to the raw-string branch + # if JSON parse fails. Lets pre-envelope gists keep working. + if [ -n "$target" ] && ! echo "$target" | grep -q '@'; then + local gist_id="${target#gist:}" + # Capture for self-heal in JOIN MODE: if the host in this gist turns + # out to be unreachable, JOIN MODE deletes the gist by this id + takes + # over as the new host of the same room. + _resolved_gist_id="$gist_id" + # Gist IDs are hex strings, typically 20-32 chars but accept any + # plausible length so future GH ID schemes don't break us. + if echo "$gist_id" | grep -qE '^[a-zA-Z0-9]{6,40}$'; then + echo " Resolving gist $gist_id ..." + local raw_content="" + # Each path's `raw_content=$(cmd | filter)` is protected with + # `|| true` so a non-zero exit on the upstream command does NOT + # abort the script via `set -euo pipefail`. Pre-fix: when gh + # rate-limited (HTTP 403), `gh api ...` exited non-zero, pipefail + # propagated it, set -e aborted the whole script BEFORE the next + # fallback ran. Net: rate-limit hit = total resolution failure + # with no diagnostic. Joel 2026-04-27: "this limit will kill + # people." Fix: per-path `|| true` makes each path advisory; the + # `[ -z "$raw_content" ]` gates control fallthrough explicitly. + # + # Prefer `gh api` over `gh gist view --raw` — the latter prepends + # the gist description as a header line ("airc room: general\n\n{...}") + # which breaks JSON parse downstream. `gh api` returns the file + # content cleanly. This bug bit hard during daemon-install dogfood: + # parser fell through to the @.*@ regex fallback which captured the + # malformed JSON `"invite": "..."` line (quotes and all), pair + # handshake failed on garbage host info, and self-heal didn't fire + # because resolved_room_name was never extracted via the jq path. + if command -v gh >/dev/null 2>&1 && command -v jq >/dev/null 2>&1; then + raw_content=$( (gh api "gists/$gist_id" 2>/dev/null \ + | jq -r '.files | to_entries[0].value.content // empty' 2>/dev/null) || true ) + fi + # Fallback path 1: gh without jq → degraded gh gist view --raw, with + # a description-strip in the consumer below. + if [ -z "$raw_content" ] && command -v gh >/dev/null 2>&1; then + raw_content=$(gh gist view "$gist_id" --raw 2>/dev/null || true) + fi + # Fallback path 2: git clone the gist's git remote. CRITICAL — this + # is the rate-limit-bypass path. The REST API has a tight gist + # sub-bucket (~60 reads/hr); a busy session blows through it + # quickly and EVERY `gh api gists/` and `gh gist view ` + # call HTTP 403's. Git transport at gist.github.com uses git HTTP + # over the same auth but on a separate quota — it keeps working + # when REST is throttled. The git-clone fallback adds ~1s on the + # slow path but unblocks discovery completely. + if [ -z "$raw_content" ] && command -v git >/dev/null 2>&1; then + local _gist_tmp; _gist_tmp=$(mktemp -d -t airc-gist-resolve.XXXXXX 2>/dev/null || echo "") + if [ -n "$_gist_tmp" ] && git clone --depth 1 --quiet "https://gist.github.com/$gist_id.git" "$_gist_tmp" 2>/dev/null; then + # Gists typically contain ONE file (airc envelopes always do). + # Take the first non-dotfile, non-.git entry. If a future gist + # shape ships multiple files we'll add an explicit airc-envelope + # filename convention; for now the single-file assumption is + # sound across every gist airc has ever published. + local _gist_file + _gist_file=$(find "$_gist_tmp" -maxdepth 1 -type f ! -name '.git*' 2>/dev/null | head -1 || true) + if [ -n "$_gist_file" ] && [ -f "$_gist_file" ]; then + raw_content=$(cat "$_gist_file" 2>/dev/null || true) + fi + fi + [ -n "$_gist_tmp" ] && rm -rf "$_gist_tmp" + fi + # Fallback path 3: anonymous curl + jq for environments without gh + # OR git. Last resort. + if [ -z "$raw_content" ] && command -v curl >/dev/null 2>&1 && command -v jq >/dev/null 2>&1; then + raw_content=$( (curl -fsSL "https://api.github.com/gists/$gist_id" 2>/dev/null \ + | jq -r '.files | to_entries[0].value.content // empty' 2>/dev/null) || true ) + fi + # Last-resort cleanup: if raw_content still has the description-header + # leak from a degraded gh-view path, strip lines before the first '{' + # (room/invite envelopes are JSON, always start with '{'). + if [ -n "$raw_content" ] && ! printf '%s' "$raw_content" | head -c 1 | grep -q '{'; then + raw_content=$(printf '%s' "$raw_content" | awk '/^\{/{flag=1} flag') + fi + if [ -z "$raw_content" ]; then + die "Failed to fetch gist '$gist_id'. Check the ID, network, and (if private) 'gh auth login'." + fi + + # Try parse as airc JSON envelope first. If it parses + has airc + # field, dispatch on `kind`. Otherwise, treat raw_content as the + # legacy raw-invite-string format (backward compat). + # _resolved_heartbeat_stale + _resolved_heartbeat_age are declared + # at function-scope above so the JOIN MODE check sees them on the + # inline-invite path too (where this gist block doesn't run). + local resolved="" + if command -v jq >/dev/null 2>&1; then + local airc_ver kind + airc_ver=$(printf '%s' "$raw_content" | jq -r '.airc // empty' 2>/dev/null) + kind=$(printf '%s' "$raw_content" | jq -r '.kind // empty' 2>/dev/null) + if [ -n "$airc_ver" ]; then + # Versioned envelope — dispatch on kind. + case "$kind" in + invite) + # Single-pair invite (legacy + --no-general flow). Gist is + # ephemeral; host deletes after pair. + resolved=$(printf '%s' "$raw_content" | jq -r '.invite // empty' 2>/dev/null \ + | head -1 | tr -d '\r\n ') + ;; + room) + # Persistent IRC-style channel (issue #39, the substrate). + # Same SSH-pair handshake as invite, but the gist persists + # so additional joiners can keep arriving. The room.invite + # field carries today's name@user@host:port#pubkey string. + resolved=$(printf '%s' "$raw_content" | jq -r '.invite // empty' 2>/dev/null \ + | head -1 | tr -d '\r\n ') + resolved_room_name=$(printf '%s' "$raw_content" | jq -r '.name // empty' 2>/dev/null) + # Multi-address: capture host.addresses[] + host.machine_id + # for the joiner's address-picker (peer_pick_address). Empty + # if the host published a pre-multi-address envelope; in + # that case JOIN MODE falls back to the parsed-from-invite + # host:port (legacy single-address path). + _resolved_addresses_json=$(printf '%s' "$raw_content" | jq -c '.host.addresses // empty' 2>/dev/null) + _resolved_host_machine_id=$(printf '%s' "$raw_content" | jq -r '.host.machine_id // empty' 2>/dev/null) + + # Heartbeat freshness check — the structural fix for + # orphan-gist class. Hosts update last_heartbeat every + # AIRC_HEARTBEAT_SEC (default 30s); if it's older than + # AIRC_HEARTBEAT_STALE (default 90s = 3 missed beats), + # the host is dead. We short-circuit the SSH attempt and + # take over directly — no minute-long timeout, no peer + # confusion about "is this thing on?". Pre-heartbeat + # gists (no field) are treated as fresh for backward + # compat; their hosts will get caught by the existing + # SSH-failure self-heal path at line ~1850. + local _hb_iso _hb_ts _now_ts _hb_stale_sec + _hb_iso=$(printf '%s' "$raw_content" | jq -r '.last_heartbeat // empty' 2>/dev/null) + _hb_stale_sec="${AIRC_HEARTBEAT_STALE:-90}" + if [ -n "$_hb_iso" ]; then + # Cross-platform ISO→epoch via the iso_to_epoch adapter. + # Pre-adapter this site had its own BSD/GNU date fallback + # chain (one of three duplicates that drifted indepen- + # dently — see commit history before the dedupe). + _hb_ts=$(iso_to_epoch "$_hb_iso") + if [ -n "$_hb_ts" ]; then + _now_ts=$(date -u +%s) + _resolved_heartbeat_age=$(( _now_ts - _hb_ts )) + if [ "$_resolved_heartbeat_age" -gt "$_hb_stale_sec" ]; then + _resolved_heartbeat_stale=1 + fi + fi + fi + ;; + "") + die "Gist has airc envelope (v$airc_ver) but no 'kind' field — malformed." + ;; + *) + # Unknown kind — fail loud. Old peers should reject + # rather than silently misinterpret a future kind. + die "Gist uses unknown kind '$kind' (airc v$airc_ver). This receiver only supports 'invite' and 'room'. Update airc: 'airc update'." + ;; + esac + fi + fi + if [ -z "$resolved" ]; then + # Legacy raw-string format OR jq missing — take the first + # non-empty line that looks like an invite. + resolved=$(printf '%s' "$raw_content" | grep -E '@.*@' | head -1 | tr -d '\r\n ') + # If the matched line is from a JSON envelope (e.g. + # `"invite": "name@user@host:port#..."`), the grep grabs the + # whole quoted line including the JSON-key prefix. Strip + # leading non-name characters: anything before the first letter + # is JSON syntax (quotes, colons, whitespace). Found by + # continuum-b69f Win→Mac e2e 2026-04-27 — bash on Git Bash + # ships without jq, falls through to this path, captured + # `"invite":"authenticator-fd63@...` as the invite, then the + # downstream @-split made the displayed peer name include + # the JSON-key fragment AND prevented resolved_room_name from + # ever being set (no JSON parse, no .name extraction). Strip + # everything up to the first letter or hyphen, then re-validate. + resolved=$(printf '%s' "$resolved" | sed -E 's/^[^a-zA-Z]+//') + # Fallback room-name extraction when jq is missing: grep the + # raw_content for `"name": "..."` and capture the value. Same + # JSON envelope shape as the jq path; sed-only so it works on + # bare-bones environments. Empty if not present (legacy gist). + if [ -z "$resolved_room_name" ]; then + resolved_room_name=$(printf '%s' "$raw_content" \ + | grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"]+"' \ + | head -1 \ + | sed -E 's/^"name"[[:space:]]*:[[:space:]]*"([^"]+)"$/\1/') + fi + fi + if [ -z "$resolved" ] || ! echo "$resolved" | grep -q '@'; then + die "Failed to resolve gist '$gist_id' to a valid invite (got: $(printf '%s' "$raw_content" | head -c 80)...)" + fi + echo " ✓ Resolved invite from gist." + target="$resolved" + fi + fi + + if [ -n "$target" ] && echo "$target" | grep -q '@'; then + # ── JOIN MODE ────────────────────────────────────────────────── + + # Stale-heartbeat fast-path takeover. If the gist we resolved had a + # last_heartbeat older than AIRC_HEARTBEAT_STALE (parsed above), the + # host is dead. Skip the SSH attempt entirely — no minute-long TCP + # timeout, no peer wondering "is this thing on" — go straight to + # take-over. Same operations as the SSH-failure self-heal at the + # bottom of JOIN MODE (delete stale gist, re-exec as host with + # AIRC_NO_DISCOVERY=1) but triggered from positive evidence (stale + # presence signal) rather than negative evidence (TCP timeout). + # + # Backward compat: pre-heartbeat gists have no last_heartbeat field, + # _resolved_heartbeat_stale stays 0, this block is a no-op, and the + # SSH-failure self-heal still catches the dead host (slower, but + # correct). + if [ "$_resolved_heartbeat_stale" = "1" ] && [ -n "$resolved_room_name" ] \ + && [ -n "$_resolved_gist_id" ] && command -v gh >/dev/null 2>&1; then + echo "" + echo " ⚠ Host of #${resolved_room_name} is stale (last heartbeat ${_resolved_heartbeat_age}s ago) — taking over..." + echo " (prior host's gist: $_resolved_gist_id)" + + # Same race-loser detection as the SSH-failure self-heal path + # below. Two tabs concurrently deciding "host is stale" both + # delete + publish, end up with split-brain — caught only by + # running two tabs together. + _self_heal_stale_host "$_resolved_gist_id" "$resolved_room_name" + fi + + # Parse name@user@host[:port]#pubkey + local host_ssh_pubkey_b64="" + if echo "$target" | grep -q '#'; then + host_ssh_pubkey_b64="${target##*#}" + target="${target%%#*}" + fi + + local peer_name ssh_target peer_port="7547" + peer_name="${target%%@*}" + ssh_target="${target#*@}" + # Extract :port if present at the end of the host part + if echo "$ssh_target" | grep -qE ':[0-9]+$'; then + peer_port="${ssh_target##*:}" + ssh_target="${ssh_target%:*}" + fi + + [ -z "$peer_name" ] || [ -z "$ssh_target" ] && die "Format: airc connect name@user@host" + + # Multi-address override: if the gist envelope carried host.addresses[] + # and host.machine_id, use peer_pick_address to choose the cheapest + # reachable scope (same-machine localhost > same-LAN > tailscale). + # This is what makes Tailscale truly optional — same-machine and + # same-LAN peers connect via 127.0.0.1 / LAN IP regardless of the + # invite string's host:port (which historically advertised one IP). + if [ -n "$_resolved_addresses_json" ] && [ "$_resolved_addresses_json" != "null" ]; then + local _picked; _picked=$(peer_pick_address "$_resolved_addresses_json" "$_resolved_host_machine_id") + if [ -n "$_picked" ]; then + local _picked_addr="${_picked%|*}" + local _picked_port="${_picked#*|}" + # Reconstruct ssh_target with the user@addr form. Original + # ssh_target was user@invite-string-host; preserve the user. + local _ssh_user="${ssh_target%@*}" + if [ "$_ssh_user" = "$ssh_target" ]; then _ssh_user=""; fi + ssh_target="${_ssh_user:+${_ssh_user}@}${_picked_addr}" + peer_port="$_picked_port" + echo " ✓ Multi-address pick: ${_picked_addr}:${_picked_port} (from host.addresses)" + fi + fi + + local my_name + my_name=$(resolve_name) + init_identity "$my_name" + + # Merge into existing config.json instead of clobbering — preserves + # the `identity` block (issue #34) across re-pairs so a teardown + + # rejoin keeps pronouns/role/bio/status without requiring users to + # re-run airc identity set every time. + set_config_val name "$my_name" + set_config_val host "$(get_host)" + set_config_val host_target "$ssh_target" + set_config_val created "$(timestamp)" + + # Remember which room we joined (issue #39). Lets `airc rooms` and + # status/diagnostics report channel context, and gives the joiner + # something to hand to a friend ("airc connect "). We don't + # need the gist_id for cmd_part on joiner side — only the host owns + # the gist lifecycle — but we save the room name for display. + if [ -n "$resolved_room_name" ]; then + echo "$resolved_room_name" > "$AIRC_WRITE_DIR/room_name" + echo " Joined #${resolved_room_name}" + fi + + # Exchange keys with host via TCP (port 7547) — public keys only + # Pre-authorize host's pubkey if in join string + if [ -n "$host_ssh_pubkey_b64" ]; then + local host_ssh_pubkey + host_ssh_pubkey=$(echo "$host_ssh_pubkey_b64" | base64 -d 2>/dev/null || echo "$host_ssh_pubkey_b64" | base64 -D 2>/dev/null || true) + if [ -n "$host_ssh_pubkey" ]; then + mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh" + grep -qF "$host_ssh_pubkey" "$HOME/.ssh/authorized_keys" 2>/dev/null || { + echo "$host_ssh_pubkey" >> "$HOME/.ssh/authorized_keys" + chmod 600 "$HOME/.ssh/authorized_keys" + } + fi + fi + + # Exchange keys with host via TCP + local peer_host_only="${ssh_target##*@}" + + # Tailscale-down pre-flight on fresh-pair / gist-discovery paths. + # Resume path (line ~1241) already calls advise_tailscale_if_down, but + # that gate doesn't cover (a) cold-start `airc join ` from a + # fresh scope or (b) the gist-discovery resolution that lands here + # with a tailnet host_target. Without this check, a logged-out + # Tailscale produced a silent unreachable-host + self-heal cascade + # (issue #78, Memento's case 2026-04-25). Same call site shape as the + # resume path: detect-and-instruct, do not auto-tailscale-up. + if ! advise_tailscale_if_down "$peer_host_only"; then + die "Re-run airc join after starting Tailscale." + fi + + echo " Connecting to $peer_host_only:$peer_port..." + local my_ssh_pub my_sign_pub + my_ssh_pub=$(cat "$IDENTITY_DIR/ssh_key.pub" 2>/dev/null) + my_sign_pub=$(cat "$IDENTITY_DIR/public.pem" 2>/dev/null) + + # Read own identity blob to send in handshake (issue #34 v2 — peers + # cache each other's identity at pair-time so airc whois works fast). + local my_identity_json; my_identity_json=$(CONFIG="$CONFIG" "$AIRC_PYTHON" -c ' +import json, os +try: + c = json.load(open(os.environ["CONFIG"])) + print(json.dumps(c.get("identity", {}))) +except Exception: + print("{}") +' 2>/dev/null) + [ -z "$my_identity_json" ] && my_identity_json="{}" + + local response + local _pair_ok=1 + # Migrated to airc_core.handshake send with proper --flags (not env + # vars). MSYS path-translation on Git Bash silently mangles env-var + # values that look like Unix paths (/Users/... → C:/Program + # Files/Git/Users/...) when they cross to a Windows-binary subprocess. + # argparse --flags are per-arg-predictable (callers can //-prefix + # or set MSYS2_ARG_CONV_EXCL targeted-ly). Continuum-b69f 2026-04-27 + # traced the env-var path-mangling class. + response=$("$AIRC_PYTHON" -m airc_core.handshake send "$peer_host_only" "$peer_port" \ + --my-name "$my_name" \ + --my-host "$(whoami)@$(get_host)" \ + --my-ssh-pub "$my_ssh_pub" \ + --my-sign-pub "$my_sign_pub" \ + --my-airc-home "$AIRC_WRITE_DIR" \ + --my-identity-json "$my_identity_json" 2>&1) || _pair_ok=0 + + if [ "$_pair_ok" = "0" ]; then + # ── Self-heal: stale-host takeover ───────────────────────────── + # If discovery handed us a kind:room gist AND the host listed in it + # is unreachable, the most likely cause is the prior host went away + # (laptop sleep, crash, network blip). Per Joel: "no claude left + # behind" — first agent back in becomes the new host of #general. + # + # Mechanics: + # 1. Delete the stale gist (we have gh perms because it's on our + # own gh account, same auth as the discovery that found it). + # 2. Tear down the half-written CONFIG that pointed at the dead + # host (else resume on next start would loop into the same + # stale pair). + # 3. exec into a fresh airc connect in HOST mode for the same + # room name. AIRC_NO_DISCOVERY=1 so we don't re-find the gist + # we just deleted (gh propagation lag). + # + # Only fires when ALL three are true: + # - We resolved a kind:room gist (resolved_room_name + _resolved_gist_id non-empty) + # - gh CLI is available (to delete the stale gist) + # - Pair handshake failed (TCP unreachable / timeout) + # If any condition isn't met, fall through to the original die(). + if [ -n "$resolved_room_name" ] && [ -n "$_resolved_gist_id" ] \ + && command -v gh >/dev/null 2>&1; then + echo "" + echo " ⚠ Host of #${resolved_room_name} unreachable — self-healing as new host..." + echo " (prior host's gist: $_resolved_gist_id)" + + # Jittered backoff before takeover. Without this, two tabs that + # hit the same dead gist concurrently both delete + publish + # within the same gh API window and you end up with two + # competing gists for the same room name (split-brain race — + # caught only by running two tabs against a stale gist + # simultaneously, NOT by the integration test). + _self_heal_stale_host "$_resolved_gist_id" "$resolved_room_name" + fi + # Either not a room flow, or no gh, or no resolved_room_name → original die. + # Surface the captured pair-handshake stderr (continuum-b69f 2026-04-27: + # Windows users got "Can't reach ..." with no clue the real cause was + # a Microsoft Store python3.exe stub returning exit 49). Per the + # global "never swallow errors" rule — evidence is for the debugger, + # not the trash. The handshake captured stderr+stdout via 2>&1 into + # $response just above, so we have the real error in hand. + if [ -n "${response:-}" ]; then + echo "" >&2 + echo " Pair handshake output (captured stderr/stdout):" >&2 + printf '%s\n' "$response" | sed 's/^/ /' >&2 + echo "" >&2 + fi + die "Can't reach $peer_host_only:$peer_port. Is the host running 'airc connect'?" + fi + + # Authorize host's SSH pubkey (for the joiner->host auth direction). + # NOTE: the handshake's ssh_pub is airc's USER identity key — not the + # sshd server host key used for known_hosts verification. Proper + # host-key handling relies on ssh's own accept-new mode, plus a + # targeted ssh-keygen -R when a PRIOR real-sshd host key in known_hosts + # is known stale (e.g. the server rotated sshd host keys). + local host_ssh_pub + host_ssh_pub=$(printf '%s' "$response" | "$AIRC_PYTHON" -m airc_core.handshake get_field ssh_pub "" 2>/dev/null || true) + if [ -n "$host_ssh_pub" ]; then + mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh" + grep -qF "$host_ssh_pub" "$HOME/.ssh/authorized_keys" 2>/dev/null || { + echo "$host_ssh_pub" >> "$HOME/.ssh/authorized_keys" + chmod 600 "$HOME/.ssh/authorized_keys" + } + fi + # Clear any stale sshd host key for this address before first SSH. + # Cheap insurance against "REMOTE HOST IDENTIFICATION HAS CHANGED" + # when the target was a different sshd host some time ago. + local host_addr="${ssh_target##*@}" + touch "$HOME/.ssh/known_hosts" 2>/dev/null && chmod 600 "$HOME/.ssh/known_hosts" 2>/dev/null + ssh-keygen -R "$host_addr" -f "$HOME/.ssh/known_hosts" >/dev/null 2>&1 || true + + # Save host as a peer (with their airc_home so wire paths are correct). + # Drop any existing peer records with the same host first — stale names + # from a prior rename chain must not linger alongside the current one. + local host_airc_home + host_airc_home=$(printf '%s' "$response" | "$AIRC_PYTHON" -m airc_core.handshake get_field airc_home "" 2>/dev/null || true) + "$AIRC_PYTHON" -c " +import json, os +peers_dir = os.path.expanduser('$PEERS_DIR') +os.makedirs(peers_dir, exist_ok=True) +peer_name = '$peer_name' +ssh_target = '$ssh_target' +if os.path.isdir(peers_dir): + for entry in os.listdir(peers_dir): + if not entry.endswith('.json'): continue + if entry == peer_name + '.json': continue + try: + d = json.load(open(os.path.join(peers_dir, entry))) + except Exception: + continue + if d.get('host') == ssh_target: + for ext in ('.json', '.pub'): + p = os.path.join(peers_dir, entry[:-5] + ext) + if os.path.isfile(p): + try: os.remove(p) + except Exception: pass +record = { + 'name': peer_name, + 'host': ssh_target, + 'airc_home': '$host_airc_home', + 'paired': '$(timestamp)' +} +with open(os.path.join(peers_dir, peer_name + '.json'), 'w') as f: + json.dump(record, f, indent=2) +" 2>/dev/null || true + + # If we resolved this pair via gist discovery (vs. inline-invite), + # persist the gist id so resume-time freshness checks can detect a + # gist-deletion / replacement before re-pairing against a stale host + # (issue #83). Cleared by cmd_part on graceful leave. + if [ -n "$_resolved_gist_id" ]; then + echo "$_resolved_gist_id" > "$AIRC_WRITE_DIR/room_gist_id" + fi + + # Persist host details in own config so `airc invite` can reconstruct + # the join string for onward sharing without a fresh handshake. Also + # cache the host's identity blob from the handshake response so + # `airc whois ` works locally (issue #34 v2). + local host_identity_json; host_identity_json=$(printf '%s' "$response" | "$AIRC_PYTHON" -m airc_core.handshake get_field identity "{}" 2>/dev/null || echo "{}") + [ -z "$host_identity_json" ] && host_identity_json="{}" + # Pass values as env vars instead of bash-substituted into the + # python heredoc body. continuum-b69f's PR #164 retest 2026-04-27 + # found host_airc_home / host_name / host_port / host_ssh_pub / + # host_identity all silently unwritten on Win→Mac join: if ANY of + # the bash substitutions broke the python source (newline in + # host_ssh_pub, weird char in host_airc_home, peer_port empty/ + # non-numeric, etc.), the whole heredoc errored out via + # `2>/dev/null || true` and zero fields landed in config. Switch + # to env-var pass — python reads from os.environ; bash never + # touches the python source. Also emit stderr to surface failures + # for the future debugger (not /dev/null). + "$AIRC_PYTHON" -m airc_core.config set_host_block \ + --config "$CONFIG" \ + --host-airc-home "$host_airc_home" \ + --host-name "$peer_name" \ + --host-port "${peer_port:-7547}" \ + --host-ssh-pub "$host_ssh_pub" \ + --host-identity-json "$host_identity_json" \ + || echo " ⚠ config write failed (host_airc_home/host_name/host_port/host_ssh_pub may be unset). airc may still work if subsequent retries refresh." >&2 + + # Pick up reminder setting from host + local host_reminder + host_reminder=$(printf '%s' "$response" | "$AIRC_PYTHON" -m airc_core.handshake get_field reminder 300 2>/dev/null || echo "300") + if [ "$host_reminder" -gt 0 ] 2>/dev/null; then + echo "$host_reminder" > "$AIRC_WRITE_DIR/reminder" + date +%s > "$AIRC_WRITE_DIR/last_sent" + fi + + # Verify SSH works + if relay_ssh "$ssh_target" "echo ok" 2>/dev/null; then + echo " Connected to '$peer_name' (SSH verified, reminder: ${host_reminder}s)" + else + echo " Connected to '$peer_name' (SSH not verified — messages may need retry)" + fi + + # Write PID file so `airc teardown` can find us later. + echo $$ > "$AIRC_WRITE_DIR/airc.pid" + # Clean exit on tab close / signal: reap the ssh tail subprocess so the + # remote doesn't see an orphaned session and the port doesn't linger. + trap ' + rm -f "$AIRC_WRITE_DIR/airc.pid" 2>/dev/null + for p in $(proc_children $$); do kill $p 2>/dev/null; done + ' EXIT INT TERM + + spawn_general_sidecar_if_wanted + echo " Monitoring for messages..." + monitor + + else + # ── HOST MODE ───────────────────────────────────────────────── + local name="${target:-}" + [ -z "$name" ] && name=$(resolve_name) + + init_identity "$name" + + # Merge into existing config.json (preserve identity across re-spawns + # — same rationale as the joiner branch above). + set_config_val name "$name" + set_config_val host "$(get_host)" + set_config_val created "$(timestamp)" + # Host mode: clear leftover host_* from any prior joiner run in + # this scope so we don't mis-read ourselves as a joiner. + unset_config_keys host_target host_name host_port host_airc_home host_ssh_pub host_identity + + local host; host=$(get_host) + local user; user=$(whoami) + local ssh_pubkey_b64; ssh_pubkey_b64=$(base64 < "$IDENTITY_DIR/ssh_key.pub" | tr -d '\n') + # Port selection: start at AIRC_PORT (or 7547) and walk up if already + # taken. Happens on machines with stale/zombie airc hosts or multiple + # concurrent scopes. Users don't need to pick a port manually. + local host_port="${AIRC_PORT:-7547}" + local original_port="$host_port" + local tried=0 + while [ -n "$(port_listeners "$host_port")" ]; do + host_port=$((host_port + 1)) + tried=$((tried + 1)) + if [ "$tried" -ge 20 ]; then + die "No free port in range ${original_port}-$((original_port + 20)). Close other airc hosts or set AIRC_PORT explicitly." + fi + done + # Only include :port in the join string when non-default, keeping strings compact. + local port_suffix="" + [ "$host_port" != "7547" ] && port_suffix=":$host_port" + + # Persist the actual listen port so `airc invite` can reconstruct the + # join string later without needing to parse the startup banner. + echo "$host_port" > "$AIRC_WRITE_DIR/host_port" + + # Set reminder interval from host + if [ "$reminder_interval" -gt 0 ] 2>/dev/null; then + echo "$reminder_interval" > "$AIRC_WRITE_DIR/reminder" + date +%s > "$AIRC_WRITE_DIR/last_sent" + fi + + echo "" + [ "$host_port" != "$original_port" ] && echo " Port $original_port was taken; using $host_port." + echo " Hosting as '$name' (reminder: ${reminder_interval}s)" + echo "" + local _invite_long="${name}@${user}@${host}${port_suffix}#${ssh_pubkey_b64}" + # When --gist is requested AND succeeds, the short gist ID becomes + # the primary handoff and the long invite is demoted to a footnote + # ("if the gist channel fails, fall back to this"). When --gist is + # NOT requested, we print the long invite as the primary as today. + local _printed_long=0 + if [ "$use_gist" != "1" ]; then + echo " On the other machine:" + echo " airc connect $_invite_long" + _printed_long=1 + fi + + # Record room name + print substrate banner BEFORE the gist push + # attempt so cmd_part / status / diagnostics know the channel name + # even when the gist push is skipped (--no-gist) or fails (gh + # missing/unauthed). The gist_id is recorded only when an actual + # gist is created (see below). The "Hosting #" banner is the + # signal both humans and the integration test use to confirm + # substrate framing took effect — emit unconditionally for room mode. + if [ "$use_room" = "1" ]; then + echo "$room_name" > "$AIRC_WRITE_DIR/room_name" + echo " Hosting #${room_name} — no existing room on your gh account, fresh start." + echo " Other agents on your gh account who run 'airc join' will auto-join." + fi + + # ── Gist transport (--gist flag, issue #37) ──────────────────── + # Push the long invite to a secret gist + print the short ID. The + # short ID is robust across chat clients (sms, slack, paste-buffer + # cross-machine) where the 200-char base64 invite gets line-wrapped + # or auto-formatted into uselessness. It's also a coordination + # layer for cross-tailnet pairing where the two peers don't share + # a VPN initially — the gist is the shared rendezvous point. + # + # Payload is a versioned JSON envelope, NOT a raw invite string. + # Same shape as image file headers: magic + version + typed body. + # `airc: 1` marks it as ours; `kind` is the dispatch field for + # future connection kinds (cross-tailnet relay, bootstrap-tailnet, + # webrtc-mesh, etc.). Receiver reads kind → calls the matching + # handler; new kinds added without breaking old peers because the + # version field gates compat. + if [ "$use_gist" = "1" ]; then + if ! command -v gh >/dev/null 2>&1; then + echo "" + echo " ⚠ --gist requested but 'gh' CLI not installed." + echo " Install: https://cli.github.com (or: brew install gh)" + echo " Skipping gist push; long invite above is the only handoff." + else + local _gist_tmp; _gist_tmp=$(mktemp -t airc-invite.XXXXXX) + local _now; _now=$(date -u +%Y-%m-%dT%H:%M:%SZ) + local _gist_kind="invite" + local _gist_desc="airc invite for $name (delete after pair)" + local _gist_payload="" + + if [ "$use_room" = "1" ]; then + # Room mode (#39 substrate): persistent gist, not deleted after + # pair. Lets additional joiners discover + auto-join the same + # channel. Same SSH-pair handshake under the hood — only the + # gist lifecycle + envelope kind differ. + _gist_kind="room" + _gist_desc="airc room: ${room_name}" + # last_heartbeat: host's presence signal, refreshed every + # AIRC_HEARTBEAT_SEC (default 30s) by the bg loop spawned + # below. Joiners detect stale → take over deterministically. + # + # machine_id + host.addresses[]: multi-address redundancy. + # Same machine, two tabs → joiner sees machine_id match, + # uses 127.0.0.1 regardless of network state. Same LAN → + # joiner picks the LAN entry. Tailscale → joiner picks + # tailscale ONLY when nothing closer works AND the host is + # actually signed in (host_address_set drops tailscale from + # the list when not authed). Tailscale becomes truly + # optional: if it's down or you're logged out, the gist's + # localhost+LAN entries still let same-machine and + # same-LAN peers connect. + local _addrs_json; _addrs_json=$(host_addresses_json "$host_port") + local _machine_id; _machine_id=$(host_machine_id) + _gist_payload=$(cat < "$_gist_tmp" + # Secret gist: URL-only-discoverable, not searchable. The gist + # ID itself is the secret. Same threat model as the long invite: + # whoever holds the string can pair. Room gists persist; invite + # gists should be deleted by the host after the first joiner. + local _gist_url; _gist_url=$(gh gist create -d "$_gist_desc" "$_gist_tmp" 2>/dev/null | tail -1) + rm -f "$_gist_tmp" + if [ -n "$_gist_url" ]; then + local _gist_id="${_gist_url##*/}" + local _hh; _hh=$(humanhash "$_gist_id" 2>/dev/null) + # Persist the gist id locally so cmd_part can delete the room + # gist on graceful host exit (room mode only — invite mode is + # one-shot and the joiner-pair flow already prompts cleanup). + if [ "$_gist_kind" = "room" ]; then + echo "$_gist_id" > "$AIRC_WRITE_DIR/room_gist_id" + echo "$room_name" > "$AIRC_WRITE_DIR/room_name" + + # Heartbeat loop: keep last_heartbeat fresh in the gist so + # joiners can deterministically detect a dead host. Without + # this, a host that dies ungracefully (sleep, kill -9, OOM, + # crashed bash) leaves a gist pointing at a corpse forever. + # Every messy state cascade today (memento, my own + # bash-bg-and-die orphan, the manual gist-delete I had to + # run by hand) traces to this missing presence signal. + # + # Loop runs every AIRC_HEARTBEAT_SEC (default 30s) and dies + # automatically when its parent (the host airc connect bash) + # exits — so kill -9 on the host stops heartbeats within one + # interval. Joiners treat last_heartbeat older than + # AIRC_HEARTBEAT_STALE (default 90s = 3 missed beats) as + # stale and self-heal as new host. + local _heartbeat_sec="${AIRC_HEARTBEAT_SEC:-30}" + local _hb_parent_pid=$$ + local _hb_invite="$_invite_long" + local _hb_name="$name" + local _hb_user="$user" + local _hb_host="$host" + local _hb_port="$host_port" + local _hb_room="$room_name" + local _hb_created="$_now" + local _hb_machine_id="$_machine_id" + ( + # Detach from job control so a parent SIGINT kills the + # whole tree but normal exit lets us race the trap to + # delete the gist first. + while sleep "$_heartbeat_sec"; do + # Parent died (PID gone) → exit. This is the kill -9 + # / OOM / sleep recovery path. + if ! kill -0 "$_hb_parent_pid" 2>/dev/null; then + exit 0 + fi + local _hb_now; _hb_now=$(date -u +%Y-%m-%dT%H:%M:%SZ) + # Refresh addresses each tick. Captures network changes + # mid-session: laptop moves to a different LAN, Tailscale + # comes up / goes down / re-auths, interface flapping. + # The next gist write reflects current reachability; + # joiners that lose connection re-discover and try the + # new address set. + local _hb_addrs; _hb_addrs=$(host_addresses_json "${_hb_port}") + local _hb_payload; _hb_payload=$(cat < "$_hb_tmp" + gh gist edit "$_gist_id" "$_hb_tmp" >/dev/null 2>&1 || true + rm -f "$_hb_tmp" + done + ) & + local _hb_pid=$! + # Stash heartbeat-loop PID + gist-id in scope-local files so + # the canonical exit-trap (set later in cmd_connect, around + # line 2498) can reap them. We don't set our own EXIT trap + # here because bash traps are last-set-wins per shell — the + # later trap would clobber us, leaving the gist orphaned on + # graceful Ctrl-C. Instead, the canonical trap reads these + # state files and cleans everything up in one place. + echo "$_hb_pid" > "$AIRC_WRITE_DIR/heartbeat.pid" + echo "$_gist_id" > "$AIRC_WRITE_DIR/host_gist_id" + + # Post-publish race-loser detection. Two tabs that ran + # `airc join --room X` simultaneously can BOTH see empty + # gist list (gh propagation lag) and BOTH publish — pre- + # publish recheck doesn't help because neither's gist is + # globally visible yet. Solution: after publishing, look + # for OTHER gists with the same room name. Deterministic + # tiebreaker (lowest gist id alphabetically) picks the + # winner; loser deletes its gist + re-execs as joiner + # targeting the winner. Light jitter spreads the listing + # so we both see the same set. + local _race_jit; _race_jit=$(awk -v r="$RANDOM" 'BEGIN{printf "%.3f", 0.5 + (r%1000)/1000}') + sleep "$_race_jit" + local _peer_rooms; _peer_rooms=$(gh gist list --limit 50 2>/dev/null \ + | awk -F'\t' -v re="airc room: ${room_name}\$" '$2 ~ re {print $1}' \ + | sort) + local _peer_count; _peer_count=$(printf '%s\n' "$_peer_rooms" | grep -c . || true) + if [ "$_peer_count" -gt 1 ]; then + local _winner_id; _winner_id=$(printf '%s\n' "$_peer_rooms" | head -1) + if [ "$_winner_id" != "$_gist_id" ]; then + echo "" + echo " ⚠ Concurrent host detected for #${room_name} — yielding to winner ($_winner_id)." + # Stop our heartbeat, delete our gist, clear state, re-exec as joiner. + kill "$_hb_pid" 2>/dev/null || true + gh gist delete "$_gist_id" --yes >/dev/null 2>&1 || true + rm -f "$AIRC_WRITE_DIR/heartbeat.pid" \ + "$AIRC_WRITE_DIR/host_gist_id" \ + "$AIRC_WRITE_DIR/room_gist_id" \ + "$AIRC_WRITE_DIR/room_name" + _reexec_into rejoin "$_winner_id" + fi + fi + + echo " Hosting #${room_name} (gh-account substrate)." + echo " Other agents on your gh account auto-join via: airc connect" + echo " Cross-account share (rare):" + echo " airc connect $_gist_id" + [ -n "$_hh" ] && echo " # mnemonic: $_hh" + echo " airc connect $_invite_long" + echo "" + echo " (Room gist: $_gist_url — persistent; deleted on 'airc part'.)" + else + echo " On the other machine (pick whichever is easiest to share):" + echo "" + echo " airc connect $_gist_id" + [ -n "$_hh" ] && echo " # mnemonic: $_hh" + echo " airc connect $_invite_long" + echo "" + echo " (Gist: $_gist_url — secret, single-use; delete after pairing.)" + fi + else + echo "" + echo " ⚠ Gist push failed (gh auth?). Falling back to long invite:" + if [ "$_printed_long" = "0" ]; then + echo " airc connect $_invite_long" + fi + fi + fi + fi + echo "" + echo " Waiting for peers on port $host_port..." + # Background: accept peer registrations via TCP (public keys only). + # + # Parent-watch (#132): the loop exits when its own parent disappears + # (PPID=1 = reparented to init = airc parent bash died). Without + # this, the loop survives terminal close / Monitor tool teardown / + # kill of the parent, keeps spawning fresh python listeners, and + # every joiner that hits the cached port gets a real-looking pair + # handshake against a ghost host. Pair-listener Python has its own + # 1s parent-watch thread (see airc_core.handshake._start_parent_watch) + # to catch the in-flight-handshake case; this loop check covers the + # between-iterations case before the next python is spawned. + _orphan_parent_pid=$$ + ( + # Loop while the airc parent bash is still alive. kill -0 is the + # cheapest "is PID still running" probe (no signal sent, just an + # error if the process is gone). When the parent dies, this exits + # before the next iteration so no fresh python is spawned. + # + # --watch-pid hands the same PID to the python listener, which + # spawns a 1s polling thread that os._exit()s mid-accept the + # moment the parent dies — covering the in-flight handshake + # case that the bash between-iterations check can't see. + while kill -0 "$_orphan_parent_pid" 2>/dev/null; do + "$AIRC_PYTHON" -m airc_core.handshake accept_one \ + --host-port "$host_port" \ + --peers-dir "$PEERS_DIR" \ + --identity-dir "$IDENTITY_DIR" \ + --config "$CONFIG" \ + --host-name "$name" \ + --reminder-interval "$reminder_interval" \ + --airc-home "$AIRC_WRITE_DIR" \ + --messages "$MESSAGES" \ + --watch-pid "$_orphan_parent_pid" 2>/dev/null || true + done + ) & + PAIR_PID=$! + + # Write PID file so `airc teardown` can find us later. Record us, the + # PAIR_PID (TCP-accept loop), and the heartbeat-loop PID (if hosting a + # room with a gist) so teardown can reap all three. + _hb_pid_persisted="" + [ -f "$AIRC_WRITE_DIR/heartbeat.pid" ] && _hb_pid_persisted=$(cat "$AIRC_WRITE_DIR/heartbeat.pid" 2>/dev/null) + echo "$$ $PAIR_PID $_hb_pid_persisted" > "$AIRC_WRITE_DIR/airc.pid" + # Clean exit on tab close (SIGTERM/SIGINT from Claude Code's Monitor tool + # going away, or any other signal): reap the accept loop, its python + # listener, the heartbeat loop, AND delete our hosted gist if any — + # don't leave orphans holding the port, the SSH session, or a stale + # gist pointing at a corpse. Single canonical trap (was previously + # split between this site + the gist-publish site, but bash traps are + # last-set-wins per shell so the split lost the gist-cleanup half). + trap ' + _exit_hb_pid="" + _exit_gist_id="" + [ -f "$AIRC_WRITE_DIR/heartbeat.pid" ] && _exit_hb_pid=$(cat "$AIRC_WRITE_DIR/heartbeat.pid" 2>/dev/null) + [ -f "$AIRC_WRITE_DIR/host_gist_id" ] && _exit_gist_id=$(cat "$AIRC_WRITE_DIR/host_gist_id" 2>/dev/null) + [ -n "$_exit_hb_pid" ] && kill $_exit_hb_pid 2>/dev/null + if [ -n "$_exit_gist_id" ] && command -v gh >/dev/null 2>&1; then + gh gist delete "$_exit_gist_id" --yes >/dev/null 2>&1 + fi + rm -f "$AIRC_WRITE_DIR/airc.pid" "$AIRC_WRITE_DIR/heartbeat.pid" "$AIRC_WRITE_DIR/host_gist_id" 2>/dev/null + for p in $PAIR_PID $(proc_children $PAIR_PID) $(proc_children $$); do + kill $p 2>/dev/null + done + ' EXIT INT TERM + + spawn_general_sidecar_if_wanted + echo " Monitoring for messages..." + monitor + kill $PAIR_PID 2>/dev/null + fi +} 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" +} diff --git a/lib/airc_bash/cmd_doctor.sh b/lib/airc_bash/cmd_doctor.sh new file mode 100644 index 0000000..980b78b --- /dev/null +++ b/lib/airc_bash/cmd_doctor.sh @@ -0,0 +1,441 @@ +# Sourced by airc. cmd_doctor + all _doctor_* helpers + +# _doctor_run_tests. Self-contained — uses helpers (die, +# detect_platform, get_config_val) defined in airc top-level +# but exposes no functions outside the doctor surface. +# Extracted from airc as part of #152 Phase 3 file split. + +cmd_doctor() { + # Three modes: + # airc doctor -- environment health check (default). + # Probes each prereq and prints the exact + # install command for whichever package + # manager this platform uses, so any AI + # reading the output can `proactively fix + # recoverable issues` (per /doctor SKILL.md). + # airc doctor --connect -- pre-flight before `airc connect`. Runs + # the default health probes PLUS connect- + # specific checks (tailscale UP not just + # installed, gist API reachable, port free, + # cached host_target reachable). Issue #80. + # Use case: airc doctor --connect && airc connect + # airc doctor --tests -- run the integration test suite (the + # airc doctor tests prior default behavior; aliased on the + # dispatch via `tests|test`). + case "${1:-}" in + --tests|-t|tests|test|run|suite) shift; _doctor_run_tests "$@"; return ;; + --connect|-c|connect) shift; _doctor_connect_preflight "$@"; return ;; + esac + + echo "" + echo " airc doctor -- environment health" + echo " --------------------------------" + echo "" + local issues=0 + + # Detect the platform's package manager so we can emit concrete fix + # commands. Same shape as install.sh's ensure_prereqs. + local mgr; mgr=$(_doctor_detect_pkgmgr) + + _doctor_probe "git" "$mgr" "VCS for clone/update" || issues=$((issues+1)) + _doctor_probe "gh" "$mgr" "Gist substrate (room discovery)" || issues=$((issues+1)) + _doctor_probe_gh_auth || issues=$((issues+1)) + _doctor_probe "openssl" "$mgr" "Ed25519 sign keys + signing" || issues=$((issues+1)) + _doctor_probe "ssh" "$mgr" "OpenSSH client for the wire" || issues=$((issues+1)) + _doctor_probe "ssh-keygen" "$mgr" "Identity keypair generation" || issues=$((issues+1)) + _doctor_probe "python3" "$mgr" "Monitor formatter + heredocs" || issues=$((issues+1)) + _doctor_probe "jq" "$mgr" "Gist envelope parser (rooms, addresses)" || issues=$((issues+1)) + _doctor_probe_sshd || issues=$((issues+1)) + _doctor_probe_tailscale "$mgr" # optional, never increments issues + + echo "" + echo " Scope:" + echo " AIRC_HOME = $AIRC_WRITE_DIR" + if [ -f "$CONFIG" ]; then + local _name; _name=$(get_name) + local _ht; _ht=$(get_config_val host_target "") + if [ -n "$_ht" ]; then + echo " Identity: $_name (joiner of $_ht)" + else + echo " Identity: $_name (host or unconnected)" + fi + else + echo " Identity: not initialized (run 'airc join' to set up)" + fi + + echo "" + if [ "$issues" -eq 0 ]; then + echo " All required prereqs present. Behavioral suite: airc doctor --tests" + else + echo " $issues prereq(s) missing -- see fix lines above." + echo " Fastest path: re-run install.sh (auto-installs via brew/apt/dnf/pacman/apk):" + echo " curl -fsSL https://raw.githubusercontent.com/CambrianTech/airc/main/install.sh | bash" + fi + echo "" +} + +_doctor_detect_pkgmgr() { + case "$(uname -s 2>/dev/null)" in + Darwin) + command -v brew >/dev/null 2>&1 && { echo "brew"; return; } + echo "brew-missing"; return ;; + Linux) + command -v apt-get >/dev/null 2>&1 && { echo "apt"; return; } + command -v dnf >/dev/null 2>&1 && { echo "dnf"; return; } + command -v pacman >/dev/null 2>&1 && { echo "pacman"; return; } + command -v apk >/dev/null 2>&1 && { echo "apk"; return; } + ;; + esac + echo "unknown" +} + +# Map a generic prereq to the install command for the detected pkgmgr. +# Empty string = we don't have a one-liner to suggest; emits a generic +# pointer instead. Mirrors install.sh:pkgname_for + install_with_pkgmgr. +_doctor_install_cmd_for() { + local mgr="$1" prereq="$2" + local pkg + case "$prereq" in + ssh|ssh-keygen) + case "$mgr" in + brew) pkg="openssh" ;; + apt) pkg="openssh-client" ;; + dnf) pkg="openssh-clients" ;; + pacman) pkg="openssh" ;; + apk) pkg="openssh-client" ;; + esac ;; + python3) + case "$mgr" in + pacman) pkg="python" ;; + *) pkg="python3" ;; + esac ;; + *) pkg="$prereq" ;; + esac + case "$mgr" in + brew) echo "brew install $pkg" ;; + apt) echo "sudo apt-get install -y $pkg" ;; + dnf) echo "sudo dnf install -y $pkg" ;; + pacman) echo "sudo pacman -S --needed $pkg" ;; + apk) echo "sudo apk add $pkg" ;; + brew-missing) + echo "Install Homebrew first: /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\", then: brew install $pkg" ;; + *) echo "Install '$pkg' via your platform's package manager" ;; + esac +} + +_doctor_probe() { + local cmd="$1" mgr="$2" purpose="$3" + # Strict-probe ONLY the binaries that have known shadow-aliases on + # Windows. PR #153's blanket strict-probe broke on macOS BSD utilities + # — `ssh-keygen --version` exits 1 ("illegal option") because BSD + # doesn't accept --version, and there's no portable single-flag that + # discriminates "real ssh-keygen" from "stub" anyway. Only the + # Microsoft Store {python.exe, python3.exe} aliases need defense + # against; everything else is uniquely shipped by the user's package + # manager (no shadowing ambiguity), so bare `command -v` is correct. + case "$cmd" in + python|python3) + if command -v "$cmd" >/dev/null 2>&1 && "$cmd" --version >/dev/null 2>&1; then + printf " [ok] %s\n" "$cmd" + return 0 + fi + ;; + *) + if command -v "$cmd" >/dev/null 2>&1; then + printf " [ok] %s\n" "$cmd" + return 0 + fi + ;; + esac + # Distinguish "absent" from "stub on PATH" so the fix hint is correct. + local fix + if command -v "$cmd" >/dev/null 2>&1; then + # Present but non-functional — almost certainly a stub. + printf " [BROKEN] %s -- %s\n" "$cmd" "$purpose" + printf " '%s' is on PATH but '%s --version' fails. " "$cmd" "$cmd" + printf "Likely a Microsoft Store alias on Windows.\n" + printf " Disable: Settings -> Apps -> Advanced app settings -> App execution aliases\n" + printf " Or PATH-prepend a real install ahead of WindowsApps/.\n" + fix=$(_doctor_install_cmd_for "$mgr" "$cmd") + printf " Or install fresh: %s\n" "$fix" + else + fix=$(_doctor_install_cmd_for "$mgr" "$cmd") + printf " [MISSING] %s -- %s\n" "$cmd" "$purpose" + printf " Fix: %s\n" "$fix" + fi + return 1 +} + +_doctor_probe_gh_auth() { + if ! command -v gh >/dev/null 2>&1; then + return 0 # already reported missing by the gh probe + fi + if gh auth status >/dev/null 2>&1; then + printf " [ok] gh authenticated\n" + return 0 + fi + printf " [MISSING] gh authenticated (gist scope)\n" + printf " Fix: gh auth login -s gist\n" + return 1 +} + +# Probe sshd (SSH server). airc joiners ssh into the host's airc_home +# to `tail -F messages.jsonl`. So every airc user who'll host a room +# (which is most users — first to discover a room becomes its host) +# needs sshd running on their box. Pre-fix: airc doctor probed for the +# ssh CLIENT but not the SERVER. Joel + continuum-b69f hit this on +# 2026-04-27 mid-cross-machine bringup: TCP handshake worked, but +# message stream silently failed because Windows ships OpenSSH client +# but NOT the server enabled by default. +# +# Per-platform probes: +# macOS — launchctl + systemsetup (Remote Login) +# linux / wsl — systemctl is-active on ssh OR sshd unit names +# (Debian/Ubuntu unit is 'ssh', RHEL/Fedora is 'sshd') +# windows-bash — powershell.exe Get-Service sshd, distinguish +# Running / Stopped / Missing-capability +# +# Returns 0 on ok, 1 on missing/broken, 0 on platforms we can't probe +# (don't penalize if we can't tell). +_doctor_probe_sshd() { + local plat; plat=$(detect_platform) + case "$plat" in + darwin) + # macOS Remote Login = launchd-managed sshd. Detect WITHOUT sudo: + # - `launchctl list` (user scope) does NOT show system services + # like com.openssh.sshd, so the user-scope probe always misses. + # - `launchctl print system` DOES list system services and works + # without sudo. Look for `com.openssh.sshd` (the service id). + # - `systemsetup -getremotelogin` requires admin to read state + # (returns "You need administrator access..." otherwise) — keep + # it as the second-attempt fallback in case sudo is cached. + if launchctl print system 2>/dev/null | grep -qE 'com\.openssh\.sshd($|[[:space:]])'; then + printf " [ok] sshd (Remote Login enabled)\n" + return 0 + fi + if systemsetup -getremotelogin 2>/dev/null | grep -qi "Remote Login: On"; then + printf " [ok] sshd (Remote Login enabled)\n" + return 0 + fi + printf " [MISSING] sshd -- needed when you HOST a room\n" + printf " Fix: System Settings -> General -> Sharing -> Remote Login (toggle on)\n" + printf " Or: sudo systemsetup -setremotelogin on\n" + return 1 + ;; + linux|wsl) + # Debian/Ubuntu uses 'ssh', RHEL/Fedora/Arch uses 'sshd'. + if systemctl is-active --quiet ssh 2>/dev/null || systemctl is-active --quiet sshd 2>/dev/null; then + printf " [ok] sshd (systemd active)\n" + return 0 + fi + printf " [MISSING] sshd -- needed when you HOST a room\n" + printf " Fix (Debian/Ubuntu): sudo apt-get install openssh-server && sudo systemctl enable --now ssh\n" + printf " Fix (RHEL/Fedora): sudo dnf install openssh-server && sudo systemctl enable --now sshd\n" + return 1 + ;; + windows) + # powershell.exe is the canonical PS launcher in Git Bash. Some + # boxes also ship pwsh.exe (PS Core); prefer powershell.exe for + # broadest reach since OpenSSH service control works in both. + local _ps="" + if command -v powershell.exe >/dev/null 2>&1; then _ps="powershell.exe" + elif command -v pwsh.exe >/dev/null 2>&1; then _ps="pwsh.exe" + fi + if [ -z "$_ps" ]; then + printf " [info] sshd probe skipped (powershell.exe not on PATH)\n" + return 0 + fi + local _state + _state=$("$_ps" -NoProfile -Command "(Get-Service sshd -ErrorAction SilentlyContinue).Status" 2>/dev/null | tr -d '\r\n ') + case "$_state" in + Running) + printf " [ok] sshd (Windows OpenSSH.Server running)\n" + return 0 + ;; + Stopped|StopPending|StartPending|Paused) + printf " [BROKEN] sshd -- installed but not running (state: %s)\n" "$_state" + printf " Fix (admin PowerShell): Start-Service sshd; Set-Service sshd -StartupType Automatic\n" + return 1 + ;; + "") + printf " [MISSING] sshd -- needed when you HOST a room\n" + printf " Fix (admin PowerShell — five lines, run all together):\n" + printf " Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0\n" + printf " reg add HKLM\\\\SYSTEM\\\\CurrentControlSet\\\\Services\\\\hns\\\\State /v EnableExcludedPortRange /d 0 /f\n" + printf " netsh int ipv4 add excludedportrange protocol=tcp startport=22 numberofports=1\n" + printf " Start-Service sshd\n" + printf " Set-Service -Name sshd -StartupType Automatic\n" + printf " (The reg+netsh lines work around Windows HNS holding port 22 randomly per boot —\n" + printf " continuum-b69f's diagnosis 2026-04-27. Without them, sshd bind returns EPERM.)\n" + return 1 + ;; + *) + printf " [info] sshd state unknown (Get-Service returned: '%s')\n" "$_state" + return 0 + ;; + esac + ;; + *) + printf " [info] sshd probe unsupported on platform '%s'\n" "$plat" + return 0 + ;; + esac +} + +_doctor_probe_tailscale() { + local mgr="$1" + # Use resolve_tailscale_bin so we find macOS GUI-installed Tailscale.app + # (the binary lives at /Applications/Tailscale.app/Contents/MacOS/Tailscale, + # not on PATH by default). Bare `command -v tailscale` false-negatives + # on every Mac that installed via the App Store / dmg — caught live + # 2026-04-27 when Mac doctor said "tailscale not installed" while + # airc was actively publishing a Tailscale IP from the running app. + local _ts_bin + _ts_bin=$(resolve_tailscale_bin 2>/dev/null || true) + if [ -n "$_ts_bin" ]; then + if "$_ts_bin" status >/dev/null 2>&1; then + printf " [ok] tailscale (optional) -- daemon up\n" + else + printf " [info] tailscale (optional) -- installed but daemon not up\n" + printf " Bring up: tailscale up (or skip; LAN mesh works without it)\n" + fi + return 0 + fi + # Optional -- print the install hint but don't count toward issues. + local fix + case "$mgr" in + brew) fix="brew install --cask tailscale" ;; + apt|dnf|pacman|apk) fix="curl -fsSL https://tailscale.com/install.sh | sh" ;; + *) fix="https://tailscale.com/download" ;; + esac + printf " [info] tailscale (optional) -- not installed; only needed for cross-LAN mesh\n" + printf " Install: %s\n" "$fix" + return 0 +} + +_doctor_connect_preflight() { + # Pre-flight check before `airc connect`. Issue #80. Runs the default + # prereq probes PLUS connect-specific checks. Output is a checklist + # with fix commands; exit non-zero if any blocking issue. Use case: + # + # airc doctor --connect && airc connect + # + # Catches the silent-fail classes that produced #78 / #85 / #79 + # cascades for first-time users and surfaced as detective-work bugs. + echo "" + echo " airc doctor --connect -- pre-flight checks" + echo " ------------------------------------------" + echo "" + local issues=0 + local mgr; mgr=$(_doctor_detect_pkgmgr) + + # ── Required prereqs (same as default doctor) ── + _doctor_probe "git" "$mgr" "VCS for clone/update" || issues=$((issues+1)) + _doctor_probe "openssl" "$mgr" "Ed25519 sign keys + signing" || issues=$((issues+1)) + _doctor_probe "ssh" "$mgr" "OpenSSH client for the wire" || issues=$((issues+1)) + _doctor_probe "ssh-keygen" "$mgr" "Identity keypair generation" || issues=$((issues+1)) + _doctor_probe "python3" "$mgr" "Monitor formatter + heredocs" || issues=$((issues+1)) + _doctor_probe "jq" "$mgr" "Gist envelope parser (rooms, addresses)" || issues=$((issues+1)) + _doctor_probe_sshd || issues=$((issues+1)) + + # ── gh chain: installed → authed → gist scope → gists API reachable. + # Single chain (early-return on first failure) so a missing gh isn't + # counted 3-4x as a separate issue per dependent probe. Gist scope is + # checked explicitly because `gh auth status` alone passes for a + # gist-scope-less token (Copilot caught this on #87 review). + if ! _doctor_probe "gh" "$mgr" "Gist substrate (room discovery)"; then + issues=$((issues+1)) + elif ! gh auth status >/dev/null 2>&1; then + printf " [BLOCKED] gh authenticated\n" + printf " Fix: gh auth login -s gist\n" + issues=$((issues+1)) + elif ! gh auth status 2>&1 | grep -qiE '(scopes|token scopes):.*\bgist\b'; then + printf " [BLOCKED] gh authed but missing 'gist' scope (room substrate needs it)\n" + printf " Fix: gh auth refresh -s gist\n" + issues=$((issues+1)) + elif ! gh api 'gists?per_page=1' >/dev/null 2>&1; then + printf " [BLOCKED] gist API not reachable -- network outage or rate-limit\n" + printf " Fix: check internet; if persistent, run 'gh auth refresh'\n" + issues=$((issues+1)) + else + printf " [ok] gh authed with gist scope, gists API reachable\n" + fi + + # ── Connect-specific: tailscale state. The default doctor only marks + # tailscale as "info" since it's optional for LAN-only mesh. In + # --connect mode, if there's a saved host_target in tailnet CGNAT + # range, Tailscale being UP is a HARD requirement. + local prior_host_target="" + [ -f "$CONFIG" ] && prior_host_target=$(get_config_val host_target "") + local prior_host_only="${prior_host_target##*@}" + local target_is_cgnat=0 + case "$prior_host_only" in + 100.6[4-9].*|100.[7-9][0-9].*|100.1[01][0-9].*|100.12[0-7].*) target_is_cgnat=1 ;; + esac + if [ "$target_is_cgnat" = "1" ]; then + # Use resolve_tailscale_bin so the .app-bundle / Program Files paths + # are checked, not just PATH (consistency with the rest of airc). + local ts_bin; ts_bin=$(resolve_tailscale_bin 2>/dev/null || true) + if [ -n "$ts_bin" ]; then + if "$ts_bin" status >/dev/null 2>&1; then + printf " [ok] tailscale UP (cached host_target is tailnet CGNAT)\n" + else + printf " [BLOCKED] tailscale CLI installed but DOWN -- cached host is tailnet, can't reach\n" + printf " Fix: tailscale up\n" + issues=$((issues+1)) + fi + else + printf " [BLOCKED] tailscale CLI missing -- cached host is tailnet, can't reach\n" + printf " Fix: install tailscale (https://tailscale.com/download), then 'tailscale up'\n" + issues=$((issues+1)) + fi + else + _doctor_probe_tailscale "$mgr" # optional, info-only + fi + + # ── Connect-specific: AIRC_PORT free or auto-shift available ── + local target_port="${AIRC_PORT:-7547}" + if [ -n "$(port_listeners "$target_port")" ]; then + printf " [info] port %s busy -- airc will auto-shift to next free port\n" "$target_port" + else + printf " [ok] port %s available for hosting\n" "$target_port" + fi + + # ── Connect-specific: cached host_target reachable (resume scenario) ── + if [ -n "$prior_host_target" ]; then + local probe_key="$IDENTITY_DIR/ssh_key" + if [ -f "$probe_key" ]; then + if ssh -i "$probe_key" -o StrictHostKeyChecking=accept-new \ + -o ConnectTimeout=3 -o BatchMode=yes \ + "$prior_host_target" "echo __PROBE_OK__" 2>/dev/null | grep -q __PROBE_OK__; then + printf " [ok] cached host %s reachable + auth works\n" "$prior_host_target" + else + printf " [warn] cached host %s not reachable -- may need re-pair\n" "$prior_host_target" + printf " Fix: airc teardown --flush && airc join (fresh pairing)\n" + # Not blocking — fresh-pair flow handles this + fi + fi + fi + + echo "" + if [ "$issues" -eq 0 ]; then + echo " ✓ READY -- airc connect should work." + return 0 + else + echo " ✗ BLOCKED on $issues issue(s) -- fix the items above before 'airc connect'." + return 1 + fi +} + +_doctor_run_tests() { + # Behavioral suite -- the prior cmd_doctor entry point. Kept reachable + # via `airc doctor --tests` (or the `tests`/`test` aliases in dispatch) + # so existing CI / muscle memory still works. + local script="${AIRC_DIR:-$HOME/.airc-src}/test/integration.sh" + if [ ! -x "$script" ]; then + local self; self="$(realpath "$0" 2>/dev/null || echo "$0")" + local here; here="$(dirname "$self")" + [ -x "$here/test/integration.sh" ] && script="$here/test/integration.sh" + fi + [ -x "$script" ] || die "Can't find test script. Expected at \$AIRC_DIR/test/integration.sh" + exec bash "$script" "$@" +} diff --git a/lib/airc_bash/cmd_identity.sh b/lib/airc_bash/cmd_identity.sh new file mode 100644 index 0000000..56f7112 --- /dev/null +++ b/lib/airc_bash/cmd_identity.sh @@ -0,0 +1,448 @@ +# Sourced by airc. Identity bundle — agent persona ops (issue #34). +# +# Functions exported back to airc's dispatch: +# cmd_away — set/clear away status (IRC /away alias for +# `identity set --status`). +# cmd_identity — verb router (show|set|link|import|push). +# cmd_whois — print identity of self / host / paired peer / cross-peer +# via host. Resolves cross-account peers by tunneling +# through the host's whois cache. +# +# Private helpers (all `_identity_*`): +# _identity_show / _identity_set / _identity_link — local CRUD on +# config.json's `identity` block. +# _identity_import / _identity_push — verb routers for cross-platform +# persona linking (issue #34 v2). +# _identity_import_continuum / _identity_push_continuum — concrete +# adapters for continuum (the only platform implemented today). +# +# External cross-references (call-time): die, ensure_init, get_config_val, +# set_config_val, resolve_name, AIRC_HOME, AIRC_PYTHON, CONFIG, plus the +# continuum CLI on PATH for import/push. +# +# Extracted from airc as part of #152 Phase 3 file split. The bundle is +# already cohesive (every helper is `_identity_*`, every public verb is +# about presence/persona) so it goes to ONE file, not three. + +# ── Identity (issue #34) ──────────────────────────────────────────────── +# +# Structured agent persona, layered on top of the bootstrap name from +# derive_name. Stored under config.json's `identity` key (single-file +# scope: `name` already lives in config.json, identity fields sit +# alongside). Five fields: +# +# pronouns — she/they/he/it; used by skill narrators for grammar +# role — short hyphenated tag, e.g. "device-link-orchestrator" +# bio — one-line free-form, IRC-realname analog +# status — mutable "what I'm working on now" (Slack-like) +# integrations — { platform: handle } mappings to other platforms +# (continuum, slack, telegram) so airc identity can +# adopt or be adopted by canonical persona elsewhere +# +# Skill-side bootstrap prompts the agent to fill these on first /join +# (set AIRC_NO_IDENTITY_PROMPT=1 to skip — used by integration tests). +# v1: airc identity show/set/link locally; airc whois on self. +# v2 (deferred): peer WHOIS over SSH; live continuum/slack import/push. + +# IRC /away: short alias for `airc identity set --status ...`. With a +# message, marks the agent as away. Without args, clears the status +# (back from away). Adheres to IRC convention; the longer form +# (airc identity set --status) still works for scripted state changes. +cmd_away() { + ensure_init + if [ $# -eq 0 ]; then + _identity_set --status "" >/dev/null + echo " back — away cleared." + else + local msg="$*" + _identity_set --status "$msg" >/dev/null + echo " away: $msg" + fi +} + +cmd_identity() { + ensure_init + local sub="${1:-show}" + shift 2>/dev/null || true + case "$sub" in + show|"") _identity_show ;; + set) _identity_set "$@" ;; + link) _identity_link "$@" ;; + import) _identity_import "$@" ;; + push) _identity_push "$@" ;; + -h|--help|help) + echo "Usage:" + echo " airc identity show Print own identity" + echo " airc identity set [--pronouns X] [--role Y] [--bio \"…\"] [--status \"…\"]" + echo " airc identity link [handle] Map this identity to a platform persona (omit handle to unlink)" + echo " airc identity import : Pull persona from platform (continuum)" + echo " airc identity push Send local fields to platform (continuum)" + ;; + *) die "Unknown identity subcommand: $sub (try: show, set, link, import, push)" ;; + esac +} + +_identity_show() { + CONFIG="$CONFIG" "$AIRC_PYTHON" -c ' +import json, os +try: + c = json.load(open(os.environ["CONFIG"])) +except Exception: + print(" (no config — run airc connect)"); raise SystemExit(0) +ident = c.get("identity", {}) or {} +fields = [ + ("name", c.get("name", "?"), ""), + ("pronouns", ident.get("pronouns", ""), "(unset)"), + ("role", ident.get("role", ""), "(unset)"), + ("bio", ident.get("bio", ""), "(unset)"), + # status field is the IRC /away analog. Surface the airc away + # command in the unset case so QA users (continuum-b741 2026-04-27) + # do not see a half-baked empty field with no obvious setter. + ("status", ident.get("status", ""), "(unset; airc away to set)"), +] +for k, v, fallback in fields: + label = k + ":" + value = v if v else fallback + print(f" {label:<11} {value}") +ints = ident.get("integrations", {}) or {} +if ints: + print(" integrations:") + for k, v in ints.items(): + print(f" {k}: {v}") +else: + print(" integrations: (none)") +' +} + +_identity_set() { + local pronouns="" role="" bio="" status="" + local set_pronouns=0 set_role=0 set_bio=0 set_status=0 + while [ $# -gt 0 ]; do + case "$1" in + --pronouns) pronouns="${2:-}"; set_pronouns=1; shift 2 ;; + --role) role="${2:-}"; set_role=1; shift 2 ;; + --bio) bio="${2:-}"; set_bio=1; shift 2 ;; + --status) status="${2:-}"; set_status=1; shift 2 ;; + *) die "Unknown flag: $1 (use --pronouns/--role/--bio/--status)" ;; + esac + done + if [ "$set_pronouns" = 0 ] && [ "$set_role" = 0 ] && [ "$set_bio" = 0 ] && [ "$set_status" = 0 ]; then + die "Pass at least one of --pronouns / --role / --bio / --status" + fi + CONFIG="$CONFIG" \ + SET_PRONOUNS="$set_pronouns" PRONOUNS="$pronouns" \ + SET_ROLE="$set_role" ROLE="$role" \ + SET_BIO="$set_bio" BIO="$bio" \ + SET_STATUS="$set_status" STATUS="$status" \ + "$AIRC_PYTHON" -c ' +import json, os +c = json.load(open(os.environ["CONFIG"])) +ident = c.setdefault("identity", {}) +for key, env_set, env_val in [ + ("pronouns", "SET_PRONOUNS", "PRONOUNS"), + ("role", "SET_ROLE", "ROLE"), + ("bio", "SET_BIO", "BIO"), + ("status", "SET_STATUS", "STATUS"), +]: + if os.environ.get(env_set) == "1": + v = os.environ.get(env_val, "").strip() + if v: + ident[key] = v + else: + ident.pop(key, None) +json.dump(c, open(os.environ["CONFIG"], "w"), indent=2) +print(" identity updated.") +' +} + +_identity_link() { + local platform="${1:-}" handle="${2:-}" + [ -z "$platform" ] && die "Usage: airc identity link [handle] (omit/blank handle to unlink)" + CONFIG="$CONFIG" PLATFORM="$platform" HANDLE="$handle" "$AIRC_PYTHON" -c ' +import json, os +c = json.load(open(os.environ["CONFIG"])) +ints = c.setdefault("identity", {}).setdefault("integrations", {}) +platform = os.environ["PLATFORM"] +handle = os.environ.get("HANDLE", "").strip() +if handle: + ints[platform] = handle + print(f" linked: {platform} -> {handle}") +else: + ints.pop(platform, None) + print(f" unlinked: {platform}") +json.dump(c, open(os.environ["CONFIG"], "w"), indent=2) +' +} + +# WHOIS: prints identity for self, host, paired peer, or other peer of +# our host. Identity blobs are exchanged at pair-handshake time and +# cached locally — no round-trip needed for self/host/local-peer. Cross- +# peer (we're a joiner asking about another joiner of our host) falls +# back to a single SSH read of the host's peer file. +# +# Cross-scope (issue #134): walks sibling scopes (.airc + .airc.) +# so a project-tab whois can find a peer who's only in the #general +# sidecar's host. Without this, JOIN events in the sidecar room emit +# names that whois can't resolve, breaking the IRC mental model where +# every room member is reachable. +cmd_whois() { + ensure_init + local target="${1:-}" + local my_name; my_name=$(get_name) + + # Self — same identity across all scopes, no walk needed. + if [ -z "$target" ] || [ "$target" = "$my_name" ]; then + _identity_show + return 0 + fi + + # Reject path-traversal / shell-injection in target before it touches + # filesystem paths (local /peers/.json) or remote SSH + # cmds (cat $host_airc_home/peers/.json) in any scope. + _validate_peer_name "$target" + + # Try primary scope first, then walk sibling sidecar scopes. First + # hit wins. The order matters: primary scope's host/peer-file lookups + # are local-only (cheap); sibling scopes may add an SSH round-trip + # per scope for the cross-peer-via-host path. + if _whois_in_scope "$AIRC_WRITE_DIR" "$target"; then + return 0 + fi + + local parent self_base prefix sibling + parent=$(dirname "$AIRC_WRITE_DIR") + self_base=$(basename "$AIRC_WRITE_DIR") + # Strip a trailing . to recover the primary prefix. Mirrors the + # detection in cmd_peers (#124) so .airc / .airc.general both resolve + # to .airc as the prefix; in tests we see state / state.general → state. + prefix=$(printf '%s' "$self_base" | sed -E 's/\.[a-z0-9-]+$//') + if [ -d "$parent" ]; then + for sibling in "$parent/$prefix" "$parent/$prefix".*; do + [ -d "$sibling" ] || continue + [ "$sibling" = "$AIRC_WRITE_DIR" ] && continue + [ -f "$sibling/config.json" ] || continue + if _whois_in_scope "$sibling" "$target"; then + return 0 + fi + done + fi + + echo " whois: no record for '$target' (try airc peers to list paired peers)" + return 1 +} + +# Per-scope whois lookup. Returns 0 + prints if found; non-zero if not. +# Args: scope-dir, target-name. Caller has already validated target. +_whois_in_scope() { + local scope="$1" target="$2" + local scope_config="$scope/config.json" + local scope_peers="$scope/peers" + [ -f "$scope_config" ] || return 1 + + # All scope-local config + peer file reads route through + # get_config_val_in / airc_core.config (#152 Phase 1). Pre-migration + # this function had six inline python heredocs reading individual + # JSON fields — each a silent-fail vector with bash-substituted + # SCOPE_CONFIG / PEER_FILE env vars. Now: one CLI per read. + # + # Host of this scope (we're a joiner, target is the host we paired with). + local host_name; host_name=$(get_config_val_in "$scope_config" host_name "") + if [ -n "$host_name" ] && [ "$target" = "$host_name" ]; then + local host_id_blob; host_id_blob=$(get_config_val_in "$scope_config" host_identity "{}") + local host_target_addr; host_target_addr=$(get_config_val_in "$scope_config" host_target "") + _whois_pretty "$target" "$host_id_blob" "$host_target_addr" + return 0 + fi + + # Local peer file under this scope. Same get_config_val_in shape — + # peer files are JSON-shaped just like config.json. + local peer_file="$scope_peers/$target.json" + if [ -f "$peer_file" ]; then + local blob; blob=$(get_config_val_in "$peer_file" identity "{}") + local host; host=$(get_config_val_in "$peer_file" host "") + _whois_pretty "$target" "$blob" "$host" + return 0 + fi + + # Cross-peer via this scope's host (we're a joiner; query host's peer + # file remotely). Skipped when we're the host of this scope (no + # host_target). The SSH key for this scope is at $scope/identity/ssh_key + # — relay_ssh picks up IDENTITY_DIR from the env, so we set it for the + # subprocess. + local host_target_addr; host_target_addr=$(get_config_val_in "$scope_config" host_target "") + local host_airc_home; host_airc_home=$(get_config_val_in "$scope_config" host_airc_home "") + if [ -n "$host_target_addr" ] && [ -n "$host_airc_home" ]; then + local remote_blob + remote_blob=$(IDENTITY_DIR="$scope/identity" relay_ssh "$host_target_addr" "cat $host_airc_home/peers/$target.json 2>/dev/null" 2>/dev/null || true) + if [ -n "$remote_blob" ]; then + local peer_id; peer_id=$(printf '%s' "$remote_blob" | "$AIRC_PYTHON" -m airc_core.handshake get_field identity "{}" 2>/dev/null || echo "{}") + local peer_host; peer_host=$(printf '%s' "$remote_blob" | "$AIRC_PYTHON" -m airc_core.handshake get_field host "" 2>/dev/null || echo "") + _whois_pretty "$target" "$peer_id" "$peer_host" + return 0 + fi + fi + + return 1 +} + +# Pretty-print an identity blob (JSON string) for a named peer. +# Args: name, identity-json, host (any may be empty). +_whois_pretty() { + local name="$1" blob="${2:-{\}}" host="${3:-}" + NAME="$name" BLOB="$blob" HOST="$host" python3 <<'PYEOF' +import json, os +name = os.environ["NAME"] +host = os.environ.get("HOST", "") +try: + ident = json.loads(os.environ.get("BLOB", "{}") or "{}") +except Exception: + ident = {} +print(f" name: {name}") +fields = [("pronouns", ident.get("pronouns", "")), + ("role", ident.get("role", "")), + ("bio", ident.get("bio", "")), + ("status", ident.get("status", ""))] +for k, v in fields: + label = k + ":" + fallback = "(unset)" + print(f" {label:<11} {v if v else fallback}") +ints = ident.get("integrations", {}) or {} +if ints: + print(" integrations:") + for k, v in ints.items(): + print(f" {k}: {v}") +else: + print(" integrations: (none)") +if host: + print(f" host: {host}") +PYEOF +} + +# cmd_kick extracted to lib/airc_bash/cmd_kick.sh +# (#152 Phase 3 file split). Host-only peer eviction lives in its own +# file rather than the identity bundle — kick is moderation, not +# identity — and pulling it out first makes the surrounding identity +# block contiguous for the next extraction PR. +if [ -n "${_airc_lib_dir:-}" ] && [ -f "$_airc_lib_dir/airc_bash/cmd_kick.sh" ]; then + # shellcheck source=lib/airc_bash/cmd_kick.sh + source "$_airc_lib_dir/airc_bash/cmd_kick.sh" +else + echo "ERROR: airc_bash/cmd_kick.sh not found via lib-dir resolver." >&2 + exit 1 +fi + +# ── Identity import/push (issue #34 v2) ───────────────────────────────── +# +# Cross-platform persona linking. The basic shape: airc has an opt-in +# tool wrapper for each known platform. If the platform's CLI is on PATH +# AND a matching profile is found, pull/push fields. Otherwise: clear +# error pointing at the manual `airc identity link `. +# +# v1 supports: continuum (the high-leverage internal case). slack/ +# telegram/discord are stubs that error with platform-install hints — +# they're scaffolding for future PRs, not productionized integrations. + +_identity_import() { + local spec="${1:-}" + [ -z "$spec" ] && die "Usage: airc identity import :" + local platform="${spec%%:*}" + local id="${spec#*:}" + if [ "$platform" = "$spec" ] || [ -z "$id" ]; then + die "Usage: airc identity import : (got '$spec' — missing colon?)" + fi + case "$platform" in + continuum) + _identity_import_continuum "$id" ;; + slack|telegram|discord) + die "import from $platform not yet implemented. For now, run: airc identity link $platform " + ;; + *) + die "Unknown platform '$platform'. Supported: continuum (v1). slack/telegram/discord stubbed." + ;; + esac +} + +_identity_push() { + local platform="${1:-}" + [ -z "$platform" ] && die "Usage: airc identity push " + case "$platform" in + continuum) + _identity_push_continuum ;; + slack|telegram|discord) + die "push to $platform not yet implemented. For now, run: airc identity link $platform " + ;; + *) + die "Unknown platform '$platform'. Supported: continuum (v1). slack/telegram/discord stubbed." + ;; + esac +} + +# Continuum integration: shells out to a `continuum` binary if it's on +# PATH. Expected interface (best-effort — we degrade gracefully if the +# binary doesn't support these subcommands yet): +# continuum persona show → prints JSON {pronouns, role, bio, ...} +# continuum persona update --bio ... → updates the persona +# If continuum isn't installed, link() the handle anyway so the mapping +# is recorded for future syncs. +_identity_import_continuum() { + local id="$1" + if ! command -v continuum >/dev/null 2>&1; then + echo " continuum CLI not on PATH — recording link only." + echo " Once you install continuum, re-run: airc identity import continuum:$id" + _identity_link continuum "$id" + return 0 + fi + local blob; blob=$(continuum persona show "$id" 2>/dev/null || true) + if [ -z "$blob" ]; then + echo " continuum persona '$id' not found — recording link only." + _identity_link continuum "$id" + return 0 + fi + # Parse the JSON; merge into our identity. Empty fields skip; existing + # fields get overwritten (the user's intent: "I want to BE this persona"). + BLOB="$blob" CONFIG="$CONFIG" "$AIRC_PYTHON" -c ' +import json, os +try: + src = json.loads(os.environ["BLOB"]) +except Exception: + src = {} +c = json.load(open(os.environ["CONFIG"])) +ident = c.setdefault("identity", {}) +for k in ("pronouns", "role", "bio"): + v = src.get(k) + if v: + ident[k] = v +ints = ident.setdefault("integrations", {}) +ints["continuum"] = src.get("name", "") +json.dump(c, open(os.environ["CONFIG"], "w"), indent=2) +print(f" imported continuum:{src.get(\"name\", \"?\")} → pronouns={src.get(\"pronouns\", \"\")} role={src.get(\"role\", \"\")} bio set={bool(src.get(\"bio\"))}") +' +} + +_identity_push_continuum() { + if ! command -v continuum >/dev/null 2>&1; then + die "continuum CLI not on PATH — install continuum before pushing." + fi + local handle; handle=$(CONFIG="$CONFIG" "$AIRC_PYTHON" -c ' +import json, os +c = json.load(open(os.environ["CONFIG"])) +print(c.get("identity", {}).get("integrations", {}).get("continuum", "")) +' 2>/dev/null) + [ -z "$handle" ] && die "No continuum handle linked. Run: airc identity link continuum " + CONFIG="$CONFIG" HANDLE="$handle" "$AIRC_PYTHON" -c ' +import json, os, subprocess +c = json.load(open(os.environ["CONFIG"])) +ident = c.get("identity", {}) +handle = os.environ["HANDLE"] +args = ["continuum", "persona", "update", handle] +for k in ("pronouns", "role", "bio"): + v = ident.get(k) + if v: + args += [f"--{k}", v] +res = subprocess.run(args, capture_output=True, text=True) +if res.returncode != 0: + print(f" continuum push failed: {res.stderr.strip() or res.stdout.strip()}") + raise SystemExit(1) +print(f" pushed local identity to continuum:{handle}") +' +} diff --git a/lib/airc_bash/cmd_kick.sh b/lib/airc_bash/cmd_kick.sh new file mode 100644 index 0000000..0e1a8bf --- /dev/null +++ b/lib/airc_bash/cmd_kick.sh @@ -0,0 +1,82 @@ +# Sourced by airc. cmd_kick — host-only peer eviction. +# +# Function exported to airc's dispatch: +# cmd_kick — forcibly remove a paired peer (IRC /kick analog). +# Emits a system event, drops the peer's SSH pubkey from +# authorized_keys, deletes the peer file. The kicked +# peer's tail loop dies on the closed pipe; future SSH +# auth attempts fail because their key is gone. +# +# External cross-references (call-time): die, ensure_init, get_config_val, +# resolve_name, AIRC_HOME, AIRC_WRITE_DIR, MESSAGES. +# +# Extracted from airc as part of #152 Phase 3 file split. Standalone +# (not bundled with identity) because kick is host moderation, not +# identity — separating now also lets the identity bundle pull cleanly +# in the next PR. + +cmd_kick() { + # Host-only: forcibly remove a paired peer. IRC analog: /kick . + # Steps: emit a system event, drop their SSH pubkey from authorized_keys, + # remove the peer file. The kicked peer's tail loop dies on the closed + # pipe AND any future auth attempts fail because their key is gone from + # authorized_keys — they can't silently keep operating after a kick. + # They can re-pair via airc connect (no ban yet) — for that, see future + # `airc ban`. + ensure_init + local target="${1:-}" + [ -z "$target" ] && die "Usage: airc kick [reason]" + _validate_peer_name "$target" + shift || true + local reason="${*:-no reason given}" + + # Joiner role check — kicking only makes sense as host. + local host_target; host_target=$(get_config_val host_target "") + if [ -n "$host_target" ]; then + die "kick: only the room host can kick. You are a joiner of $host_target — talk to the host." + fi + + local peer_file="$PEERS_DIR/$target.json" + if [ ! -f "$peer_file" ]; then + die "kick: '$target' not in peers list (try: airc peers)" + fi + + # Read the joiner's SSH pubkey from the peer JSON record (the host + # handshake stores it there — `.pub` holds the SIGNING pubkey, + # not the SSH auth key, so we can't use that file). Without this, + # kick would leave the joiner's SSH key in authorized_keys and the + # peer could keep authenticating despite the "kick" — caught by + # Copilot review on PR #73. + local peer_ssh_pub + peer_ssh_pub=$(PEER_FILE="$peer_file" "$AIRC_PYTHON" -c ' +import json, os +try: + p = json.load(open(os.environ["PEER_FILE"])) + print((p.get("ssh_pub") or "").strip()) +except Exception: + pass +' 2>/dev/null || echo "") + + if [ -n "$peer_ssh_pub" ] && [ -f "$HOME/.ssh/authorized_keys" ]; then + # grep -v returns 1 when every line matches (or the file is empty); + # both are fine outcomes here, so eat the exit code. + grep -vF "$peer_ssh_pub" "$HOME/.ssh/authorized_keys" > "$HOME/.ssh/authorized_keys.tmp" 2>/dev/null || true + [ -f "$HOME/.ssh/authorized_keys.tmp" ] && mv "$HOME/.ssh/authorized_keys.tmp" "$HOME/.ssh/authorized_keys" + chmod 600 "$HOME/.ssh/authorized_keys" 2>/dev/null || true + fi + + # Remove peer files (rm -f is set-e-safe). The .pub here is the + # signing key file, separate from authorized_keys. + rm -f "$peer_file" "$PEERS_DIR/$target.pub" + + # Emit a system event so the kicked peer (and others) see it in the + # tail stream. Reuse cmd_send's plumbing. + cmd_send "[kick] $target ($reason)" >/dev/null 2>&1 || true + + if [ -n "$peer_ssh_pub" ]; then + echo " Kicked $target ($reason). SSH key removed from authorized_keys; peer file gone." + else + echo " Kicked $target ($reason). Peer file gone, but no SSH key recorded for this peer — they were paired before #34's handshake update; their authorized_keys entry survived. Run airc peers to confirm." + fi + echo " They can re-pair via airc connect; for permanent ban, see future 'airc ban'." +} diff --git a/lib/airc_bash/cmd_reminder.sh b/lib/airc_bash/cmd_reminder.sh new file mode 100644 index 0000000..9c51ca6 --- /dev/null +++ b/lib/airc_bash/cmd_reminder.sh @@ -0,0 +1,46 @@ +# Sourced by airc. cmd_reminder — idle-message-nudge cadence control. +# +# Function exported back to airc's dispatch: +# cmd_reminder — show / set / pause / disable the auto-nudge interval +# that the monitor loop emits when the room has been +# silent for N seconds. `airc reminder 300` sets it to +# 5 min, `off`/`pause` disable, no-arg shows current. +# +# External cross-references (call-time): die, ensure_init, get_config_val, +# set_config_val, AIRC_REMINDER (env override). +# +# Extracted from airc as part of #152 Phase 3 file split — the final +# structural sweep that takes the bash top-level back below ~1500 lines. + +cmd_reminder() { + ensure_init + local arg="${1:-status}" + local reminder_file="$AIRC_WRITE_DIR/reminder" + + case "$arg" in + off|0) + rm -f "$reminder_file" + echo " Reminders off." + ;; + pause) + echo "0" > "$reminder_file" + echo " Reminders paused. 'airc reminder ' to resume." + ;; + status) + if [ -f "$reminder_file" ]; then + local val; val=$(cat "$reminder_file") + if [ "$val" = "0" ]; then + echo " Reminders paused." + else + echo " Reminder every ${val}s." + fi + else + echo " Reminders off." + fi + ;; + *) + echo "$arg" > "$reminder_file" + echo " Reminder every ${arg}s if no messages." + ;; + esac +} diff --git a/lib/airc_bash/cmd_rename.sh b/lib/airc_bash/cmd_rename.sh new file mode 100644 index 0000000..a413102 --- /dev/null +++ b/lib/airc_bash/cmd_rename.sh @@ -0,0 +1,121 @@ +# Sourced by airc. cmd_rename — change identity name + propagate. +# +# Function exported back to airc's dispatch: +# cmd_rename — sanitize new name (a-z 0-9 -), write to config.json, +# emit a [rename] system event so peers update their +# local peer files, and recurse into sibling scopes +# (#179 — multi-scope propagation: a rename in the +# project scope also bumps the .general sidecar's +# nick so peers see one consistent identity). +# +# Flags: +# --no-propagate recursion guard for the multi-scope walk; the +# sub-call writes its own scope without re-entering. +# +# External cross-references (call-time): die, ensure_init, resolve_name, +# get_config_val, set_config_val, AIRC_HOME, AIRC_WRITE_DIR, MESSAGES. +# +# Extracted from airc as part of #152 Phase 3 file split — the final +# structural sweep. + +cmd_rename() { + # Parse flags. --no-propagate is the recursion guard for sibling-scope + # propagation (#179): when cmd_rename recurses into `airc rename` for + # each sibling scope, it passes --no-propagate so the sub-call does + # its own scope's work without re-recursing into us. + local no_propagate=0 + local new_name="" + while [ $# -gt 0 ]; do + case "$1" in + --no-propagate) no_propagate=1; shift ;; + -h|--help|"") + echo "Usage: airc rename " + echo " Renames this identity and broadcasts [rename] to paired peers." + echo " --no-propagate skip sibling-scope propagation (internal — used during recursion)" + [ -z "${1:-}" ] && exit 1 || exit 0 ;; + -*) die "Unknown flag: $1 (try: airc rename --help)" ;; + *) + [ -n "$new_name" ] && die "rename takes one name (got '$new_name' and '$1')" + new_name="$1"; shift ;; + esac + done + [ -z "$new_name" ] && { echo "Usage: airc rename "; exit 1; } + # Sanitize: lowercase, replace non-[a-z0-9-] with '-', collapse runs of + # dashes, strip leading/trailing dashes, then cap. The post-sanitization + # leading-dash strip matters because input like `.foo` becomes `-foo` + # after the `[^a-z0-9-]` replacement and would slip past the case check + # above — making the resulting name unreachable by `airc whois` / + # `airc kick` (both reject leading-dash). Caught by Copilot review on + # PR #75 follow-up. + new_name=$(echo "$new_name" \ + | tr '[:upper:]' '[:lower:]' \ + | sed 's/[^a-z0-9-]/-/g' \ + | sed 's/--*/-/g; s/^-*//; s/-*$//' \ + | cut -c1-24 \ + | sed 's/-*$//') + [ -z "$new_name" ] && die "Invalid name (must be a-z 0-9 -)" + [ ! -f "$CONFIG" ] && die "Not initialized — run 'airc connect' first" + + local old_name; old_name=$(get_config_val name "") + if [ "$old_name" = "$new_name" ]; then + echo " Already named '$new_name'." + return + fi + + # Phase 1: write the new name into THIS scope's config (the truth- + # layer effect for this scope). Goes through airc_core.config rather + # than an inline-python heredoc — the heredoc was quoting-fragile + # (would have broken on a name containing a single quote — currently + # safe because the sanitizer keeps names in [a-z0-9-], but a sharp + # edge in code that's about to recurse). + "$AIRC_PYTHON" -m airc_core.config set_name --config "$CONFIG" --name "$new_name" + echo " Renamed: $old_name → $new_name" + + # Phase 2: propagate the config write to sibling scopes BEFORE + # broadcasting (#179 — vhsm-d1f4 + ideem-local-4bef caught 2026-04-28 + # that nick rename only updated the current scope's config, leaving + # any sidecar to broadcast under the OLD name). + # + # Order matters: configs first, broadcast last. cmd_send calls die() + # if the scope's monitor is down, and die() is `exit 1` (kills the + # whole shell, ignoring our `|| true`). Doing configs first means a + # broadcast failure after this point cannot prevent propagation. + # + # --no-propagate prevents the sub-call from recursing back into us. + # Each sibling scope writes its own config AND broadcasts in its own + # room's host_target. + if [ "$no_propagate" != "1" ]; then + local _primary _parent _primary_base _sibling + _primary=$(_primary_scope_for "$AIRC_WRITE_DIR") + _parent=$(dirname "$_primary") + _primary_base=$(basename "$_primary") + # Glob all sibling sidecars (named .) — does NOT + # match the primary itself (which has no trailing `.`). + for _sibling in "$_parent/$_primary_base".*; do + [ -d "$_sibling" ] || continue + [ -f "$_sibling/config.json" ] || continue + [ "$_sibling" = "$AIRC_WRITE_DIR" ] && continue + AIRC_HOME="$_sibling" "$0" rename --no-propagate "$new_name" \ + || echo " warn: rename propagation to $_sibling failed (exit $?)" >&2 + done + # If WE are a sidecar (current scope != primary), also rename the + # primary scope. + if [ "$AIRC_WRITE_DIR" != "$_primary" ] && [ -f "$_primary/config.json" ]; then + AIRC_HOME="$_primary" "$0" rename --no-propagate "$new_name" \ + || echo " warn: rename propagation to primary $_primary failed (exit $?)" >&2 + fi + fi + + # Phase 3: best-effort broadcast in this scope. Include a stable + # `host` field so receivers can find THIS peer's record even if their + # name-keyed lookup would miss (a prior rename marker got dropped; + # their peer file for us still sits under an older name). host is + # immutable per machine+user. + # + # --internal tells cmd_send to append-and-return rather than die() + # when this scope's monitor is down. [rename] is informational; + # receivers heal via monitor_formatter's host-fallback on next + # traffic regardless of whether they saw this specific event. + local my_host; my_host="$(whoami)@$(get_host)" + cmd_send --internal "[rename] old=$old_name new=$new_name host=$my_host" >/dev/null || true +} diff --git a/lib/airc_bash/cmd_rooms.sh b/lib/airc_bash/cmd_rooms.sh new file mode 100644 index 0000000..e56852b --- /dev/null +++ b/lib/airc_bash/cmd_rooms.sh @@ -0,0 +1,441 @@ +# Sourced by airc. Channel/peer cluster — IRC-style channel + peer ops. +# +# Functions exported back to airc's dispatch: +# cmd_rooms — list open airc invite gists on this gh account. +# The gist namespace IS the room registry; this is +# the /list verb. Walks the gist API, filters for +# `airc invite for ` description prefix, pretty-prints. +# cmd_part — leave the current room. If we're the host, deletes +# the room gist (channel dissolves). If we're a +# joiner, just local teardown. Records parted_rooms +# so re-join doesn't auto-resume. +# cmd_send_file — host-mediated file transfer to a peer. Pre-pairing- +# aware: writes to the host's files// dir. +# cmd_invite — print the long join string for cross-account share +# (the historical fallback when gist isn't reachable). +# cmd_peers — list paired peers in the current scope, with +# last-seen + role/status from peer files. +# +# External cross-references (call-time): die, ensure_init, get_config_val, +# set_config_val, unset_config_keys, get_host, resolve_name, relay_ssh, +# remote_home, AIRC_HOME, AIRC_WRITE_DIR, AIRC_PYTHON, plus cmd_teardown +# (which cmd_part calls to do the actual local kill). +# +# Extracted from airc as part of #152 Phase 3 file split. Bundled because +# in IRC mental model these are all the same conceptual surface: "what +# rooms exist? who's in this one? how do I leave/invite/transfer?" One +# domain = one file. + +# ── cmd_rooms: list open airc invite gists on this gh account ──────── +# Issue #38. The gist namespace IS the room registry — every airc invite +# pushed via the default gist transport (#37) shows up here. Filter is +# the description prefix `"airc invite for "` that push-image side writes. +# +# The Claude Code skill (/list, /rooms) calls this and lets the AI use +# conversation context to pick. The CLI itself stays orthogonal — it +# emits the menu, doesn't decide. +cmd_rooms() { + # Parse flags (#142). Default hides items already marked stale (older + # than the threshold in _is_stale) so an active user with several + # rooms + several days of test runs doesn't have stale-invite count + # dominating the active-rooms count. --all / --include-stale shows + # everything (the pre-#142 behavior); --prune deletes stale gists. + local include_stale=0 + local prune=0 + while [ $# -gt 0 ]; do + case "$1" in + --all|--include-stale) include_stale=1; shift ;; + --prune) prune=1; include_stale=1; shift ;; + -h|--help) + echo "Usage: airc list [--all|--include-stale] [--prune]" + echo " --all / --include-stale show stale items (default: hidden)" + echo " --prune delete stale gists from your gh account" + return 0 ;; + *) echo " Unknown flag: $1 (try: airc list --help)" >&2; return 1 ;; + esac + done + + if ! command -v gh >/dev/null 2>&1; then + echo " airc rooms requires the 'gh' CLI: https://cli.github.com" >&2 + echo " airc IS aIRC — github gist is the coordination layer; gh is mandatory." >&2 + return 1 + fi + # Match BOTH the persistent IRC-style rooms (#39, prefix `airc room:`) + # and the legacy single-pair invites (#37/#38, prefix `airc invite for`). + # Show kind explicitly so the AI / human can tell them apart. + # gh gist list columns: id description files visibility updated_at + # Use $5 (timestamp) for the updated field — pre-#82 we were using + # $4 (visibility, "secret") under the "updated:" label, which is a + # display bug fixed here on the way to adding stale markers. + local raw; raw=$(gh gist list --limit 50 2>/dev/null \ + | awk -F'\t' ' + /airc room:/ { print "room\t" $1 "\t" $2 "\t" $5 } + /airc invite for/ { print "invite\t" $1 "\t" $2 "\t" $5 } + ') + local count; count=$(printf '%s' "$raw" | grep -c . || true) + if [ "$count" = "0" ]; then + echo " No open airc rooms or invites on your gh account." + echo " Host the default room: airc connect" + echo " Host a named room: airc connect --room " + return 0 + fi + # First pass: count how many are stale vs fresh, so we can show an + # accurate header AND a hint about --all when items got hidden. + local stale_count=0 fresh_count=0 + while IFS=$'\t' read -r _kind _id _desc updated; do + [ -z "$_kind" ] && continue + if _is_stale "$updated"; then + stale_count=$((stale_count + 1)) + else + fresh_count=$((fresh_count + 1)) + fi + done <<< "$raw" + + echo "" + if [ "$include_stale" = "1" ]; then + echo " $count open on your gh account ($fresh_count active, $stale_count stale):" + elif [ "$stale_count" -gt 0 ]; then + echo " $fresh_count active on your gh account ($stale_count stale hidden — see 'airc list --all')" + else + echo " $count open on your gh account:" + fi + echo "" + + local pruned=0 + while IFS=$'\t' read -r kind id desc updated; do + [ -z "$kind" ] && continue + local is_stale=0 + _is_stale "$updated" && is_stale=1 + # Default: skip stale entries. --all/--include-stale shows all. + if [ "$is_stale" = "1" ] && [ "$include_stale" = "0" ]; then + continue + fi + if [ "$prune" = "1" ] && [ "$is_stale" = "1" ]; then + if gh gist delete "$id" --yes >/dev/null 2>&1; then + echo " pruned: $desc (id: $id)" + pruned=$((pruned + 1)) + else + echo " prune FAILED for $desc (id: $id)" >&2 + fi + continue + fi + local hh; hh=$(humanhash "$id" 2>/dev/null) + local marker + case "$kind" in + room) marker="#" ;; # persistent channel + invite) marker="(1:1)" ;; # ephemeral pairing + esac + local age_str; age_str=$(_format_relative_time "$updated") + local stale_marker="" + [ "$is_stale" = "1" ] && stale_marker=" (stale)" + printf ' %s %s%s\n id: %s\n mnemonic: %s\n updated: %s\n\n' \ + "$marker" "$desc" "$stale_marker" "$id" "$hh" "$age_str" + done <<< "$raw" + + if [ "$prune" = "1" ]; then + echo " pruned $pruned stale gist(s)." + return 0 + fi + echo " Join (auto-resolves on same gh account): airc connect" + echo " Join by id (cross-account share): airc connect " + echo "" +} + +# Convert an ISO 8601 timestamp into a relative-time string ("12m ago", +# "3h ago", "2d ago"). Falls back to the raw timestamp on parse failure. +# Used by cmd_rooms to display gist activity (#82). Date parsing goes +# through iso_to_epoch so the BSD/GNU/python fallback chain is shared. +_format_relative_time() { + local ts="${1:-}" + [ -z "$ts" ] && { echo "(unknown)"; return; } + local epoch; epoch=$(iso_to_epoch "$ts") + if [ -z "$epoch" ]; then echo "$ts"; return; fi + local now; now=$(date -u +%s) + local diff=$((now - epoch)) + if [ "$diff" -lt 0 ]; then echo "$ts"; return; fi + if [ "$diff" -lt 60 ]; then echo "${diff}s ago" + elif [ "$diff" -lt 3600 ]; then echo "$((diff / 60))m ago" + elif [ "$diff" -lt 86400 ]; then echo "$((diff / 3600))h ago" + else echo "$((diff / 86400))d ago" + fi +} + +# Return 0 if the given ISO timestamp is older than AIRC_STALE_HOURS +# (default 24h). Used to mark abandoned rooms in cmd_rooms output (#82). +# Shares iso_to_epoch with _format_relative_time so a future date-parse +# fix lands once. +_is_stale() { + local ts="${1:-}" + local threshold_hours="${AIRC_STALE_HOURS:-24}" + [ -z "$ts" ] && return 1 + local epoch; epoch=$(iso_to_epoch "$ts") + [ -z "$epoch" ] && return 1 + local now; now=$(date -u +%s) + local diff=$((now - epoch)) + [ "$diff" -gt $((threshold_hours * 3600)) ] +} + +# ── cmd_part: leave the current room ────────────────────────────────── +# Issue #39. Two paths, distinguished by config.json's host_target: +# - Host (no host_target): delete the room gist if we created one, then +# teardown. Joiners watching us will see SSH die — IRC's "ircd +# restart" — and the next reconnect re-elects a new host. +# - Joiner (host_target set): just teardown local processes; host's +# gist stays open for other joiners (we're one of N). +# Either way, local config + identity + peer records persist (use +# `airc teardown --flush` for nuclear). +# +# Detection note: we use config.json::host_target as the host-vs-joiner +# signal, NOT presence of room_gist_id. The gist file may be absent for +# a legitimate host case (`--no-gist`, or gh push failed) — falling back +# to "you're a joiner" would be wrong. +cmd_part() { + ensure_init + + local gist_id_file="$AIRC_WRITE_DIR/room_gist_id" + local room_name_file="$AIRC_WRITE_DIR/room_name" + local room_name="(unnamed)" + [ -f "$room_name_file" ] && room_name=$(cat "$room_name_file") + + local host_target; host_target=$(get_config_val host_target "") + + if [ -z "$host_target" ]; then + # ── Host path ── + if [ -f "$gist_id_file" ]; then + local gid; gid=$(cat "$gist_id_file") + if command -v gh >/dev/null 2>&1; then + echo " Host of #${room_name} parting — deleting room gist ${gid}..." + gh gist delete "$gid" --yes 2>/dev/null \ + && echo " ✓ Room gist deleted." \ + || echo " ⚠ Couldn't delete gist ${gid} (already gone? gh auth?). Continuing teardown." + else + echo " ⚠ gh CLI not available — can't delete room gist ${gid} automatically." + echo " Delete it manually: gh gist delete ${gid} --yes" + fi + else + # Host but no gist (--no-gist or gh-push failed). Nothing to delete + # in the gh namespace; just clean local state. + echo " Host of #${room_name} parting (no gist was published; nothing to clean up in gh)." + fi + rm -f "$gist_id_file" "$room_name_file" + else + # ── Joiner path ── + echo " Joiner of #${room_name} parting — host's gist stays open for others." + # Clear our cached gist_id too, matching the comment on the joiner- + # side cache write site (PR #92 Copilot feedback). Without this, a + # parted joiner that later reconnects via the same scope would + # incorrectly trigger the stale-pairing-detect path on the next + # resume even though they parted intentionally. + rm -f "$room_name_file" "$gist_id_file" + fi + + # Issue #136: persist the /part. Record the room into the PRIMARY + # scope's parted_rooms list so a later `airc join` won't auto- + # resubscribe. Only meaningful for sidecar rooms (general, future + # opt-in #repo etc.) — parting your project's primary scope means + # the whole scope is gone, so persistence there is moot. + local _primary_scope; _primary_scope=$(_primary_scope_for "$AIRC_WRITE_DIR") + if [ "$_primary_scope" != "$AIRC_WRITE_DIR" ] && [ "$room_name" != "(unnamed)" ]; then + _record_parted_room "$_primary_scope" "$room_name" + echo " /part persisted — #${room_name} won't auto-resubscribe. Rejoin with: airc join --${room_name}" + fi + + # IRC `/part` semantics — leave THIS room only; the #general sidecar + # (or any other sibling subscription) keeps running. cmd_teardown + # respects AIRC_TEARDOWN_PART_ONLY=1 by skipping its sidecar block, + # so the kill is scope-local. cmd_teardown without this guard remains + # the "kill everything in this scope tree" command. + local AIRC_TEARDOWN_PART_ONLY=1 + cmd_teardown +} + +cmd_send_file() { + local peer_name="${1:-}" filepath="${2:-}" + [ -z "$peer_name" ] || [ -z "$filepath" ] && die "Usage: airc send-file " + [ -f "$filepath" ] || die "File not found: $filepath" + ensure_init + + local host_target my_name + host_target=$(get_config_val host_target "") + my_name=$(get_name) + + local filename; filename=$(basename "$filepath") + local target_host="$host_target" + [ -z "$target_host" ] && target_host="localhost" + + local rhome; rhome=$(remote_home) + relay_ssh "$target_host" "mkdir -p $rhome/files/${my_name}" 2>/dev/null + # Use the airc identity key for scp — same key relay_ssh uses. Without -i, + # scp falls back to system ssh_config (~/.ssh/id_* etc), which doesn't know + # about isolated AIRC_HOME identities. Surfaced by m5-test's send-file test. + local ssh_key="$IDENTITY_DIR/ssh_key" + local scp_out + if [ -f "$ssh_key" ]; then + scp_out=$(scp -i "$ssh_key" -o StrictHostKeyChecking=accept-new -q "$filepath" "${target_host}:${rhome}/files/${my_name}/${filename}" 2>&1) + else + scp_out=$(scp -o StrictHostKeyChecking=accept-new -q "$filepath" "${target_host}:${rhome}/files/${my_name}/${filename}" 2>&1) + fi + if [ $? -ne 0 ]; then + die "Failed to transfer $filename: $scp_out" + fi + + local filesize; filesize=$(file_size "$filepath") + cmd_send "$peer_name" "Sent file: $filename ($filesize bytes)" + echo "Sent $filename ($filesize bytes)" +} + +cmd_invite() { + ensure_init + local host_target pubkey_b64 join_string + host_target=$(get_config_val host_target "") + + if [ -n "$host_target" ]; then + # Joiner: reconstruct the HOST's join string from stored pairing info. + # Any connected peer can share the same join string — everyone converges + # on the same host. + local host_name host_port host_ssh_pub + host_name=$(get_config_val host_name "") + host_port=$(get_config_val host_port 7547) + host_ssh_pub=$(get_config_val host_ssh_pub "") + if [ -z "$host_name" ] || [ -z "$host_ssh_pub" ]; then + die "Host info missing from config. Re-pair with 'airc teardown' then 'airc connect '." + fi + pubkey_b64=$(printf '%s\n' "$host_ssh_pub" | base64 | tr -d '\n') + local port_suffix="" + [ "$host_port" != "7547" ] && port_suffix=":$host_port" + join_string="${host_name}@${host_target}${port_suffix}#${pubkey_b64}" + else + # Host: build own join string from live state. + local my_name user host port + my_name=$(get_name) + user=$(whoami) + host=$(get_host) + port=$(cat "$AIRC_WRITE_DIR/host_port" 2>/dev/null || echo 7547) + local port_suffix="" + [ "$port" != "7547" ] && port_suffix=":$port" + pubkey_b64=$(base64 < "$IDENTITY_DIR/ssh_key.pub" | tr -d '\n') + join_string="${my_name}@${user}@${host}${port_suffix}#${pubkey_b64}" + fi + + echo "$join_string" +} + +cmd_peers() { + ensure_init + # `airc peers --prune` — remove stale records that share a host with a + # newer record (cruft left from rename chain-breaks before the stable-host + # matching logic landed). + if [ "${1:-}" = "--prune" ]; then + "$AIRC_PYTHON" -c " +import json, os, sys +peers_dir = os.path.expanduser('$PEERS_DIR') +if not os.path.isdir(peers_dir): + sys.exit(0) +# Group records by host; keep the most-recently-paired, remove the rest. +by_host = {} +for entry in sorted(os.listdir(peers_dir)): + if not entry.endswith('.json'): continue + p = os.path.join(peers_dir, entry) + try: + d = json.load(open(p)) + except Exception: + continue + host = d.get('host', '') + if not host: continue + by_host.setdefault(host, []).append((d.get('paired', ''), entry, d.get('name', entry[:-5]))) +removed = [] +for host, records in by_host.items(): + if len(records) < 2: continue + records.sort(reverse=True) # newest paired first + for _, entry, name in records[1:]: + for ext in ('.json', '.pub'): + f = os.path.join(peers_dir, entry[:-5] + ext) + if os.path.isfile(f): + try: os.remove(f) + except Exception: pass + removed.append((name, host)) +if removed: + for name, host in removed: + print(f' pruned: {name} -> {host}') +else: + print(' No stale records to prune.') +" + return + fi + + # Walk scopes that count as "subscribed rooms" for this tab: primary + # (current AIRC_WRITE_DIR) plus any sibling sidecar scopes (.airc. + # pattern under the project scope's parent). For each, read peers/ + # records and annotate with the scope's room_name. Same peer in both + # scopes folds into one line with both room tags. + # + # Intent (issue #121 follow-up): multi-room presence shouldn't fragment + # the operator's view of "who am I connected to" into separate per-scope + # listings. From the user's perspective they're in N rooms; airc peers + # should reflect that as one unified roster with room context per peer. + "$AIRC_PYTHON" -c " +import json, os, sys, re + +primary_scope = os.path.expanduser('$AIRC_WRITE_DIR') +parent = os.path.dirname(primary_scope) +self_basename = os.path.basename(primary_scope) + +# Prefix detection: a sidecar scope is named like \`.\` +# (e.g. .airc.general). Strip a trailing . to recover the +# primary scope's basename. Works for both production layout +# (.airc / .airc.general) and test ad-hoc paths (state / state.general) +# without baking in the .airc literal. +prefix_match = re.match(r'(.+?)\.[a-z0-9-]+\$', self_basename) +prefix = prefix_match.group(1) if prefix_match else self_basename + +# Collect: the primary scope itself, plus every sibling whose name is +# .. We additionally require room_name + peers/ on +# each candidate so unrelated dirs in the same parent (e.g. .airc-old, +# .airc.bak) don't pollute the listing. +candidates = [] +if os.path.isdir(parent): + for entry in sorted(os.listdir(parent)): + if entry == prefix or entry.startswith(prefix + '.'): + candidates.append(os.path.join(parent, entry)) +scopes = [s for s in candidates + if os.path.isfile(os.path.join(s, 'room_name')) + and os.path.isdir(os.path.join(s, 'peers'))] +# Always include primary even if it doesn't have room_name yet — that's +# the legacy 1:1 invite mode case (use_room=0). +if primary_scope not in scopes and os.path.isdir(os.path.join(primary_scope, 'peers')): + scopes.insert(0, primary_scope) + +# Build {(name, host): [room1, room2, ...]} by walking each scope's peers/. +peers_by_id = {} +for scope in scopes: + peers_dir = os.path.join(scope, 'peers') + if not os.path.isdir(peers_dir): + continue + rn_file = os.path.join(scope, 'room_name') + room = '(?)' + if os.path.isfile(rn_file): + try: room = open(rn_file).read().strip() + except Exception: pass + for f in sorted(os.listdir(peers_dir)): + if not f.endswith('.json'): continue + try: + d = json.load(open(os.path.join(peers_dir, f))) + except Exception: + continue + key = (d.get('name', f[:-5]), d.get('host', '')) + peers_by_id.setdefault(key, []).append(room) + +if not peers_by_id: + print(' No peers yet.') + sys.exit(0) + +# Render. Each peer once, with room annotations sorted + deduped. +for (name, host), rooms in sorted(peers_by_id.items()): + seen = set(); ordered = [] + for r in rooms: + if r not in seen: + ordered.append(r); seen.add(r) + tags = ', '.join('#' + r for r in ordered) + print(f' {name} → {host} [{tags}]') +" +} diff --git a/lib/airc_bash/cmd_send.sh b/lib/airc_bash/cmd_send.sh new file mode 100644 index 0000000..834c591 --- /dev/null +++ b/lib/airc_bash/cmd_send.sh @@ -0,0 +1,383 @@ +# Sourced by airc. cmd_send + cmd_ping — outbound message verbs. +# +# Functions exported back to airc's dispatch: +# cmd_send — broadcast to current room, or DM via @peer prefix. +# Handles --room, --to, queueing on host failure (pending.jsonl +# + [QUEUED] mirror in messages.jsonl), and the "speak as" rewrite +# for sidecar scopes. +# cmd_ping — liveness probe wrapped as a regular signed [PING:] message, +# so older airc clients without auto-pong support degrade +# gracefully (they just log it). +# +# External cross-references (resolved at call time): die, ensure_init, +# get_config_val, set_config_val, relay_ssh, AIRC_HOME, MESSAGES, +# resolve_name, get_host, _hash, plus airc_core.* python modules +# (airc_core.message, airc_core.queue) for envelope construction. +# +# Extracted from airc as part of #152 Phase 3 file split. Joel 2026-04-27: +# "1) simplify and modularize 2) build host logic correctly 3) never +# ever again make 5000 line dumbass designs." This pulls outbound-message +# concerns out of the bash monolith. Inbound-message handling stays in +# airc top-level (monitor + relay_ssh) for now. + +cmd_send() { + # Chat-room semantics. Default: broadcast to everyone in the current + # scope's room. Prefix the first arg with '@' to DM a specific peer. + # airc send "hello everyone" → broadcast to current room + # airc send @alice "hey" → DM alice in current room + # airc send --room general "hi lobby" → broadcast to a SIBLING room + # airc send --room general @alice "..."→ DM alice via the sibling room + # + # --room route (issue #122 follow-up): the multi-room sidecar + # model means a tab is in #project-room AND #general simultaneously, + # but each room has its own scope. Without --room support here, sending + # to a non-current room required `AIRC_HOME=$cwd/.airc. airc msg`, + # which is nonobvious (vhsm-Claude attempted `airc msg --room general` + # on 2026-04-26, the unrecognized flag silently became part of the + # message body — exactly the evidence-eating shape the project rejects). + # + # Implementation: parse --room here. If it names a sibling sidecar scope + # (e.g. ${AIRC_WRITE_DIR}.), re-exec ourselves with AIRC_HOME + # pointed at that scope so the rest of the function runs there. Errors + # loudly when the requested room isn't in the user's subscription set + # — never silently broadcasts to the wrong place. + local target_room="" + # --internal: best-effort send for internal informational broadcasts + # ([rename], etc.) where the monitor-down guard is the wrong UX. Append + # to the local log + return 0 even when the monitor isn't running. + # Receivers heal via monitor_formatter's host-fallback / next-traffic + # passes, so missing one event in a quiet scope isn't a correctness + # issue. Exposed as a flag (not an env var) so call sites are + # grep-able and the pattern matches the rest of the airc CLI surface. + local internal=0 + local positional=() + while [ $# -gt 0 ]; do + case "$1" in + --room|-room) + target_room="${2:-}" + [ -z "$target_room" ] && die "Usage: airc send --room " + shift 2 ;; + --internal) + internal=1 + shift ;; + *) positional+=("$1"); shift ;; + esac + done + set -- "${positional[@]+"${positional[@]}"}" + + if [ -n "$target_room" ]; then + # Resolve target_room to a scope dir. Two cases: + # 1. We ARE in target_room already (current scope's room_name file + # matches) → just continue here, no re-exec. + # 2. A sibling scope `${primary_scope}.${target_room}` exists → + # re-exec with AIRC_HOME there. Recursion guard via + # AIRC_SEND_REROUTED=1 — without it, a misconfigured sibling + # scope could loop. + # + # Determining "primary scope" is the awkward bit because we may + # ALREADY be in a sidecar scope (AIRC_WRITE_DIR ends in `.X`). Strip + # any trailing `.` to find the project scope, then append + # `.` for the requested sibling. If target_room IS the + # project room name (read from primary's room_name file), point at + # the project scope itself, not a sibling. + local _here_room="" + [ -f "$AIRC_WRITE_DIR/room_name" ] && _here_room=$(cat "$AIRC_WRITE_DIR/room_name" 2>/dev/null) + if [ "$_here_room" = "$target_room" ]; then + : # already in the right scope, fall through to normal send + else + [ "${AIRC_SEND_REROUTED:-0}" = "1" ] \ + && die "send: --room re-route loop detected (scope $AIRC_WRITE_DIR room=$_here_room target=$target_room)" + # Strip any sibling suffix from current scope to get the project + # scope path. e.g. /path/.airc.general → /path/.airc + local _project_scope="$AIRC_WRITE_DIR" + case "$_project_scope" in + *.airc.*) + _project_scope="${_project_scope%.*}" ;; + esac + # Read the project scope's room_name to compare with target. + local _project_room="" + [ -f "$_project_scope/room_name" ] && _project_room=$(cat "$_project_scope/room_name" 2>/dev/null) + local _target_scope="" + if [ "$_project_room" = "$target_room" ]; then + _target_scope="$_project_scope" + else + # Sibling sidecar scope under the project scope's parent. + # Convention: primary scope is `/.airc`, sidecar scope is + # `/.airc.` (e.g. `.airc.general`). + _target_scope="${_project_scope}.${target_room}" + fi + if [ ! -d "$_target_scope" ] || [ ! -f "$_target_scope/room_name" ]; then + echo " send --room #${target_room}: not subscribed in this scope." >&2 + echo " looked at: $_target_scope" >&2 + echo " rooms you ARE in:" >&2 + for _d in "$_project_scope" "$_project_scope".*; do + [ -f "$_d/room_name" ] && echo " - #$(cat "$_d/room_name" 2>/dev/null) (scope: $_d)" >&2 + done + echo " Fix: 'airc join --room ${target_room}' (in a separate scope), or drop the --room flag." >&2 + die "send: not subscribed to #${target_room}" + fi + # Re-exec with AIRC_HOME pointed at the target scope. Pass the + # remaining positional args (peer/message) through. The recursion + # guard prevents infinite re-routing if the target scope is itself + # misconfigured. + exec env AIRC_HOME="$_target_scope" AIRC_SEND_REROUTED=1 "$0" send "$@" + fi + fi + + local first="${1:-}" + [ -z "$first" ] && die "Usage: airc send or airc send @peer " + + local peer_name msg + case "$first" in + @*) + peer_name="${first#@}" + shift + msg="$*" + [ -z "$msg" ] && die "Usage: airc send @peer " + ;; + *) + peer_name="all" + msg="$*" + ;; + esac + ensure_init + + local my_name ts_val + my_name=$(get_name) + ts_val=$(timestamp) + + local escaped_msg + escaped_msg=$(printf '%s' "$msg" | "$AIRC_PYTHON" -c "import sys,json; print(json.dumps(sys.stdin.read())[1:-1])") + + local payload="{\"from\":\"$my_name\",\"to\":\"$peer_name\",\"ts\":\"$ts_val\",\"msg\":\"$escaped_msg\"}" + local sig; sig=$(sign_message "$payload") + local full_msg="{\"from\":\"$my_name\",\"to\":\"$peer_name\",\"ts\":\"$ts_val\",\"msg\":\"$escaped_msg\",\"sig\":\"$sig\"}" + + local host_target + host_target=$(get_config_val host_target "") + + if [ -n "$host_target" ]; then + local rhome; rhome=$(remote_home) + # Always mirror locally FIRST so we have an audit trail regardless of + # what the wire does. If send succeeds: local + remote both have it. + # If send fails: local has it (user can see it + retry), remote doesn't. + # This prevents silent loss where both sides forget a message that + # never arrived. + echo "$full_msg" >> "$MESSAGES" + + # Fast-path: when tailscale status already reports this peer offline, + # don't burn 10s on the ssh ConnectTimeout — queue immediately with a + # cleaner "peer offline in tailnet" marker. flush_pending_loop + + # monitor reconnect handle the drain automatically when the peer + # wakes. Skipped entirely for non-CGNAT targets, LAN peers, or when + # tailscale CLI is unavailable (falls through to normal ssh attempt). + if is_peer_offline_in_tailnet "$host_target"; then + echo "$full_msg" >> "$AIRC_WRITE_DIR/pending.jsonl" + local queue_marker; queue_marker=$(printf '{"from":"airc","ts":"%s","msg":"[QUEUED to %s — peer offline in tailnet, auto-delivers on wake]"}' \ + "$(timestamp)" "$peer_name") + echo "$queue_marker" >> "$MESSAGES" + date +%s > "$AIRC_WRITE_DIR/last_sent" 2>/dev/null + rm -f "$AIRC_WRITE_DIR/reminded" 2>/dev/null + return 0 + fi + + # Attempt the wire. Trust the remote's __APPENDED__ marker — some shells + # bubble benign ssh stderr warnings up as non-zero exit, but the append + # itself succeeded. We check stdout for the marker, not the exit code. + # `|| true` prevents set -e from aborting when ssh itself fails (exit 255 + # on unreachable host); we want to reach the failure-marker branch below. + # Pipe message via stdin so apostrophes (or any shell metachar) in the + # payload cannot break the single-quoted remote echo. + local out err + err=$(mktemp -t airc-send-err.XXXXXX) + out=$(printf '%s\n' "$full_msg" | relay_ssh "$host_target" "cat >> $rhome/messages.jsonl && echo __APPENDED__" 2>"$err" || true) + if ! echo "$out" | grep -q '^__APPENDED__$'; then + # Wire failed. Queue the payload for automatic retry by flush_pending_loop + # in the monitor, then annotate the local log with a [QUEUED] marker so + # `airc logs` makes the state obvious. Don't die() — queued is a form of + # success. The user's shell scripts can still check pending.jsonl if + # they need to block on delivery. + # Distinguish auth failures (user must re-pair — retrying won't help) + # from network failures (queue + retry makes sense). Prior behavior + # silently queued both the same way, hiding auth errors behind a + # misleading "Host unreachable" message. This bit the cross-mesh + # coordination: fresh-install joiner's SSH key wasn't in host's + # authorized_keys, cmd_send queued + returned 0, the joiner thought + # their send succeeded when the host never saw anything. + local stderr_raw; stderr_raw=$(cat "$err" 2>/dev/null) + local stderr; stderr=$(printf '%s' "$stderr_raw" | tr '\n' ' ' | sed 's/"/\\"/g' | cut -c1-300) + rm -f "$err" + + local is_auth_fail=0 + if echo "$stderr_raw" | grep -qiE 'permission denied|publickey|host key verification|authentication fail|identification has changed|no supported authentication'; then + is_auth_fail=1 + fi + + if [ "$is_auth_fail" = "1" ]; then + local fail_marker; fail_marker=$(printf '{"from":"airc","ts":"%s","msg":"[AUTH FAILED to %s — repair required, NOT queued] %s"}' \ + "$(timestamp)" "$peer_name" "${stderr:-no stderr}") + echo "$fail_marker" >> "$MESSAGES" + echo " SSH auth to host FAILED. Message NOT queued — every retry would fail identically." >&2 + echo " SSH stderr: ${stderr}" >&2 + echo " Fix: airc teardown --flush && airc connect " >&2 + die "Authentication failure — re-pair required" + fi + + # Network-class wire failure: legitimately transient, queue for retry. + echo "$full_msg" >> "$AIRC_WRITE_DIR/pending.jsonl" + local queue_marker; queue_marker=$(printf '{"from":"airc","ts":"%s","msg":"[QUEUED to %s — network error, will retry] %s"}' \ + "$(timestamp)" "$peer_name" "${stderr:-no stderr}") + echo "$queue_marker" >> "$MESSAGES" + echo " Network error reaching host — message queued for retry. Monitor will flush when host returns." >&2 + # Surface the actual stderr so the user understands WHY — the old + # generic "host unreachable" was hiding real errors. + echo " SSH stderr: ${stderr:-}" >&2 + else + rm -f "$err" + fi + else + # Host path: append to OUR messages.jsonl. Joiners' SSH tails will + # pick it up and route to their monitors. BUT — if our monitor isn't + # actually running, no joiner is connected (the SSH tail rides on the + # monitor process tree), and this append goes to a log nobody reads. + # The send returns 0 and the user thinks it succeeded. + # + # That's exactly how Joel hit "I see no communication going on" on + # 2026-04-26: shell auto-cd'd into a different scope mid-session, that + # scope's monitor was dead, every `airc msg` returned 0 with zero + # delivery, and the peer in the actual room waited forever for a + # reply that never landed. + # + # Detect: pidfile exists AND every PID in it is alive. Anything else + # = monitor dead = broadcasting into a void. Die loudly so the user + # immediately knows their cwd / scope / monitor state is wrong. + local _pidfile="$AIRC_WRITE_DIR/airc.pid" + local _monitor_alive=0 + if [ -f "$_pidfile" ]; then + local _pids; _pids=$(cat "$_pidfile" 2>/dev/null) + if [ -n "$_pids" ]; then + local _all_alive=1 _p + for _p in $_pids; do + kill -0 "$_p" 2>/dev/null || { _all_alive=0; break; } + done + [ "$_all_alive" = "1" ] && _monitor_alive=1 + fi + fi + if [ "$_monitor_alive" = "0" ]; then + # --internal callers (informational broadcasts: [rename], etc.): + # append to the local log silently and return 0. The monitor-down + # die is appropriate UX for explicit `airc send` — it surfaces + # "you're broadcasting to nobody" loudly so the user doesn't wait + # for a reply that can't arrive. For [rename] the broadcast is + # informational; receivers heal via monitor_formatter's host- + # fallback on next traffic, so noisily failing the rename in any + # scope whose monitor isn't running today (a perfectly normal + # multi-scope state) would give the rename feature a worse UX + # than no-propagation had. + if [ "$internal" = "1" ]; then + echo "$full_msg" >> "$MESSAGES" + date +%s > "$AIRC_WRITE_DIR/last_sent" 2>/dev/null + rm -f "$AIRC_WRITE_DIR/reminded" 2>/dev/null + return 0 + fi + echo " Send NOT delivered — this scope's monitor isn't running." >&2 + echo " scope: $AIRC_WRITE_DIR" >&2 + echo " identity: $my_name (host)" >&2 + if [ -f "$_pidfile" ]; then + echo " pidfile: $_pidfile (stale — process not alive)" >&2 + else + echo " pidfile: absent (monitor never started in this scope)" >&2 + fi + echo " Joiners ride on the monitor's SSH tail; with the monitor down, your message reaches no one." >&2 + echo " Fix: run 'airc connect' to start (or resume) this scope's monitor, then retry." >&2 + echo " OR cd into the scope you actually meant to send from." >&2 + die "monitor down — refusing to silently broadcast into a void" + fi + echo "$full_msg" >> "$MESSAGES" + fi + + # Reset reminder — you sent something, clock restarts + date +%s > "$AIRC_WRITE_DIR/last_sent" 2>/dev/null + rm -f "$AIRC_WRITE_DIR/reminded" 2>/dev/null +} + +# Ping a peer to verify their monitor is alive AND processing traffic. +# +# Sends [PING:] to the peer via cmd_send, then tails the local +# messages.jsonl for a [PONG:] response from that peer with a +# timeout. Three outcomes the caller can distinguish: +# +# - PONG arrives within timeout → peer's monitor is alive + running +# a compatible airc version (one with the auto-pong handler in +# monitor_formatter). +# - Timeout, but [PING:] IS visible in local log → the ping +# landed on the wire (SSH append succeeded) but no response. Either +# (a) peer's monitor is dead, or (b) peer is running an older airc +# without the auto-pong handler, or (c) peer is a non-airc agent +# (e.g., Codex) that reads the log but doesn't respond. +# - Timeout, [PING:] NOT visible → the send itself failed or +# queued (see cmd_send's wire-failure branch). Wire is broken. +# +# Design: ping is a regular signed message with a prefix marker. Clients +# that don't implement auto-pong see it as "a message starting with +# [PING:]" — harmless, logs it, life continues. Forward-compatible + +# gracefully-degrading across airc versions AND across agent types. +# +# Usage: +# airc ping @peer # default 10s timeout +# airc ping @peer 30 # 30s timeout +cmd_ping() { + local first="${1:-}" + [ -z "$first" ] && die "Usage: airc ping @peer [timeout_secs]" + case "$first" in + @*) ;; + *) die "Usage: airc ping @peer — ping requires an @peer target (broadcast ping not supported)" ;; + esac + local peer_name="${first#@}" + local timeout="${2:-10}" + # Basic sanity: timeout must be a positive integer. Guards against + # typos that would make the wait-loop spin forever or exit early. + case "$timeout" in + ''|*[!0-9]*) die "timeout must be a positive integer (got '$timeout')" ;; + esac + ensure_init + + # uuid from python for format consistency with the regex in monitor_formatter. + local ping_id + ping_id=$("$AIRC_PYTHON" -c "import uuid; print(uuid.uuid4())") + + local start_time + start_time=$(date +%s) + + # Use cmd_send so the ping rides the same signed-message path as + # normal traffic — guaranteed shape parity with what the receiver's + # monitor_formatter reads. + cmd_send "@$peer_name" "[PING:$ping_id]" >/dev/null || die "ping send failed — check SSH/auth state (airc status)" + + echo "ping sent to $peer_name (id=$ping_id) — waiting up to ${timeout}s for pong..." + + # Poll local messages.jsonl for the matching pong. We check the FULL + # log since the ping was written (cmd_send mirrors locally first). + # 0.5s poll is responsive without spinning. + while true; do + local now elapsed + now=$(date +%s) + elapsed=$((now - start_time)) + if grep -q "\[PONG:$ping_id\]" "$MESSAGES" 2>/dev/null; then + echo "PONG received from $peer_name after ${elapsed}s — monitor alive + auto-responder working." + return 0 + fi + if [ "$elapsed" -ge "$timeout" ]; then + echo "TIMEOUT after ${timeout}s — no pong from $peer_name." + # Secondary diagnosis: did the ping land on the wire at all? + if grep -q "\[PING:$ping_id\]" "$MESSAGES" 2>/dev/null; then + echo " Ping IS visible in local log (cmd_send mirrored it). That proves our outbound works." + echo " No pong likely means: (a) peer's monitor is dead, (b) peer runs older airc without auto-pong, or (c) peer is a non-airc agent." + else + echo " Ping is NOT in local log — cmd_send's mirror may have failed. Check: airc status, airc logs." + fi + return 1 + fi + sleep 0.5 + done +} diff --git a/lib/airc_bash/cmd_status.sh b/lib/airc_bash/cmd_status.sh new file mode 100644 index 0000000..daacbea --- /dev/null +++ b/lib/airc_bash/cmd_status.sh @@ -0,0 +1,170 @@ +# Sourced by airc. cmd_status + cmd_logs — introspection verbs. +# +# Functions exported back to airc's dispatch: +# cmd_status — human-readable liveness snapshot. Fast (no network) +# by default; `--probe` adds an SSH host check. +# cmd_logs — tail messages.jsonl. Falls back to host's log via +# ssh when not the host. +# +# Both are read-only introspection and share no helpers, but live in +# the same conceptual group ("what is happening?"). External cross- +# references (call-time): die, ensure_init, get_config_val, relay_ssh, +# remote_home, MESSAGES, AIRC_PYTHON. +# +# Extracted from airc as part of #152 Phase 3 file split. + +cmd_status() { + # Human-readable liveness view. Fast — no network calls by default; `--probe` + # opts into a 3s SSH reachability check. + ensure_init + local probe=0 + [ "${1:-}" = "--probe" ] && probe=1 + + local my_name host_target host_name host_port + my_name=$(get_name) + host_target=$(get_config_val host_target "") + host_name=$(get_config_val host_name "") + host_port=$(get_config_val host_port 7547) + + echo " airc status — scope $AIRC_WRITE_DIR" + + # Identity + role line. + if [ -n "$host_target" ]; then + echo " identity: $my_name (joiner of ${host_name:-?} @ ${host_target}:${host_port})" + else + local my_port; my_port="${AIRC_PORT:-7547}" + [ -f "$AIRC_WRITE_DIR/host_port" ] && my_port=$(cat "$AIRC_WRITE_DIR/host_port" 2>/dev/null) + echo " identity: $my_name (hosting on port ${my_port})" + fi + + # Monitor alive? Read the scope's pidfile — cmd_connect writes its own PID + # there. pgrep'd descendants (python listener, tail loop) should be children + # of that PID. If the main PID is gone, the monitor is down. + local monitor_state="not running" + local pidfile="$AIRC_WRITE_DIR/airc.pid" + if [ -f "$pidfile" ]; then + # cmd_connect writes multiple space-separated PIDs on one line (parent + + # python listener). Monitor is "running" if ANY of them is alive. + local pids_raw; pids_raw=$(cat "$pidfile" 2>/dev/null | tr '\n' ' ' || true) + local any_alive="" + for p in $pids_raw; do + if kill -0 "$p" 2>/dev/null; then any_alive="$p"; break; fi + done + if [ -n "$any_alive" ]; then + monitor_state="running (PID $any_alive)" + else + monitor_state="stale pidfile (PIDs $pids_raw not alive — run 'airc connect' to self-heal)" + fi + fi + echo " monitor: $monitor_state" + + # Host reachability. Only meaningful for joiners; opt-in via --probe to keep + # `airc status` fast by default (SSH connect can hang for seconds). + if [ -n "$host_target" ] && [ "$probe" = "1" ]; then + local ssh_key="$IDENTITY_DIR/ssh_key" + local probe_out + probe_out=$(ssh -i "$ssh_key" -o StrictHostKeyChecking=accept-new \ + -o ConnectTimeout=3 -o BatchMode=yes \ + "$host_target" "echo __REACHABLE__" 2>/dev/null || true) + if echo "$probe_out" | grep -q '^__REACHABLE__$'; then + echo " host: reachable" + else + echo " host: UNREACHABLE (ssh timeout or auth failure)" + fi + fi + + # Last send / receive timestamps. last_sent is a unix epoch written by + # cmd_send. last receive: tail the local messages.jsonl for the most recent + # inbound line (from != $my_name). + local now; now=$(date +%s) + if [ -f "$AIRC_WRITE_DIR/last_sent" ]; then + local ls; ls=$(cat "$AIRC_WRITE_DIR/last_sent" 2>/dev/null) + if [ -n "$ls" ] && [ "$ls" -gt 0 ] 2>/dev/null; then + echo " last send: $(( now - ls ))s ago" + else + echo " last send: never" + fi + else + echo " last send: never" + fi + + if [ -s "$MESSAGES" ]; then + local last_rx_ts + last_rx_ts=$(PEERS_DIR="$PEERS_DIR" MY_NAME="$my_name" "$AIRC_PYTHON" -c " +import sys, json, os, calendar, time +name = os.environ.get('MY_NAME', '') +last_ts = None +try: + with open('$MESSAGES') as f: + for line in f: + try: + m = json.loads(line) + if m.get('from') and m.get('from') != name and m.get('from') != 'airc': + last_ts = m.get('ts') + except: pass +except: pass +if last_ts: + # ts is ISO8601 UTC (Z-suffix). Convert to epoch. + try: + t = time.strptime(last_ts.replace('Z',''), '%Y-%m-%dT%H:%M:%S') + print(int(calendar.timegm(t))) + except: print('') +else: + print('') +" 2>/dev/null) + if [ -n "$last_rx_ts" ]; then + echo " last recv: $(( now - last_rx_ts ))s ago" + else + echo " last recv: never" + fi + else + echo " last recv: never" + fi + + # Pending queue — how many sends are waiting for a drain. Populated by + # cmd_send's wire-failure branch; drained by flush_pending_loop. + local pending="$AIRC_WRITE_DIR/pending.jsonl" + local pending_count=0 + [ -f "$pending" ] && pending_count=$(grep -c '^.' "$pending" 2>/dev/null || echo 0) + if [ "$pending_count" -gt 0 ]; then + echo " queue: ${pending_count} pending (auto-retries every ~5s)" + else + echo " queue: empty" + fi + + # Reminder state + local reminder_file="$AIRC_WRITE_DIR/reminder" + if [ -f "$reminder_file" ]; then + local rv; rv=$(cat "$reminder_file" 2>/dev/null) + if [ "$rv" = "0" ]; then + echo " reminder: paused" + elif [ -n "$rv" ] && [ "$rv" -gt 0 ] 2>/dev/null; then + echo " reminder: every ${rv}s" + fi + else + echo " reminder: off" + fi +} + +cmd_logs() { + ensure_init + local count="${1:-20}" + local host_target + host_target=$(get_config_val host_target "") + + local raw + if [ -n "$host_target" ]; then + local rhome; rhome=$(remote_home) + raw=$(relay_ssh "$host_target" "tail -${count} $rhome/messages.jsonl 2>/dev/null" 2>/dev/null) || true + else + raw=$(tail -"$count" "$MESSAGES" 2>/dev/null) || true + fi + echo "$raw" | "$AIRC_PYTHON" -c " +import sys, json +for line in sys.stdin: + try: + m = json.loads(line.strip()) + print(f\"[{m.get('ts','')}] {m.get('from','?')}: {m.get('msg','')}\") + except: pass +" +} diff --git a/lib/airc_bash/cmd_teardown.sh b/lib/airc_bash/cmd_teardown.sh new file mode 100644 index 0000000..a76a834 --- /dev/null +++ b/lib/airc_bash/cmd_teardown.sh @@ -0,0 +1,273 @@ +# Sourced by airc. cmd_teardown + cmd_disconnect — leave/cleanup verbs. +# +# Functions exported back to airc's dispatch: +# cmd_teardown — kill all airc processes in this scope, free ports; +# --flush wipes state dir, --all nukes every airc- +# looking process on the machine. +# cmd_disconnect — "leave the room" softly: kill processes, clear +# host-pairing fields, preserve identity + peers + +# message history. Next `airc connect` is a fresh +# host instead of resume. +# +# External cross-references (call-time): die, ensure_init, get_config_val, +# unset_config_keys, proc_airc_pids_matching, port_listeners, AIRC_HOME, +# AIRC_WRITE_DIR. Both verbs share the kill loop but split on what to +# clear afterwards. +# +# Extracted from airc as part of #152 Phase 3 file split. Continues the +# Joel 2026-04-27 modularization push: every cmd_X group becomes its own +# file so the airc top-level retains only bootstrap + helpers + dispatch. + +cmd_teardown() { + # Kill all airc processes for this user and free any ports they hold. + # Add --flush to also wipe the state dir (identity, peers, messages) — nuclear. + # Add --all to nuke EVERY airc-looking process on this machine, ignoring + # scope/PID file — for the "I just want it all dead" case after stale + # zombies survive across sessions (verified 2026-04-21: /tmp/airc-prefix + # connect processes from a previous session were still alive 2 days later + # because teardown's PID file no longer existed for them). + local flush=0 all=0 + while [ $# -gt 0 ]; do + case "$1" in + --flush) flush=1 ;; + --all) all=1 ;; + *) echo " unknown teardown flag: $1" >&2; return 2 ;; + esac + shift + done + + # ── --all: nuclear, scope-blind ─────────────────────────────────── + # Find every airc-related process for THIS user and kill it. Targets: + # - bash processes running `airc connect` (any scope) + # - bash processes running `/airc connect` or `/tmp/airc-prefix connect` + # - python processes spawned by airc (the inline -u -c monitor with + # the `WATCHDOG_SEC` heredoc) — identified by ppid pointing at one + # of the bash processes we're killing + # - python listeners holding any TCP port in the airc range (7547-7559) + # Then proceeds to the scope-aware path below to clean up our own pidfile + # + reap any orphaned listener on our specific port. + if [ "$all" = "1" ]; then + local nuked=0 + # Bash airc-connect processes (any path that ends in /airc connect or + # the /tmp/airc-prefix bootstrap variant the curl|bash installer uses). + local bash_pids + bash_pids=$(proc_airc_pids_matching '(airc|airc-prefix)[[:space:]]+connect' || true) + if [ -n "$bash_pids" ]; then + echo " --all: killing airc bash processes: $(echo $bash_pids | tr '\n' ' ')" + kill -9 $bash_pids 2>/dev/null || true + nuked=1 + fi + # Python listeners on airc port range (7547-7559). Don't touch python + # outside that range — could be unrelated processes. + local port + for port in 7547 7548 7549 7550 7551 7552 7553 7554 7555 7556 7557 7558 7559; do + local lpids + lpids=$(port_listeners "$port" || true) + for lpid in $lpids; do + local cmd + cmd=$(proc_cmdline "$lpid" || true) + if echo "$cmd" | grep -q "socket.SOCK_STREAM\|socket.AF_INET"; then + echo " --all: freeing port $port (python pid $lpid)" + kill -9 "$lpid" 2>/dev/null || true + nuked=1 + fi + done + done + # Stale tail/ssh subprocesses that look like airc message tails + # (ssh ... tail -F .../.airc/messages.jsonl). + local tail_pids + tail_pids=$(proc_airc_pids_matching '\.airc/messages\.jsonl' || true) + if [ -n "$tail_pids" ]; then + echo " --all: killing stale airc message tails: $(echo $tail_pids | tr '\n' ' ')" + kill -9 $tail_pids 2>/dev/null || true + nuked=1 + fi + [ "$nuked" = "0" ] && echo " --all: no machine-wide airc processes to kill." + # Fall through to scope-aware path below to also clean up THIS scope's + # pidfile + flush if requested. (--all is additive, not exclusive.) + fi + + + local killed=0 + # Hosted gist cleanup BEFORE process kill. The cmd_connect EXIT trap + # would normally delete our hosted gist on graceful shutdown, but the + # kill -9 below skips traps entirely. Without this explicit step, + # every `airc teardown` of a host left an orphan gist on the gh + # account that joiners couldn't tell apart from a live host until + # heartbeat went stale (~90s later). Caught by Joel's other tab + # bouncing repeatedly and accumulating fresh #general gists each + # cycle. + if [ -f "$AIRC_WRITE_DIR/host_gist_id" ] && command -v gh >/dev/null 2>&1; then + local _td_gist; _td_gist=$(cat "$AIRC_WRITE_DIR/host_gist_id" 2>/dev/null) + if [ -n "$_td_gist" ]; then + if gh gist delete "$_td_gist" --yes >/dev/null 2>&1; then + echo " deleted hosted gist: $_td_gist" + fi + rm -f "$AIRC_WRITE_DIR/host_gist_id" + fi + fi + + # Sidecar scope cleanup (issue #121 — multi-room presence). + # When the primary tab spawned a #general sidecar, that sidecar runs + # in a sibling .general scope with its own pidfile + (if hosting) + # its own host_gist_id. Mirror the primary's gist cleanup + pidfile + # kill there. Without this, killing the primary leaves an orphan + # #general gist on the gh account AND an orphan sidecar process that + # the primary's pidfile descendant-walk wouldn't catch (sidecar's + # bash isn't a child of cmd_teardown — it was forked detached). + # + # Guard: AIRC_TEARDOWN_PART_ONLY=1 (set by cmd_part) skips the sidecar + # block. IRC `/part` should leave only the current channel; the + # sidecar (#general lobby) should keep running. cmd_teardown without + # this flag is the "kill everything in this scope tree" semantic. + local _sidecar_scope="${AIRC_WRITE_DIR}.general" + if [ "${AIRC_TEARDOWN_PART_ONLY:-0}" = "1" ]; then + : # cmd_part path — skip sidecar + elif [ -d "$_sidecar_scope" ]; then + if [ -f "$_sidecar_scope/host_gist_id" ] && command -v gh >/dev/null 2>&1; then + local _td_sc_gist; _td_sc_gist=$(cat "$_sidecar_scope/host_gist_id" 2>/dev/null) + if [ -n "$_td_sc_gist" ]; then + if gh gist delete "$_td_sc_gist" --yes >/dev/null 2>&1; then + echo " deleted sidecar #general gist: $_td_sc_gist" + fi + rm -f "$_sidecar_scope/host_gist_id" + fi + fi + if [ -f "$_sidecar_scope/airc.pid" ]; then + local _sc_pids; _sc_pids=$(cat "$_sidecar_scope/airc.pid" 2>/dev/null | tr '\n' ' ') + if [ -n "$_sc_pids" ]; then + local _all_sc="$_sc_pids" + for _p in $_sc_pids; do + local _kids; _kids=$(proc_children "$_p" | tr '\n' ' ' || true) + [ -n "$_kids" ] && _all_sc="$_all_sc $_kids" + done + _all_sc=$(echo "$_all_sc" | tr ' ' '\n' | sort -u | grep -v '^$' || true) + if [ -n "$_all_sc" ]; then + echo " killing sidecar scope $_sidecar_scope: $(echo $_all_sc | tr '\n' ' ')" + kill -9 $_all_sc 2>/dev/null || true + killed=1 + fi + fi + rm -f "$_sidecar_scope/airc.pid" + fi + if [ "$flush" = "1" ]; then + rm -rf "$_sidecar_scope" + fi + fi + + # Scope-aware via PID file: cmd_connect wrote its PID(s) to $AIRC_WRITE_DIR/airc.pid. + # We kill ONLY those PIDs + their descendants. Never touches other scopes. + local pidfile="$AIRC_WRITE_DIR/airc.pid" + if [ -f "$pidfile" ]; then + local main_pids + # `|| true` — same class as #6: if $pidfile is racily removed between the + # `-f` test and this read, cat+pipefail would abort cmd_teardown before we + # reach `rm -f` below. Empty main_pids → we fall through cleanly. + main_pids=$(cat "$pidfile" 2>/dev/null | tr '\n' ' ' || true) + if [ -n "$main_pids" ]; then + # Collect descendants (Python listener etc) before killing the parent. + local all_pids="$main_pids" + for pid in $main_pids; do + local kids + kids=$(proc_children "$pid" | tr '\n' ' ' || true) + [ -n "$kids" ] && all_pids="$all_pids $kids" + done + all_pids=$(echo "$all_pids" | tr ' ' '\n' | sort -u | grep -v '^$' || true) + # Part-only path: exclude the sidecar's bash + its descendants so + # `airc part` doesn't sweep them via the primary's child-tree. + # The sidecar's bash is forked from primary, so pgrep -P picks it + # up here; without exclusion we'd kill the sidecar in violation + # of IRC /part semantics (leave one channel, keep others alive). + if [ "${AIRC_TEARDOWN_PART_ONLY:-0}" = "1" ] && [ -n "$all_pids" ]; then + local _exclude_pids="" + local _sc_pidfile="${AIRC_WRITE_DIR}.general/airc.pid" + if [ -f "$_sc_pidfile" ]; then + local _sc_pids; _sc_pids=$(cat "$_sc_pidfile" 2>/dev/null | tr '\n' ' ') + for _scp in $_sc_pids; do + _exclude_pids="$_exclude_pids $_scp" + local _scp_kids; _scp_kids=$(proc_children "$_scp" | tr '\n' ' ' || true) + [ -n "$_scp_kids" ] && _exclude_pids="$_exclude_pids $_scp_kids" + done + fi + if [ -n "$_exclude_pids" ]; then + local _filtered="" + for _p in $all_pids; do + local _skip=0 + for _ex in $_exclude_pids; do + [ "$_p" = "$_ex" ] && { _skip=1; break; } + done + [ "$_skip" = "0" ] && _filtered="$_filtered $_p" + done + all_pids=$(echo "$_filtered" | tr ' ' '\n' | grep -v '^$' || true) + fi + fi + if [ -n "$all_pids" ]; then + echo " killing scope $AIRC_WRITE_DIR: $(echo $all_pids | tr '\n' ' ')" + kill -9 $all_pids 2>/dev/null || true + killed=1 + fi + fi + rm -f "$pidfile" 2>/dev/null + fi + + # Brief pause to let the kernel reparent any airc python listener children + # to init (PID 1) after we killed their bash parent. Then reap orphans. + [ "$killed" = "1" ] && sleep 0.5 + + # Free the TCP port we were listening on. Kill any python socket listener + # that's now orphaned (parent=1). Don't touch anything else. + local ports="${AIRC_PORT:-7547}" + [ "$ports" != "7547" ] && ports="$ports 7547" + for port in $ports; do + local lpids + lpids=$(port_listeners "$port" || true) + for lpid in $lpids; do + # `|| true` on both — $lpid came from lsof a moment ago; if the process + # exited in the interim, `ps -p` returns 1 and pipefail/errexit would + # abort the port-reap loop mid-scan, leaving later ports unchecked. + # Empty parent/cmd → the `if` below falls through, which is correct. + local parent; parent=$(proc_parent "$lpid" || true) + local cmd; cmd=$(proc_cmdline "$lpid" || true) + # Reap if orphaned AND is a python socket listener. + if [ "$parent" = "1" ] && echo "$cmd" | grep -q "socket.SOCK_STREAM"; then + echo " freeing orphaned port $port (pid $lpid)" + kill -9 "$lpid" 2>/dev/null || true + killed=1 + fi + done + done + + if [ "$flush" = "1" ]; then + # Wipe current tier's state. Leaves the other tier alone. + local dir="$AIRC_WRITE_DIR" + if [ -n "$dir" ] && [ -d "$dir" ]; then + echo " flushing state: $dir" + rm -rf "$dir" + fi + fi + + [ "$killed" = "0" ] && echo " No airc processes running." || echo " Teardown complete." +} + +cmd_disconnect() { + # "Leave the room" — kill running processes in scope, then clear only the + # host-pairing fields from config.json. Your identity (name + keys), peers + # list, and message history are all preserved. Next `airc connect` (no + # args) starts fresh host mode instead of auto-resuming the prior pairing. + # Use when you want to switch to a different mesh or host a new one, but + # keep your agent identity stable. + cmd_teardown >/dev/null 2>&1 || true + if [ -f "$CONFIG" ]; then + "$AIRC_PYTHON" -c " +import json +try: + c = json.load(open('$CONFIG')) + for k in ('host_target', 'host_name', 'host_airc_home', 'host_port', 'host_ssh_pub'): + c.pop(k, None) + json.dump(c, open('$CONFIG', 'w'), indent=2) +except Exception: + pass +" 2>/dev/null || true + fi + echo " Disconnected. Identity preserved. Next 'airc connect' starts fresh (not a resume)." +} diff --git a/lib/airc_bash/cmd_update.sh b/lib/airc_bash/cmd_update.sh new file mode 100644 index 0000000..2f48cb5 --- /dev/null +++ b/lib/airc_bash/cmd_update.sh @@ -0,0 +1,148 @@ +# Sourced by airc. Release-info cluster — cmd_update + cmd_channel + cmd_version. +# +# Functions exported back to airc's dispatch: +# cmd_update — `git pull` the install dir on the active channel and +# re-run install.sh so new skills get symlinked. Idempotent. +# --channel switches branch first. +# cmd_channel — show or set the release channel (canary | main) without +# pulling. Lightweight inverse of `airc canary`. +# cmd_version — print the running install's git rev + branch + path. +# Same shape as `airc --version` / `airc -v`. +# +# Bundled because all three answer the same user question: "what release +# am I on, and how do I move?" External cross-references (call-time): die, +# AIRC_CHANNEL (env), the install_dir resolver in airc top-level. +# +# Extracted from airc as part of #152 Phase 3 file split — the final +# structural sweep. + +cmd_update() { + # Refresh install dir AND re-run install.sh so new skills get symlinked + # into ~/.claude/skills/ and old ones get cleaned up. install.sh is + # idempotent — it handles the pull, the binary symlink, and the skill + # directory refresh in one pass. Does NOT teardown or reconnect. + # + # Channels (#40 followup): airc supports release channels for opt-in + # pre-merge testing. main = stable; canary = features-not-yet-promoted. + # The chosen channel persists in $AIRC_DIR/.channel so subsequent + # `airc update` (no args) keeps the user on their chosen track. + # airc update # stay on current channel (default: main) + # airc update --channel canary # switch to canary + update + # airc update --channel main # switch back to main + update + # airc channel # show current channel without updating + local dir="${AIRC_DIR:-$HOME/.airc-src}" + local channel_file="$dir/.channel" + local requested_channel="" + while [ $# -gt 0 ]; do + case "$1" in + --channel|-c) + requested_channel="${2:-}" + [ -z "$requested_channel" ] && die "Usage: airc update --channel " + shift 2 + ;; + --canary) requested_channel="canary"; shift ;; + --main) requested_channel="main"; shift ;; + *) shift ;; + esac + done + + if [ ! -d "$dir/.git" ]; then + die "No git checkout at $dir. Reinstall: curl -fsSL https://raw.githubusercontent.com/CambrianTech/airc/main/install.sh | bash" + fi + + # Determine target channel: explicit request > saved preference > main. + local channel + if [ -n "$requested_channel" ]; then + channel="$requested_channel" + elif [ -f "$channel_file" ]; then + channel=$(cat "$channel_file" 2>/dev/null | tr -d '[:space:]') + [ -z "$channel" ] && channel="main" + else + channel="main" + fi + + # Switch to the target branch BEFORE pulling. install.sh will then ff-pull + # whatever branch is checked out. Fail loud if the channel doesn't exist + # on origin — silently falling back to main would defeat the opt-in test + # purpose. + local before; before=$(git -C "$dir" rev-parse --short HEAD 2>/dev/null) + local current_branch; current_branch=$(git -C "$dir" rev-parse --abbrev-ref HEAD 2>/dev/null) + if [ "$current_branch" != "$channel" ]; then + git -C "$dir" fetch --quiet origin "$channel" 2>/dev/null \ + || die "Channel '$channel' not found on origin. Try: airc channel (to see options)." + git -C "$dir" checkout -q "$channel" 2>/dev/null \ + || git -C "$dir" checkout -q -B "$channel" "origin/$channel" 2>/dev/null \ + || die "Failed to checkout '$channel'. Resolve manually in $dir." + fi + + if [ ! -x "$dir/install.sh" ]; then + die "install.sh missing at $dir. Reinstall via curl|bash." + fi + AIRC_DIR="$dir" bash "$dir/install.sh" || die "install.sh failed." + + # Persist channel choice AFTER successful update so a failed switch + # doesn't leave a dangling preference for a broken state. + echo "$channel" > "$channel_file" + + local after; after=$(git -C "$dir" rev-parse --short HEAD 2>/dev/null) + if [ "$before" = "$after" ]; then + echo " Already at ${after} on channel '${channel}'. Skills refreshed." + else + echo " Updated: ${before} -> ${after} on channel '${channel}'. Skills refreshed." + echo " Running monitor still uses the old code. To pick up: airc teardown && airc connect" + fi +} + +# ── cmd_channel: show or set the release channel without pulling ────── +# `airc channel` → print current channel + how to switch +# `airc channel canary` → set preferred channel; doesn't pull (use +# `airc update` after to actually switch) +# Allows the AI / human to inspect + decide before the heavier update. +cmd_channel() { + local dir="${AIRC_DIR:-$HOME/.airc-src}" + local channel_file="$dir/.channel" + local current="main" + [ -f "$channel_file" ] && current=$(cat "$channel_file" 2>/dev/null | tr -d '[:space:]') + [ -z "$current" ] && current="main" + + local target="${1:-}" + if [ -z "$target" ]; then + echo " Channel: $current" + echo " Available channels (any branch on origin can be a channel):" + echo " main — stable, what most users run" + echo " canary — features queued for the next main merge; opt-in testing" + echo " Switch:" + echo " airc channel # set preference (run 'airc update' after)" + echo " airc update --channel # set + pull in one step" + return 0 + fi + + echo "$target" > "$channel_file" + echo " Channel preference set: '$target'. Run 'airc update' to actually switch + pull." +} + +cmd_version() { + # Report git state for whichever airc actually ran. Prefer the binary's + # own directory so a dev-checkout run doesn't lie about AIRC_DIR. + local self; self="$(realpath "$0" 2>/dev/null || echo "$0")" + local here; here="$(dirname "$self")" + local dir="" + if [ -d "$here/.git" ]; then + dir="$here" + elif [ -d "${AIRC_DIR:-$HOME/.airc-src}/.git" ]; then + dir="${AIRC_DIR:-$HOME/.airc-src}" + fi + if [ -z "$dir" ]; then + echo " unknown (no git metadata found)" + return + fi + local sha subject branch dirty + sha=$(git -C "$dir" rev-parse --short HEAD 2>/dev/null) + subject=$(git -C "$dir" log -1 --format=%s 2>/dev/null) + branch=$(git -C "$dir" rev-parse --abbrev-ref HEAD 2>/dev/null) + dirty="" + [ -n "$(git -C "$dir" status --porcelain 2>/dev/null)" ] && dirty=" (dirty)" + echo " airc ${sha}${dirty} on ${branch}" + [ -n "$subject" ] && echo " ${subject}" + echo " install: $dir" +} diff --git a/lib/airc_bash/platform_adapters.sh b/lib/airc_bash/platform_adapters.sh new file mode 100644 index 0000000..0058d88 --- /dev/null +++ b/lib/airc_bash/platform_adapters.sh @@ -0,0 +1,176 @@ +# Sourced by airc. Cross-platform helpers — proc_*, port_*, file_*, +# detect_platform, iso_to_epoch. See top-of-file comment for the +# extracted-from-airc rationale (#152 Phase 3). + +# ── Platform adapters ─────────────────────────────────────────────────── +# +# Single-purpose helpers that hide platform-specific differences in the +# process / port / filesystem APIs. Every callsite that needs "find +# children of PID X" or "find PIDs listening on port Y" goes through +# these helpers, NOT inline pgrep/lsof. That way: +# +# 1. The platform-specific implementation lives in ONE place per +# capability — adding a Windows fallback for `lsof` (e.g. via +# `netstat -ano`) means editing one helper, not 4+ callsites. +# 2. The business logic above the adapter line stays platform- +# agnostic. Refactor risk drops. +# 3. We hold the line on Joel's "fixing one platform shouldn't +# degrade another" rule (2026-04-26): without adapters, a Mac +# AI's tweak to a pgrep callsite easily diverges from the Linux +# AI's tweak. With adapters, both AIs touch the same helper. +# +# Each adapter takes simple inputs and emits a one-thing-per-line +# stream, suitable for `while IFS= read -r` consumption. Callers can +# `tr '\n' ' '` if they want space-separated, but the canonical +# representation is newline-delimited (POSIX-friendly). +# +# Conventions: +# - `proc_*` — process / PID introspection +# - `port_*` — TCP port introspection +# - `file_*` — filesystem metadata +# - `detect_*` — environment classification + +# Return PIDs of direct children of $1, one per line. +# Implementations: pgrep -P (POSIX/macOS/Linux), ps fallback for +# environments without pgrep (Git Bash for Windows ships only msys +# coreutils — no pgrep by default; the fallback uses `ps -axo pid,ppid` +# which msys2 ps DOES support). Empty output if no children or pid is +# already gone. +proc_children() { + local pid="$1" + [ -z "$pid" ] && return 0 + if command -v pgrep >/dev/null 2>&1; then + pgrep -P "$pid" 2>/dev/null + else + # POSIX-portable fallback. Works on Git Bash (msys ps), Linux ps, + # macOS ps. Awk filters by ppid column. + ps -axo pid,ppid 2>/dev/null | awk -v p="$pid" '$2 == p { print $1 }' + fi +} + +# Return parent PID of $1. Empty if $1 is gone. +proc_parent() { + local pid="$1" + [ -z "$pid" ] && return 0 + ps -p "$pid" -o ppid= 2>/dev/null | tr -d ' ' +} + +# Return the command line of $1 (full argv, space-joined). Empty if gone. +proc_cmdline() { + local pid="$1" + [ -z "$pid" ] && return 0 + ps -p "$pid" -o command= 2>/dev/null +} + +# Find airc-related PIDs owned by the current user matching a pattern. +# Used by `airc teardown --all` to nuke every airc process. +# Pattern is a regex passed to pgrep -f or to awk's =~. +proc_airc_pids_matching() { + local pattern="$1" + [ -z "$pattern" ] && return 0 + if command -v pgrep >/dev/null 2>&1; then + pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null + else + # Fallback: ps + awk. Less precise than pgrep -f (no anchored regex) + # but covers the same shape. Filter by user since msys ps -u option + # may not match POSIX semantics. + local me; me=$(whoami 2>/dev/null) + ps -axo pid,user,command 2>/dev/null \ + | awk -v u="$me" -v p="$pattern" 'NR>1 && $2 == u && $0 ~ p { print $1 }' + fi +} + +# Return PIDs listening on TCP port $1 (LISTEN state), one per line. +# Implementations: +# 1. lsof -tiTCP: -sTCP:LISTEN — macOS, most BSDs, modern Linux +# with lsof installed. +# 2. ss -tlnp — modern Linux distros (iproute2 default since ~2017), +# replaces deprecated netstat. Output post-processing extracts pid. +# 3. netstat -ano — Windows native (cmd / PowerShell), and also a +# fallback on minimal Linux containers without lsof or ss. Output +# shape differs per platform; awk parses the LISTENING column. +# Empty output = nobody listening. +port_listeners() { + local port="$1" + [ -z "$port" ] && return 0 + if command -v lsof >/dev/null 2>&1; then + lsof -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null + elif command -v ss >/dev/null 2>&1; then + # ss output: 'LISTEN 0 ... users:(("python",pid=12345,fd=4))' + # Awk extracts pid= number. + ss -tlnp "( sport = :$port )" 2>/dev/null \ + | awk 'NR>1 { match($0, /pid=[0-9]+/); if (RSTART) print substr($0, RSTART+4, RLENGTH-4) }' + elif command -v netstat >/dev/null 2>&1; then + # netstat -ano output (Windows + some Linux): + # TCP 0.0.0.0:7547 0.0.0.0:0 LISTENING 12345 + # Trailing column is PID. Match $port at end of local-address column. + netstat -ano 2>/dev/null \ + | awk -v p=":$port" '$2 ~ p"$" && /LISTEN/ { print $NF }' + fi +} + +# Return file size in bytes. Empty / 0 on failure. +# stat is not POSIX (different flags on BSD vs GNU); chain both with +# fallback to wc -c which IS POSIX. +file_size() { + local path="$1" + [ -f "$path" ] || { echo 0; return 0; } + stat -f%z "$path" 2>/dev/null \ + || stat -c%s "$path" 2>/dev/null \ + || wc -c < "$path" 2>/dev/null \ + || echo 0 +} + +# Detect platform: emits one of macos, linux, wsl, windows-bash (Git Bash +# on Windows native), unknown. Most callers don't need this — they +# should use the proc_/port_/file_ adapters, which handle platform +# differences internally. detect_platform is for the rare case where +# a top-level decision genuinely depends on platform (e.g. Tailscale.app +# launching on macOS). +detect_platform() { + case "$(uname -s 2>/dev/null)" in + Darwin) echo darwin ;; + Linux) grep -qiE 'microsoft|wsl' /proc/version 2>/dev/null && echo wsl || echo linux ;; + MINGW*|MSYS*|CYGWIN*) echo windows ;; + *) echo unknown ;; + esac +} + +# Convert an ISO 8601 UTC timestamp to a Unix epoch (seconds since 1970). +# Echoes the epoch on success, empty on failure. +# +# Migrated to airc_core.datetime as Phase 0a of the Python truth-layer +# (#152 architecture). Pre-migration this was a 3-fallback adapter +# chain inline in bash (BSD date / GNU date / python3 heredoc). +# Post-migration the bash function is a one-line call into the +# Python module — same contract, same stdout shape, but the logic +# lives in a testable Python file with no bash → python heredoc +# substitution risk. First migration; pattern for the rest. +iso_to_epoch() { + local ts="${1:-}" + [ -z "$ts" ] && return 0 + "$AIRC_PYTHON" -m airc_core.datetime iso_to_epoch "$ts" 2>/dev/null +} + +# MSYS / Git Bash path conversion. Six callsites in airc + three in +# install.sh used the same `if command -v cygpath ... else sed ...` +# block; #205 Target #3 collapsed them. cygpath when present (MSYS2, +# modern Git Bash); sed fallback for stripped-down environments. +# Both directions exposed so callers don't have to remember which sed +# regex inverts the other. +_to_win_path() { + if command -v cygpath >/dev/null 2>&1; then + cygpath -w "$1" 2>/dev/null + else + printf '%s' "$1" | sed 's|^/\([a-z]\)/|\U\1:\\\\|; s|/|\\\\|g' + fi +} +_to_bash_path() { + if command -v cygpath >/dev/null 2>&1; then + cygpath -u "$1" 2>/dev/null + else + printf '%s' "$1" | sed 's|\\|/|g; s|^\([A-Za-z]\):|/\L\1|' + fi +} + +# ── End platform adapters ─────────────────────────────────────────────── diff --git a/lib/airc_core/__init__.py b/lib/airc_core/__init__.py new file mode 100644 index 0000000..1e60996 --- /dev/null +++ b/lib/airc_core/__init__.py @@ -0,0 +1,26 @@ +"""airc_core — shared Python truth-layer for airc. + +Both the bash entrypoint (airc) and the PowerShell entrypoint (airc.ps1) +invoke functions in this package instead of duplicating logic across +shell heredocs. Goals: + +1. **One source of truth for business logic.** Config CRUD, gist envelope + parse/build, pair handshake JSON, monitor formatting, etc. live here. + The shell scripts become thin dispatch + arg parsers. + +2. **No bash → python heredoc fragility.** Every fix today (silent + SyntaxErrors when bash variable substitution drifted into the python + source, function-export leaks across $() subshells, etc.) was a + symptom of mixing the two. Python files are parsed once, tested + once, and behave identically across shells. + +3. **Cross-port consistency.** Bash on macOS/Linux/Git-Bash and + PowerShell on Windows can call the SAME Python module. Drift + between airc bash and airc.ps1 (which today is ~20 PRs behind) + becomes mechanical to detect — same input → same output. + +This package is sourced by setting PYTHONPATH to include the parent +'lib' directory. The airc bash script does this at startup. +""" + +__version__ = "0.1.0" diff --git a/lib/airc_core/config.py b/lib/airc_core/config.py new file mode 100644 index 0000000..160d789 --- /dev/null +++ b/lib/airc_core/config.py @@ -0,0 +1,211 @@ +"""airc config.json CRUD. + +CLI takes paths as `--config /path/to/config.json` (argparse args), not +env vars. Avoids MSYS path-translation surprises on Git Bash and makes +the module present as a normal Python CLI. +""" + +from __future__ import annotations + +import argparse +import json +import sys + + +def get(config_path: str, key: str, default: str = "") -> str: + """Read a key from config.json. Returns default on any failure. + Nested objects (dicts/lists) round-trip as JSON-encoded strings so + callers can re-parse if needed. + """ + try: + with open(config_path) as f: + c = json.load(f) + v = c.get(key) + if v is None or v == "": + return default + if isinstance(v, (dict, list)): + return json.dumps(v) + return str(v) + except (OSError, ValueError, KeyError): + return default + + +def get_name(config_path: str) -> str: + return get(config_path, "name", "unknown") + + +def cmd_get(args) -> int: + print(get(args.config, args.key, args.default)) + return 0 + + +def cmd_get_name(args) -> int: + print(get_name(args.config)) + return 0 + + +def cmd_set_name(args) -> int: + """Atomically write the identity name into config.json. + + Replaces the inline-Python heredoc that lived in cmd_rename. With + multi-scope rename propagation (#179), cmd_rename writes the name + into the primary scope AND every sidecar scope's config; doing it + via a single CLI call per scope keeps the write quoting-safe (the + heredoc inlined `$new_name` into a python string literal which + would have broken on names containing single quotes — fortunately + the rename sanitizer only allows [a-z0-9-] today, but the heredoc + pattern was a sharp edge). + """ + try: + c = json.load(open(args.config)) + except (OSError, ValueError) as e: + print(f"airc-config-set-error: cannot read {args.config}: {e}", file=sys.stderr) + return 1 + c["name"] = args.name + try: + json.dump(c, open(args.config, "w"), indent=2) + return 0 + except OSError as e: + print(f"airc-config-set-error: cannot write {args.config}: {e}", file=sys.stderr) + return 1 + + +def _load(path): + try: return json.load(open(path)) + except (OSError, ValueError): return {} + + +def _save(path, c): + try: json.dump(c, open(path, "w"), indent=2); return 0 + except OSError as e: + print(f"airc-config-set-error: {e}", file=sys.stderr); return 1 + + +def cmd_set(args) -> int: + c = _load(args.config); c[args.key] = args.value; return _save(args.config, c) + + +def cmd_unset_keys(args) -> int: + c = _load(args.config) + for k in args.keys: c.pop(k, None) + return _save(args.config, c) + + +def cmd_read_parted(args) -> int: + for r in _load(args.config).get("parted_rooms", []) or []: print(r) + return 0 + + +def cmd_record_parted(args) -> int: + c = _load(args.config); p = list(c.get("parted_rooms", []) or []) + if args.room not in p: + p.append(args.room); c["parted_rooms"] = p; return _save(args.config, c) + return 0 + + +def cmd_clear_parted(args) -> int: + c = _load(args.config); cur = c.get("parted_rooms", []) or [] + new = [r for r in cur if r != args.room] + if new != cur: + c["parted_rooms"] = new; return _save(args.config, c) + return 0 + + +def cmd_set_host_block(args) -> int: + """Atomically write the post-handshake host_* fields into config. + + Replaces a fragile env-var-passed python heredoc that bit on MSYS + Git Bash (continuum-b69f's catch 2026-04-27): MSYS translates env + var values that look like Unix paths INTO the Windows-binary + subprocess, so /Users/... silently became C:/Program Files/Git/... + Argparse `--flags` are per-arg-predictable (callers can `//`-prefix + individual values or use MSYS2_ARG_CONV_EXCL targeted-ly), and + the python source is fixed bytes regardless of the values. + """ + try: + c = json.load(open(args.config)) + except (OSError, ValueError) as e: + print(f"airc-config-set-error: cannot read {args.config}: {e}", file=sys.stderr) + return 1 + c["host_airc_home"] = args.host_airc_home or "" + c["host_name"] = args.host_name or "" + try: + c["host_port"] = int(args.host_port) + except (TypeError, ValueError): + c["host_port"] = 7547 + c["host_ssh_pub"] = args.host_ssh_pub or "" + try: + c["host_identity"] = json.loads(args.host_identity_json or "{}") + except ValueError: + c["host_identity"] = {} + try: + json.dump(c, open(args.config, "w"), indent=2) + return 0 + except OSError as e: + print(f"airc-config-set-error: cannot write {args.config}: {e}", file=sys.stderr) + return 1 + + +def _build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="airc_core.config") + sub = p.add_subparsers(dest="cmd", required=True) + + g = sub.add_parser("get") + g.add_argument("--config", required=True) + g.add_argument("key") + g.add_argument("default", nargs="?", default="") + g.set_defaults(func=cmd_get) + + n = sub.add_parser("get_name") + n.add_argument("--config", required=True) + n.set_defaults(func=cmd_get_name) + + sn = sub.add_parser("set_name") + sn.add_argument("--config", required=True) + sn.add_argument("--name", required=True) + sn.set_defaults(func=cmd_set_name) + + ss = sub.add_parser("set") + ss.add_argument("--config", required=True) + ss.add_argument("--key", required=True) + ss.add_argument("--value", required=True) + ss.set_defaults(func=cmd_set) + + us = sub.add_parser("unset_keys") + us.add_argument("--config", required=True) + us.add_argument("keys", nargs="+") + us.set_defaults(func=cmd_unset_keys) + + rp = sub.add_parser("read_parted") + rp.add_argument("--config", required=True) + rp.set_defaults(func=cmd_read_parted) + + rcp = sub.add_parser("record_parted") + rcp.add_argument("--config", required=True) + rcp.add_argument("--room", required=True) + rcp.set_defaults(func=cmd_record_parted) + + cp = sub.add_parser("clear_parted") + cp.add_argument("--config", required=True) + cp.add_argument("--room", required=True) + cp.set_defaults(func=cmd_clear_parted) + + s = sub.add_parser("set_host_block") + s.add_argument("--config", required=True) + s.add_argument("--host-airc-home", default="") + s.add_argument("--host-name", default="") + s.add_argument("--host-port", default="7547") + s.add_argument("--host-ssh-pub", default="") + s.add_argument("--host-identity-json", default="{}") + s.set_defaults(func=cmd_set_host_block) + + return p + + +def _cli() -> int: + args = _build_parser().parse_args() + return args.func(args) + + +if __name__ == "__main__": + sys.exit(_cli()) diff --git a/lib/airc_core/datetime.py b/lib/airc_core/datetime.py new file mode 100644 index 0000000..7e3e7fe --- /dev/null +++ b/lib/airc_core/datetime.py @@ -0,0 +1,62 @@ +"""ISO 8601 ↔ Unix epoch conversion for airc. + +Migrated from the bash `iso_to_epoch` adapter (PR #151) into the Python +truth-layer (PR #152 architecture). The bash adapter handled three +fallback paths (BSD date, GNU date, python3 datetime); now that we +have Python as the canonical layer, we just use stdlib datetime. + +The bash side calls into this module via: + + "$AIRC_PYTHON" -m airc_core.datetime iso_to_epoch + +That subprocess call is the new shape — bash never re-implements logic +that lives here. +""" + +from __future__ import annotations + +import datetime +import sys + + +def iso_to_epoch(ts: str) -> int | None: + """Convert an ISO 8601 UTC timestamp to a Unix epoch integer. + + Accepts the canonical airc gist envelope timestamp shape + `YYYY-MM-DDTHH:MM:SSZ` (e.g. `2026-04-27T03:25:54Z`). Returns None + on parse failure rather than raising — callers in bash use the + empty/non-empty distinction to decide whether to skip a stale + check (matches the pre-migration adapter contract). + """ + if not ts: + return None + try: + dt = datetime.datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ") + dt = dt.replace(tzinfo=datetime.timezone.utc) + return int(dt.timestamp()) + except (ValueError, TypeError): + return None + + +def _cli() -> int: + """CLI entry: `python -m airc_core.datetime iso_to_epoch `. + + Echoes the epoch on success; empty output on failure (exit 0). + Matches the bash adapter's stdout contract — callers do + `epoch=$(... iso_to_epoch "$ts")` and check for empty. + """ + if len(sys.argv) < 2: + return 2 + cmd = sys.argv[1] + if cmd == "iso_to_epoch": + ts = sys.argv[2] if len(sys.argv) > 2 else "" + result = iso_to_epoch(ts) + if result is not None: + print(result) + return 0 + print(f"unknown subcommand: {cmd}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(_cli()) diff --git a/lib/airc_core/handshake.py b/lib/airc_core/handshake.py new file mode 100644 index 0000000..85bad12 --- /dev/null +++ b/lib/airc_core/handshake.py @@ -0,0 +1,307 @@ +"""airc pair-handshake — joiner send + host accept + response field reads. + +CLI tools take ARGS, not env vars. Paths come in via --airc-home / +--peers-dir / --identity-dir / --config / --messages so MSYS path- +translation behavior is predictable per-arg (callers can `//`-prefix +or set MSYS2_ARG_CONV_EXCL targeted-ly), and so the modules look +like normal Python CLIs instead of bash-shaped env-var contraptions. + +Subcommands: + + python -m airc_core.handshake send + --my-name X --my-host Y --my-ssh-pub Z --my-sign-pub W + --my-airc-home /path --my-identity-json '{}' + + python -m airc_core.handshake accept_one + --host-port 7547 --peers-dir /path --identity-dir /path + --config /path/config.json --host-name X + --reminder-interval 300 --airc-home /path --messages /path + + python -m airc_core.handshake get_field [default] + # reads JSON envelope from stdin, prints field +""" + +from __future__ import annotations + +import argparse +import json +import sys + + +# ── parse_response + get_field ────────────────────────────────────────── + + +def parse_response(response_json: str) -> dict: + """Parse a handshake-response JSON string. Returns {} on failure.""" + if not response_json: + return {} + try: + obj = json.loads(response_json) + return obj if isinstance(obj, dict) else {} + except (ValueError, TypeError): + return {} + + +def cmd_get_field(args) -> int: + try: + response = sys.stdin.read() + except Exception: + print(args.default) + return 0 + obj = parse_response(response) + v = obj.get(args.field, args.default) + if isinstance(v, (dict, list)): + print(json.dumps(v)) + else: + print(v if v != "" else args.default) + return 0 + + +# ── joiner: send ──────────────────────────────────────────────────────── + + +def cmd_send(args) -> int: + import socket + + payload = json.dumps({ + "name": args.my_name, + "host": args.my_host, + "ssh_pub": args.my_ssh_pub, + "sign_pub": args.my_sign_pub, + "airc_home": args.my_airc_home, + "identity": json.loads(args.my_identity_json or "{}"), + }) + + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(30) + try: + s.connect((args.host, args.port)) + s.sendall((payload + "\n").encode()) + s.shutdown(socket.SHUT_WR) + data = b"" + while True: + chunk = s.recv(4096) + if not chunk: + break + data += chunk + s.close() + print(data.decode().strip()) + return 0 + except Exception as e: + print(f"airc-handshake-send-error: {e}", file=sys.stderr) + return 1 + + +# ── host: accept_one ──────────────────────────────────────────────────── + + +def _start_parent_watch(watch_pid: int): + """Daemon thread that os._exit()s the moment the watched PID dies (#132). + + The accept_one process is a grandchild of the airc parent bash: + airc bash → accept-loop subshell → python accept_one + If the airc parent bash dies (terminal closed, kill, Monitor tool + teardown), the accept-loop subshell reparents to init but stays + alive (running its `while kill -0 PARENT` loop until the next + iteration). During python's in-flight accept() / recv() we'd miss + that — getppid() points at the accept-loop subshell, which is + still alive — so any joiner that connects during this window gets + a real-looking pair handshake against a ghost host (keys land in + authorized_keys, peer record gets written, no relay behind it). + + Watching the airc bash PID directly (passed in via --watch-pid) + fixes this. `os.kill(pid, 0)` is the probe: it sends no signal, + just raises OSError if the PID is gone. Poll once a second; the + moment the airc bash disappears, os._exit(0) breaks out of any + blocking syscall and dies cleanly. + + Daemon thread so it doesn't block clean shutdown when the parent + IS alive and accept_one returns normally. + """ + import os + import threading + import time + + def _watch(): + while True: + try: + os.kill(watch_pid, 0) + except (OSError, ProcessLookupError): + # airc bash gone — break out of any blocking syscall. + os._exit(0) + time.sleep(1) + + t = threading.Thread(target=_watch, daemon=True) + t.start() + + +def cmd_accept_one(args) -> int: + import datetime + import os + import socket + + if args.watch_pid: + _start_parent_watch(args.watch_pid) + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("0.0.0.0", args.host_port)) + sock.listen(1) + sock.settimeout(10) + while True: + try: + conn, _addr = sock.accept() + break + except socket.timeout: + if os.getppid() == 1: + sock.close() + return 0 + + data = b"" + while True: + chunk = conn.recv(4096) + if not chunk: + break + data += chunk + if b"\n" in data: + break + + joiner = json.loads(data.decode().strip()) + + # Authorize joiner's SSH key. + ssh_dir = os.path.expanduser("~/.ssh") + os.makedirs(ssh_dir, mode=0o700, exist_ok=True) + ak = os.path.join(ssh_dir, "authorized_keys") + ssh_key = joiner.get("ssh_pub", "") + if ssh_key: + existing = open(ak).read() if os.path.exists(ak) else "" + if ssh_key not in existing: + with open(ak, "a") as f: + f.write(ssh_key.strip() + "\n") + os.chmod(ak, 0o600) + + # Save joiner as peer (with stable-host stale cleanup). + peers_dir = os.path.expanduser(args.peers_dir) + os.makedirs(peers_dir, exist_ok=True) + jname = joiner["name"] + jhost = joiner.get("host", "") + if jhost and os.path.isdir(peers_dir): + for entry in os.listdir(peers_dir): + if not entry.endswith(".json") or entry == jname + ".json": + continue + try: + d = json.load(open(os.path.join(peers_dir, entry))) + except Exception: + continue + if d.get("host") == jhost: + for ext in (".json", ".pub"): + p = os.path.join(peers_dir, entry[:-5] + ext) + if os.path.isfile(p): + try: + os.remove(p) + except Exception: + pass + + timestamp = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") + with open(os.path.join(peers_dir, jname + ".json"), "w") as f: + json.dump({ + "name": jname, + "host": joiner.get("host", ""), + "airc_home": joiner.get("airc_home", ""), + "paired": timestamp, + "ssh_pub": joiner.get("ssh_pub", ""), + "identity": joiner.get("identity", {}), + }, f, indent=2) + if joiner.get("sign_pub"): + with open(os.path.join(peers_dir, jname + ".pub"), "w") as f: + f.write(joiner["sign_pub"]) + + # Build response. + identity_dir = os.path.expanduser(args.identity_dir) + host_pub = open(os.path.join(identity_dir, "ssh_key.pub")).read().strip() + host_identity = {} + try: + host_config = json.load(open(args.config)) + host_identity = host_config.get("identity", {}) or {} + except Exception: + pass + response = json.dumps({ + "ssh_pub": host_pub, + "name": args.host_name, + "reminder": args.reminder_interval, + "airc_home": args.airc_home, + "identity": host_identity, + }) + conn.sendall((response + "\n").encode()) + conn.close() + sock.close() + + print(f" Peer joined: {jname}") + # Surface the join as a system event in messages.jsonl. + try: + room_name_path = os.path.join(args.airc_home, "room_name") + room_name = open(room_name_path).read().strip() if os.path.isfile(room_name_path) else "general" + event = { + "ts": timestamp, + "from": "airc", + "to": "all", + "msg": f"{jname} joined #{room_name}", + } + with open(args.messages, "a") as f: + f.write(json.dumps(event) + "\n") + except Exception: + pass + return 0 + + +# ── CLI entry ─────────────────────────────────────────────────────────── + + +def _build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="airc_core.handshake") + sub = p.add_subparsers(dest="cmd", required=True) + + # get_field — stdin-driven response field extract + g = sub.add_parser("get_field") + g.add_argument("field") + g.add_argument("default", nargs="?", default="") + g.set_defaults(func=cmd_get_field) + + # send — joiner-side TCP handshake + s = sub.add_parser("send") + s.add_argument("host") + s.add_argument("port", type=int) + s.add_argument("--my-name", default="") + s.add_argument("--my-host", default="") + s.add_argument("--my-ssh-pub", default="") + s.add_argument("--my-sign-pub", default="") + s.add_argument("--my-airc-home", default="") + s.add_argument("--my-identity-json", default="{}") + s.set_defaults(func=cmd_send) + + # accept_one — host-side TCP listener (one accept per call) + a = sub.add_parser("accept_one") + a.add_argument("--host-port", type=int, default=7547) + a.add_argument("--peers-dir", required=True) + a.add_argument("--identity-dir", required=True) + a.add_argument("--config", required=True) + a.add_argument("--host-name", required=True) + a.add_argument("--reminder-interval", type=int, default=300) + a.add_argument("--airc-home", required=True) + a.add_argument("--messages", required=True) + # --watch-pid: airc parent bash PID. The listener spawns a daemon + # thread that os._exit()s the moment this PID disappears (#132). + # 0 disables the watch (legacy callers / direct invocations). + a.add_argument("--watch-pid", type=int, default=0) + a.set_defaults(func=cmd_accept_one) + + return p + + +def _cli() -> int: + args = _build_parser().parse_args() + return args.func(args) + + +if __name__ == "__main__": + sys.exit(_cli()) diff --git a/lib/airc_core/monitor_formatter.py b/lib/airc_core/monitor_formatter.py new file mode 100644 index 0000000..b44cf44 --- /dev/null +++ b/lib/airc_core/monitor_formatter.py @@ -0,0 +1,314 @@ +"""airc monitor formatter. + +Reads JSONL message stream from stdin, emits human-readable lines, +handles [rename] markers + ping/pong control traffic + own-send +filtering. Inactivity watchdog forces fmt_exit=2 if the channel +goes silent so the bash retry loop can probe the host. + +Migrated from the bash monitor_formatter heredoc (~250 lines of +Python embedded in airc) to a proper Python module (#152 Phase 1). +Same logic, same stdin/stdout contract, but testable + readable in +a real .py file with no `'\\''` shell-escape gymnastics. + +CLI: + + python -u -m airc_core.monitor_formatter --peers-dir --my-name +""" + +from __future__ import annotations + +import json +import os +import re +import signal +import sys + +# Inactivity watchdog: if no inbound line arrives in WATCHDOG_SEC, +# exit with a distinct code so the caller's while-loop reconnects. +# Why: the outer SSH tail can hang silently — middleboxes drop idle +# TCP while still ACK'ing SSH ServerAlive keepalives, so SSH does +# not notice the channel is dead, and tail -F never returns EOF. The +# Python read just blocks forever. With an application-level watchdog, +# a truly dead channel forces the formatter out and the reconnect loop +# restarts the ssh. Normal chat traffic keeps resetting the alarm so +# there is no penalty when the channel is healthy. +# +# Joel 2026-04-24: heartbeat is OFF by default (canary 95d9907), so +# every fmt_exit=2 used to look like "host went quiet" and spam restart +# notifications on healthy idle. Fix is in the bash retry loop: it +# probes the host on fmt_exit=2 BEFORE counting/notifying. Probe +# success = healthy idle (silent reset); probe failure = real death +# (notify + count toward escalation). +# +# With the probe, WATCHDOG_SEC is just the polling cadence at which +# we re-check the channel. 150s × ESCALATE_AFTER=2 = 5 minutes total +# dead-host detection per Joel's spec. +WATCHDOG_SEC = 150 + + +def _watchdog_exit(signum=None, frame=None): + # Diagnostic to stderr only. The bash retry loop owns the + # user-visible notification — it probes the host on fmt_exit=2 + # to decide whether silence means "healthy idle" (silent reset) + # or "host actually unreachable" (notify + count). Emitting from + # python here would notify on every healthy-idle cycle. + sys.stderr.write(f"[airc:monitor] no inbound in {WATCHDOG_SEC}s — exiting for probe\n") + sys.stderr.flush() + os._exit(2) + + +# Cross-platform watchdog. POSIX (mac/linux/WSL) gets signal.SIGALRM +# which is cheaper (single-thread, kernel-armed). Windows Python has +# no SIGALRM so we fall back to threading.Timer — same exit semantics, +# slight overhead from the timer thread. Either way the fmt_exit=2 +# contract is preserved. +try: + signal.signal(signal.SIGALRM, _watchdog_exit) + signal.alarm(WATCHDOG_SEC) + + def _arm_watchdog(): + signal.alarm(WATCHDOG_SEC) +except (AttributeError, ValueError): + import threading + + _wd_timer_holder = [None] + + def _arm_watchdog(): + if _wd_timer_holder[0] is not None: + _wd_timer_holder[0].cancel() + t = threading.Timer(WATCHDOG_SEC, _watchdog_exit) + t.daemon = True + t.start() + _wd_timer_holder[0] = t + + _arm_watchdog() + + +# Marker may carry an optional `host=user@ip` so receivers can find the +# sender via stable host field even when name-keyed lookup would miss +# (chain break from a dropped rename, stale records, etc). +RENAME_RE = re.compile(r"^\[rename\] old=([a-z0-9-]+) new=([a-z0-9-]+)(?:\s+host=(\S+))?") + + +def _rename_files(peers_dir: str, old: str, new: str) -> bool: + old_json = os.path.join(peers_dir, f"{old}.json") + new_json = os.path.join(peers_dir, f"{new}.json") + if not os.path.isfile(old_json): + return False + try: + os.rename(old_json, new_json) + d = json.load(open(new_json)) + d["name"] = new + json.dump(d, open(new_json, "w"), indent=2) + except Exception: + pass + old_pub = os.path.join(peers_dir, f"{old}.pub") + new_pub = os.path.join(peers_dir, f"{new}.pub") + if os.path.isfile(old_pub): + try: + os.rename(old_pub, new_pub) + except Exception: + pass + return True + + +def _find_peer_by_host(peers_dir: str, host: str): + """Return current name of the peer record whose host matches, or None.""" + if not host or not os.path.isdir(peers_dir): + return None + for entry in os.listdir(peers_dir): + if not entry.endswith(".json"): + continue + try: + d = json.load(open(os.path.join(peers_dir, entry))) + except Exception: + continue + if d.get("host") == host: + return d.get("name") or entry[:-5] + return None + + +def _handle_rename(peers_dir: str, msg: str) -> bool: + m = RENAME_RE.match(msg) + if not m: + return False + old, new, host = m.group(1), m.group(2), m.group(3) + # Primary path: name-keyed rename. + if _rename_files(peers_dir, old, new): + print(f"airc: nick {old} → {new}", flush=True) + return True + # Fallback: peer file sits under a different (older) name due to a + # previous chain break. Resolve via stable host field. + if host: + current = _find_peer_by_host(peers_dir, host) + if current and current != new and _rename_files(peers_dir, current, new): + print(f"airc: nick (chain-repair) {current} → {new}", flush=True) + return True + return False + + +def run(my_name: str, peers_dir: str) -> int: + """Stream the formatter loop. Returns process exit code.""" + scope_dir = os.path.dirname(peers_dir) + config_path = os.path.join(scope_dir, "config.json") + local_log = os.path.join(scope_dir, "messages.jsonl") + offset_path = os.path.join(scope_dir, "monitor_offset") + + # Only mirror inbound to the local log when we are a joiner (tailing a + # REMOTE host over SSH). For a HOST, the local log IS the source the + # tail reads from — mirroring creates an infinite feedback loop. + is_joiner = False + try: + is_joiner = bool(json.load(open(config_path)).get("host_target", "")) + except Exception: + pass + + # Room name for the chat-line prefix. Read once at startup; a rename + # of the room would require a fresh airc connect to pick up. Default + # is "general"; legacy single-pair invite scope shows "1:1" as the + # visual marker. + room_path = os.path.join(scope_dir, "room_name") + try: + room_name = open(room_path).read().strip() or "general" + except Exception: + room_name = "1:1" + + def current_name(): + """Read identity name fresh from config.json each time so a rename + during the session immediately takes effect for own-send filtering. + Without this the monitor keeps the name it saw at startup and fails + to filter our own outbound rename markers, which can trigger the + host-fallback chain-repair against other peers sharing our host.""" + try: + return json.load(open(config_path)).get("name", "") + except Exception: + return "" + + offset_counter = 0 + try: + with open(offset_path) as f: + offset_counter = int(f.read().strip() or 0) + except Exception: + pass + + for line in sys.stdin: + # Any inbound line — real message, heartbeat, whatever — means the + # channel is alive. Reset the watchdog. + _arm_watchdog() + line = line.strip() + if not line: + continue + offset_counter += 1 + try: + with open(offset_path, "w") as f: + f.write(str(offset_counter)) + except Exception: + pass + try: + m = json.loads(line) + except Exception: + continue + fr = m.get("from", "?") + to = m.get("to", "") + msg = m.get("msg", "") + # Filter own sends early, including our own [rename] markers. Read + # the name fresh so a mid-session rename takes effect immediately. + if fr == current_name(): + continue + # Mirror inbound to the local messages.jsonl ONLY when we are a + # joiner (tailing the remote host). For a host the local log is + # already the source of truth; mirroring would create a feedback + # loop (tail sees line -> we append line -> tail sees it again). + if is_joiner: + try: + with open(local_log, "a") as f: + f.write(line + "\n") + except Exception: + pass + if _handle_rename(peers_dir, msg): + continue + # Ping/pong monitor-liveness probe. Prefix marker on a normal + # message so non-implementing clients (older airc, Codex, etc) + # just see a weird message. Auto-pong here is opportunistic; + # cmd_ping tails the log for PONG with matching uuid + timeout, + # which distinguishes wire-dead vs monitor-dead vs peer-no-support. + ping_match = re.match(r"^\[PING:([a-f0-9-]+)\]", msg or "") + pong_match = re.match(r"^\[PONG:([a-f0-9-]+)\]", msg or "") + if ping_match: + ping_id = ping_match.group(1) + # Only auto-pong when the ping is addressed to US specifically. + # Without this check every peer on the mesh auto-replies to + # every ping they see in the log (monitor tails are shared + # across the whole host), so a single ping fans out to N + # PONGs and makes liveness diagnosis meaningless. Broadcast + # pings (to=all) also skip here — a broadcast ping is a + # discovery message the operator reads, not a round-trip. + my_current = current_name() + if to == my_current: + # Auto-reply pong via subprocess. Fire-and-forget. Uses + # airc send so the reply rides the same signed-message + # path as normal traffic (no protocol divergence). + import subprocess + try: + subprocess.Popen( + ["airc", "send", f"@{fr}", f"[PONG:{ping_id}]"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception: + pass + # Suppress from user-visible output (control traffic), + # regardless of whether we auto-ponged. + continue + if pong_match: + # cmd_ping picks PONG up by tailing messages.jsonl directly. + # Suppress to keep the chat surface clean. + continue + # One-liner per event. Every line starts with `airc:` so the source + # is unambiguous when other Monitor tasks (continuum, tests, etc.) + # are also firing notifications. + # + # No length cap any more — consumers (Claude Code Monitor, Codex, + # log tailers, etc.) decide their own display truncation. Truncating + # in the substrate forced everyone downstream to fall back to + # `airc logs` to see anything past the cap, which is exactly the + # polling-vs-substrate anti-pattern Joel called out 2026-04-24. + # Newlines collapsed to spaces so each emitted event is still a + # single line, but the full body always reaches the consumer. + msg_one_line = (msg or "").replace("\n", " ").replace("\r", " ").strip() + try: + if fr in ("airc", "sys"): + # System events (joins, parts, drain, auth, watchdog). + # Example: airc: [#general] alice joined + print(f"airc: [#{room_name}] {msg_one_line}", flush=True) + elif to and to not in ("all", ""): + # DM with addressed recipient. + # Example: airc: [#general] bigmama → alice: quick question + print(f"airc: [#{room_name}] {fr} → {to}: {msg_one_line}", flush=True) + else: + # Broadcast. + # Example: airc: [#general] bigmama: hello everyone + print(f"airc: [#{room_name}] {fr}: {msg_one_line}", flush=True) + except Exception as e: + # Belt-and-suspenders — one bad message must never take the + # whole monitor down. Surface to stderr (which the bash retry + # loop captures) and keep going. + try: + sys.stderr.write(f"[airc:formatter] skipped one line: {e}\n") + sys.stderr.flush() + except Exception: + pass + return 0 + + +def _cli() -> int: + import argparse + p = argparse.ArgumentParser(prog="airc_core.monitor_formatter") + p.add_argument("--peers-dir", required=True) + p.add_argument("--my-name", required=True) + args = p.parse_args() + return run(args.my_name, args.peers_dir) + + +if __name__ == "__main__": + sys.exit(_cli()) diff --git a/test/integration.sh b/test/integration.sh index faf1f3c..b7cd9a9 100755 --- a/test/integration.sh +++ b/test/integration.sh @@ -2760,10 +2760,19 @@ scenario_platform_adapters() { # statement and either die ("Unknown command") or print cmd_help. # Extract just the marked adapter section into a temp file we can # safely source. - local _adapters_extract; _adapters_extract=$(mktemp -t airc-it-pa.XXXXXX) - awk '/^# ── Platform adapters/,/^# ── End platform adapters/' "$AIRC" > "$_adapters_extract" + # Phase 3 (#152): adapters live in lib/airc_bash/platform_adapters.sh, + # sourced by airc at startup. The test bash directly sources that file + # — no awk extraction needed any more. + local _airc_lib_dir; _airc_lib_dir=$(cd "$(dirname "$AIRC")/lib" 2>/dev/null && pwd) + local _adapters_file="$_airc_lib_dir/airc_bash/platform_adapters.sh" + if [ ! -f "$_adapters_file" ]; then + fail "platform_adapters.sh not found at $_adapters_file" + return + fi _adapter_call() { - bash -c "source '$_adapters_extract'; $*" + AIRC_PYTHON="${AIRC_PYTHON:-python3}" \ + PYTHONPATH="${_airc_lib_dir}${PYTHONPATH:+:$PYTHONPATH}" \ + bash -c "source '$_adapters_file'; export AIRC_PYTHON='${AIRC_PYTHON:-python3}'; $*" } # ── proc_children ── @@ -2868,7 +2877,36 @@ time.sleep(30) # automatically (no special simulation needed). echo " (proc_children fallback exercised for real on platforms without pgrep — see Windows runs)" - rm -f "$_adapters_extract" + # ── iso_to_epoch ── + # Single adapter replacing the BSD/GNU date split that used to live at + # 3 callsites (heartbeat parse, _format_relative_time, _is_stale). + # Same fixed timestamp + arithmetic check on the result keeps the + # assertion deterministic regardless of which date flavor wins. + # 2026-01-15T12:34:56Z = 1768480496 (UTC epoch seconds; computed via + # python3 -c "import datetime; print(int(datetime.datetime(2026,1,15,12,34,56,tzinfo=datetime.timezone.utc).timestamp()))"). + local _epoch_known + _epoch_known=$(_adapter_call "iso_to_epoch '2026-01-15T12:34:56Z'" 2>/dev/null) + [ "$_epoch_known" = "1768480496" ] \ + && pass "iso_to_epoch: known timestamp parses to expected epoch" \ + || fail "iso_to_epoch: parse mismatch (expected 1768480496, got '$_epoch_known')" + + # Empty input → empty output (callers test for empty to skip stale check) + local _epoch_empty + _epoch_empty=$(_adapter_call "iso_to_epoch ''" 2>/dev/null) + [ -z "$_epoch_empty" ] \ + && pass "iso_to_epoch: empty input → empty output (graceful)" \ + || fail "iso_to_epoch: empty input returned '$_epoch_empty' (should be empty)" + + # Garbage input → empty output (no crash, no false epoch) + local _epoch_bad + _epoch_bad=$(_adapter_call "iso_to_epoch 'not-a-timestamp'" 2>/dev/null) + [ -z "$_epoch_bad" ] \ + && pass "iso_to_epoch: garbage input → empty (no false-positive epoch)" \ + || fail "iso_to_epoch: garbage parsed to '$_epoch_bad' (should be empty)" + + # _adapters_extract no longer used post-Phase-3 (the file is sourced + # from its real location in lib/airc_bash/); nothing to clean up. + : cleanup_all }