From 2cd61ee5981c1026fcb4ce05febab04b52fcf549 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Tue, 7 Jul 2026 22:10:53 -0700 Subject: [PATCH] feat(mobile): hh launcher + direnv auto-host for Termux/Fairphone Add a single `hh` launcher (host/join/web/status/stop + operator passthrough) and a direnv snippet so `cd`-ing into a project dir auto-hosts a room named after it. Verified live on the Fairphone: two rooms coexist on port-scoped tmux windows (manual :8799 + direnv :8801), status health-checks each via curl (ss/netstat are unreliable on Android), stop-all tears down cleanly. Termux notes baked in: no /usr/bin/env (invoke via the `hh` alias, not the shebang); status enumerates operator daemons under $TMPDIR/hh-bridge. Co-Authored-By: Claude Opus 4.6 --- scripts/hh | 119 +++++++++++++++++++++++++++++++++++++++++++ scripts/hh-direnv.sh | 22 ++++++++ 2 files changed, 141 insertions(+) create mode 100644 scripts/hh create mode 100644 scripts/hh-direnv.sh diff --git a/scripts/hh b/scripts/hh new file mode 100644 index 0000000..7d9f0a8 --- /dev/null +++ b/scripts/hh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# hh — hack-house mobile launcher (Termux/Fairphone friendly). +# +# hh host [NAME] [PORT] start a room ON THIS DEVICE (tmux window) +# hh join HOST PORT [NAME] join an existing room as an operator (daemon) +# hh web [PORT] serve the mobile web console (tmux window) +# hh op passthrough to `python -m cmd_chat.operator` +# hh status what's running + listening +# hh stop [all|host|web|join] tear it down +# +# Config via env (all optional): +# HH_PASSWORD room password (default: hh-mobile) +# HH_PORT host port (default: 8799) +# HH_WEB_PORT web console port (default: 8790) +# HH_NAME room / operator name (default: current dir basename) +# HH_BIND host/web bind addr (default: 0.0.0.0 — reachable over Tailscale) +# HH_TLS set to 1 to drop --no-tls (default: no-tls) +set -euo pipefail + +# --- locate the repo root (this script lives in /scripts/hh) ----------- +_self="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(dirname "$_self")" +cd "$ROOT" + +PY="${HH_PY:-python}" +PW="${HH_PASSWORD:-hh-mobile}" +PORT="${HH_PORT:-8799}" +WEB_PORT="${HH_WEB_PORT:-8790}" +BIND="${HH_BIND:-0.0.0.0}" +NAME="${HH_NAME:-$(basename "$ROOT")}" +TLS_FLAG="--no-tls"; [ "${HH_TLS:-0}" = "1" ] && TLS_FLAG="" +TMUX_SES="hh" +OP="$PY -m cmd_chat.operator" + +_die() { echo "hh: $*" >&2; exit 1; } +command -v "$PY" >/dev/null || _die "python ('$PY') not found" + +# run a long-lived process in a named tmux window (survives SSH disconnect) +_tmux_run() { # + local win="$1"; shift + command -v tmux >/dev/null || _die "tmux not found (pkg install tmux)" + tmux has-session -t "$TMUX_SES" 2>/dev/null || tmux new-session -d -s "$TMUX_SES" -n main + if tmux list-windows -t "$TMUX_SES" -F '#W' 2>/dev/null | grep -qx "$win"; then + echo "hh: window '$win' already running (hh stop $win to replace)"; return 0 + fi + tmux new-window -t "$TMUX_SES" -n "$win" "cd '$ROOT'; $*; echo '[$win exited — press enter]'; read" +} + +# best-effort Tailscale IP for the "reachable at" hint +_ts_ip() { command -v tailscale >/dev/null && tailscale ip -4 2>/dev/null | head -1 || true; } + +cmd="${1:-status}"; shift || true +case "$cmd" in + host) + [ $# -ge 1 ] && NAME="$1"; [ $# -ge 2 ] && PORT="$2" + # window name is port-scoped so several rooms (one per direnv dir) coexist + _tmux_run "host-$PORT" "$PY cmd_chat.py serve '$BIND' '$PORT' --password '$PW' $TLS_FLAG" + ip="$(_ts_ip)" + echo "hh: hosting room '$NAME' on $BIND:$PORT (password: $PW)" + [ -n "$ip" ] && echo " reachable at: $ip:$PORT" + ;; + join) + [ $# -ge 2 ] || _die "usage: hh join HOST PORT [NAME]" + local_host="$1"; local_port="$2"; jname="${3:-$NAME}" + $OP up "$local_host" "$local_port" "$jname" --password "$PW" $TLS_FLAG + echo "hh: joined $local_host:$local_port as '$jname' — use: hh op read --wait / hh op say '...'" + ;; + web) + [ $# -ge 1 ] && WEB_PORT="$1" + _tmux_run "web-$WEB_PORT" "$OP web --bind '$BIND' --port '$WEB_PORT'" + ip="$(_ts_ip)" + echo "hh: web console on $BIND:$WEB_PORT" + [ -n "$ip" ] && echo " open on phone browser: http://127.0.0.1:$WEB_PORT | from laptop: http://$ip:$WEB_PORT" + ;; + op) exec $OP "$@" ;; + status) + wins="$(tmux list-windows -t "$TMUX_SES" -F '#W' 2>/dev/null || true)" + echo "=== tmux windows (session $TMUX_SES) ===" + [ -n "$wins" ] && echo "$wins" | sed 's/^/ /' || echo " (no $TMUX_SES session)" + echo "=== rooms (curl health per host-* window, Android-reliable) ===" + echo "$wins" | grep '^host-' | while read -r w; do + p="${w#host-}"; h="$(curl -s --max-time 3 "http://127.0.0.1:$p/health" 2>/dev/null || true)" + [ -n "$h" ] && echo " host :$p UP ($h)" || echo " host :$p down" + done + echo "$wins" | grep '^web-' | while read -r w; do + p="${w#web-}"; c="$(curl -s --max-time 3 -o /dev/null -w '%{http_code}' "http://127.0.0.1:$p/" 2>/dev/null || true)" + [ "$c" = "200" ] && echo " web :$p UP" || echo " web :$p down" + done + echo "$wins" | grep -qE '^(host|web)-' || echo " (no rooms/web running)" + echo "=== operator daemons ($(basename "${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}")/hh-bridge) ===" + _br="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/hh-bridge" + if [ -d "$_br" ] && ls -1 "$_br" >/dev/null 2>&1 && [ -n "$(ls -A "$_br" 2>/dev/null)" ]; then + for d in "$_br"/*/; do + [ -S "$d/control.sock" ] && echo " $(basename "$d") (socket live)" || echo " $(basename "$d") (stale — no socket)" + done + else echo " (no operator sessions)"; fi + ;; + stop) + what="${1:-all}"; arg="${2:-}" + _kill_windows() { # [port] + local pfx="$1" port="${2:-}" + local pat; [ -n "$port" ] && pat="^${pfx}-${port}$" || pat="^${pfx}-" + tmux list-windows -t "$TMUX_SES" -F '#W' 2>/dev/null | grep -E "$pat" | while read -r w; do + tmux kill-window -t "$TMUX_SES:$w" 2>/dev/null && echo "hh: stopped $w" + done + } + case "$what" in + host) _kill_windows host "$arg" ;; + web) _kill_windows web "$arg" ;; + join) $OP down 2>/dev/null && echo "hh: operator down" || echo "hh: no operator session" ;; + all) + _kill_windows host; _kill_windows web + $OP down 2>/dev/null && echo "hh: operator down" || true ;; + *) _die "usage: hh stop [all|host [PORT]|web [PORT]|join]" ;; + esac + ;; + -h|--help|help) awk 'NR>1 && /^#/{sub(/^# ?/,"");print;next} NR>1{exit}' "${BASH_SOURCE[0]}" ;; + *) _die "unknown command '$cmd' (try: hh help)" ;; +esac diff --git a/scripts/hh-direnv.sh b/scripts/hh-direnv.sh new file mode 100644 index 0000000..f08864e --- /dev/null +++ b/scripts/hh-direnv.sh @@ -0,0 +1,22 @@ +# hh-direnv.sh — source this from a directory's .envrc to auto-host a +# hack-house room whenever you `cd` in (direnv triggers it). +# +# # .envrc +# source "$HOME/hh-mobile/scripts/hh-direnv.sh" +# +# The room is named after the directory. Override before sourcing: +# export HH_PORT=8801 ; export HH_PASSWORD=hunter2 +# source "$HOME/hh-mobile/scripts/hh-direnv.sh" +# +# Auto-host is ON by default; disable with `export HH_AUTOHOST=0` (env still set). + +export HH_NAME="${HH_NAME:-$(basename "$PWD")}" +export HH_PORT="${HH_PORT:-8799}" +export HH_PASSWORD="${HH_PASSWORD:-hh-mobile}" +export HH_BIND="${HH_BIND:-0.0.0.0}" + +if [ "${HH_AUTOHOST:-1}" = "1" ]; then + # idempotent: the launcher no-ops if the host window already exists + bash "$HOME/hh-mobile/scripts/hh" host "$HH_NAME" "$HH_PORT" >/dev/null 2>&1 || true + echo "hh: auto-hosting room '$HH_NAME' on :$HH_PORT (HH_AUTOHOST=0 to disable)" >&2 +fi