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 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-07-07 22:10:53 -07:00
parent 4a17f1c6f1
commit 2cd61ee598
2 changed files with 141 additions and 0 deletions
+119
View File
@@ -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 <operator args...> 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 <root>/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() { # <window> <command string>
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() { # <prefix> [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
+22
View File
@@ -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