#!/usr/bin/env bash
# AitherOS — phone node bootstrap
#
#   curl -fsSL https://aitherium.com/phone.sh | bash
#
# Turns a phone's Linux environment into an AitherOS node running Bonsai locally,
# then points the browser on the SAME phone at it — so elysium.aitherium.com and
# the AitherOS PWA answer from YOUR model over loopback instead of our fleet.
#
# WHERE THIS RUNS
#   - Android 16 "Linux Terminal" (the Debian VM on Pixel 9/10 — pKVM/AVF).  CPU only:
#     the VM gets no GPU or NPU passthrough, so Vulkan is not an option there.
#   - Termux (no root).  Same CPU story, but no VM overhead and all cores available.
#   - Any arm64/x86_64 Linux box.  Nothing here is phone-specific except the defaults.
#
# WHY CPU IS FINE: Bonsai ships Q1_0 (~1.13 bits/weight) and Q1_0 is merged in MAINLINE
# llama.cpp for CPU generic + ARM NEON, so no fork is needed.  Decode is memory-bandwidth
# bound, and llama.cpp mmaps the GGUF — weights stay clean, file-backed and evictable
# instead of being multi-GB of dirty anonymous memory the low-memory-killer will target.
#
# ── NO SOURCE BUILD. THIS IS THE WHOLE POINT OF v2 ─────────────────────────────────
# v1 cloned llama.cpp and ran cmake. On a phone that is 15-40 minutes, needs clang +
# cmake + build-essential (~1 GB of packages on the Debian VM), and fails outright if
# the device sleeps mid-build. "Painless" and "compile a C++ project on your phone"
# are not compatible, and painless is the requirement.
#
# Both target environments already have a PREBUILT binary and neither was being used:
#   - Termux ships `llama-cpp` in its MAIN repo (7 MB deb, 21 MB installed).
#   - llama.cpp publishes `llama-<tag>-bin-ubuntu-arm64.tar.gz` (13.5 MB) per release,
#     carrying runtime-dispatched CPU variants (armv8.0 / 8.2 / 8.6 / 9.2) in ONE
#     tarball — so a single download covers every arm64 phone with no -march guessing.
# Both were verified to contain llama-server, llama-cli and llama-bench before this
# script was rewritten onto them. The source build survives as the LAST rung, so an
# architecture with no prebuilt is degraded rather than dead.
#
# Re-running is cheap and safe: every step is idempotent and skips when already done.

set -euo pipefail

# Byte-ordered regex ranges everywhere. Under some locales (Android's Linux
# terminal, Termux) GNU grep rejects bracket ranges with
# "grep: Invalid range end" and the install dies at a probe, not at a download --
# no file is missing and nothing is red. LC_ALL=C makes every range in this script
# valid regardless of LANG. Same failure and same fix as install-bonsai.sh, which
# measured it on a Pixel; this lane had kept its own copy of the bug.
export LC_ALL=C LANG=C

# ── Tunables (every one is an env var, so this stays scriptable) ──────────────────
MODEL_SIZE="${BONSAI_MODEL:-auto}"     # auto | 1.7B | 4B | 8B | 27B
PORT="${BONSAI_PORT:-8080}"
CTX="${BONSAI_CTX:-4096}"
ENROLL="${AITHER_ENROLL:-ask}"         # ask | yes | no
WANT_SHELL="${AITHER_SHELL:-ask}"      # ask | yes | no   -- awsh, needs Node
PREFIX_DIR="${AITHER_HOME:-$HOME/.aither}"
LLAMA_DIR="$PREFIX_DIR/llama.cpp"
BIN_DIR="$PREFIX_DIR/bin"
MODEL_DIR="$PREFIX_DIR/models"

# Bring your OWN model instead of Bonsai — either a direct URL, or a HuggingFace repo
# plus filename. The picker below covers the Bonsai sizes; these two cover the rest of
# HuggingFace, which is the "models of your choice" half of the requirement.
GGUF_URL="${BONSAI_GGUF_URL:-}"
HF_REPO="${BONSAI_HF_REPO:-}"
HF_FILE="${BONSAI_HF_FILE:-}"

# Pinned fallback for the prebuilt tarball. The script resolves the LATEST release
# first; this is what it falls back to. Not decoration: the unauthenticated GitHub API
# allows 60 requests/hour PER IP, and a phone on carrier CGNAT shares that IP with
# thousands of strangers — so "resolve latest" is the step most likely to 403 on
# exactly the device this script exists for, and a 403 there must not end the install.
LLAMA_TAG_FALLBACK="b10545"

# ── Flags. This lane is env-driven, so the only flags are the ones the canonical door
#    forwards (install.sh --phone --playbook <name> [--workspace <slug>]). An unknown
#    flag exits 2: silently ignoring one is how `--phone --playbook x` dropped the
#    playbook for a day (B7, 2026-09-10). The playbook itself runs LAST, through the
#    canonical door, once the local node is up -- it needs the bearer that door mints.
PLAYBOOK=""; WORKSPACE=""
# The canonical door signs in FIRST when enrollment is wanted and exports the bearer
# here. It arrives in the ENVIRONMENT rather than argv because _route_mode pipes this
# script into `bash -s -- "$@"`, and everything in that argv is world-readable in
# /proc/<pid>/cmdline. --token still overrides, for a human passing one by hand.
TOKEN="${AITHER_ENROLL_TOKEN:-}"
INSTALL_BASE="${AITHER_INSTALL_BASE:-https://aitherium.com}"
while [ $# -gt 0 ]; do case "$1" in
  --playbook)  [ $# -ge 2 ] || { echo "phone.sh: --playbook needs a name" >&2; exit 2; }; PLAYBOOK="$2"; shift 2;;
  --workspace) [ $# -ge 2 ] || { echo "phone.sh: --workspace needs a slug" >&2; exit 2; }; WORKSPACE="$2"; shift 2;;
  # --token: the canonical door signs in FIRST when enrollment is wanted and hands the
  # bearer down, so this lane does not make a phone hold a second device-code prompt.
  # Without it an enrolling phone had no identity at all and `adk enroll` correctly
  # refused -- and the refusal was swallowed, so the summary still said success.
  --token)     [ $# -ge 2 ] || { echo "phone.sh: --token needs a value" >&2; exit 2; }; TOKEN="$2"; shift 2;;
  -h|--help) sed -n '2,30p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//'; exit 0;;
  *) echo "phone.sh: unknown option: $1 (flags: --playbook <name> --workspace <slug> --token <t>; everything else is an env var)" >&2; exit 2;;
esac; done

say()   { printf '\033[36m>\033[0m %s\n' "$*"; }
warn()  { printf '\033[33m!\033[0m %s\n' "$*" >&2; }
die()   { printf '\033[31mx\033[0m %s\n' "$*" >&2; exit 1; }
head_() { printf '\n\033[1m%s\033[0m\n' "$*"; }

# ── Resumable download that survives a STALE ".part" ─────────────────────────────
# `curl -C -` resuming a .part that is ALREADY the whole file (a previous run was
# killed between the download finishing and the mv below), or one written before the
# file on the server changed, asks for a range that starts at or past end-of-file.
#
# MEASURED on our own mirror, 2026-09-13, weights.aitherium.com/Bonsai-1.7B-Q1_0.gguf
# (248,302,272 bytes), by handing curl three range requests:
#   Range: bytes=248302272-       -> 404 Not Found      (start == size)
#   Range: bytes=248302273-       -> 404 Not Found      (one past)
#   Range: bytes=999999999999-    -> 416 Range Not Satisfiable
#                                    <Code>InvalidRange</Code>The range specified is
#                                    invalid for the current size of the resource
# The 404 is the crueller of the two: `curl -f` turns it into exit 22, the caller's
# own hint says "a 404 means that repo or filename does not exist", and the file is
# RIGHT THERE -- so the install reads as "our mirror does not have the weights" when
# the weights are fine and the RESUME is what broke. Either way every re-run died the
# same way, because the .part is never cleaned up: the script "kept throwing" and
# could not recover on its own. So, before resuming: if the partial is >= the remote
# size it is either complete (rename it) or stale (delete it); and if a resumed
# transfer still fails, retry once from zero. Never leaves a .part behind on failure
# -- for a 404 or a captive portal that partial is an HTML error page, and resuming
# onto it glues a GGUF header to the error page.
#
# Ported verbatim-in-behaviour from install-bonsai.sh (which fixed this on
# 2026-09-12) so the two install doors cannot drift apart again; this lane carried
# the bug for a day after the desk lane had fixed it.
download_model() { # download_model <url> <dest>
  local u="$1" d="$2" p="$2.part" remote local_sz
  if [ -f "$p" ]; then
    remote="$(curl -sIL ${AUTHCFG:+-K "$AUTHCFG"} "$u" 2>/dev/null \
              | awk 'tolower($1)=="content-length:" {v=$2} END {print v}')"
    local_sz="$(wc -c < "$p" | tr -d ' ')"
    if [ -n "$remote" ] && [ "$local_sz" -ge "$remote" ] 2>/dev/null; then
      if [ "$local_sz" -eq "$remote" ]; then
        say "partial download is already complete ($local_sz bytes) — using it"
        mv "$p" "$d"; return 0
      fi
      warn "stale partial download ($local_sz bytes, server has $remote) — starting over"
      rm -f "$p"
    fi
  fi
  # -# is a single progress BAR, not curl's default table. The default emits a fresh
  # line per update, so a 3.6 GB download on a phone writes thousands of them and
  # scrolls every earlier message — including the ones that say what went wrong — off
  # the top of the terminal.
  curl -fL -C - -# --retry 5 --retry-delay 3 ${AUTHCFG:+-K "$AUTHCFG"} -o "$p" "$u" \
    || { warn "resume failed — retrying from the beginning"; rm -f "$p"
         curl -fL -# --retry 5 --retry-delay 3 ${AUTHCFG:+-K "$AUTHCFG"} -o "$p" "$u"; } \
    || { rm -f "$p"; return 1; }
  mv "$p" "$d"
}

# Is a human watching? `curl ... | bash` leaves stdin as the PIPE, not the terminal, so
# `[ -t 0 ]` is FALSE for every single person who follows the advertised one-liner —
# which would silently disable every prompt below for this script's main audience.
# v1 gated its one prompt on `[ -t 0 ]` and therefore never asked anybody anything.
# The terminal is still reachable as /dev/tty; that is what to test.
INTERACTIVE=0
if [ -e /dev/tty ] && { : >/dev/tty; } 2>/dev/null; then INTERACTIVE=1; fi

ask() { # ask <prompt> <default y|n>  -> exit 0 for yes
  local p="$1" d="${2:-n}" r="" hint
  if [ "$INTERACTIVE" != "1" ]; then [ "$d" = "y" ]; return; fi
  if [ "$d" = "y" ]; then hint="Y/n"; else hint="y/N"; fi
  printf '  %s [%s] ' "$p" "$hint" > /dev/tty
  read -r r < /dev/tty || r=""
  [ -z "$r" ] && r="$d"
  case "$r" in [yY]*) return 0 ;; *) return 1 ;; esac
}

# ── 1. Where are we? ─────────────────────────────────────────────────────────────
ENVKIND="linux"
if [ -n "${PREFIX:-}" ] && [ "${PREFIX#*com.termux}" != "${PREFIX:-}" ]; then
  ENVKIND="termux"
elif [ -f /etc/debian_version ] && grep -qi -e android -e avf /proc/version 2>/dev/null; then
  ENVKIND="android-vm"
fi
ARCH="$(uname -m)"
head_ "AitherOS phone node"
say "environment: $ENVKIND ($ARCH)"

# Total RAM in MiB. In the Android Linux Terminal this is the VM's slice, NOT the
# phone's 12/16 GB — which is exactly why the model is sized off it rather than off
# the marketing spec. A 27B needs ~4.8 GiB at 4K context and the VM may not have it.
RAM_MB=$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo 2>/dev/null || echo 0)
say "usable RAM: ${RAM_MB} MiB"

# Below this even the 1.7B cannot hold its peak with any headroom, and the honest move
# is to say so BEFORE a multi-GB download rather than OOM after it.
if [ "$RAM_MB" -gt 0 ] && [ "$RAM_MB" -lt 2200 ]; then
  die "only ${RAM_MB} MiB available — the smallest model (1.7B) peaks near 1.4 GiB and needs ~2.2 GiB to run without being reclaimed.
     Android Linux Terminal: raise the VM's memory in the Terminal app settings, then re-run.
     Termux: close background apps, or use the hosted brain in the browser instead."
fi

# ── 2. Pick a model that actually fits ───────────────────────────────────────────
# Peak = weights + KV + activations + ~1.2 GiB overhead, from the Bonsai 27B table
# (3.53 GiB weights -> 4.8 GiB at 4K). Smaller sizes scale down roughly linearly:
#   27B ~4.8 GiB   8B ~2.3 GiB   4B ~1.7 GiB   1.7B ~1.4 GiB
# Every threshold is peak x1.5, because Android reclaims under pressure and a phone is
# never idle — something else always wants memory back.

# ── Threads: NOT $(nproc) ────────────────────────────────────────────────────────
# v1 passed `-t $(nproc)` and that is the single worst-performing line it had.
#
# Measured 2026-08-21, same machine, same Bonsai-1.7B-Q1_0, minutes apart, on a
# 28-core host under real load (load average 88):
#     -t 28  ->  0.023 tok/s generation      (the box was oversubscribed)
#     -t 4   -> 29.570 tok/s generation      (57 tok/s prompt)
# A 1265x difference from the thread count alone. Nothing else changed.
#
# This is not a server-only concern — it is WORSE on a phone. A phone is never idle
# (this script already says so about memory) and its cores are heterogeneous: a Tensor
# G5 is 1 prime + 3 performance + 4 efficiency, and `nproc` reports 8. Scheduling
# inference onto the efficiency cores makes every big core wait on the slowest thread
# in the batch, so asking for all 8 is reliably slower than asking for 4.
#
# The cap is 4 unless overridden, and never more than the machine has. Someone on a
# big idle server can raise it with BONSAI_THREADS.
CORES="$(nproc 2>/dev/null || echo 4)"
if [ "$CORES" -lt 4 ]; then THREADS_DEFAULT="$CORES"; else THREADS_DEFAULT=4; fi
THREADS="${BONSAI_THREADS:-$THREADS_DEFAULT}"

fits() { # fits <need_mib> -> 0 when RAM_MB clears it
  [ "$RAM_MB" -le 0 ] && return 0
  [ "$RAM_MB" -ge "$1" ]
}
auto_size() {
  if   fits 7400; then echo 27B
  elif fits 3600; then echo 8B
  elif fits 2800; then echo 4B
  else                 echo 1.7B
  fi
}

# A custom GGUF short-circuits the picker — you named a specific file.
CUSTOM=0
if [ -n "$GGUF_URL" ] || { [ -n "$HF_REPO" ] && [ -n "$HF_FILE" ]; }; then
  CUSTOM=1
  [ -z "$GGUF_URL" ] && GGUF_URL="https://huggingface.co/${HF_REPO}/resolve/main/${HF_FILE}"
  GGUF="$(basename "${GGUF_URL%%\?*}")"
  say "custom model: $GGUF"
  case "$GGUF" in
    *.gguf) ;;
    *) die "that URL does not end in .gguf — llama.cpp needs a GGUF file, not a repo page or a safetensors checkpoint." ;;
  esac
else
  BEST="$(auto_size)"
  if [ "$MODEL_SIZE" = "auto" ] && [ "$INTERACTIVE" = "1" ]; then
    head_ "Which Bonsai?"
    printf '  Your %s MiB fits up to \033[1m%s\033[0m.\n\n' "$RAM_MB" "$BEST" > /dev/tty
    # Print a verdict PER ROW rather than hiding the ones that do not fit. A picker
    # that silently omits the 27B is indistinguishable from one that does not know it
    # exists — and "models of your choice" includes the choice to push it. Anything
    # over the line is offered and LABELLED, never hidden.
    printf '%s\n' \
      "1.7B|236 MB download|2100" \
      "4B|545 MB download|2550" \
      "8B|1.1 GB download|3450" \
      "27B|3.6 GB download|7200" | while IFS='|' read -r sz dl need; do
        if fits "$need"; then verdict='\033[32mfits\033[0m'; else verdict='\033[33mtight - may be killed\033[0m'; fi
        printf "    %-5s  %-16s $verdict\n" "$sz" "$dl" > /dev/tty
      done
    printf '\n  ...or paste a HuggingFace GGUF URL for any other model.\n' > /dev/tty
    printf '  Choice [%s]: ' "$BEST" > /dev/tty
    read -r pick < /dev/tty || pick=""
    [ -z "$pick" ] && pick="$BEST"
    case "$pick" in
      http*://*.gguf)
        CUSTOM=1
        GGUF_URL="$pick"
        GGUF="$(basename "${GGUF_URL%%\?*}")"
        say "custom model: $GGUF"
        ;;
      *) MODEL_SIZE="$pick" ;;
    esac
  elif [ "$MODEL_SIZE" = "auto" ]; then
    MODEL_SIZE="$BEST"
    say "auto-selected Bonsai-$MODEL_SIZE for ${RAM_MB} MiB (override with BONSAI_MODEL=)"
  fi
fi

if [ "$CUSTOM" != "1" ]; then
  case "$MODEL_SIZE" in
    1.7B|4B|8B|27B) ;;
    *) die "BONSAI_MODEL must be one of: 1.7B 4B 8B 27B — or set BONSAI_GGUF_URL to any .gguf (got '$MODEL_SIZE')" ;;
  esac
  GGUF="Bonsai-${MODEL_SIZE}-Q1_0.gguf"
  GGUF_URL="https://huggingface.co/prism-ml/Bonsai-${MODEL_SIZE}-gguf/resolve/main/${GGUF}"
fi

# ── 3. llama.cpp — PREBUILT first, source only as the last rung ──────────────────
mkdir -p "$PREFIX_DIR" "$MODEL_DIR" "$BIN_DIR"
SERVER=""

have_server() { # have_server <path> -> echoes it when it is genuinely runnable
  local c="$1"
  [ -n "$c" ] || return 1
  if [ -x "$c" ] && "$c" --version >/dev/null 2>&1; then printf '%s' "$c"; return 0; fi
  return 1
}

# The tarball ships its OWN libllama/libggml, and NOT the system libraries those link
# against. Measured 2026-08-22 by running the real aarch64 llama-server under
# qemu-aarch64 on a Debian that had neither: it needs libssl.so.3 AND libgomp.so.1,
# and dies with
#   error while loading shared libraries: libssl.so.3: cannot open shared object file
# which is the SAME sentence the whole-directory copy above exists to prevent, so it
# reads as a corrupt download of a file that is fine.
#
# have_server() execs --version, so this is not a broken install -- it is worse in a
# quieter way: the script correctly concludes the prebuilt is unusable and falls
# through to rung 3, spending 15-40 MINUTES compiling on a phone because two small
# packages were absent. The painless path is lost and nothing looks wrong.
#
# Driven by ldd rather than a hardcoded list: the dependency set is llama.cpp's to
# change, and a guessed list installs the wrong thing silently. Anything ldd names
# that is not mapped is WARNED about rather than ignored, so an unknown dependency
# is visible instead of becoming an unexplained source build.
# Best effort throughout -- never fatal. If this cannot help, rung 3 still works.
ensure_runtime_libs() { # ensure_runtime_libs <binary>
  command -v ldd >/dev/null 2>&1 || return 0
  _miss="$(LD_LIBRARY_PATH="$BIN_DIR:${LD_LIBRARY_PATH:-}" ldd "$1" 2>/dev/null | awk '/not found/{print $1}' | sort -u)"
  [ -n "$_miss" ] || return 0
  _pkgs=""
  for _lib in $_miss; do
    case "$_lib" in
      libssl.so.*|libcrypto.so.*) _pkgs="$_pkgs libssl3" ;;
      libcurl.so.*)              _pkgs="$_pkgs libcurl4" ;;
      libgomp.so.*)              _pkgs="$_pkgs libgomp1" ;;
      libstdc++.so.*)            _pkgs="$_pkgs libstdc++6" ;;
      *) warn "llama-server needs $_lib and this script does not know which package provides it - report this" ;;
    esac
  done
  [ -n "$_pkgs" ] || return 0
  say "the prebuilt needs:$_pkgs - installing"
  if [ "$ENVKIND" = "termux" ]; then
    pkg install -y $_pkgs >/dev/null 2>&1 || warn "could not install:$_pkgs"
  else
    _sudo=""; [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1 && _sudo="sudo"
    $_sudo apt-get install -y -qq $_pkgs >/dev/null 2>&1 || warn "could not install:$_pkgs (rung 3 will build from source instead)"
  fi
}

# Rung 0 — already installed by a previous run, or by the visitor.
for cand in "$BIN_DIR/llama-server" "$(command -v llama-server 2>/dev/null || true)"; do
  [ -n "$cand" ] || continue
  if SERVER="$(have_server "$cand")"; then say "llama-server already present: $SERVER"; break; fi
  SERVER=""
done

# Rung 1 — Termux's own package. Seconds, no toolchain, maintained by Termux.
if [ -z "$SERVER" ] && [ "$ENVKIND" = "termux" ]; then
  head_ "Installing llama.cpp (Termux package)"
  pkg install -y llama-cpp 2>&1 | tail -2 || warn "pkg install llama-cpp failed"
  SERVER="$(have_server "$(command -v llama-server 2>/dev/null || true)")" || SERVER=""
  [ -n "$SERVER" ] && say "installed: $SERVER"
fi

# Rung 2 — the official prebuilt tarball. Covers the Android Linux Terminal (Debian
# arm64 VM) and any ordinary Linux box, with no compiler present at all.
if [ -z "$SERVER" ]; then
  case "$ARCH" in
    aarch64|arm64) ASSET_ARCH="ubuntu-arm64" ;;
    x86_64|amd64)  ASSET_ARCH="ubuntu-x64" ;;
    *)             ASSET_ARCH="" ;;
  esac
  if [ -n "$ASSET_ARCH" ]; then
    head_ "Installing llama.cpp (official prebuilt — no compiler needed)"
    # Pick the newest release that ACTUALLY CARRIES THIS ASSET -- never `releases/latest`.
    #
    # Measured 2026-08-21 18:32 UTC: llama.cpp published a release tagged `v0.2.0` whose
    # ONLY asset is a 7-byte `nightly-tag.txt`. `releases/latest` returned it, so the
    # constructed `llama-v0.2.0-bin-ubuntu-arm64.tar.gz` 404d and EVERY phone install
    # from that moment fell through to the source build -- 15-40 minutes on a handset,
    # behind a `warn` nobody reads, and the install still SUCCEEDS. The painless path
    # was gone and nothing was red. `releases/latest` answers "the newest release",
    # which is not the question; the question is "the newest release I can install from".
    #
    # So take the download URL straight out of the release list (newest-first) and let
    # the presence of the asset decide. That survives this failure and a tag-naming
    # change alike, because it never constructs a filename it has not seen.
    #
    # Anchored on `-bin-<arch>.tar.gz` deliberately: the same release also ships
    # `-bin-ubuntu-vulkan-arm64.tar.gz` and `-bin-ubuntu-sycl-fp16-x64.tar.gz`, and a
    # looser match would hand a phone a Vulkan build for hardware whose Vulkan path is
    # measurably slower than NEON.
    # TWO defects lived on these two lines, and the second hid behind the first.
    #
    # 1. THE QUOTING. `[^"]` inside a DOUBLE-quoted string is not a class containing a
    #    quote -- that `"` TERMINATES the string, shell-legal but not what was meant, so
    #    the pattern the shell actually built was `[^]*` (measured under `bash -x`,
    #    2026-09-13). GNU grep then answered "grep: Invalid range end" and, under
    #    `set -euo pipefail`, THE INSTALLER DIED RIGHT THERE -- at Rung 2, before any
    #    download, on x86_64 and on a phone alike. `[^\"]` is the literal quote.
    #    Reproduced in a clean Debian 12 container 2026-09-13; check_phone_bootstrap
    #    PB009 now asserts it statically.
    # 2. THE FALLBACK WAS UNREACHABLE. Under `set -e`, `ASSET_URL="$(... | grep ...)"`
    #    that matches nothing exits the script -- so the pinned `LLAMA_TAG_FALLBACK`
    #    branch below could never run, and the comment above it ("a 403 there must not
    #    end the install") described code that was already dead. Hence `|| true` outside
    #    the substitution: the assignment must be ALLOWED to fail so the fallback lives.
    ASSET_RE="https://github.com/ggml-org/llama.cpp/releases/download/[^\"]*/llama-[^\"]*-bin-${ASSET_ARCH}[.]tar[.]gz"
    ASSET_URL="$(curl -fsSL --max-time 25 "https://api.github.com/repos/ggml-org/llama.cpp/releases?per_page=30" 2>/dev/null \
                 | grep -o "$ASSET_RE" | head -1 || true)"
    if [ -n "$ASSET_URL" ]; then
      TARBALL="${ASSET_URL##*/}"
      TAG="${TARBALL#llama-}"; TAG="${TAG%%-bin-*}"
    else
      TAG="$LLAMA_TAG_FALLBACK"
      TARBALL="llama-${TAG}-bin-${ASSET_ARCH}.tar.gz"
      ASSET_URL="https://github.com/ggml-org/llama.cpp/releases/download/${TAG}/${TARBALL}"
      warn "could not resolve a llama.cpp release carrying ${ASSET_ARCH} (the GitHub API allows 60 requests/hour per IP and carrier NAT shares yours) - using pinned $TAG"
    fi
    say "downloading $TARBALL ..."
    TMPD="$(mktemp -d)"
    if curl -fL --retry 3 --max-time 600 -o "$TMPD/$TARBALL" "$ASSET_URL" 2>/dev/null; then
      tar -xzf "$TMPD/$TARBALL" -C "$TMPD"
      # The tarball unpacks to llama-<tag>/ holding the binaries AND their .so files
      # side by side. Copy the WHOLE directory, not just llama-server: the binary
      # dynamically links libllama.so / libggml*.so from beside itself, so a lone
      # llama-server dies with "error while loading shared libraries" — which reads
      # as a corrupt download rather than an incomplete copy.
      SRCD="$(find "$TMPD" -maxdepth 1 -type d -name 'llama-*' | head -1)"
      [ -z "$SRCD" ] && SRCD="$TMPD"
      cp -f "$SRCD"/* "$BIN_DIR"/ 2>/dev/null || true
      chmod +x "$BIN_DIR"/llama-* 2>/dev/null || true
      # Its own directory must be on the runtime link path, for the same reason.
      export LD_LIBRARY_PATH="$BIN_DIR:${LD_LIBRARY_PATH:-}"
      # ...before deciding the prebuilt is unusable. Ordering is the whole point: a
      # have_server() check first would already have condemned it to a source build.
      ensure_runtime_libs "$BIN_DIR/llama-server"
      SERVER="$(have_server "$BIN_DIR/llama-server")" || SERVER=""
      [ -n "$SERVER" ] && say "installed: $SERVER ($TAG)"
    else
      warn "prebuilt download failed — falling through to a source build"
    fi
    rm -rf "$TMPD"
  fi
fi

# Rung 3 — build from source. Slow, needs a toolchain, and exists only so that an
# architecture with no prebuilt is degraded rather than a dead end.
if [ -z "$SERVER" ]; then
  head_ "Building llama.cpp from source (no prebuilt for this system)"
  warn "this takes 15-40 minutes on a phone — keep the screen on"
  if [ "$ENVKIND" = "termux" ]; then
    pkg install -y git cmake clang libcurl python 2>&1 | tail -2
  else
    SUDO=""; [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null && SUDO="sudo"
    $SUDO apt-get update -qq
    $SUDO apt-get install -y -qq git cmake build-essential libcurl4-openssl-dev python3 python3-pip
  fi
  if [ ! -d "$LLAMA_DIR/.git" ]; then
    git clone --depth 1 https://github.com/ggml-org/llama.cpp "$LLAMA_DIR"
  else
    git -C "$LLAMA_DIR" pull --ff-only || warn "pull failed; building what is on disk"
  fi
  # No GGML_VULKAN: the Android VM has no GPU passthrough, and on Termux the Adreno/Mali
  # Vulkan path in llama.cpp is frequently SLOWER than NEON. Measure before believing.
  cmake -S "$LLAMA_DIR" -B "$LLAMA_DIR/build" \
    -DCMAKE_BUILD_TYPE=Release -DLLAMA_CURL=ON -DGGML_NATIVE=ON >/dev/null
  cmake --build "$LLAMA_DIR/build" -j"$(nproc)" --target llama-server llama-cli llama-bench 2>&1 | tail -3
  SERVER="$(have_server "$LLAMA_DIR/build/bin/llama-server")" || SERVER=""
fi

[ -n "$SERVER" ] || die "could not obtain a working llama-server by any route (package, prebuilt, or source). See the output above."
BIN="$(dirname "$SERVER")"
export LD_LIBRARY_PATH="$BIN:${LD_LIBRARY_PATH:-}"

# ── 4. Model ─────────────────────────────────────────────────────────────────────
if [ ! -f "$MODEL_DIR/$GGUF" ]; then
  head_ "Downloading $GGUF"
  say "resumable — if the phone sleeps, just re-run this script"
  # The token goes in a 0600 curl CONFIG, not in argv. Argv is world-readable through
  # /proc/<pid>/cmdline, and this download runs for MINUTES on the visitor's own
  # device — so a `--header "Authorization: Bearer $TOK"` would publish their
  # HuggingFace token to every process on the phone for the whole transfer.
  # Asserted by ARGV001 (dev/tools/check_argv_credentials.py). A file rather than
  # `-K -` because stdin is conditional here and `-C -` resume must keep working
  # when there is no token at all.
  AUTHCFG=""
  if [ -n "${BONSAI_TOKEN:-}" ]; then
    AUTHCFG="$(mktemp -t bonsai-auth.XXXXXX)"
    chmod 600 "$AUTHCFG"
    trap 'rm -f "$AUTHCFG"' EXIT INT TERM
    printf 'header = "Authorization: Bearer %s"\n' "$BONSAI_TOKEN" > "$AUTHCFG"
  fi
  # The transfer itself lives in download_model(): it refuses to resume onto a stale
  # .part (the 416 that made every re-run die the same way) and never leaves a
  # partial behind for the next run to resume onto.
  download_model "$GGUF_URL" "$MODEL_DIR/$GGUF" \
    || die "download failed. A gated repo needs BONSAI_TOKEN=hf_... ; a 404 means that repo or filename does not exist."
  [ -n "$AUTHCFG" ] && rm -f "$AUTHCFG" && AUTHCFG=""
  # Verify it IS a GGUF before declaring success. A gated or moved HuggingFace file
  # answers 200 with an HTML page, and `curl -f` cannot see that — so without this
  # check the install "succeeds" and llama-server then dies on a login page wearing
  # a .gguf name, which reads as a broken model rather than a failed download.
  #
  # READ THE FINAL FILE, not a .part: download_model() renames the partial itself
  # (that is the whole point — a .part must never survive a failure), so a magic check
  # on "$GGUF.part" reads a file that no longer exists and dies with "its first bytes
  # were ''" on a download that was PERFECT. Measured in a clean container 2026-09-13,
  # caught by running the installer end to end rather than by reading it.
  MAGIC="$(head -c 4 "$MODEL_DIR/$GGUF" 2>/dev/null || true)"
  if [ "$MAGIC" != "GGUF" ]; then
    rm -f "$MODEL_DIR/$GGUF"
    die "what downloaded is not a GGUF (its first bytes were '${MAGIC}'). That URL served a web page, not model weights — the repo is probably gated, or the filename is wrong."
  fi
  say "downloaded $(du -h "$MODEL_DIR/$GGUF" 2>/dev/null | cut -f1)"
else
  say "model already present: $MODEL_DIR/$GGUF"
fi

# ── 5. Optional benchmark — turns this script's estimates into facts ─────────────
if [ "${BONSAI_BENCH:-0}" = "1" ]; then
  head_ "Benchmark"
  "$BIN/llama-bench" -m "$MODEL_DIR/$GGUF" -p 128 -n 64 -t "$THREADS" 2>&1 | tail -12
fi

# ── 6. Serve ─────────────────────────────────────────────────────────────────────
# --no-mmap is deliberately NOT set: mmap is what makes multi-GB weights survivable
# under Android memory pressure. Do not "optimize" it away.
#
# A previous run's server may still hold the port. Reuse it rather than dying with
# "address already in use", which reads as a broken install on the second run.
if curl -fsS --max-time 3 "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
  say "a server is already answering on 127.0.0.1:$PORT — leaving it alone"
  SERVER_PID="$(cat "$PREFIX_DIR/llama-server.pid" 2>/dev/null || echo '?')"
else
  head_ "Starting llama-server"
  say "127.0.0.1:$PORT (ctx $CTX, $THREADS of $CORES threads)"
  nohup "$SERVER" \
    -m "$MODEL_DIR/$GGUF" \
    --host 127.0.0.1 --port "$PORT" \
    -c "$CTX" -t "$THREADS" \
    > "$PREFIX_DIR/llama-server.log" 2>&1 &
  SERVER_PID=$!
  echo "$SERVER_PID" > "$PREFIX_DIR/llama-server.pid"

  UP=0
  for i in $(seq 1 90); do
    if curl -fsS --max-time 3 "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then UP=1; break; fi
    kill -0 "$SERVER_PID" 2>/dev/null || die "server died on startup — see $PREFIX_DIR/llama-server.log
     $(tail -5 "$PREFIX_DIR/llama-server.log" 2>/dev/null)"
    sleep 2
  done
  if [ "$UP" = 1 ]; then
    say "server is up (pid $SERVER_PID)"
  else
    warn "server slow to answer /health after 3 min; check $PREFIX_DIR/llama-server.log"
  fi
fi

# A re-runnable launcher, so the second session is one word instead of this whole
# script. Written with an UNQUOTED heredoc so the paths resolve now; the runtime
# expansions that must survive are escaped.
cat > "$BIN_DIR/aither-bonsai" <<LAUNCHER
#!/usr/bin/env bash
# Written by aitherium.com/phone.sh — start/stop the local Bonsai server.
set -euo pipefail
export LD_LIBRARY_PATH="$BIN:\${LD_LIBRARY_PATH:-}"
case "\${1:-start}" in
  start)
    if curl -fsS --max-time 3 http://127.0.0.1:$PORT/health >/dev/null 2>&1; then
      echo "already running on http://127.0.0.1:$PORT"; exit 0
    fi
    nohup "$SERVER" -m "$MODEL_DIR/$GGUF" --host 127.0.0.1 --port $PORT -c $CTX -t $THREADS \\
      > "$PREFIX_DIR/llama-server.log" 2>&1 &
    echo \$! > "$PREFIX_DIR/llama-server.pid"
    echo "started on http://127.0.0.1:$PORT"
    ;;
  stop)
    kill "\$(cat "$PREFIX_DIR/llama-server.pid" 2>/dev/null)" 2>/dev/null && echo stopped || echo "not running"
    ;;
  log) tail -f "$PREFIX_DIR/llama-server.log" ;;
  *)   echo "usage: aither-bonsai {start|stop|log}"; exit 2 ;;
esac
LAUNCHER
chmod +x "$BIN_DIR/aither-bonsai"

# ── 7. The agent tools: awdk (adk) and awsh ──────────────────────────────────────
# Both are OPTIONAL and neither blocks the model. awdk is a pure-Python wheel and
# cheap; awsh needs Node, which on the Debian VM is an apt install the visitor may
# not want — so it is asked for rather than assumed.
install_tools() {
  head_ "Agent tools"
  local PIP=""
  for c in pip3 pip; do
    if command -v "$c" >/dev/null 2>&1; then PIP="$c"; break; fi
  done
  if [ -z "$PIP" ]; then
    if [ "$ENVKIND" = "termux" ]; then
      pkg install -y python >/dev/null 2>&1 || true
    else
      SUDO=""; [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null && SUDO="sudo"
      $SUDO apt-get install -y -qq python3-pip >/dev/null 2>&1 || true
    fi
    for c in pip3 pip; do
      if command -v "$c" >/dev/null 2>&1; then PIP="$c"; break; fi
    done
  fi

  if [ -n "$PIP" ]; then
    say "installing awdk (the 'adk' command) ..."
    # --break-system-packages: Debian 12+ marks the system Python EXTERNALLY-MANAGED
    # (PEP 668) and refuses a plain --user install with an error that reads like a
    # broken pip. The Android Linux Terminal is Debian, so that is the DEFAULT
    # environment here, not an edge case. The clean form is tried FIRST so a venv or
    # Termux (which is not externally managed) never takes the override path.
    "$PIP" install --quiet --user --upgrade awdk 2>/dev/null \
      || "$PIP" install --quiet --user --upgrade --break-system-packages awdk 2>&1 | tail -2 \
      || warn "awdk install failed — the model still works; retry with: $PIP install --user awdk"
    case ":$PATH:" in
      *":$HOME/.local/bin:"*) ;;
      *) export PATH="$HOME/.local/bin:$PATH" ;;
    esac
    if command -v adk >/dev/null 2>&1; then
      say "adk ready: $(command -v adk)"
    else
      warn "awdk installed but 'adk' is not on PATH — add \$HOME/.local/bin to PATH"
    fi
  else
    warn "no pip available — skipping awdk"
  fi

  if [ "$WANT_SHELL" = "ask" ]; then
    if ask "Install awsh too? (the terminal that answers questions — needs Node, ~50 MB)" n; then
      WANT_SHELL=yes
    else
      WANT_SHELL=no
    fi
  fi

  if [ "$WANT_SHELL" = "yes" ]; then
    if ! command -v npm >/dev/null 2>&1; then
      say "installing Node ..."
      if [ "$ENVKIND" = "termux" ]; then
        pkg install -y nodejs-lts 2>&1 | tail -1
      else
        SUDO=""; [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null && SUDO="sudo"
        $SUDO apt-get install -y -qq nodejs npm 2>&1 | tail -1
      fi
    fi
    if command -v npm >/dev/null 2>&1; then
      say "installing awsh ..."
      # The SHIPPING npm package is `@aitherium/awsh` (measured 2026-09-13: 1.18.8,
      # bins awsh/aither/aither-shell — the playbook and the one door install it).
      # `@aitherium/shell-cli` is the LEGACY name (1.16.1, bins aither/aither-shell
      # ONLY — it installs no `awsh` command at all, which is what this line did
      # while its comment claimed the reverse); the bare name `awsh` is a 404 on the
      # public registry, so `npm i -g awsh` fails for every stranger who follows
      # advice naming the COMMAND instead of the PACKAGE.
      # check_phone_bootstrap.py asserts the name against its real registry so
      # this cannot rot back.
      npm install -g @aitherium/awsh 2>&1 | tail -2 \
        || warn "awsh install failed — retry with: npm i -g @aitherium/awsh"
      command -v awsh >/dev/null 2>&1 && say "awsh ready: $(command -v awsh)"
    else
      warn "no npm — skipping awsh"
    fi
  fi
}
install_tools

# ── 8. Enroll into your mesh (optional) ──────────────────────────────────────────
# Loopback already works without this — the browser on THIS phone reaches the server
# directly. Enrolling publishes the node so your OTHER devices and the authenticated
# PWA can pick it as tier 2. That is the only thing it buys, so it is opt-in.
if [ "$ENROLL" = "ask" ]; then
  if ask "Enroll this node into your AitherOS mesh? (not needed for this phone's browser)" n; then
    ENROLL=yes
  else
    ENROLL=no
  fi
fi
if [ "$ENROLL" = "yes" ]; then
  # THE VERB. `adk up` was never a top-level command (only `sandbox up` and `relay up`
  # exist), so this line failed on the COMMAND as well as on the flag -- and the
  # `|| warn` swallowed both while the summary below printed success. Enrollment has
  # therefore never once worked from the advertised one-liner. Two real verbs now:
  # `adk login` for identity (`adk enroll` correctly refuses without one), then
  # `adk enroll --inference-url ... --node-class phone`.
  #
  # LOUD ON FAILURE. You asked to enrol; a failed enrol is a failed run, not a note.
  if ! command -v adk >/dev/null 2>&1; then
    die "enrollment was requested but 'adk' is not on PATH. Install it and re-run:
       pip install --user awdk
       adk login && adk enroll --inference-url http://127.0.0.1:$PORT --node-class phone
     (or re-run with AITHER_ENROLL=no to skip enrollment; the local model works either way)"
  fi
  if [ -n "$TOKEN" ]; then
    say "signing in with the token the installer already minted ..."
    if ! adk login --api-key "$TOKEN"; then
      die "sign-in failed with the token install.sh forwarded. Re-run without --token
     to use the browser device-code flow instead."
    fi
  else
    say "signing in (a browser device-code prompt follows) ..."
    if ! adk login; then
      die "sign-in did not complete, so there is no identity to enrol under.
     Re-run when you can finish the browser prompt."
    fi
  fi
  say "enrolling this phone into your mesh ..."
  if ! adk enroll --inference-url "http://127.0.0.1:$PORT" --node-class phone; then
    die "enrollment failed. Your local model is still serving on http://127.0.0.1:$PORT;
     re-run this script to retry."
  fi
  say "enrolled. This phone now appears under Connected Devices."
fi

if [ "$CUSTOM" = "1" ]; then MODEL_LABEL="$GGUF"; else MODEL_LABEL="Bonsai-$MODEL_SIZE"; fi

cat <<SUMMARY

  ─────────────────────────────────────────────────────────────────────────
  $MODEL_LABEL is serving on http://127.0.0.1:$PORT

  NOW OPEN A BROWSER ON THIS PHONE — either one picks the server up by itself:

      https://elysium.aitherium.com     chat, character cards, lorebooks
      https://aitherium.com              the AitherOS desktop

  Both probe 127.0.0.1:$PORT automatically. The model chip flips from "no node"
  to your own, and every reply is generated on this device.

  No sign-in needed. That is the point of this path: a phone cannot run these
  models inside the BROWSER — a tab's GPU budget is a fraction of what the
  weights need, which is why loading one there kills the tab — and using a
  REMOTE node of your own needs an account. This server and the browser are on
  the same device, so they just talk over loopback.

  RUN IT AGAIN LATER
      aither-bonsai start | stop | log        ($BIN_DIR/aither-bonsai)
      curl -fsSL https://aitherium.com/phone.sh | bash    (re-runnable, resumes)

  A DIFFERENT MODEL
      BONSAI_MODEL=8B curl -fsSL https://aitherium.com/phone.sh | bash
      BONSAI_GGUF_URL=https://huggingface.co/<repo>/resolve/main/<file>.gguf \\
          curl -fsSL https://aitherium.com/phone.sh | bash

  CHECK IT BY HAND
      curl -s http://127.0.0.1:$PORT/v1/chat/completions \\
        -H 'Content-Type: application/json' \\
        -d '{"messages":[{"role":"user","content":"hi"}],"max_tokens":128}'
      logs:  tail -f $PREFIX_DIR/llama-server.log

  Bonsai is a REASONING model: it thinks before it answers. On a phone that is
  the bulk of the wait, so cap it — pass "reasoning_effort":"low" (or "off") per
  request, or start the server with --reasoning-budget 512.

  Benchmarked it? Re-run with BONSAI_BENCH=1 and send the numbers.
  ─────────────────────────────────────────────────────────────────────────
SUMMARY

# ── Playbook hand-off (B7). The node is up; now become the owner's machine. The
#    canonical door owns the device flow and the playbook rungs, so this forwards
#    rather than re-implementing them. A failure here is the playbook's exit code.
if [ -n "$PLAYBOOK" ]; then
  head_ "Playbook: $PLAYBOOK"
  _door="$(curl -fsSL "$INSTALL_BASE/install.sh")" || die "cannot fetch $INSTALL_BASE/install.sh for the playbook step"
  if [ -n "$WORKSPACE" ]; then
    printf '%s' "$_door" | bash -s -- --playbook "$PLAYBOOK" --workspace "$WORKSPACE"
  else
    printf '%s' "$_door" | bash -s -- --playbook "$PLAYBOOK"
  fi
  exit $?
fi
