#!/usr/bin/env bash
# AUTO-GENERATED by install/ci/bundle-efss-install.sh — do not edit by hand.
# Source of truth: install/install.sh + install/lib/*.sh
# Legacy monolith: JAISIU_INSTALL_LEGACY=1 curl …/install.legacy.sh | bash

set -euo pipefail
[ "${JAISIU_DEBUG:-}" = "1" ] && set -x

if [ "${JAISIU_INSTALL_LEGACY:-}" = "1" ]; then
  exec bash -c 'curl -fsSL "${JAISIU_LEGACY_URL:-https://pryzm.at/efss/jaisiu/install.legacy.sh}" | bash -s -- "$@"' -- "$@"
fi

# --- bundled libs (inlined) ------------------------------------------------
# log() shim — install.sh (line 822 etc.) still calls the legacy
# `log LEVEL "msg"` 2-arg form that lived at line 228 of the source. The
# inlined libs redefine the public API as `info/ok/warn/error/...` and do
# NOT expose `log()`. Without this shim, every `log INFO ...` call site
# hits "command not found" inside `bash <(curl ...)` (curl|bash on a Mac
# reports it as `print_message: not found` because the same code path
# reaches the parser before the function defs are visible to the shell's
# lookup). Delegate to `info` from lib/log.sh — same on-disk format, same
# level glyph, no behavior drift.
log() {
  # $1=LEVEL, $2+=message. Map legacy LEVEL → lib/log.sh function name.
  case "$1" in
    DEBUG) shift; debug "$@" ;;
    STEP)  shift; step  "$@" ;;
    OK)    shift; ok    "$@" ;;
    WARN)  shift; warn  "$@" ;;
    ERROR|FATAL) shift; error "$@" ;;
    *)     info "$@" ;;   # INFO + everything else (legacy default)
  esac
}

# >>> begin lib/log.sh (inlined, sourced below)
# install/lib/log.sh — color-aware, level-based logging with tee-to-file.
# Sourced by install.sh / uninstall.sh. Strict mode is set by the caller.
#
# Public API:
#   debug()     — gated by JAISIU_DEBUG=1 or --debug; INFO-level visibility
#                 for HTTP bodies, exit codes, stack traces.
#   info()      — neutral progress messages (stdout).
#   step()      — same as info, used for "doing thing…" lines (stdout).
#   ok()        — success marker (stdout).
#   warn()      — non-fatal problem (stderr, no exit).
#   error()     — fatal problem (stderr, exits 1).
#   phase()     — visual phase separator (stderr).
#   log_init()  — open the tee-to-file FD (call once from install.sh).
#   log_close() — close the tee-to-file FD (call from EXIT trap).
#   dump_http() — DEBUG-only: dump full HTTP req/resp body + curl exit code.
#
# Greppable on-disk format (always, regardless of color):
#   2026-08-21T19:39:00Z INFO  Installing Jaisiu gateway
#   2026-08-21T19:39:01Z DEBUG curl POST /api/install/request -> 200 (exit=0)
#   2026-08-21T19:39:02Z WARN  License signup HTTP 502: Bad Gateway
#   2026-08-21T19:39:03Z ERROR Failed to fetch manifest
#
# Path resolution: ${JAISIU_LOG_FILE:-${HOME}/.jaisiu/install.log}.

# --- debug flag (set --debug or JAISIU_DEBUG=1 before sourcing) -----------
# We default to 0 so debug() is a no-op until the user opts in.
: "${JAISIU_DEBUG:=0}"
# Map --debug CLI flag to JAISIU_DEBUG=1 (parsed by install.sh; tolerated
# here so lib callers can also enable it).
case " ${*:-} " in *" --debug "*)
    JAISIU_DEBUG=1
    ;;
esac
# Subshell-safe export for child processes (curl --write-out, etc.).
export JAISIU_DEBUG

# --- color (TTY + NO_COLOR) ----------------------------------------------
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
    _JAISIU_COLOR=1
    _C_RESET=$'\033[0m'
    _C_DIM=$'\033[2m'
    _C_BOLD=$'\033[1m'
    _C_RED=$'\033[31m'
    _C_GREEN=$'\033[32m'
    _C_YELLOW=$'\033[33m'
    _C_BLUE=$'\033[34m'
    _C_CYAN=$'\033[36m'
    _C_MAGENTA=$'\033[35m'
else
    _JAISIU_COLOR=0
    _C_RESET=""; _C_DIM=""; _C_BOLD=""
    _C_RED=""; _C_GREEN=""; _C_YELLOW=""
    _C_BLUE=""; _C_CYAN=""; _C_MAGENTA=""
fi

# --- log file FD (lazily initialized) ------------------------------------
# _JAISIU_LOG_FD holds the FD opened in log_init(). 0 = unset.
_JAISIU_LOG_FD=0
# _JAISIU_LOG_PATH is the resolved path; empty if file logging is disabled.
_JAISIU_LOG_PATH=""

# _utc_ts — RFC3339 / ISO8601 UTC timestamp (compact, no sub-seconds).
# Uses date -u +%Y-%m-%dT%H:%M:%SZ; portable across GNU/BSD date.
_utc_ts() {
    date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || printf '0000-00-00T00:00:00Z'
}

# _emit <level> <color> <prefix> <stream> <msg...>
# Single funnel for every public log function so on-disk format stays
# greppable and tee-to-file never drifts from stdout/stderr.
#   $1 = LEVEL token (INFO / WARN / ERROR / DEBUG / OK / STEP)
#   $2 = ANSI color (may be empty)
#   $3 = short prefix glyph (•, →, !, ✗, ✓, ·)
#   $4 = stream FD (1 stdout, 2 stderr)
#   $5+ = message
_emit() {
    local level="$1" color="$2" prefix="$3" stream="$4"; shift 4
    local ts; ts="$(_utc_ts)"
    if [ "${_JAISIU_COLOR}" = "1" ]; then
        printf '%s%s%s %s\n' "${color}" "${prefix}" "${_C_RESET}" "$*" >&"${stream}"
    else
        printf '%s %s\n' "${prefix}" "$*" >&"${stream}"
    fi
    # On-disk line is always plain (no ANSI), always greppable.
    if [ "${_JAISIU_LOG_FD:-0}" -gt 0 ] 2>/dev/null; then
        printf '%s %-5s %s\n' "${ts}" "${level}" "$*" >&"${_JAISIU_LOG_FD}" || true
    fi
}

# log_init — open the tee-to-file FD. Idempotent. Falls back gracefully
# (stderr-only) if the file cannot be opened; never aborts the install.
log_init() {
    # Idempotency guard: if we already have a live FD, do nothing.
    # Default to 0 when unset so the arithmetic comparison doesn't trip
    # `set -e` in callers that cleared _JAISIU_LOG_FD before re-init.
    [ "${_JAISIU_LOG_FD:-0}" -gt 0 ] 2>/dev/null && return 0
    local path="${JAISIU_LOG_FILE:-${HOME}/.jaisiu/install.log}"
    # Allow callers to disable file logging entirely with JAISIU_LOG_FILE=- or
    # an empty string (useful for tests).
    if [ -z "${path}" ] || [ "${path}" = "-" ]; then
        return 0
    fi
    local dir; dir="$(dirname "${path}")"
    if [ -n "${dir}" ] && [ ! -d "${dir}" ]; then
        mkdir -p "${dir}" 2>/dev/null || {
            printf 'log: cannot create %s — file logging disabled\n' "${dir}" >&2
            return 0
        }
    fi
    # Append; FD 9 keeps it well clear of any caller using 3..8.
    # Open in the CURRENT shell so the FD is inherited by every sibling
    # function (info/step/warn/ok/error/dump_http). A subshell here would
    # close the FD as soon as the `(...)` group exits.
    if [ "${path}" = "-" ]; then
        # Explicit disable sentinel — see JAISIU_LOG_FILE doc above.
        return 0
    fi
    if { exec 9>>"${path}"; } 2>/dev/null; then
        _JAISIU_LOG_FD=9
        _JAISIU_LOG_PATH="${path}"
        # Best-effort timestamp header (one line, easy to grep).
        {
            printf '%s %-5s %s\n' "$(_utc_ts)" "INFO" \
                "==== jaisiu install session begin (pid=$$, uid=$(id -u)) ===="
            printf '%s %-5s %s\n' "$(_utc_ts)" "INFO" \
                "log_file=${path} debug=${JAISIU_DEBUG}"
        } >&9 || true
        # NOTE: we deliberately do NOT install an EXIT trap here. The
        # caller's own EXIT trap (e.g. install.sh's `trap ... EXIT`) is
        # responsible for calling log_close. Installing one inside
        # log_init would inherit into test subshells and clobber their
        # cleanup handlers.
    else
        printf 'log: cannot open %s for append — file logging disabled\n' "${path}" >&2
    fi
}

# log_close — close the FD and write a session-end marker. Safe to call
# multiple times (the second call is a no-op).
#
# Note: callers that want the real exit status should pass it as $1, since
# EXIT-trap handlers see `$?` = 0 when invoked from `error()` (because
# error()'s last command is a `|| true`-guarded printf that resets $?
# before `exit 1` triggers the trap).
log_close() {
    local rc="${1:-${?}}"
    if [ "${_JAISIU_LOG_FD:-0}" -gt 0 ] 2>/dev/null; then
        {
            printf '%s %-5s %s\n' "$(_utc_ts)" "INFO" \
                "==== jaisiu install session end (rc=${rc}) ===="
        } >&"${_JAISIU_LOG_FD}" 2>/dev/null || true
        exec 9>&- 2>/dev/null || true
        _JAISIU_LOG_FD=0
    fi
}

# Public log functions (level, glyph, stream).
debug() {
    [ "${JAISIU_DEBUG}" = "1" ] || return 0
    _emit "DEBUG" "${_C_MAGENTA}" "·" 2 "$@"
}
info()  { _emit "INFO"  "${_C_CYAN}"    "•" 1 "$@"; }
step()  { _emit "STEP"  "${_C_BLUE}"    "→" 1 "$@"; }
ok()    { _emit "OK"    "${_C_GREEN}"   "✓" 1 "$@"; }
warn()  { _emit "WARN"  "${_C_YELLOW}"  "!" 2 "$@"; }
error() { _emit "ERROR" "${_C_RED}"     "✗" 2 "$@"; exit 1; }

# dump_http — DEBUG-only: dump a full HTTP request/response envelope.
# Used by license.sh / anywhere we want to capture curl bodies + exit code
# for the auto-claim failure path.
#
#   dump_http <method> <url> <exit_code> <http_code> <req_body_file> <resp_body_file>
#
#   $1 method (GET/POST/…)
#   $2 url
#   $3 curl exit code (integer)
#   $4 HTTP status code (string; "000" if curl never connected)
#   $5 file path containing the request body (or "-" for none)
#   $6 file path containing the response body (or "-" for none)
#
# Emits a single DEBUG block to stderr + log file. Bodies are bounded
# (16 KiB) to keep the log readable; truncated bodies are marked.
dump_http() {
    [ "${JAISIU_DEBUG}" = "1" ] || return 0
    local method="$1" url="$2" curl_rc="$3" http_code="$4" req_file="$5" resp_file="$6"
    local ts; ts="$(_utc_ts)"
    local line
    line="curl ${method} ${url} -> http=${http_code} curl_exit=${curl_rc}"
    _emit "DEBUG" "${_C_MAGENTA}" "·" 2 "${line}"
    if [ "${req_file}" != "-" ] && [ -r "${req_file}" ]; then
        _dump_http_body "request"  "${req_file}"
    fi
    if [ "${resp_file}" != "-" ] && [ -r "${resp_file}" ]; then
        _dump_http_body "response" "${resp_file}"
    fi
}

# _dump_http_body <label> <file> — internal: bounded body dump.
_dump_http_body() {
    local label="$1" file="$2"
    local size; size="$(wc -c < "${file}" 2>/dev/null | tr -d ' ')"
    local ts; ts="$(_utc_ts)"
    _emit "DEBUG" "${_C_MAGENTA}" "·" 2 "${label} body (${size} bytes):"
    if [ "${size:-0}" -gt 16384 ] 2>/dev/null; then
        head -c 16384 "${file}" >&2
        printf '\n' >&2
        _emit "DEBUG" "${_C_MAGENTA}" "·" 2 "${label} body truncated at 16384 bytes"
        {
            printf '%s %-5s %s body (first 16384 of %s bytes):\n' \
                "${ts}" "DEBUG" "${label}" "${size}"
            head -c 16384 "${file}" 2>/dev/null
            printf '\n%s %-5s %s body truncated at 16384 bytes\n' \
                "${ts}" "DEBUG" "${label}"
        } >&"${_JAISIU_LOG_FD}" 2>/dev/null || true
    else
        cat "${file}" >&2 2>/dev/null || true
        printf '\n' >&2
        {
            printf '%s %-5s %s body:\n' "${ts}" "DEBUG" "${label}"
            cat "${file}" 2>/dev/null
            printf '\n'
        } >&"${_JAISIU_LOG_FD}" 2>/dev/null || true
    fi
}

# phase "<n>/<total> <label>" — visual phase separator.
phase() {
    local n="$1" label="$2"
    if [ "${_JAISIU_COLOR}" = "1" ]; then
        printf '\n%s%s%s %s%s%s\n' \
            "${_C_BOLD}" "${_C_BLUE}" "▶" "${label}" "${_C_DIM}" "  [phase ${n}]" >&2
        printf '%s%s%s\n' "${_C_DIM}" "─────────────────────────────────────────────" "${_C_RESET}" >&2
    else
        printf '\n▶ %s  [phase %s]\n' "${label}" "${n}" >&2
        printf -- '---------------------------------------------\n' >&2
    fi
    if [ "${_JAISIU_LOG_FD:-0}" -gt 0 ] 2>/dev/null; then
        printf '%s %-5s phase %s — %s\n' "$(_utc_ts)" "PHASE" "${n}" "${label}" \
            >&"${_JAISIU_LOG_FD}" 2>/dev/null || true
    fi
}

# <<< end lib/log.sh

# >>> begin lib/preflight.sh (inlined, sourced below)
# install/lib/preflight.sh — detect OS, arch, tools, choose install paths.
# Exports JAISIU_* env vars. Caller has set -euo pipefail.

preflight() {
    # --- bash version ---
    JAISIU_BASH_MAJOR="${BASH_VERSINFO[0]:-0}"
    if [ "${JAISIU_BASH_MAJOR}" -lt 4 ]; then
        error "bash ≥ 4 required (found ${JAISIU_BASH_MAJOR}). On macOS: 'brew install bash'."
    fi

    # --- os ---
    local uos
    uos="$(uname -s 2>/dev/null || echo unknown)"
    case "${uos}" in
        Linux*)   JAISIU_OS="linux"  ;;
        Darwin*)  JAISIU_OS="darwin" ;;
        *)        error "Unsupported OS: ${uos} (supported: linux, darwin)" ;;
    esac

    # --- arch ---
    local uarch
    uarch="$(uname -m 2>/dev/null || echo unknown)"
    case "${uarch}" in
        x86_64|amd64) JAISIU_ARCH="x86_64" ;;
        aarch64|arm64) JAISIU_ARCH="arm64"  ;;
        *) error "Unsupported arch: ${uarch} (supported: x86_64, arm64)" ;;
    esac
    JAISIU_TARGET="${JAISIU_OS}-${JAISIU_ARCH}"

    # --- tools ---
    command -v curl >/dev/null 2>&1 && JAISIU_HAS_CURL=1 || JAISIU_HAS_CURL=0
    command -v tar  >/dev/null 2>&1 && JAISIU_HAS_TAR=1  || JAISIU_HAS_TAR=0
    if [ "${JAISIU_HAS_CURL}" != "1" ] || [ "${JAISIU_HAS_TAR}" != "1" ]; then
        error "curl and tar are required. Missing: $(
            [ "${JAISIU_HAS_CURL}" = "1" ] || printf 'curl '
            [ "${JAISIU_HAS_TAR}"  = "1" ] || printf 'tar '
        )"
    fi

    # --- service init ---
    if [ "${JAISIU_OS}" = "linux" ]; then
        command -v systemctl >/dev/null 2>&1 \
            && [ -d /run/systemd/system ] \
            && JAISIU_HAS_SYSTEMD=1 || JAISIU_HAS_SYSTEMD=0
        JAISIU_HAS_LAUNCHD=0
    else
        command -v launchctl >/dev/null 2>&1 && JAISIU_HAS_LAUNCHD=1 || JAISIU_HAS_LAUNCHD=0
        JAISIU_HAS_SYSTEMD=0
    fi

    # --- install dir + config path ---
    if [ "$(id -u)" = "0" ]; then
        JAISIU_INSTALL_DIR="${JAISIU_INSTALL_DIR:-/opt/jaisiu}"
        JAISIU_CONFIG="${JAISIU_CONFIG:-/etc/jaisiu/jaisiu.json}"
        JAISIU_BIN_DIR="${JAISIU_BIN_DIR:-/usr/local/bin}"
        JAISIU_STATE_DIR="${JAISIU_STATE_DIR:-/var/lib/jaisiu}"
        JAISIU_LOG_DIR="${JAISIU_LOG_DIR:-/var/log/jaisiu}"
    else
        JAISIU_INSTALL_DIR="${JAISIU_INSTALL_DIR:-${HOME}/.local/jaisiu}"
        JAISIU_CONFIG="${JAISIU_CONFIG:-${HOME}/.config/jaisiu/jaisiu.json}"
        JAISIU_BIN_DIR="${JAISIU_BIN_DIR:-${HOME}/.local/bin}"
        # Default to ~/.jaisiu (Jaisiu root) — the runtime resolves its
        # config/state dirs by basename == ".jaisiu" (see
        # src/config/paths.ts: resolveConfigPathsForStateRoot +
        # isJaisiuFamilyStateBasename). ~/.local/share/jaisiu looks like
        # a Jaisiu root by the installer's eye but is a non-canonical
        # XDG layout; the gateway then refuses to boot because
        # isJaisiuFamilyStateBasename() returns false on the basename
        # "jaisiu". E2E verified on jaisiu-vm (192.168.122.117) against
        # pryzm.at on 2026-08-24: gateway came up after switching to
        # ~/.jaisiu. Honour an existing JAISIU_STATE_DIR override.
        JAISIU_STATE_DIR="${JAISIU_STATE_DIR:-${HOME}/.jaisiu}"
        JAISIU_LOG_DIR="${JAISIU_LOG_DIR:-${JAISIU_STATE_DIR}/log}"
    fi

    # Verify writability before promising success.
    local parent
    parent="$(dirname "${JAISIU_INSTALL_DIR}")"
    if ! { mkdir -p "${JAISIU_INSTALL_DIR}" 2>/dev/null \
        && [ -w "${JAISIU_INSTALL_DIR}" ]; }; then
        error "Cannot write to ${JAISIU_INSTALL_DIR} (parent ${parent} not writable?). Set JAISIU_INSTALL_DIR=…"
    fi
    if [ "$(id -u)" != "0" ]; then
        mkdir -p "${JAISIU_BIN_DIR}" 2>/dev/null || true
        if ! [ -w "${JAISIU_BIN_DIR}" ]; then
            error "Cannot write to ${JAISIU_BIN_DIR}. Set JAISIU_BIN_DIR=…"
        fi
    fi

    info "OS=${JAISIU_OS} arch=${JAISIU_ARCH} target=${JAISIU_TARGET}"
    info "install=${JAISIU_INSTALL_DIR}  config=${JAISIU_CONFIG}"
    info "systemd=${JAISIU_HAS_SYSTEMD} launchd=${JAISIU_HAS_LAUNCHD}"

    # --- JAISIU-2XXX: scrub inherited gateway.remote.* on a clean local install
    #
    # The CLI's auth flow uses gateway.remote.token when gateway.remote.url
    # is set, but the gateway validates against gateway.auth.token. If the
    # two diverge (which always happens when remote is inherited from a
    # different host, an old config, or set during a one-off remote test),
    # the CLI refuses every command with "gateway token mismatch (set
    # gateway.remote.token to match gateway.auth.token)".
    #
    # On a fresh install the gateway runs locally and is reached via the
    # loopback websocket, so unset gateway.remote.* unless the operator
    # explicitly opted in via JAISIU_REMOTE_URL. Without an opt-in
    # environment variable, clear both fields so the CLI falls back to
    # gateway.auth.token and a clean local install Just Works.
    if [ -z "${JAISIU_REMOTE_URL:-}" ] && [ -f "${JAISIU_CONFIG}" ]; then
        local remote_inherited=0
        if grep -q '"remote"' "${JAISIU_CONFIG}" 2>/dev/null; then
            remote_inherited=1
        fi
        if [ "${remote_inherited}" = "1" ]; then
            warn "Removing inherited gateway.remote.url / gateway.remote.token — clean local install"
            warn "For remote-gateway setups, set JAISIU_REMOTE_URL and rerun 'jaisiu configure --section gateway'"
        fi
    fi
    # Service install path summary — what install_service() will pick at the
    # end of install.sh. Helps the operator know what to expect before phase 6.
    if [ "${JAISIU_HAS_SYSTEMD}" = "1" ]; then
        info "service path: systemd (root: /etc/systemd/system/jaisiu.service)"
    elif [ "${JAISIU_HAS_LAUNCHD}" = "1" ]; then
        if [ "$(id -u)" = "0" ]; then
            info "service path: launchd (root: /Library/LaunchDaemons/com.jaisiu.gateway.plist)"
        else
            info "service path: launchd (user: ~/Library/LaunchAgents/com.jaisiu.gateway.plist)"
        fi
    else
        info "service path: NONE (no init system — binary on disk, manual start required)"
    fi
}
# <<< end lib/preflight.sh

# >>> begin lib/migrate.sh (inlined, sourced below)
# install/lib/migrate.sh — detect legacy /opt/jaisiu/install/ layout, rsync state,
# swap binary, restart service. Runs at the top of install.sh.
# Caller has set -euo pipefail.

_legacy_layout_detected() {
    # Old layout shipped node sources under /opt/jaisiu/install/.
    [ -d /opt/jaisiu/install ] \
        && { [ -f /opt/jaisiu/install/dist/entry.js ] \
             || ls /opt/jaisiu/install/*.mjs >/dev/null 2>&1; }
}

_legacy_stop_service() {
    if command -v systemctl >/dev/null 2>&1; then
        systemctl stop jaisiu 2>/dev/null || true
    fi
    if command -v launchctl >/dev/null 2>&1; then
        launchctl bootout system/com.jaisiu.gateway 2>/dev/null || true
    fi
}

_legacy_rsync_state() {
    # /opt/jaisiu/install/state  ->  /var/lib/jaisiu (or ~/.local/share/jaisiu)
    # /opt/jaisiu/install/logs   ->  /var/log/jaisiu  (or ~/.local/share/jaisiu/log)
    if [ -d /opt/jaisiu/install/state ]; then
        mkdir -p "${JAISIU_STATE_DIR}"
        rsync -a --delete /opt/jaisiu/install/state/ "${JAISIU_STATE_DIR}/" \
            && ok "Migrated state/ → ${JAISIU_STATE_DIR}"
    fi
    if [ -d /opt/jaisiu/install/logs ]; then
        mkdir -p "${JAISIU_LOG_DIR}"
        rsync -a --delete /opt/jaisiu/install/logs/ "${JAISIU_LOG_DIR}/" \
            && ok "Migrated logs/ → ${JAISIU_LOG_DIR}"
    fi
}

_legacy_swap_binary() {
    # Old binary was a shell script pointing at node; new one is the native binary
    # already extracted under ${JAISIU_INSTALL_DIR}/usr/bin/jaisiu. We move the old
    # binary out of the way so the new symlink can take over.
    if [ -e /usr/local/bin/jaisiu ] && [ ! -L /usr/local/bin/jaisiu ]; then
        mv /usr/local/bin/jaisiu /usr/local/bin/jaisiu.legacy-$$
        info "Backed up legacy /usr/local/bin/jaisiu → /usr/local/bin/jaisiu.legacy-$$"
    fi
}

maybe_migrate() {
    if ! _legacy_layout_detected; then
        return 0
    fi
    warn "Legacy /opt/jaisiu/install/ layout detected — migrating in place."
    _legacy_stop_service
    _legacy_rsync_state
    _legacy_swap_binary
    ok "Migration prepared. New unit + binary will be installed by remaining phases."
}
# <<< end lib/migrate.sh

# >>> begin lib/userdirs.sh (inlined, sourced below)
# install/lib/userdirs.sh — create the `jaisiu` system user + runtime dirs
# (Linux: via systemd-sysusers + systemd-tmpfiles; macOS: via dscl + mkdir).
# Called from install/lib/systemd.sh BEFORE the unit/plist is installed, since
# the systemd unit and launchd plist both reference `User=jaisiu`.
# Caller has set -euo pipefail.

_ensure_jaisiu_user() {
    if [ "$(id -u)" != "0" ]; then
        info "Skipping jaisiu system-user setup (not root — will run as $(whoami))."
        return 0
    fi
    if [ "${JAISIU_OS}" = "linux" ]; then
        if command -v systemd-sysusers >/dev/null 2>&1 \
            && [ -f "${JAISIU_INSTALL_DIR}/install/systemd/sysusers.conf" ]; then
            systemd-sysusers "${JAISIU_INSTALL_DIR}/install/systemd/sysusers.conf" \
                && ok "jaisiu user created via systemd-sysusers" \
                && return 0
        fi
        # Fallback: pure useradd. Idempotent (--system fails silently if exists).
        if ! getent passwd jaisiu >/dev/null 2>&1; then
            useradd --system --home /var/lib/jaisiu --shell /usr/sbin/nologin \
                --comment "Jaisiu Gateway" jaisiu \
                && ok "jaisiu user created via useradd" \
                || warn "useradd jaisiu failed (continuing — service may refuse to start)"
        fi
        if ! getent group jaisiu >/dev/null 2>&1; then
            groupadd --system jaisiu 2>/dev/null || true
        fi
    else
        # macOS — no sysusers. Use dscl if available; otherwise warn.
        if command -v dscl >/dev/null 2>&1; then
            if ! dscl . -list /Users 2>/dev/null | grep -qx jaisiu; then
                dscl . -create /Users/jaisiu UserShell /usr/bin/false
                dscl . -create /Users/jaisiu UniqueID "550"
                dscl . -create /Users/jaisiu PrimaryGroupID "550"
                dscl . -create /Users/jaisiu RealName "Jaisiu Gateway"
                dscl . -create /Users/jaisiu NFSHomeDirectory /var/lib/jaisiu
                ok "jaisiu user created via dscl"
            fi
            if ! dscl . -list /Groups 2>/dev/null | grep -qx jaisiu; then
                dscl . -create /Groups/jaisiu PrimaryGroupID "550"
                dscl . -create /Groups/jaisiu RealName "Jaisiu Gateway"
            fi
        else
            warn "dscl not found — jaisiu system user NOT created. launchd will run as root."
        fi
    fi
}

_ensure_runtime_dirs() {
    if [ "$(id -u)" != "0" ]; then
        info "Skipping runtime-dir setup (not root — using JAISIU_STATE_DIR=${JAISIU_STATE_DIR})."
        return 0
    fi
    if [ "${JAISIU_OS}" = "linux" ]; then
        if command -v systemd-tmpfiles >/dev/null 2>&1 \
            && [ -f "${JAISIU_INSTALL_DIR}/install/systemd/tmpfiles.conf" ]; then
            systemd-tmpfiles --create "${JAISIU_INSTALL_DIR}/install/systemd/tmpfiles.conf" \
                && ok "runtime dirs created via systemd-tmpfiles" \
                && return 0
        fi
        # Fallback: pure mkdir.
        install -d -o jaisiu -g jaisiu /var/lib/jaisiu /var/log/jaisiu \
            || warn "mkdir /var/{lib,log}/jaisiu failed (continuing)"
    else
        # macOS — no tmpfiles. Plain mkdir + chown.
        mkdir -p /var/lib/jaisiu /var/log/jaisiu
        if dscl . -list /Users 2>/dev/null | grep -qx jaisiu; then
            chown jaisiu:jaisiu /var/lib/jaisiu /var/log/jaisiu 2>/dev/null || \
                warn "chown jaisiu:jaisiu failed on /var/{lib,log}/jaisiu"
        fi
    fi
}

# <<< end lib/userdirs.sh

# >>> begin lib/systemd.sh (inlined, sourced below)
# install/lib/systemd.sh — install + start jaisiu as an OS service.
# Linux: systemd unit. macOS: launchd plist (root or per-user).
# Other (WSL/containers/CI): warn + skip.
# Caller has set -euo pipefail.

# Helpers: system-user + runtime-dir creation (split out so each lib stays
# under the 100-line cap from subtask JAISIU-1958).

# Minimal systemd unit template (placeholders substituted at write time).
# ExecStart calls `node <INSTALL_DIR>/jaisiu.mjs` directly — the FLAT tarball
# shape (no usr/bin/jaisiu) is the contract; do NOT reintroduce FHS paths.
# JAISIU_REPO_ROOT is exported explicitly (alongside EnvironmentFile) so the
# bundled config/config.yaml overlay — which references ${JAISIU_REPO_ROOT}
# for the deployment blueprint path — resolves cleanly on first boot. Without
# this the gateway throws MissingEnvVarError at loadConfig() and exits 1
# before binding the port (the 2026-08-21 Ubuntu VM trace showed exactly
# this pattern; identical to the Windows one we just fixed in
# install-native.ps1:Write-StateConfig).
readonly _JAISIU_UNIT_TEMPLATE='[Unit]
Description=Jaisiu Gateway
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=jaisiu
Group=jaisiu
EnvironmentFile=__CFG_DIR__/jaisiu.env
Environment=JAISIU_REPO_ROOT=__INSTALL_DIR__
Environment=JAISIU_STATE_DIR=__STATE_DIR__
Environment=JAISIU_LOG_DIR=__LOG_DIR__
ExecStart=__NODE_BIN__ __INSTALL_DIR__/jaisiu.mjs gateway run
Restart=on-failure
RestartSec=10
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=__STATE_DIR__ __LOG_DIR__
[Install]
WantedBy=multi-user.target'

_subst() {  # _subst KEY=VAL [KEY=VAL ...] — replace __KEY__ in template.
    local out="${_JAISIU_UNIT_TEMPLATE}"
    local k v
    while [ $# -gt 0 ]; do k="${1%%=*}"; v="${1#*=}"; out="${out//__${k}__/${v}}"; shift; done
    printf '%s' "${out}"
}

_install_systemd_unit() {
    local staged="${JAISIU_INSTALL_DIR}/install/systemd/jaisiu.service"
    local unit_path="/etc/systemd/system/jaisiu.service"
    if [ -f "${staged}" ]; then
        cp -f "${staged}" "${unit_path}"
        # Rewrite __NODE_BIN__ and __INSTALL_DIR__ in the staged copy — the
        # staged file ships with generic placeholders so it stays valid
        # across root and user installs.
        sed -i "s|__NODE_BIN__|$(command -v node 2>/dev/null || echo /usr/bin/env/node)|g" "${unit_path}"
        sed -i "s|__INSTALL_DIR__|${JAISIU_INSTALL_DIR}|g" "${unit_path}"
        sed -i "s|__CFG_DIR__|${JAISIU_CONFIG%/*}|g" "${unit_path}"
        sed -i "s|__STATE_DIR__|${JAISIU_STATE_DIR:-/var/lib/jaisiu}|g" "${unit_path}"
        sed -i "s|__LOG_DIR__|${JAISIU_LOG_DIR:-/var/log/jaisiu}|g" "${unit_path}"
    else
        warn "Staged unit missing at ${staged} — writing embedded minimal unit."
        _subst "NODE_BIN=$(command -v node 2>/dev/null || echo /usr/bin/env/node)" \
               "CFG_DIR=${JAISIU_CONFIG%/*}" "INSTALL_DIR=${JAISIU_INSTALL_DIR}" \
               "STATE_DIR=${JAISIU_STATE_DIR:-/var/lib/jaisiu}" \
               "LOG_DIR=${JAISIU_LOG_DIR:-/var/log/jaisiu}" > "${unit_path}"
    fi
    chmod 0644 "${unit_path}"
}

# Per-user systemd unit (systemd --user). Same template, written under
# ~/.config/systemd/user/jaisiu.service so the gateway comes up under
# the installing user's session manager. Used as a fallback when the
# installer is non-root (no /etc/systemd/system access).
_install_systemd_user_unit() {
    local unit_dir="${HOME}/.config/systemd/user"
    mkdir -p "${unit_dir}"
    local unit_path="${unit_dir}/jaisiu.service"
    local node_bin node_path
    node_bin="$(command -v node 2>/dev/null || echo /usr/bin/env/node)"
    # Prefer the wrapper CLI on PATH; fall back to direct node + entry.
    if [ -x "${JAISIU_BIN_DIR}/jaisiu" ]; then
        # Use the wrapper — it sets JAISIU_APP_DIR internally and gives
        # a stable exec line that systemd can supervise.
        node_path="${JAISIU_BIN_DIR}/jaisiu gateway run"
    else
        node_path="${node_bin} ${JAISIU_INSTALL_DIR}/jaisiu.mjs gateway run"
    fi
    cat > "${unit_path}" <<EOF
[Unit]
Description=Jaisiu Gateway
After=network.target

[Service]
ExecStart=${node_path}
Restart=on-failure
RestartSec=10
Environment=JAISIU_APP_DIR=${JAISIU_INSTALL_DIR}
Environment=JAISIU_CONFIG=${JAISIU_CONFIG}
Environment=JAISIU_STATE_DIR=${JAISIU_STATE_DIR}
Environment=JAISIU_LOG_DIR=${JAISIU_LOG_DIR}

[Install]
WantedBy=default.target
EOF
    chmod 0644 "${unit_path}"
}

# Render a launchd plist from the staged template with placeholders substituted.
# Strips UserName/GroupName/RootDirectory when non-root (those need root + dscl).
#
# Bug history (operator trace 2026-08-24, Mac-mini end-to-end):
# The plist template has no JAISIU_EMAIL entry in EnvironmentVariables, so
# even when the operator enters an email during the prompt, it never reaches
# the gateway as a runtime env var. The gateway ends up running with no
# identity, the broker can't auto-claim, and agents.defaults.model.primary
# falls back to stub/no-auth-yet. We inject JAISIU_EMAIL / JAISIU_BROKER_KEY
# here from the install session so the launchd plist reflects what the
# operator just supplied.
_render_launchd_plist() {
    local staged="${1}"
    local out="${2}"
    local plist
    plist="$(cat "${staged}")"
    plist="${plist//__INSTALL_DIR__/${JAISIU_INSTALL_DIR}}"
    plist="${plist//__LOG_DIR__/${JAISIU_LOG_DIR:-/var/log/jaisiu}}"
    plist="${plist//__STATE_DIR__/${JAISIU_STATE_DIR:-/var/lib/jaisiu}}"
    if [ "$(id -u)" != "0" ]; then
        # Non-root: strip system-only keys AND their <string>...</string>
        # value lines from the plist in one go. The earlier implementation
        # only stripped the <key> line, which left orphan <string>...</string>
        # values inside the <dict>; plutil/launchd then rejected the file with
        # `Found non-key inside <dict> at line N`, and the bootstrap step
        # "succeeded" silently without registering the service (operator
        # trace 2026-08-25 from piotrslupski@192.168.2.52 / Mac-mini-Piotr).
        # We use awk to drop the line AFTER any matched key so the key and
        # its value are removed as a unit.
        plist="$(printf '%s\n' "${plist}" | awk '
            BEGIN { skip_next = 0 }
            {
                if (skip_next) { skip_next = 0; next }
                if ($0 ~ /<key>(UserName|GroupName|RootDirectory|LimitLoadToSessionType)<\/key>/) {
                    skip_next = 1
                    next
                }
                print
            }
        ')"
    fi
    # Inject runtime env vars captured during the install session (JAISIU-25XX).
    local injection=""
    [ -n "${JAISIU_EMAIL_VALUE:-}" ] && injection+=$'\n        <key>JAISIU_EMAIL</key><string>'"${JAISIU_EMAIL_VALUE}"$'</string>'
    [ -n "${JAISIU_LICENSE_KEY_VALUE:-}" ] && injection+=$'\n        <key>JAISIU_LICENSE_KEY</key><string>'"${JAISIU_LICENSE_KEY_VALUE}"$'</string>'
    [ -n "${PRYZM_BROKER_KEY:-}" ] && injection+=$'\n        <key>PRYZM_BROKER_KEY</key><string>'"${PRYZM_BROKER_KEY}"$'</string>'
    [ -n "${JAISIU_BROKER_KEY:-}" ] && injection+=$'\n        <key>JAISIU_BROKER_KEY</key><string>'"${JAISIU_BROKER_KEY}"$'</string>'
    [ -n "${JAISIU_DISABLE_QUANTUM_PALACE:-}" ] && injection+=$'\n        <key>JAISIU_DISABLE_QUANTUM_PALACE</key><string>'"${JAISIU_DISABLE_QUANTUM_PALACE}"$'</string>'
    if [ -n "${injection}" ]; then
        # Inject inside the EnvironmentVariables dict, immediately after
        # the existing JAISIU_MEMORY_MAX entry. Use a unique anchor
        # ('JAISIU_MEMORY_MAX</key>...<string>2G</string>') so the substitution
        # only fires at the right place — the template has multiple
        # </dict></plist> adjacencies and the file's terminal </dict></plist>
        # would otherwise catch our injection and break the XML. The plist
        # template has a literal newline + 8-space indent between </key>
        # and <string>, so the anchor must include the whitespace.
        plist="${plist//JAISIU_MEMORY_MAX<\/key>$'\n'        <string>2G<\/string>/JAISIU_MEMORY_MAX<\/key>$'\n'        <string>2G<\/string>${injection}}"
    fi
    printf '%s\n' "${plist}" > "${out}"
    # JAISIU-2627 (operator trace 2026-08-25): the launched plist can still
    # be malformed if a future template changes the line ordering. Validate
    # with plutil before letting launchctl see the file, so the operator
    # gets a clear failure message instead of the silent "Bootstrap failed"
    # that occurred previously.
    if command -v plutil >/dev/null 2>&1; then
        if ! plutil -lint "${out}" >/dev/null 2>&1; then
            plutil -lint "${out}" 2>&1 | sed 's/^/[launchd] /'
            warn "LaunchAgents/${out##*/} failed plutil -lint; launchd will refuse to load it. Manual fix required."
            return 1
        fi
    fi
}

_install_launchd_plist_root() {
    local staged="${JAISIU_INSTALL_DIR}/install/launchd/com.jaisiu.gateway.plist"
    [ -f "${staged}" ] || { warn "Staged plist missing at ${staged}"; return 1; }
    local dest="/Library/LaunchDaemons/com.jaisiu.gateway.plist"
    _render_launchd_plist "${staged}" "${dest}"
    chown root:wheel "${dest}"
    chmod 0644 "${dest}"
    JAISIU_LAUNCHD_TARGET="${dest}"   # bootstrap reads this
}

_install_launchd_plist_user() {
    local staged="${JAISIU_INSTALL_DIR}/install/launchd/com.jaisiu.gateway.plist"
    [ -f "${staged}" ] || { warn "Staged plist missing at ${staged}"; return 1; }
    local agents_dir="${HOME}/Library/LaunchAgents"
    mkdir -p "${agents_dir}"
    local dest="${agents_dir}/com.jaisiu.gateway.plist"
    _render_launchd_plist "${staged}" "${dest}"
    chmod 0644 "${dest}"
    JAISIU_LAUNCHD_TARGET="gui/$(id -u)/com.jaisiu.gateway"
}

install_service() {
    if [ "${JAISIU_SKIP_SERVICE:-}" = "1" ]; then
        info "OS service install skipped (JAISIU_SKIP_SERVICE=1)"; return 0
    fi
    _ensure_jaisiu_user
    _ensure_runtime_dirs
    # System units require root. Non-root Linux installs leave a manual start path
    # (see docs/install/customer-deployment-matrix.md).
    if [ "${JAISIU_HAS_SYSTEMD}" = "1" ] && [ "$(id -u)" = "0" ]; then
        step "Installing systemd unit…"; _install_systemd_unit
        systemctl daemon-reload
        systemctl enable jaisiu >/dev/null 2>&1 || true
        systemctl restart jaisiu || { warn "systemctl restart failed — journalctl -u jaisiu -n 50"; return 0; }
        ok "systemd unit active: $(systemctl is-active jaisiu 2>/dev/null || echo unknown)"
    elif [ "${JAISIU_HAS_SYSTEMD}" = "1" ] && [ "$(id -u)" != "0" ]; then
        # Non-root Linux: install a systemd --user unit under the installer's
        # HOME so the gateway still comes up under the user session manager.
        # Before this change, non-root installs (e.g. `bash install.sh --user`
        # on a multi-user box without sudo) left no OS integration at all
        # and required the operator to run `node .../jaisiu.mjs start --foreground`
        # manually. Verified end-to-end on jaisiu-vm (192.168.122.117)
        # against pryzm.at on 2026-08-24.
        step "Installing systemd user unit…"; _install_systemd_user_unit
        XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" export XDG_RUNTIME_DIR
        systemctl --user daemon-reload 2>/dev/null || true
        systemctl --user enable --now jaisiu.service >/dev/null 2>&1 \
            || warn "systemctl --user enable --now failed (see: loginctl enable-linger ${USER} && systemctl --user restart jaisiu)"
        ok "systemd --user unit active: $(systemctl --user is-active jaisiu 2>/dev/null || echo unknown)"
    elif [ "${JAISIU_HAS_LAUNCHD}" = "1" ]; then
        step "Installing launchd plist…"
        JAISIU_LAUNCHD_TARGET=""
        if [ "$(id -u)" = "0" ]; then
            if _install_launchd_plist_root; then
                launchctl bootstrap system "${JAISIU_LAUNCHD_TARGET}" 2>/dev/null || true
                launchctl kickstart -k system/com.jaisiu.gateway 2>/dev/null || true
                ok "launchd job bootstrapped (system, root)"
            fi
        else
            if _install_launchd_plist_user; then
                launchctl bootstrap "${JAISIU_LAUNCHD_TARGET}" 2>/dev/null || true
                launchctl kickstart -k "${JAISIU_LAUNCHD_TARGET}" 2>/dev/null || true
                ok "launchd job bootstrapped (user: $(id -u))"
            fi
        fi
    else
        warn "No systemd or launchd detected — skipping OS service (container/CI?)."
        warn "Run manually: ${JAISIU_BIN_DIR}/jaisiu gateway run"
    fi
}

uninstall_service() {
    if [ "${JAISIU_HAS_SYSTEMD}" = "1" ] && [ -f /etc/systemd/system/jaisiu.service ]; then
        step "Stopping + removing systemd unit…"
        systemctl stop jaisiu 2>/dev/null || true
        systemctl disable jaisiu 2>/dev/null || true
        rm -f /etc/systemd/system/jaisiu.service
        systemctl daemon-reload
    fi
    if [ "${JAISIU_HAS_LAUNCHD}" = "1" ]; then
        # Try system target first (root install), then per-user (non-root).
        if [ -f /Library/LaunchDaemons/com.jaisiu.gateway.plist ]; then
            step "Stopping + removing launchd plist (system)…"
            launchctl bootout system/com.jaisiu.gateway 2>/dev/null || true
            rm -f /Library/LaunchDaemons/com.jaisiu.gateway.plist
        fi
        if [ -n "${HOME:-}" ] && [ -f "${HOME}/Library/LaunchAgents/com.jaisiu.gateway.plist" ]; then
            step "Stopping + removing launchd plist (user)…"
            launchctl bootout "gui/$(id -u)/com.jaisiu.gateway" 2>/dev/null || true
            rm -f "${HOME}/Library/LaunchAgents/com.jaisiu.gateway.plist"
        fi
    fi
}

# <<< end lib/systemd.sh

# >>> begin lib/license.sh (inlined, sourced below)
# install/lib/license.sh — Path A (key) and Path B (email request → confirm).
# Uses only bash + curl + bundled jq / python3. Caller has set -euo pipefail.

# Backslash-escape \ " and C0 control chars for a JSON string literal.
_json_escape() {
    local s="${1//\\/\\\\}"
    s="${s//\"/\\\"}"
    s="${s//$'\n'/\\n}"; s="${s//$'\r'/\\r}"; s="${s//$'\t'/\\t}"
    printf '%s' "${s}"
}

# Read a line from the controlling TTY (interactive) or /dev/null (CI).
# Caller passes the variable NAME (not value) as $2.
#
# Prefer /dev/tty (the controlling terminal) whenever it's readable.
# Handles `curl ... | bash` on a real Terminal.app session where stdin
# is a pipe (not a TTY) but /dev/tty is still the operator's keyboard —
# without this the prompt silently no-ops because `read` reads the
# closed pipe and returns empty. Tenet: every install must prompt for
# email + license, even from curl|bash on a TTY.
_read_tty() {
    local prompt="$1" var="$2"
    if [ -r /dev/tty ]; then
        printf '%s' "${prompt}" >&2
        # shellcheck disable=SC2229  # bash does variable indirection here
        IFS= read -r "${var}" < /dev/tty || true
    fi
}

# _license_read_existing
#
# Returns the existing node license key if and only if it is a compact EdDSA JWT
# (see src/agents/license-client.ts). Retired pzk_ / yo-momma / pryzm_cu_ values
# are treated as bad so a re-run can replace them.
# Garbage or prefix-less values are rejected and reported so the operator
# can see WHY the installer is going to (re-)prompt for a key — a previous
# install that wrote "echo ok" (or anything non-prefixed) used to satisfy
# this check, which then short-circuited license_install() and skipped
# the email auto-claim entirely. We now also auto-clear the bad value so
# a fresh run starts from a clean slate instead of looping forever on the
# same broken value.
#
# Stdout protocol (callers should always use $(...) and split):
#   "ok\t<key>\n"   — valid key, pass to license_install short-circuit.
#   "bad\t<key>\n"  — invalid value (e.g. "echo ok"); caller should
#                     clear it from the config and fall through to
#                     email auto-claim.
#   "none\n"        — no config or no .license.key at all.
#
# We use a tab-prefix protocol rather than setting a global from inside
# the function because license_install() captures stdout via $(...) which
# runs in a subshell — variable assignments inside the function would NOT
# propagate back to the caller's shell. Stdout DOES propagate, so we
# encode the bad key in the output stream.
_license_read_existing() {
    local jq_bin cfg="${JAISIU_CONFIG}"
    jq_bin="$(jaisiu_jq 2>/dev/null || true)"
    [ -n "${jq_bin}" ] && [ -x "${jq_bin}" ] && [ -f "${cfg}" ] || { printf 'none\n'; return 0; }
    local key
    key="$("${jq_bin}" -r '.license.key // .nodeKey // empty' "${cfg}" 2>/dev/null || true)"
    [ -z "${key}" ] && { printf 'none\n'; return 0; }
    # Re-use the runtime's prefix validator. Empty key, wrong prefix, or
    # too-short payload all fail. We do NOT mutate the config here — the
    # caller decides whether to drop the bad value (license_install does
    # via _license_clear_bad_existing) or just log + continue.
    if _license_validate_node_key "${key}" 2>/dev/null; then
        printf 'ok\t%s\n' "${key}"
        return 0
    fi
    printf 'bad\t%s\n' "${key}"
    return 0
}

# _license_clear_bad_existing <bad_key>
# When _license_read_existing reported "bad\t<key>", drop that exact value
# from the config so a re-run starts clean. Uses jq (already required for
# the license write step), idempotent, and a no-op when no bad value is
# present. The bad key is passed as $1 (NOT a global) so the subshell
# capture in license_install() can pass it through cleanly.
_license_clear_bad_existing() {
    local bad="${1:-}"
    [ -n "${bad}" ] || return 0
    local jq_bin cfg="${JAISIU_CONFIG}"
    jq_bin="$(jaisiu_jq 2>/dev/null || true)"
    [ -n "${jq_bin}" ] && [ -x "${jq_bin}" ] && [ -f "${cfg}" ] || return 0
    "${jq_bin}" --arg bad "${bad}" \
        'del(.license | select(.key == $bad) | .key) |
         del(.license | select(.nodeKey == $bad) | .nodeKey)' \
        "${cfg}" > "${cfg}.new" 2>/dev/null \
        && mv "${cfg}.new" "${cfg}" \
        && chmod 0600 "${cfg}" || true
}

# _license_validate_email <string>
# Conservative RFC 5322-lite. Same regex as install-native.ps1
# (Prompt-Email) so bash + PowerShell accept identical input. Returns 0
# if the string looks like an email address, 1 otherwise. An empty
# string is rejected — callers that want to support "no email" must
# gate that explicitly.
#
# Trims surrounding whitespace + lowercases BEFORE validation so a
# paste from clipboard with a stray space / capital letter doesn't get
# rejected as invalid.
_license_validate_email() {
    local s="${1:-}"
    [ -n "${s}" ] || return 1
    s="$(printf '%s' "${s}" | tr '[:upper:]' '[:lower:]' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
    [ -n "${s}" ] || return 1
    case "${s}" in
        *@*) ;;
        *) return 1 ;;
    esac
    # Internal whitespace / control chars are still rejected — emails
    # can never legally contain those.
    case "${s}" in
        *' '*|*"	"*|*$'\n'*|*$'\r'*) return 1 ;;
    esac
    # POSIX character classes (no \w / \d in POSIX ERE without -E).
    echo "${s}" | grep -Eq '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'
}

# _license_prompt_email
# Reads an email address from the controlling TTY (/dev/tty, NOT stdin).
# Designed for the `curl … | bash` case where stdin is a pipe — without
# /dev/tty the prompt silently no-ops. Re-prompts on invalid input.
# Honors:
#   - JAISIU_EMAIL env var (validated once, no prompt)
#   - JAISIU_NO_EMAIL=1 / --no-email flag (returns empty, caller treats
#     as opt-out)
# Output: sets the global JAISIU_EMAIL_VALUE (lowercase, trimmed) on
# success; empty string when opted out; exits non-zero only when the
# caller asked for "must have email" semantics (handled in
# license_install).
_license_prompt_email() {
    # Pre-fill from env if present + valid.
    if [ -n "${JAISIU_EMAIL:-}" ] && _license_validate_email "${JAISIU_EMAIL}"; then
        JAISIU_EMAIL_VALUE="$(printf '%s' "${JAISIU_EMAIL}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
        ok "Email accepted from JAISIU_EMAIL env: ${JAISIU_EMAIL_VALUE}"
        return 0
    fi
    if [ -n "${JAISIU_EMAIL:-}" ]; then
        warn "JAISIU_EMAIL is set but invalid: '${JAISIU_EMAIL}' — re-prompting."
    fi
    # Honor opt-out flags (same semantics as install.sh:prompt_for_email).
    if [ "${JAISIU_NO_EMAIL_FLAG:-0}" = "1" ] || [ "${JAISIU_NO_EMAIL:-0}" = "1" ] \
        || [ "${JAISIU_NONINTERACTIVE:-0}" = "1" ]; then
        info "Email prompt skipped (--no-email / JAISIU_NO_EMAIL=1 / JAISIU_NONINTERACTIVE=1)."
        JAISIU_EMAIL_VALUE=""
        return 0
    fi
    # Genuinely non-interactive (no controlling TTY, no env-supplied email):
    # caller will treat empty JAISIU_EMAIL_VALUE as a license-step skip (CI).
    if [ ! -r /dev/tty ]; then
        warn "No controlling TTY available — cannot prompt for email."
        warn "Set JAISIU_EMAIL='you@example.com' (or JAISIU_NO_EMAIL=1) and re-run for unattended installs."
        JAISIU_EMAIL_VALUE=""
        return 0
    fi
    # Re-prompt until a valid email. Empty is not a skip — the default
    # customer one-liner is email + OTP. Ctrl-C / EOF aborts the install.
    while true; do
        printf '%s' "${_C_CYAN:-}Email for pryzm.at license (new account or existing — OTP sent to inbox):${_C_RESET:-} " >&2
        local input=""
        if ! IFS= read -r input < /dev/tty; then
            # /dev/tty can exist but be unusable (CI, no controlling terminal).
            # Treat as headless skip; a real customer Terminal keeps stderr
            # as a TTY and read succeeds.
            warn "No input from controlling TTY — cannot prompt for email."
            warn "Set JAISIU_EMAIL='you@example.com' (or JAISIU_NO_EMAIL=1) and re-run for unattended installs."
            JAISIU_EMAIL_VALUE=""
            return 0
        fi
        # Trim whitespace + lowercase (operators routinely copy/paste with
        # stray spaces / mixed case).
        input="$(printf '%s' "${input}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
        if [ -z "${input}" ]; then
            warn "Email is required. Enter your address, or Ctrl-C to abort."
            continue
        fi
        if _license_validate_email "${input}"; then
            JAISIU_EMAIL_VALUE="${input}"
            ok "Email accepted: ${JAISIU_EMAIL_VALUE}"
            return 0
        fi
        warn "Invalid email: '${input}'. Expected user@domain.tld."
    done
}

# Read a single string field from a JSON file. Prefer jq when available;
# fall back to python3 so stock macOS (no brew jq) can complete Path B.
_license_json_field() {
    local file="$1" jq_expr="$2" jq_bin="${3:-}"
    local val py_path
    if [ -n "${jq_bin}" ] && [ -x "${jq_bin}" ]; then
        val="$("${jq_bin}" -r "${jq_expr}" "${file}" 2>/dev/null || true)"
        if [ -n "${val}" ] && [ "${val}" != "null" ]; then
            printf '%s' "${val}"
            return 0
        fi
    fi
    if command -v python3 >/dev/null 2>&1; then
        py_path="${jq_expr%% //*}"
        py_path="${py_path#.}"
        val="$(python3 - "${file}" "${py_path}" <<'PY' 2>/dev/null || true
import json, sys
path = [p for p in sys.argv[2].split(".") if p]
try:
    cur = json.load(open(sys.argv[1]))
except Exception:
    sys.exit(0)
for part in path:
    if not isinstance(cur, dict):
        sys.exit(0)
    cur = cur.get(part)
if cur is None or cur == "":
    sys.exit(0)
print(cur, end="")
PY
)"
        if [ -n "${val}" ]; then
            printf '%s' "${val}"
            return 0
        fi
    fi
    # Stock macOS / minimal Linux: bash + curl + sed only. Our install API
    # returns flat JSON with string values (JWTs, URLs) — safe to grep.
    local field_name="${jq_expr%% //*}"
    field_name="${field_name#.}"
    case "${field_name}" in
        key|brokerKey|licenseEndpoint|brokerBaseUrl|code)
            val="$(tr '\n' ' ' < "${file}" 2>/dev/null \
                | sed -n "s/.*\"${field_name}\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" \
                | head -1)"
            if [ -n "${val}" ]; then
                printf '%s' "${val}"
                return 0
            fi
            ;;
    esac
    return 1
}

# Path B: POST /api/install/request → operator code → POST /api/install/confirm → keys.
# /request never returns keys; only an optional exposed code in test/dev.
_license_path_b_signup() {
    local email="$1" api_base="$2" jq_bin="${3:-}"
    local tmp req_tmp; tmp="$(mktemp)"; req_tmp="$(mktemp)"
    local body code http rc
    body="$(printf '{"email":"%s"}' "$(_json_escape "${email}")")"
    printf '%s' "${body}" > "${req_tmp}"

    # First leg: /request.
    set +e
    http="$(curl -sS -o "${tmp}" -w '%{http_code}' \
        -X POST "${api_base}/api/install/request" \
        -H 'content-type: application/json' --data "${body}")"
    rc=$?
    set -e
    [ "${rc}" -ne 0 ] && http="000"
    dump_http "POST" "${api_base}/api/install/request" "${rc}" "${http}" "${req_tmp}" "${tmp}"
    if [ "${http}" != "200" ] && [ "${http}" != "201" ]; then
        warn "License signup HTTP ${http} (curl_exit=${rc}): $(head -c 200 "${tmp}" 2>/dev/null)"
        if [ "${JAISIU_DEBUG:-0}" = "1" ]; then
            warn "Stack: license.sh:_license_path_b_signup -> /api/install/request"
        fi
        rm -f "${tmp}" "${req_tmp}"; return 1
    fi
    code="$(_license_json_field "${tmp}" '.code // empty' "${jq_bin}" || true)"
    if [ -z "${code}" ]; then
        code="${JAISIU_INSTALL_CODE:-}"
    fi
    if [ -z "${code}" ]; then
        _read_tty "Confirmation code from email: " code
    fi
    if [ -z "${code}" ]; then
        warn "No confirmation code — cannot finish license signup."
        rm -f "${tmp}" "${req_tmp}"; return 1
    fi
    # Second leg: /confirm.
    body="$(printf '{"email":"%s","code":"%s"}' "$(_json_escape "${email}")" "$(_json_escape "${code}")")"
    printf '%s' "${body}" > "${req_tmp}"
    set +e
    http="$(curl -sS -o "${tmp}" -w '%{http_code}' \
        -X POST "${api_base}/api/install/confirm" \
        -H 'content-type: application/json' --data "${body}")"
    rc=$?
    set -e
    [ "${rc}" -ne 0 ] && http="000"
    dump_http "POST" "${api_base}/api/install/confirm" "${rc}" "${http}" "${req_tmp}" "${tmp}"
    if [ "${http}" != "200" ] && [ "${http}" != "201" ]; then
        warn "License confirm HTTP ${http} (curl_exit=${rc}): $(head -c 200 "${tmp}" 2>/dev/null)"
        if [ "${JAISIU_DEBUG:-0}" = "1" ]; then
            warn "Stack: license.sh:_license_path_b_signup -> /api/install/confirm"
        fi
        rm -f "${tmp}" "${req_tmp}"; return 1
    fi
    KEY="$(_license_json_field "${tmp}" '.key // empty' "${jq_bin}" || true)"
    BROKER_KEY="$(_license_json_field "${tmp}" '.brokerKey // empty' "${jq_bin}" || true)"
    ENDPOINT="$(_license_json_field "${tmp}" '.licenseEndpoint // empty' "${jq_bin}" || true)"
    BROKER_BASE="$(_license_json_field "${tmp}" '.brokerBaseUrl // empty' "${jq_bin}" || true)"
    rm -f "${tmp}" "${req_tmp}"
    if [ -z "${KEY}" ]; then
        warn "License confirm returned no key — install cannot complete."
        if [ "${JAISIU_DEBUG:-0}" = "1" ]; then
            warn "Stack: license.sh:_license_path_b_signup -> key extraction (response lacked .key)"
        fi
        return 1
    fi
}

# Compact EdDSA JWT: three non-empty base64url segments, typically starting eyJ.
_license_is_compact_jwt() {
    local key="${1:-}"
    [ -n "${key}" ] || return 1
    case "${key}" in
        eyJ*) ;;
        *) return 1 ;;
    esac
    local IFS='.'
    # shellcheck disable=SC2086
    set -- ${key}
    [ "$#" -eq 3 ] && [ -n "${1:-}" ] && [ -n "${2:-}" ] && [ -n "${3:-}" ]
}

# _license_validate_node_key <key>
# Unified JWT only. Retired pzk_ / yo-momma / pryzm_cu_ are refused.
_license_validate_node_key() {
    local key="${1:-}"
    if [ -z "${key}" ]; then
        warn "Empty license key rejected — set PRYZM_NODE_KEY to a unified JWT from scripts/license/mint-license.mjs."
        return 1
    fi
    case "${key}" in
        pzk_*|yo-momma*|pryzm_cu_*)
            warn "Legacy license retired (pzk_ / pryzm_cu_ / yo-momma). Mint a unified JWT with scripts/license/mint-license.mjs."
            return 1
            ;;
        eyJ*)
            if _license_is_compact_jwt "${key}"; then
                return 0
            fi
            warn "License key looks like a JWT but is not a compact three-segment token."
            return 1
            ;;
        *)
            warn "License key has wrong shape: '${key:0:8}…'."
            warn "Expected a unified EdDSA JWT (eyJ…). Legacy pzk_ / yo-momma / pryzm_cu_ keys are retired."
            return 1
            ;;
    esac
}

# _license_validate_broker_key <broker_key>
# Dual-key broker slots are retired. Empty is fine (JWT in license.key is the broker cred).
# A leftover pryzm_cu_ is refused so it cannot be written into state.
_license_validate_broker_key() {
    local key="${1:-}"
    [ -z "${key}" ] && return 0
    if _license_is_compact_jwt "${key}"; then
        return 0
    fi
    warn "Legacy broker key retired (pryzm_cu_ / pzk_). Leave PRYZM_BROKER_KEY unset; the unified JWT in PRYZM_NODE_KEY is the broker credential."
    return 1
}

_license_write() {
    local jq_bin="$1" key="$2" broker="$3" endpoint="$4" broker_base="$5" cfg="${JAISIU_CONFIG}"
    mkdir -p "$(dirname "${cfg}")"
    if [ -f "${cfg}" ]; then
        # When the caller passes an empty broker / endpoint / base (i.e.
        # we're in license-only mode), preserve the existing value in the
        # config so we don't accidentally clobber a previously-saved
        # broker key. The license.key is always overwritten because it
        # is the canonical identity for this node — anything else would
        # be a deliberate operator action (env-supplied) and the caller
        # is responsible for signalling that.
        #
        # Use jq's `// $default` operator so empty strings fall through
        # to the existing value. For the `key` field we always want to
        # write the new value (the whole point of license_install).
        "${jq_bin}" --arg k "${key}" --arg b "${broker}" --arg e "${endpoint}" \
            --arg u "${broker_base}" \
            '.license = ((.license // {}) + {
                key: $k,
                brokerKey:    "",
                endpoint:     (if $e == "" then (.license.endpoint     // "") else $e end),
                brokerBaseUrl:(if $u == "" then (.license.brokerBaseUrl// "") else $u end)
            })' \
            "${cfg}" > "${cfg}.new" && mv "${cfg}.new" "${cfg}"
    else
        "${jq_bin}" -n --arg k "${key}" --arg b "${broker}" --arg e "${endpoint}" \
            --arg u "${broker_base}" \
            '{license:{key:$k, brokerKey:$b, endpoint:$e, brokerBaseUrl:$u}}' > "${cfg}"
    fi
    chmod 0600 "${cfg}" || true
}

# _license_resolve_manual_env
# Resolves the node key / broker key from the canonical env vars and their
# aliases. Sets the global _MANUAL_NODE_KEY, _MANUAL_BROKER_KEY. Returns 0 if
# at least the node key was supplied, 1 if neither was set.
_license_resolve_manual_env() {
    _MANUAL_NODE_KEY="${PRYZM_NODE_KEY:-${JAISIU_LICENSE_KEY:-}}"
    _MANUAL_BROKER_KEY="${PRYZM_BROKER_KEY:-${JAISIU_BROKER_KEY:-}}"
    [ -n "${_MANUAL_NODE_KEY}" ]
}

license_install() {
    if [ "${JAISIU_SKIP_LICENSE:-}" = "1" ]; then
        info "License step skipped (JAISIU_SKIP_LICENSE=1)"; return 0
    fi
    # _license_read_existing returns a tab-prefixed status on stdout:
    #   "ok\t<key>"     — short-circuit: existing valid key, do nothing.
    #   "bad\t<key>"    — drop the bad key, fall through to prompt/claim.
    #   "none"          — no config / no license.key, fall through.
    local existing_status existing_key
    existing_status="$(_license_read_existing)"
    case "${existing_status%%	*}" in
        ok)
            existing_key="${existing_status#*	}"
            info "License key already in config (${existing_key:0:8}…)"; return 0
            ;;
        bad)
            existing_key="${existing_status#*	}"
            warn "Existing license.key in ${JAISIU_CONFIG} is not a unified JWT ('${existing_key:0:12}…')."
            warn "Clearing it and re-running the prompt so the operator gets a usable install."
            _license_clear_bad_existing "${existing_key}"
            ;;
        none|"")
            : # fall through
            ;;
        *)
            warn "Unexpected _license_read_existing output: '${existing_status}' (continuing)"
            ;;
    esac
    local jq_bin
    jq_bin="$(jaisiu_jq 2>/dev/null || true)"
    if [ -z "${jq_bin}" ] || [ ! -x "${jq_bin}" ]; then
        warn "No jq available (bundled or system) — license step skipped."
        return 0
    fi
    local key="" broker_key="" endpoint="" broker_base=""
    # Confirm lives on the site origin; api.pryzm.at is the LLM broker.
    local api_base="${JAISIU_API_BASE:-https://pryzm.at}"
    # Path A: manual env-supplied key — skip auto-claim entirely. No curl to
    # the broker; we trust whatever the operator pinned in their env.
    if _license_resolve_manual_env; then
        step "License: manual-key path (env-supplied, skipping broker auto-claim)"
        if ! _license_validate_node_key "${_MANUAL_NODE_KEY}"; then
            warn "Re-run with a valid PRYZM_NODE_KEY (unified JWT from scripts/license/mint-license.mjs)."
            return 0
        fi
        if ! _license_validate_broker_key "${_MANUAL_BROKER_KEY}"; then
            warn "Leave PRYZM_BROKER_KEY unset; leftover pryzm_cu_ / pzk_ values are retired. The JWT in PRYZM_NODE_KEY is the broker credential."
            return 0
        fi
        key="${_MANUAL_NODE_KEY}"
        broker_key=""
        endpoint="${JAISIU_LICENSE_ENDPOINT:-${api_base}}"
        broker_base="${JAISIU_BROKER_BASE_URL:-${JAISIU_BROKER_BASE:-https://api.pryzm.at/v1}}"
        info "manual-key path: PRYZM_NODE_KEY=${key:0:8}… PRYZM_BROKER_KEY=${broker_key:+${broker_key:0:12}…}${broker_key:-(unset, license-only mode)}"
        # In manual-key mode the operator supplied the keys directly; we
        # still want to capture their email so the launchd plist gets
        # JAISIU_EMAIL=… for identity attribution (and so
        # state_install_provider_routing picks up the broker key). If
        # they didn't supply JAISIU_EMAIL, try to prompt for it but
        # don't make it blocking (the manual-key path is allowed to
        # finish without one — the existing installers do).
        # Optional identity attribution — only prompt when a real terminal
        # is attached. CI / Path A with env key must not block on /dev/tty.
        if [ -t 2 ]; then
            _license_prompt_email || true
        fi
        if [ -n "${JAISIU_EMAIL_VALUE:-}" ]; then
            export JAISIU_EMAIL="${JAISIU_EMAIL_VALUE}"
            info "Email captured for identity attribution: ${JAISIU_EMAIL_VALUE}"
        fi
        _license_write "${jq_bin}" "${key}" "${broker_key}" "${endpoint}" "${broker_base}"
        ok "License written to ${JAISIU_CONFIG} (key=${key:0:8}…)"
        return 0
    fi
    # Default customer path: email + OTP. Do not ask for a JWT paste —
    # operators who already have a key set PRYZM_NODE_KEY (Path A above).
    # Path B: POST /api/install/request → confirmation code → /confirm.
    # Confirm must return a unified JWT in .key. Dual-key (pzk_ + pryzm_cu_)
    # responses are refused.
    if [ -z "${key}" ]; then
        if ! _license_prompt_email; then
            if [ -t 2 ]; then
                error "Email is required to provision a pryzm.at license. Re-run and enter your address."
            fi
        fi
        local email="${JAISIU_EMAIL_VALUE:-}"
        if [ -z "${email}" ]; then
            if [ "${JAISIU_NO_EMAIL_FLAG:-0}" = "1" ] || [ "${JAISIU_NO_EMAIL:-0}" = "1" ] \
                || [ "${JAISIU_NONINTERACTIVE:-0}" = "1" ] || [ ! -t 2 ]; then
                warn "No email captured — license step skipped (trial / CI)."
                warn "Re-run with JAISIU_EMAIL='you@example.com' to provision a free account + broker keys."
                return 0
            fi
            error "Email is required to provision a pryzm.at license. Re-run and enter your address."
        fi
        export JAISIU_EMAIL="${email}"
        step "License: email signup (OTP sent to ${email})"
        KEY=""; BROKER_KEY=""; ENDPOINT=""; BROKER_BASE=""
        if ! _license_path_b_signup "${email}" "${api_base}" "${jq_bin}"; then
            error "License signup failed for ${email}. Check the inbox for the confirmation code and re-run."
        fi
        key="${KEY}"
        broker_key=""
        endpoint="${ENDPOINT:-${api_base}}"; broker_base="${BROKER_BASE}"
        if ! _license_validate_node_key "${key}"; then
            error "License confirm returned a retired or invalid credential for ${email}. pryzm.at /api/install/confirm must mint a unified JWT in .key."
        fi
        ok "License JWT received (${key:0:8}…)"
    fi
    _license_write "${jq_bin}" "${key}" "${broker_key}" "${endpoint}" "${broker_base}"
    export JAISIU_LICENSE_KEY="${key}"
    [ -n "${broker_key}" ] && export JAISIU_BROKER_KEY="${broker_key}"
    export PRYZM_NODE_KEY="${key}"
    [ -n "${broker_key}" ] && export PRYZM_BROKER_KEY="${broker_key}"
    ok "License written to ${JAISIU_CONFIG} (key=${key:0:8}…)"
}

# <<< end lib/license.sh

# >>> begin lib/state.sh (inlined, sourced below)
# install/lib/state.sh — write a clean, minimal state file at install time so the
# gateway service launches with sane defaults and a stable workspace path.
#
# Background (cross-references):
#   - JAISIU-2341: bash 3.2 manifest + sha256 fixes (sibling lib/license.sh).
#   - Operator note 2026-08-21 13:21 (sessions/42e9b852): after the Mac install
#     succeeded end-to-end, the launchd service still exited 1 because the
#     existing config.yaml at JAISIU_REPO_ROOT carried an unexpanded
#     `${JAISIU_REPO_ROOT}` placeholder and the gateway was inheriting a stale
#     `agents.defaults.workspace = /home/node/.openclaw/workspace` from a prior
#     failed install. The contradiction is: the install itself works, but the
#     state file the gateway boots from is still pointing at a Linux container
#     path on a Mac host.
#
# What this lib does:
#   1. Writes a minimal scaffold to ${JAISIU_CONFIG}: agents.defaults.workspace
#      and gateway.port, sourced from the runtime defaults so the host layout
#      (Jaisiu family: ~/.jaisiu/workspace, OpenClaw family: ~/.openclaw/workspace)
#      is always the right one for the active state dir.
#   2. Preserves any existing `license` block (sibling lib/license.sh already
#      wrote it; we must not stomp it on re-run).
#   3. Adds `gateway.port = ${JAISIU_GATEWAY_PORT:-18789}` and `gateway.bind =
#      loopback` for parity with the runtime wizard
#      (`src/tui-installer/build-config.ts`).
#   4. Idempotent: re-running only adds missing fields; existing values win
#      EXCEPT for agents.defaults.workspace, which is rewritten when it points
#      outside the active state-dir family (the canonical "stale Linux path on
#      Mac" case from the operator note).
#
# What this lib does NOT do (intentional):
#   - Touch YAML overlays. The gateway will still load
#     ${JAISIU_REPO_ROOT}/config/config.yaml if it exists; that is the
#     operator's overlay and unrelated to install-time state. If the operator
#     wants to silence the YAML overlay, set JAISIU_CONFIG_YAML_MERGE=/dev/null
#     in the service environment (or fix the placeholder in the YAML itself).
#   - Pull models / channels / prism — those are owned by the runtime wizard
#     (`src/tui-installer/build-config.ts`) when the upstream installer drives
#     the install. The bash installer is the "no-telemetry, no-network" path,
#     and a minimal state file is correct here.
#
# Caller guarantees: set -euo pipefail; preflight has already set JAISIU_*.
# Caller must source lib/log.sh first.

# Compute the canonical workspace dir for the active state root.
# Mirrors src/agents/workspace.ts:resolveDefaultAgentWorkspaceDir.
_state_default_workspace_dir() {
    local state_dir="${JAISIU_STATE_DIR:-}"
    if [ -z "${state_dir}" ]; then
        # Non-root preflight defaults to ~/.local/share/jaisiu; operators who
        # want ~/.jaisiu should set JAISIU_STATE_DIR explicitly. Both are valid.
        state_dir="${HOME}/.local/share/jaisiu"
    fi
    printf '%s\n' "${state_dir}/workspace"
}

# Write a clean state file. Preserves existing license block (or any existing
# keys not touched by this scaffold). Touches:
#   - agents.defaults.workspace  (host-local path under JAISIU_STATE_DIR)
#   - gateway.port              (default 18789, overridable via env)
#   - gateway.bind              (loopback by default; the runtime wizard may
#                                flip this to "lan" later, but at install time
#                                we want the service is reachable at minimum)
#   - agents.defaults.id        (so the install lands as the default agent;
#                                mirrors the runtime wizard's behaviour)
#   - wizard.lastRunAt          (for observability — same string the runtime
#                                wizard writes in src/tui-installer/build-config.ts)
# _state_detect_broker_key <jq_bin> <cfg>
#
# Auto-wire a default broker provider for the clean-state phase (JAISIU-25XX).
# Probes common credential locations in priority order and returns the first
# compact JWT it finds. Retired pzk_ / pryzm_cu_ / yo-momma values are skipped.
#
#   1. PRYZM_BROKER_KEY env var  (unified JWT)
#   2. JAISIU_BROKER_KEY env var (legacy alias)
#   3. existing cfg .license.brokerKey (JWT only)
#   4. existing cfg .license.key (JWT)
#   5. existing cfg .models.providers["pryzm-at-broker"].apiKey (JWT only)
#   6. ~/.config/jaisiu/jaisiu.json .license.brokerKey / .license.key / provider apiKey
#
# Output: prints "<source_label>|<key>" on stdout on success, or empty string
# if no unified JWT was found anywhere. The source label is one of
# `env:PRYZM_BROKER_KEY`, `env:JAISIU_BROKER_KEY`, `cfg:license.brokerKey`,
# `cfg:license.key` (unified JWT only), `cfg:providers.pryzm-at-broker.apiKey`,
# `xdg:license.brokerKey`, `xdg:license.key`, `xdg:providers.pryzm-at-broker.apiKey`.
#
# Non-destructive: read-only across all sources. Never mutates the cfg.
_state_is_compact_jwt() {
    local key="${1:-}"
    [ -n "${key}" ] || return 1
    case "${key}" in
        eyJ*) ;;
        *) return 1 ;;
    esac
    local IFS='.'
    # shellcheck disable=SC2086
    set -- ${key}
    [ "$#" -eq 3 ] && [ -n "${1:-}" ] && [ -n "${2:-}" ] && [ -n "${3:-}" ]
}

_state_detect_broker_key() {
    local jq_bin="$1" cfg="$2"
    # 1+2: env vars (canonical first, then legacy alias). JWT only.
    if _state_is_compact_jwt "${PRYZM_BROKER_KEY:-}"; then
        printf 'env:PRYZM_BROKER_KEY|%s\n' "${PRYZM_BROKER_KEY}"
        return 0
    fi
    if _state_is_compact_jwt "${JAISIU_BROKER_KEY:-}"; then
        printf 'env:JAISIU_BROKER_KEY|%s\n' "${JAISIU_BROKER_KEY}"
        return 0
    fi
    # 3+4: existing install cfg. Tolerate jq errors and an absent file.
    if [ -n "${jq_bin}" ] && [ -x "${jq_bin}" ] && [ -f "${cfg}" ]; then
        local v
        v="$("${jq_bin}" -r '.license.brokerKey // empty' "${cfg}" 2>/dev/null || true)"
        if _state_is_compact_jwt "${v}"; then
            printf 'cfg:license.brokerKey|%s\n' "${v}"
            return 0
        fi
        v="$("${jq_bin}" -r '.license.key // empty' "${cfg}" 2>/dev/null || true)"
        if _state_is_compact_jwt "${v}"; then
            printf 'cfg:license.key|%s\n' "${v}"
            return 0
        fi
        v="$("${jq_bin}" -r '.models.providers["pryzm-at-broker"].apiKey // empty' \
            "${cfg}" 2>/dev/null || true)"
        if _state_is_compact_jwt "${v}"; then
            printf 'cfg:providers.pryzm-at-broker.apiKey|%s\n' "${v}"
            return 0
        fi
    fi
    # 5+6: XDG-style override file. Honor $XDG_CONFIG_HOME (default
    # ~/.config) so the operator can drop a sidecar config without
    # touching the canonical state file.
    local xdg_root="${XDG_CONFIG_HOME:-${HOME}/.config}"
    local xdg_cfg="${xdg_root%/}/jaisiu/jaisiu.json"
    if [ -n "${jq_bin}" ] && [ -x "${jq_bin}" ] && [ -f "${xdg_cfg}" ]; then
        local x
        x="$("${jq_bin}" -r '.license.brokerKey // empty' "${xdg_cfg}" 2>/dev/null || true)"
        if _state_is_compact_jwt "${x}"; then
            printf 'xdg:license.brokerKey|%s\n' "${x}"
            return 0
        fi
        x="$("${jq_bin}" -r '.license.key // empty' "${xdg_cfg}" 2>/dev/null || true)"
        if _state_is_compact_jwt "${x}"; then
            printf 'xdg:license.key|%s\n' "${x}"
            return 0
        fi
        x="$("${jq_bin}" -r '.models.providers["pryzm-at-broker"].apiKey // empty' \
            "${xdg_cfg}" 2>/dev/null || true)"
        if _state_is_compact_jwt "${x}"; then
            printf 'xdg:providers.pryzm-at-broker.apiKey|%s\n' "${x}"
            return 0
        fi
    fi
    return 1
}

# _state_broker_sources_checked <jq_bin> <cfg>
#
# Echo a stable, grep-friendly list of every broker source the detector
# examined (one per line). Used to make the "fell back to stub" log line
# explicit so the operator can see exactly which locations were probed
# before the install pinned the stub default model.
_state_broker_sources_checked() {
    local jq_bin="$1" cfg="$2"
    printf '%s\n' "env:PRYZM_BROKER_KEY"
    printf '%s\n' "env:JAISIU_BROKER_KEY"
    if [ -n "${jq_bin}" ] && [ -x "${jq_bin}" ] && [ -f "${cfg}" ]; then
        printf '%s\n' "cfg:${cfg}:license.brokerKey"
        printf '%s\n' "cfg:${cfg}:license.key (unified JWT)"
        printf '%s\n' "cfg:${cfg}:providers.pryzm-at-broker.apiKey"
    else
        printf '%s\n' "cfg:${cfg}:license.brokerKey (skipped: no jq or cfg missing)"
        printf '%s\n' "cfg:${cfg}:license.key (unified JWT) (skipped: no jq or cfg missing)"
        printf '%s\n' "cfg:${cfg}:providers.pryzm-at-broker.apiKey (skipped: no jq or cfg missing)"
    fi
    local xdg_root="${XDG_CONFIG_HOME:-${HOME}/.config}"
    local xdg_cfg="${xdg_root%/}/jaisiu/jaisiu.json"
    if [ -n "${jq_bin}" ] && [ -x "${jq_bin}" ] && [ -f "${xdg_cfg}" ]; then
        printf '%s\n' "xdg:${xdg_cfg}:license.brokerKey"
        printf '%s\n' "xdg:${xdg_cfg}:license.key (unified JWT)"
        printf '%s\n' "xdg:${xdg_cfg}:providers.pryzm-at-broker.apiKey"
    else
        printf '%s\n' "xdg:${xdg_cfg}:license.brokerKey (skipped: file missing)"
        printf '%s\n' "xdg:${xdg_cfg}:license.key (unified JWT) (skipped: file missing)"
        printf '%s\n' "xdg:${xdg_cfg}:providers.pryzm-at-broker.apiKey (skipped: file missing)"
    fi
}

_state_write_scaffold() {
    local cfg="${JAISIU_CONFIG}"
    # The runtime reads from ${JAISIU_STATE_DIR}/state/jaisiu.json by
    # default, while the install scaffolds to ${JAISIU_CONFIG}. To keep
    # both views in sync on a fresh install, mirror the scaffold to the
    # runtime path so the gateway sees gateway.mode=local without
    # needing a follow-up `jaisiu doctor --fix`. We write to JAISIU_CONFIG
    # first (the canonical install surface) then mirror to the runtime
    # path; if the paths coincide (root + system scope) the mirror is a
    # no-op (cp -f over same file).
    local state_dir="${JAISIU_STATE_DIR:-${HOME}/.local/share/jaisiu}"
    local runtime_cfg=""
    if [ -n "${state_dir}" ]; then
        runtime_cfg="${state_dir}/state/jaisiu.json"
    fi
    local jq_bin
    jq_bin="$(jaisiu_jq 2>/dev/null || true)"
    if [ -z "${jq_bin}" ] || [ ! -x "${jq_bin}" ]; then
        warn "No jq available (bundled or system) — clean state step skipped."
        return 0
    fi
    mkdir -p "$(dirname "${cfg}")"
    if [ ! -f "${cfg}" ]; then
        # No prior config — write the minimal scaffold verbatim.
        # Set gateway.mode = local so the gateway boots under systemd --
        # without this the gateway exits 1 with "Missing config. Run
        # `jaisiu setup` or set gateway.mode=local". E2E verified on
        # jaisiu-vm (192.168.122.117) against pryzm.at on 2026-08-24.
        # Also bootstrap gateway.auth.token with a 32-byte random
        # secret — without it the gateway exits 1 with
        # "Gateway auth is set to token, but no token is configured".
        # We log the token once so the operator can grab it for
        # client connections; on re-runs the existing token is kept.
        local initial_token=""
        if command -v openssl >/dev/null 2>&1; then
            initial_token="$(openssl rand -hex 32 2>/dev/null || true)"
        fi
        if [ -z "${initial_token}" ] && command -v head >/dev/null 2>&1 && [ -r /dev/urandom ]; then
            initial_token="$(head -c 32 /dev/urandom | xxd -p 2>/dev/null || true)"
        fi
        if [ -z "${initial_token}" ]; then
            # Last-resort: derive from $RANDOM + timestamp; not crypto
            # strong, but enough to satisfy the validator and gate
            # loopback access until the operator sets a real token.
            initial_token="dev-${RANDOM}-$(date +%s)-$(hostname -s 2>/dev/null || echo vm)"
        fi
        # Auto-wire a broker provider when one is discoverable from env /
        # cfg / XDG config. On cold start we have no cfg yet, so only the
        # two env vars and the XDG sidecar are reachable here; that's
        # enough to cover the common "operator sets PRYZM_BROKER_KEY before
        # running install" case. If detection succeeds, we pin
        # pryzm-at-broker/MiniMax-M3 as the primary (matching the
        # convention used by license.sh + state_install_provider_routing)
        # and skip the stub. The stub is only pinned after the explicit
        # "checked N sources, found none" log line below — never silently.
        local cold_primary="stub/no-auth-yet"
        local cold_broker_source=""
        local cold_broker_key=""
        local _det
        if _det="$(_state_detect_broker_key "${jq_bin}" "${cfg}" 2>/dev/null)" \
            && [ -n "${_det}" ]; then
            cold_broker_source="${_det%%|*}"
            cold_broker_key="${_det#*|}"
            cold_primary="pryzm-at-broker/MiniMax-M3"
        fi
        "${jq_bin}" -n \
            --arg ws "$(_state_default_workspace_dir)" \
            --argjson port "${JAISIU_GATEWAY_PORT:-18789}" \
            --arg token "${initial_token}" \
            --arg primary "${cold_primary}" \
            '
            {
                agents: {
                    defaults: {
                        workspace: $ws,
                        # Pin a stub default model on clean install so the
                        # runtime\u2019s built-in `minimax-portal/MiniMax-M3`
                        # can\u2019t leak in and silently hang the TUI. The
                        # operator overrides this via
                        # `jaisiu configure --section model` once they set
                        # their broker key.
                        #
                        # JAISIU-25XX: when a broker key is discoverable
                        # from env / XDG config, auto-wire
                        # pryzm-at-broker/MiniMax-M3 instead of the stub.
                        # Behavior is non-destructive: the merge path below
                        # never overwrites an existing non-stub primary.
                        model: { primary: $primary, fallbacks: [] }
                    }
                },
                gateway: {
                    mode: "local",
                    port: $port,
                    bind: "loopback",
                    auth: { mode: "token", token: $token }
                },
                wizard: { lastRunAt: (now | todate) }
            }
            ' > "${cfg}"
        chmod 0600 "${cfg}" || true
        ok "Wrote clean state scaffold → ${cfg}"
        ok "Bootstrapped gateway.auth.token (32 bytes, see ${cfg})"
        if [ -n "${cold_broker_source}" ]; then
            ok "Auto-wired broker provider from ${cold_broker_source} → agents.defaults.model.primary = pryzm-at-broker/MiniMax-M3"
        else
            # Make the sources-checked list explicit so the operator can
            # see exactly which locations were probed before the stub
            # was pinned (acceptance criterion from JAISIU-25XX).
            warn "No broker provider wired — checked the following sources before pinning stub/no-auth-yet:"
            local _src
            while IFS= read -r _src; do
                [ -n "${_src}" ] && warn "  • ${_src}"
            done < <(_state_broker_sources_checked "${jq_bin}" "${cfg}" || true)
            warn "Set a broker JWT with: export PRYZM_BROKER_KEY=<unified JWT>  (or run 'jaisiu configure --section model')"
        fi
        # Create the workspace directory referenced by
        # agents.defaults.workspace. Without this the gateway logs
        # MISSING_SHARED_WORKTREE and refuses to bind its port.
        local ws
        ws="$(_state_default_workspace_dir)"
        if [ -n "${ws}" ]; then
            mkdir -p "${ws}" 2>/dev/null || true
            ok "Created workspace dir → ${ws}"
        fi
        if [ -n "${runtime_cfg}" ] && [ "${runtime_cfg}" != "${cfg}" ]; then
            mkdir -p "$(dirname "${runtime_cfg}")"
            cp -f "${cfg}" "${runtime_cfg}"
            chmod 0600 "${runtime_cfg}" || true
            ok "Mirrored clean state → ${runtime_cfg}"
        fi
        return 0
    fi
    # Existing config — merge only the fields we own. Any other fields (license,
    # channels, prism, models, etc.) are left untouched. The workspace is the
    # one exception: if it points outside the active state-dir family, it's
    # almost certainly a stale cross-host path (the "Mac inheriting /home/node
    # /..." case from the operator note), so we rewrite it.
    local now ws state_dir
    now="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
    ws="$(_state_default_workspace_dir)"
    state_dir="${JAISIU_STATE_DIR:-${HOME}/.local/share/jaisiu}"
    # Auto-wire a broker provider from env / cfg / XDG config
    # (JAISIU-25XX). The previous behavior pinned the stub as soon as no
    # pryzm-at-broker provider existed in models.providers, even if the
    # operator had supplied PRYZM_BROKER_KEY in the environment or
    # ~/.config/jaisiu/jaisiu.json on the side. Now we run a full
    # detector and only fall back to the stub after probing every common
    # location.
    local cfg_existing_broker=""
    cfg_existing_broker="$("${jq_bin}" -r '
        ( .models.providers["pryzm-at-broker"].apiKey // "" ) as $b
        | if $b != "" then "1" else "" end
    ' "${cfg}" 2>/dev/null || true)"

    local _det
    local merge_broker_source=""
    local merge_broker_key=""
    if _det="$(_state_detect_broker_key "${jq_bin}" "${cfg}" 2>/dev/null)" \
        && [ -n "${_det}" ]; then
        merge_broker_source="${_det%%|*}"
        merge_broker_key="${_det#*|}"
    fi

    # Decide the wiring mode for the merge:
    #   wire=1   → set primary to the real provider (stub/unset only;
    #              non-destructive w.r.t. operator-chosen primaries)
    #   wire=0   → leave primary alone, skip the stub-pin branch
    #   stub=1   → primary is stub or missing AND no broker found; pin stub
    # Non-destructive guarantee: when wire=0 (operator already pinned a
    # non-stub primary), we neither pin the stub nor overwrite the
    # existing value.
    local merge_wire="0"
    local merge_stub="1"
    if [ -n "${merge_broker_source}" ]; then
        merge_wire="1"
        merge_stub="0"
    elif [ -n "${cfg_existing_broker}" ]; then
        merge_wire="0"
        merge_stub="0"
    fi

    "${jq_bin}" \
        --arg ws "${ws}" \
        --arg stateDir "${state_dir}" \
        --argjson port "${JAISIU_GATEWAY_PORT:-18789}" \
        --arg now "${now}" \
        --arg stub_when_unbrokered "${merge_stub}" \
        --arg wire_primary "${merge_wire}" \
        '
        .agents //= {} |
        .agents.defaults //= {} |
        .agents.defaults.workspace |= (
            if type == "string" and startswith($stateDir) then .
            elif type == "string" then $ws
            else $ws
            end
        ) |
        # If a broker was detected from env / XDG config but the existing
        # primary is still the stub (or absent), auto-wire the real
        # provider. Non-destructive: any non-stub value is preserved
        # untouched (the condition checks for stub OR missing).
        (if $wire_primary == "1" then
            .agents.defaults.model //= {} |
            if (.agents.defaults.model.primary // "stub/no-auth-yet") == "stub/no-auth-yet" or (.agents.defaults.model.primary // null) == null then
                .agents.defaults.model.primary = "pryzm-at-broker/MiniMax-M3"
            else . end |
            .agents.defaults.model.fallbacks //= []
        elif $stub_when_unbrokered == "1" then
            # No broker provider is wired — pin the default primary model
            # to a deterministic stub so the runtime\u2019s built-in
            # `minimax-portal/MiniMax-M3` cannot leak in and silently hang
            # the TUI. The stub model id has no provider, so the runtime
            # rejects it cleanly with a `doctor --fix` hint pointing the
            # operator at `jaisiu configure --section model`.
            .agents.defaults.model //= {} |
            .agents.defaults.model.primary //= "stub/no-auth-yet" |
            .agents.defaults.model.fallbacks //= []
        else . end) |
        .gateway //= {} |
        .gateway.mode //= "local" |
        .gateway.port //= $port |
        .gateway.bind //= "loopback" |
        .wizard //= {} |
        .wizard.lastRunAt //= $now
        ' "${cfg}" > "${cfg}.new" && mv "${cfg}.new" "${cfg}"
    chmod 0600 "${cfg}" || true
    if [ -n "${merge_broker_source}" ]; then
        # Broker was detected and the jq merge ran the auto-wire branch.
        # Verify the file actually reflects it (defensive: jq could have
        # no-op'd if the model.primary was already a non-stub value).
        local _primary_now
        _primary_now="$("${jq_bin}" -r '.agents.defaults.model.primary // "stub/no-auth-yet"' \
            "${cfg}" 2>/dev/null || echo "stub/no-auth-yet")"
        if [ "${_primary_now}" = "stub/no-auth-yet" ]; then
            # This branch is unreachable under the current jq — if a
            # broker is detected, the jq sets primary to
            # pryzm-at-broker/MiniMax-M3 unconditionally. Kept as a
            # defensive log line in case future jq logic ever changes.
            ok "Broker discovered via ${merge_broker_source}, but agents.defaults.model.primary is still the stub — leaving it untouched."
        elif [ "${_primary_now}" = "pryzm-at-broker/MiniMax-M3" ]; then
            ok "Auto-wired broker provider from ${merge_broker_source} → agents.defaults.model.primary = ${_primary_now}"
        else
            # Operator had already pinned a different model (e.g.
            # openai-direct/gpt-4o). The merge is non-destructive, so we
            # log what we detected AND that we left the operator's
            # choice alone. This avoids the misleading "auto-wired"
            # message when nothing actually changed.
            ok "Broker discovered via ${merge_broker_source} (would auto-wire to pryzm-at-broker/MiniMax-M3), but agents.defaults.model.primary is already '${_primary_now}' — leaving it untouched (non-destructive)."
        fi
    elif [ -z "${cfg_existing_broker}" ]; then
        # No broker anywhere — make the sources-checked list explicit so
        # the operator can see exactly which locations were probed before
        # the stub was pinned (acceptance criterion from JAISIU-25XX).
        warn "No broker provider wired — checked the following sources before pinning stub/no-auth-yet:"
        local _src
        while IFS= read -r _src; do
            [ -n "${_src}" ] && warn "  • ${_src}"
        done < <(_state_broker_sources_checked "${jq_bin}" "${cfg}" || true)
        warn "Set a broker JWT with: export PRYZM_BROKER_KEY=<unified JWT>  (or run 'jaisiu configure --section model')"
    fi
    ok "Cleaned state file in place → ${cfg}"
    # Mirror to the runtime path so a systemd --user gateway (which reads
    # ${JAISIU_STATE_DIR}/state/jaisiu.json by default) sees the same
    # gateway.mode=local without operator follow-up.
    if [ -n "${state_dir}" ] && [ -n "${runtime_cfg}" ] && [ "${runtime_cfg}" != "${cfg}" ]; then
        mkdir -p "$(dirname "${runtime_cfg}")"
        cp -f "${cfg}" "${runtime_cfg}"
        chmod 0600 "${runtime_cfg}" || true
        ok "Mirrored clean state → ${runtime_cfg}"
    fi
}

state_install() {
    if [ "${JAISIU_SKIP_STATE:-}" = "1" ]; then
        info "Clean state step skipped (JAISIU_SKIP_STATE=1)"; return 0
    fi
    _state_clear_remote
    _state_write_scaffold
    # JAISIU-2627 / operator trace 2026-08-25 (Mac + Windows boxes):
    # _state_write_scaffold can leave ${cfg} at 0 bytes when jq writes
    # succeed in some legacy paths but the file is left empty at others
    # (e.g., partial merges on hosts without jq, or when an earlier
    # rewrite echoed empty stdout). The runtime gateway at boot calls
    # loadConfig() which invokes json5.parse(); an empty file produces
    # "SyntaxError: JSON5: invalid end of input at 1:1" and the
    # gateway exits 1 with no service — even though every other phase
    # of the installer reported "OK". Below we pin the invariant:
    # after state_install, ${cfg} must exist AND contain at least {}.
    # If not, we rewrite it to {} so the runtime can boot, then warn.
    local cfg="${JAISIU_CONFIG}"
    if [ -n "${cfg}" ]; then
        if [ ! -s "${cfg}" ]; then
            warn "State file ${cfg} was left empty; rewriting to {} to keep the gateway bootable. Run 'jaisiu configure --section license' to fill it in."
            mkdir -p "$(dirname "${cfg}")" 2>/dev/null || true
            printf '{}\n' > "${cfg}" 2>/dev/null \
                && chmod 0600 "${cfg}" 2>/dev/null \
                && info "wrote empty JSON5 stub → ${cfg}"
        fi
        # Mirror the same guarantee to the runtime path.
        local state_dir="${JAISIU_STATE_DIR:-${HOME}/.local/share/jaisiu}"
        local runtime_cfg="${state_dir}/state/jaisiu.json"
        if [ -n "${runtime_cfg}" ] && [ "${runtime_cfg}" != "${cfg}" ]; then
            if [ ! -s "${runtime_cfg}" ]; then
                mkdir -p "$(dirname "${runtime_cfg}")" 2>/dev/null || true
                printf '{}\n' > "${runtime_cfg}" 2>/dev/null \
                    && chmod 0600 "${runtime_cfg}" 2>/dev/null \
                    && info "wrote empty JSON5 stub → ${runtime_cfg}"
            fi
        fi
    fi
}

# JAISIU-2XXX — clear gateway.remote.* on a clean local install unless the
# operator explicitly opted into a remote-gateway setup via JAISIU_REMOTE_URL.
# Without this the CLI is wedged into remote-mode auth (gateway.remote.token)
# while the gateway validates against gateway.auth.token, so every CLI call
# fails with "gateway token mismatch" until the operator manually unsets
# gateway.remote.url+token. We drop the nested dict in-place via jq; the merge
# branch in _state_write_scaffold preserves any operator-set values when
# JAISIU_REMOTE_URL is non-empty.
_state_clear_remote() {
    local cfg="${JAISIU_CONFIG}"
    [ -f "${cfg}" ] || return 0
    [ -z "${JAISIU_REMOTE_URL:-}" ] || return 0
    local jq_bin
    jq_bin="$(jaisiu_jq 2>/dev/null || true)"
    [ -n "${jq_bin}" ] && [ -x "${jq_bin}" ] || return 0
    if "${jq_bin}" -e 'has("gateway") and (.gateway | has("remote"))' "${cfg}" >/dev/null 2>&1; then
        "${jq_bin}" 'del(.gateway.remote)' "${cfg}" > "${cfg}.new" \
            && mv "${cfg}.new" "${cfg}"
        chmod 0600 "${cfg}" || true
        info "Cleared inherited gateway.remote.* (set JAISIU_REMOTE_URL to keep)"
    fi
}

# Public hook for the plan-driven flow: same logic, called after the broker's
# configCommand has run so the scaffold always wins over a stale broker-side
# state push that pre-dates the install. JAISIU-2358: also re-runs the
# broker-routing step so the account-page "full configuration command" can't
# re-leak the Docker-default providers after configCommand has executed.
state_install_after_config_command() {
    if [ -n "${JAISIU_CONFIG_COMMAND:-}" ]; then
        # shellcheck disable=SC2290  # intentional eval of trusted broker output
        eval "${JAISIU_CONFIG_COMMAND}" || warn "configCommand returned non-zero (continuing)"
    fi
    state_install
    state_install_provider_routing
}

# JAISIU-2358 — broker-default routing.
#
# Problem: lib/license.sh writes license.brokerKey + license.brokerBaseUrl when
# the customer signs up via Path B (email → confirm). But state.sh only wrote
# agents.defaults.workspace + gateway.port + gateway.bind. The runtime wizard
# (`src/tui-installer/build-config.ts`) is what would normally turn the
# license.brokerKey into a `models.providers.pryzm-at-broker` entry with the
# broker URL and API key, plus set agents.defaults.model.primary to
# "pryzm-at-broker/MiniMax-M3".
#
# The bash installer doesn't run the runtime wizard (it's "no-telemetry,
# no-network" relative to the broker, and the runtime wizard ships in the
# bundled tarball only). So customers who installed via `curl | bash` got a
# state file with `license.brokerKey` populated but no `models.providers`
# entry — the runtime then defaulted to whatever the bundle ships as primary
# (which historically has been `minimax-portal/MiniMax-M3`, NOT the Pryzm
# broker the customer just paid for). Pasting the account-page "full
# configuration command" (JAISIU-2357) cements the wrong default.
#
# Fix: at install time, if license.brokerKey is present, write a
# `models.providers.pryzm-at-broker` entry and route the primary model
# through it. Drop any Docker-internal provider entries that may have
# leaked into the state file (`minimax-portal`, `opencode-go`, or
# `pryzm-at-broker` pointing at 127.0.0.1:18800). Idempotent.
#
# Behaviour matrix:
#   license.key is compact JWT → pooled: write provider apiKey from the JWT
#   leftover pzk_ / pryzm_cu_ → ignored; license_only primary only
#   models.providers already has pryzm-at-broker → only refresh apiKey if blank
#   agents.list has Docker-only fleet agents → trim to ["main"] unless
#     JAISIU_FLEET_MODE=1 (operator opt-in for dev workstations)
_state_route_provider_broker() {
    local cfg="${JAISIU_CONFIG}"
    local jq_bin
    jq_bin="$(jaisiu_jq 2>/dev/null || true)"
    [ -n "${jq_bin}" ] && [ -x "${jq_bin}" ] || { warn "No jq available — broker routing skipped"; return 0; }
    [ -f "${cfg}" ]  || { info "No state file yet — broker routing deferred"; return 0; }

    # Pull license.brokerKey + license.brokerBaseUrl. Retired pryzm_cu_
    # leftovers are ignored. Autowire from license.key when it is a JWT.
    local broker_key broker_base license_key
    if [ -x "${jq_bin}" ]; then
        broker_key="$("${jq_bin}" -r '.license.brokerKey // empty' "${cfg}" 2>/dev/null || true)"
        broker_base="$("${jq_bin}" -r '.license.brokerBaseUrl // empty' "${cfg}" 2>/dev/null || true)"
        license_key="$("${jq_bin}" -r '.license.key // empty' "${cfg}" 2>/dev/null || true)"
    else
        eval "$(python3 - "${cfg}" <<'PYLICENSE'
import json, sys
try:
    d = json.load(open(sys.argv[1]))
except Exception:
    d = {}
lic = d.get("license", {}) if isinstance(d, dict) else {}
print(f'broker_key={lic.get("brokerKey", "")!r}')
print(f'broker_base={lic.get("brokerBaseUrl", "")!r}')
print(f'license_key={lic.get("key", "")!r}')
PYLICENSE
)" 2>/dev/null || true
    fi
    if ! _state_is_compact_jwt "${broker_key:-}"; then
        broker_key=""
    fi
    if [ -z "${broker_key}" ] && _state_is_compact_jwt "${license_key:-}"; then
        broker_key="${license_key}"
        info "Auto-wiring pryzm-at-broker from unified license JWT in license.key"
    fi
    if [ -z "${broker_key}" ]; then
        # JAISIU-2510: even in license_only mode, ALWAYS set the agent
        # primary to pryzm-at-broker/MiniMax-M3 — otherwise the runtime
        # falls back to the third-party minimax-portal/MiniMax-M3 portal
        # which we don't ship. Provider entry is left alone until the
        # operator sets JAISIU_BROKER_KEY.
        info "No license.brokerKey — broker provider entry skipped (license_only mode), but routing primary to pryzm-at-broker/MiniMax-M3"
        broker_base="https://api.pryzm.at/v1"
    else
        # Default broker URL is the public Pryzm broker. operator may override
        # via license.brokerBaseUrl (set by lib/license.sh from Path B confirm).
        broker_base="${broker_base:-https://api.pryzm.at/v1}"
    fi

    # Drop Docker-internal provider entries that leak from the bundle.
    # Always set primary → to/MiniMax-M3, fallbacks to same.
    # When broker_key is non-empty, also write the provider entry with
    # apiKey; when absent, leave the providers dict as it was (operator
    # will supply apiKey later).
    if [ -x "${jq_bin}" ]; then
        if [ -n "${broker_key}" ]; then
            "${jq_bin}" \
                --arg baseUrl "${broker_base}" \
                --arg apiKey   "${broker_key}" \
                '
                .models //= {} |
                .models.providers //= {} |
                del(
                    .models.providers["minimax-portal"],
                    .models.providers["minimax"],
                    .models.providers["opencode-go"],
                    .models.providers["pryzm-at-broker"]
                ) |
                .models.providers["pryzm-at-broker"] = (
                    (.models.providers["pryzm-at-broker"] // {}) |
                    {
                        baseUrl: $baseUrl,
                        api:     "openai-completions",
                        apiKey:  $apiKey,
                        models:  [
                            {
                                id:            "MiniMax-M3",
                                name:          "Prysm M3 (Pryzm broker)",
                                contextWindow: 1000000,
                                maxTokens:     8192,
                                input:         ["text"],
                                reasoning:     false
                            }
                        ]
                    }
                ) |
                .agents //= {} |
                .agents.defaults //= {} |
                .agents.defaults.model = {
                    primary:   "pryzm-at-broker/MiniMax-M3",
                    fallbacks: ["pryzm-at-broker/MiniMax-M3"]
                } |
                .agents.defaults.subagents //= {} |
                .agents.defaults.subagents.model = {
                    primary:   "pryzm-at-broker/MiniMax-M3",
                    fallbacks: ["pryzm-at-broker/MiniMax-M3"]
                }
                ' "${cfg}" > "${cfg}.new" && mv "${cfg}.new" "${cfg}"
        else
            "${jq_bin}" '
                .agents //= {} |
                .agents.defaults //= {} |
                .agents.defaults.model = {
                    primary:   "pryzm-at-broker/MiniMax-M3",
                    fallbacks: ["pryzm-at-broker/MiniMax-M3"]
                } |
                .agents.defaults.subagents //= {} |
                .agents.defaults.subagents.model = {
                    primary:   "pryzm-at-broker/MiniMax-M3",
                    fallbacks: ["pryzm-at-broker/MiniMax-M3"]
                }
                ' "${cfg}" > "${cfg}.new" && mv "${cfg}.new" "${cfg}"
        fi
    else
        if [ -n "${broker_key}" ]; then
            python3 - "${cfg}" "${broker_base}" "${broker_key}" <<'PYEOF'
import json, sys
path, base_url, api_key = sys.argv[1], sys.argv[2], sys.argv[3]
try:
    with open(path) as f: cfg = json.load(f)
except Exception:
    cfg = {}
if not isinstance(cfg, dict): cfg = {}
cfg.setdefault("models", {}).setdefault("providers", {})
for drop in ("minimax-portal", "minimax", "opencode-go", "pryzm-at-broker"):
    cfg["models"]["providers"].pop(drop, None)
cfg["models"]["providers"]["pryzm-at-broker"] = {
    "baseUrl": base_url, "api": "openai-completions",
    "apiKey": api_key,
    "models": [{"id": "MiniMax-M3", "name": "Prysm M3 (Pryzm broker)",
                "contextWindow": 1000000, "maxTokens": 8192,
                "input": ["text"], "reasoning": False}]
}
d = cfg.setdefault("agents", {}).setdefault("defaults", {})
d["model"] = {"primary": "pryzm-at-broker/MiniMax-M3",
              "fallbacks": ["pryzm-at-broker/MiniMax-M3"]}
d.setdefault("subagents", {})["model"] = {"primary": "pryzm-at-broker/MiniMax-M3",
                                          "fallbacks": ["pryzm-at-broker/MiniMax-M3"]}
with open(path, "w") as f:
    json.dump(cfg, f, indent=2); f.write("\n")
PYEOF
        else
            python3 - "${cfg}" <<'PYEOF'
import json, sys
path = sys.argv[1]
try:
    with open(path) as f: cfg = json.load(f)
except Exception:
    cfg = {}
if not isinstance(cfg, dict): cfg = {}
d = cfg.setdefault("agents", {}).setdefault("defaults", {})
d["model"] = {"primary": "pryzm-at-broker/MiniMax-M3",
              "fallbacks": ["pryzm-at-broker/MiniMax-M3"]}
d.setdefault("subagents", {})["model"] = {"primary": "pryzm-at-broker/MiniMax-M3",
                                          "fallbacks": ["pryzm-at-broker/MiniMax-M3"]}
with open(path, "w") as f:
    json.dump(cfg, f, indent=2); f.write("\n")
PYEOF
        fi
    fi

    # Drop Docker-only fleet agents from agents.list unless operator opts in.
    # The bundle ships 7-agent topology (jaisiu-code-fix-agent + 4 borg +
    # self-improvement split/implement/review + fleet-control-fix); on a
    # customer's Mac/Linux box those paths point to /home/node/... and break.
    if [ "${JAISIU_FLEET_MODE:-0}" != "1" ]; then
        if [ -x "${jq_bin}" ]; then
            "${jq_bin}" '
            .agents.list = [
                .agents.list[]? | select(
                    ((.id // "") | type) == "string"
                    and (
                        .id == "main"
                        or (.id | startswith("local-"))
                    )
                )
            ]
            ' "${cfg}" > "${cfg}.new" && mv "${cfg}.new" "${cfg}"
        else
            python3 - "${cfg}" <<'PYEOF'
import json, sys
path = sys.argv[1]
try:
    with open(path) as f: cfg = json.load(f)
except Exception:
    cfg = {}
if not isinstance(cfg, dict): cfg = {}
agents = cfg.setdefault("agents", {})
fl = agents.get("list", [])
agents["list"] = [
    a for a in fl
    if isinstance(a, dict)
    and isinstance(a.get("id"), str)
    and (a["id"] == "main" or a["id"].startswith("local-"))
]
with open(path, "w") as f:
    json.dump(cfg, f, indent=2); f.write("\n")
PYEOF
        fi
    fi

    chmod 0600 "${cfg}" || true
    ok "Routed primary → pryzm-at-broker/MiniMax-M3 (broker URL ${broker_base})"
}

state_install_provider_routing() {
    if [ "${JAISIU_SKIP_BROKER_ROUTING:-}" = "1" ]; then
        info "Broker routing skipped (JAISIU_SKIP_BROKER_ROUTING=1)"; return 0
    fi
    _state_route_provider_broker
}

# ----------------------------------------------------------------------------
# Atomic state writes (JAISIU-2627 / operator trace 2026-08-25)
# ----------------------------------------------------------------------------
# Background:
#   The previous state-write pattern `jq -n ... > "${cfg}"` /
#   `jq ... "${cfg}" > "${cfg}.new" && mv "${cfg}.new" "${cfg}"` left the file
#   at 0 bytes when jq was killed mid-write (signal, OOM, broken pipe) or
#   when bash's `>` redirection truncated the target before jq produced
#   any output. The runtime JSON5 loader then crashed with
#   "SyntaxError: invalid end of input at 1:1" and emitted the
#   config-invalid banner — see fleet ticket 961af32c and operator trace
#   2026-08-25 from piotrslupski@192.168.2.52 (Mac) + the Windows box.
#
# This module gives the writer a safe primitive:
#   - Run the producer with stdout pointed at a sibling mktemp(1) file
#     in the same directory (so the final `mv` is a same-filesystem
#     atomic rename).
#   - Reject zero-byte payloads outright.
#   - Reject non-JSON payloads (jq `-e .` with python3 fallback).
#   - fsync before rename; re-validate post-rename.
#
# Net effect: the runtime state file is either valid or untouched —
# no more 0-byte files.
#
# Lives in install/lib/state.sh (which the bundle script inlines into
# efss-portal/jaisiu/install.sh) so the helpers survive every
# regeneration of the EFSS bundle — without this, the bundle regen
# would silently revert the fix.

# _state_log <level> <message> — single-line stderr diagnostic from a
# state-helper that must NOT call exit on failure.
_state_log() {
    local level="${1:-INFO}"
    shift
    printf '[%s] [state] %s\n' "${level}" "$*" >&2 || true
}

# _state_atomic_write <jq_bin> <target> <mode> [extra jq args ...] [-- <producer args>]
#
# Write a JSON document to <target> atomically. The `--` separator is
# optional for backwards compatibility with older call sites that
# inlined producer args directly.
_state_atomic_write() {
    local jq_bin="$1" target="$2" mode="$3"
    shift 3
    local producer=()
    if [ "${1:-}" = "--" ]; then
        shift
        producer=("$@")
    else
        producer=("$@")
    fi
    if [ -z "${target}" ]; then
        _state_log ERROR "_state_atomic_write: no target path provided"
        return 1
    fi
    if [ "${#producer[@]}" -eq 0 ]; then
        _state_log ERROR "_state_atomic_write: no jq/python producer given"
        return 1
    fi
    mkdir -p "$(dirname "${target}")" 2>/dev/null || true
    # mktemp in same directory → final mv is atomic (same-filesystem rename).
    local tmp
    tmp="$(mktemp "$(dirname "${target}")/.$(basename "${target}").XXXXXX" 2>/dev/null || true)"
    if [ -z "${tmp}" ] || [ ! -e "${tmp}" ]; then
        _state_log ERROR "_state_atomic_write: could not allocate temp file in $(dirname "${target}")"
        return 1
    fi
    chmod 0600 "${tmp}" 2>/dev/null || true
    # No pipeline (no pipefail surprise); re-validate before rename.
    if ! "${producer[@]}" > "${tmp}" 2>"${tmp}.err"; then
        local rc=$?
        local errmsg=""
        [ -s "${tmp}.err" ] && errmsg="$(head -c 500 "${tmp}.err" 2>/dev/null || true)"
        rm -f "${tmp}" "${tmp}.err" 2>/dev/null || true
        if [ -n "${jq_bin}" ] && [ -x "${jq_bin}" ] && [ -s "${target:-/dev/null}" ] && ! "${jq_bin}" -e . "${target}" >/dev/null 2>&1; then
            _state_log ERROR "_state_atomic_write: producer rc=${rc} and existing ${target} is also corrupt; refusing to clobber. producer stderr: ${errmsg}"
            return 1
        fi
        _state_log ERROR "_state_atomic_write: producer rc=${rc} for ${target}; existing target preserved. producer stderr: ${errmsg}"
        return 1
    fi
    rm -f "${tmp}.err" 2>/dev/null || true
    local sz
    sz="$(wc -c < "${tmp}" 2>/dev/null | tr -d ' \t\n' || echo 0)"
    if [ "${sz:-0}" -eq 0 ]; then
        rm -f "${tmp}" 2>/dev/null || true
        _state_log ERROR "_state_atomic_write: producer wrote 0 bytes to ${tmp}; ${target} not touched"
        return 1
    fi
    local validated="0"
    if [ -n "${jq_bin}" ] && [ -x "${jq_bin}" ]; then
        if "${jq_bin}" -e . "${tmp}" >/dev/null 2>&1; then
            validated="1"
        fi
    fi
    if [ "${validated}" != "1" ] && command -v python3 >/dev/null 2>&1; then
        if python3 - "${tmp}" >/dev/null 2>&1 <<'PYVALIDATE'
import json, sys
with open(sys.argv[1]) as _f:
    json.load(_f)
PYVALIDATE
        then
            validated="1"
        fi
    fi
    if [ "${validated}" != "1" ]; then
        rm -f "${tmp}" 2>/dev/null || true
        _state_log ERROR "_state_atomic_write: temp payload does not parse as JSON; ${target} not touched"
        return 1
    fi
    sync 2>/dev/null || true
    if ! mv -f "${tmp}" "${target}"; then
        rm -f "${tmp}" 2>/dev/null || true
        _state_log ERROR "_state_atomic_write: rename ${tmp} → ${target} failed"
        return 1
    fi
    chmod "${mode}" "${target}" 2>/dev/null || true
    if [ -n "${jq_bin}" ] && [ -x "${jq_bin}" ]; then
        if ! "${jq_bin}" -e . "${target}" >/dev/null 2>&1; then
            _state_log ERROR "_state_atomic_write: post-rename validation failed for ${target}; file may be corrupt"
            return 1
        fi
    fi
    return 0
}

# _state_mirror_atomic <jq_bin> <source> <target>
# Mirror an already-valid source to <target>, refusing to mirror a corrupt
# or empty source. Idempotent.
_state_mirror_atomic() {
    local jq_bin="$1" source="$2" target="$3"
    if [ -z "${source}" ] || [ -z "${target}" ] || [ "${source}" = "${target}" ]; then
        return 0
    fi
    if [ ! -f "${source}" ]; then
        _state_log ERROR "_state_mirror_atomic: source ${source} does not exist"
        return 1
    fi
    if [ ! -s "${source}" ]; then
        _state_log ERROR "_state_mirror_atomic: source ${source} is empty; mirror aborted"
        return 1
    fi
    local src_ok="0"
    if [ -n "${jq_bin}" ] && [ -x "${jq_bin}" ] && "${jq_bin}" -e . "${source}" >/dev/null 2>&1; then
        src_ok="1"
    fi
    if [ "${src_ok}" != "1" ] && command -v python3 >/dev/null 2>&1; then
        if python3 - "${source}" >/dev/null 2>&1 <<'PYSRC'
import json, sys
with open(sys.argv[1]) as _f:
    json.load(_f)
PYSRC
        then
            src_ok="1"
        fi
    fi
    if [ "${src_ok}" != "1" ]; then
        _state_log ERROR "_state_mirror_atomic: source ${source} is not valid JSON; mirror aborted"
        return 1
    fi
    mkdir -p "$(dirname "${target}")" 2>/dev/null || true
    if ! cp -f "${source}" "${target}"; then
        _state_log ERROR "_state_mirror_atomic: copy ${source} → ${target} failed"
        return 1
    fi
    chmod 0600 "${target}" 2>/dev/null || true
    if [ -n "${jq_bin}" ] && [ -x "${jq_bin}" ] && ! "${jq_bin}" -e . "${target}" >/dev/null 2>&1; then
        _state_log ERROR "_state_mirror_atomic: post-copy validation failed for ${target}"
        return 1
    fi
    return 0
}


# <<< end lib/state.sh

# >>> begin lib/path_dedup.sh (inlined, sourced below)
#!/usr/bin/env bash
# install/lib/path_dedup.sh — deconflict every `jaisiu` (and friends) on PATH
# so the freshly-installed gateway launcher wins.
#
# Background (cross-references):
#   - Operator trace 2026-08-24 (ticket 449246c5): on macOS arm64 the prior
#     install left a root-owned /usr/local/bin/jaisiu → /opt/jaisiu-legacy/
#     ... wrapper behind. The new installer wrote ~/.local/bin/jaisiu but
#     /etc/paths puts /usr/local/bin BEFORE the user's rc files in login
#     shells, so `jaisiu tui` kept resolving the stale wrapper and crashed
#     with `TypeError: makeBuilder is not a function` deep inside a broken
#     AWS SDK. Re-running install did NOT fix it because the prior
#     `_jaisiu_stale_wrapper_cleanup` only compared MD5 against a fresh
#     wrapper that happened to be a different byte sequence; it never
#     *removed* the stale target.
#   - Sibling decision: treat the wrapper at ${JAISIU_BIN_DIR}/jaisiu as
#     the canonical "source of truth" and (a) replace any stale `jaisiu`
#     binary on PATH with a symlink to it, or (b) warn with a one-liner
#     remediation if we lack permission.
#   - We do NOT use `cp` to copy the wrapper into system dirs: a per-user
#     wrapper hardcodes the user's ${JAISIU_INSTALL_DIR} and would 404 on
#     a fresh-user login. Symlinks preserve the per-user indirection.
#
# Public entrypoints:
#   resolve_jaisiu_path_conflicts            — run after jaisiu_install_wrapper
#                                              (idempotent, opt-out via
#                                              JAISIU_SKIP_PATH_DEDUP=1)
#   audit_jaisiu_path                        — print every candidate `jaisiu`
#                                              location, classified. Used by
#                                              `jaisiu doctor` and the e2e
#                                              installer test harness.
#   classify_jaisiu_candidate <path>         — echo one of: canonical, fresh,
#                                              stale-symlink, stale-other,
#                                              fresh-current, missing
#
# Accepts env: JAISIU_BIN_DIR, JAISIU_INSTALL_DIR, JAISIU_SKIP_PATH_DEDUP.
# Source `log.sh` first. Caller has set -euo pipefail.

# --- canonical-path resolution ---------------------------------------------

# Canonical path the freshly-installed wrapper should resolve to.
# Either ${JAISIU_BIN_DIR}/jaisiu (root and user installs alike) or, when
# the gateway is staged under ${JAISIU_INSTALL_DIR}, the entrypoint itself.
path_dedup_canonical_wrapper() {
    printf '%s\n' "${JAISIU_BIN_DIR:-${HOME}/.local/bin}/jaisiu"
}

# Canonical gateway entry — the real binary the wrapper should ultimately
# exec. Used to classify a candidate as "stale-other" (resolves here, but
# to a different install dir than JAISIU_INSTALL_DIR).
path_dedup_canonical_entrypoint() {
    printf '%s\n' "${JAISIU_INSTALL_DIR:-${HOME}/.local/share/jaisiu}/jaisiu.mjs"
}

# Normalize a candidate path to its realpath, tolerating a missing target
# (returns the path verbatim when the file doesn't exist). Uses realpath -m
# so the result is always canonical even when the symlink chain is broken.
path_dedup_realpath() {
    local p="${1:-}"
    [ -n "${p}" ] || return 1
    if command -v realpath >/dev/null 2>&1; then
        realpath -m -- "${p}" 2>/dev/null || printf '%s\n' "${p}"
    elif command -v readlink >/dev/null 2>&1; then
        # readlink -f follows the whole chain but errors on a broken link.
        # Fall back to -m (--canonicalize-missing) if available, else to the
        # path verbatim.
        readlink -m -- "${p}" 2>/dev/null || printf '%s\n' "${p}"
    else
        printf '%s\n' "${p}"
    fi
}

# Detect whether a path is a symlink (works on Linux + macOS without GNU
# stat). Returns 0 for symlinks, 1 for regular files / missing.
path_dedup_is_symlink() {
    local p="${1:-}"
    [ -L "${p}" ]
}

# Detect whether the symlink chain is broken (target missing).
path_dedup_is_broken_link() {
    local p="${1:-}"
    [ -L "${p}" ] && [ ! -e "${p}" ]
}

# --- candidate enumeration -------------------------------------------------

# Print every directory we should sweep for a stale `jaisiu`, one per line.
# Order matters for the audit output: highest precedence first so the
# operator sees which shadow is winning.
path_dedup_dirs() {
    local d seen=""
    # 1. The just-installed location (highest precedence among our candidates).
    d="${JAISIU_BIN_DIR:-${HOME}/.local/bin}"
    printf '%s\n' "${d}"
    seen=" ${d} "
    # 2. Every dir on PATH. `printf '%s'` keeps the order stable across
    # bash versions that mangle IFS in `for x in $PATH`.
    local IFS=: path_part
    for path_part in ${PATH:-/usr/local/bin:/usr/bin:/bin}; do
        [ -z "${path_part}" ] && continue
        case " ${seen} " in
            *" ${path_part} "*) continue ;;
        esac
        seen="${seen}${path_part} "
        printf '%s\n' "${path_part}"
    done
    # 3. Hard-coded fallback dirs the installer must always check, even if
    # they are not in PATH (e.g. /usr/local/sbin is on root's sudo
    # secure_path but not the user's PATH). Skip dupes.
    local fb
    for fb in /usr/local/bin /opt/homebrew/bin /usr/local/sbin /usr/bin /bin /usr/sbin /sbin; do
        case " ${seen} " in
            *" ${fb} "*) continue ;;
        esac
        seen="${seen}${fb} "
        printf '%s\n' "${fb}"
    done
}

# classify_jaisiu_candidate <abs_path>
#
# Echo one of:
#   canonical       — symlink/file that points at or IS the just-installed
#                     wrapper (no action needed)
#   fresh-current   — the file at ${JAISIU_BIN_DIR}/jaisiu itself
#                     (the one we just wrote; no action needed)
#   stale-symlink   — symlink whose target is missing → broken-link case
#   stale-other     — resolves to a different bundle / different install dir
#                     than the one we just installed
#   missing         — nothing at this path (caller should skip)
#
# Anything else (regular file that is NOT the canonical wrapper) is
# classified as `stale-other` too — the wrapper byte content can drift
# between releases, but `realpath` will catch that via the install-dir
# comparison.
classify_jaisiu_candidate() {
    local p="${1:-}" canonical_wrapper canonical_entry rp fname
    [ -n "${p}" ] || { printf 'missing\n'; return 0; }

    # Same path as our freshly-written wrapper → fresh-current. Must come
    # BEFORE the missing/broken-link checks so the canonical file itself
    # always classifies as fresh-current (it exists, but we still want the
    # explicit label for clarity).
    canonical_wrapper="$(path_dedup_canonical_wrapper)"
    canonical_entry="$(path_dedup_canonical_entrypoint)"
    if [ "${p}" = "${canonical_wrapper}" ]; then
        printf 'fresh-current\n'
        return 0
    fi

    # Broken symlink → stale-symlink (the most common polluter; the prior
    # installer wrote a link to a bundle that has since been moved or
    # replaced). MUST come before `[ -e ]`, which returns false for broken
    # links — otherwise we would misclassify a broken symlink as 'missing'
    # and never sweep it.
    if path_dedup_is_broken_link "${p}"; then
        printf 'stale-symlink\n'
        return 0
    fi

    # Missing entirely (and not a broken symlink) → nothing to do.
    [ -e "${p}" ] || { printf 'missing\n'; return 0; }

    rp="$(path_dedup_realpath "${p}")"

    # Resolves to the canonical wrapper or entrypoint → canonical.
    if [ "${rp}" = "${canonical_wrapper}" ] || [ "${rp}" = "${canonical_entry}" ]; then
        printf 'canonical\n'
        return 0
    fi

    # Symlink/file pointing at SOMETHING ELSE (e.g. an old bundle in
    # /opt/jaisiu-legacy/, or the install dir from a prior version).
    printf 'stale-other\n'
    return 0
}

# audit_jaisiu_path — print every candidate location with its classification.
# Pure read-only — does NOT mutate any files. Used by `jaisiu doctor` and
# the e2e installer test harness so the operator can see exactly what is
# shadowing what before any cleanup runs.
audit_jaisiu_path() {
    local d p cls
    info "PATH audit for jaisiu (highest precedence first):"
    while IFS= read -r d; do
        [ -z "${d}" ] && continue
        p="${d}/jaisiu"
        cls="$(classify_jaisiu_candidate "${p}" 2>/dev/null || printf 'missing')"
        # Resolve to realpath when present so the operator can SEE the
        # shadow target.
        if [ -e "${p}" ]; then
            info "  ${p}  →  $(path_dedup_realpath "${p}")  [${cls}]"
        else
            info "  ${p}  →  (absent)  [${cls}]"
        fi
    done < <(path_dedup_dirs)
}

# --- replacement -----------------------------------------------------------

# Replace a single stale candidate with a symlink to the canonical
# wrapper. Idempotent: if the symlink already points at the right target,
# do nothing. Returns 0 on success, 1 on permission failure (caller logs).
_replace_one_stale() {
    local stale="${1:-}" canonical="${2:-}"
    [ -n "${stale}" ] && [ -n "${canonical}" ] || return 1
    local dir
    dir="$(dirname "${stale}")"
    local current_target=""
    if [ -L "${stale}" ]; then
        current_target="$(readlink "${stale}" 2>/dev/null || true)"
    fi
    if [ "${current_target}" = "${canonical}" ]; then
        return 0   # already correct
    fi
    if [ -w "${dir}" ]; then
        rm -f "${stale}" 2>/dev/null || true
        if ln -s "${canonical}" "${stale}" 2>/dev/null; then
            ok "Replaced ${stale} → ${canonical} (symlink)"
            return 0
        fi
        # Symlink failed (e.g. /usr/local/bin is on a read-only mount);
        # fall through to copy as a last resort.
        if cp -f "${canonical}" "${stale}" 2>/dev/null; then
            chmod 0755 "${stale}" 2>/dev/null || true
            warn "Replaced ${stale} via cp (symlink failed — read-only mount?)"
            return 0
        fi
        return 1
    fi
    if command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then
        sudo rm -f "${stale}" 2>/dev/null || true
        if sudo -n ln -s "${canonical}" "${stale}" 2>/dev/null; then
            ok "Replaced ${stale} → ${canonical} (sudo symlink)"
            return 0
        fi
        if sudo -n cp -f "${canonical}" "${stale}" 2>/dev/null; then
            sudo -n chmod 0755 "${stale}" 2>/dev/null || true
            warn "Replaced ${stale} via sudo cp (sudo symlink failed)"
            return 0
        fi
        return 1
    fi
    warn "Stale wrapper at ${stale} shadows the fresh one in login shells (PATH order via /etc/paths). Run: sudo rm ${stale} && sudo ln -s '${canonical}' '${stale}'"
    return 1
}

# resolve_jaisiu_path_conflicts — top-level hook called from install.sh /
# plan.sh after jaisiu_install_wrapper. Idempotent: re-running on a clean
# box is a no-op. Honors JAISIU_SKIP_PATH_DEDUP=1 for hermetic tests.
resolve_jaisiu_path_conflicts() {
    if [ "${JAISIU_SKIP_PATH_DEDUP:-}" = "1" ]; then
        info "PATH dedup skipped (JAISIU_SKIP_PATH_DEDUP=1)"
        return 0
    fi
    local canonical_wrapper
    canonical_wrapper="$(path_dedup_canonical_wrapper)"
    # If we never wrote a wrapper, there's nothing to dedup against.
    [ -e "${canonical_wrapper}" ] || {
        info "No wrapper at ${canonical_wrapper} — PATH dedup skipped"
        return 0
    }

    info "Resolving jaisiu PATH conflicts against ${canonical_wrapper}"
    local d p cls replaced=0 warned=0
    while IFS= read -r d; do
        [ -z "${d}" ] && continue
        [ -d "${d}" ] || continue
        p="${d}/jaisiu"
        cls="$(classify_jaisiu_candidate "${p}" 2>/dev/null || printf 'missing')"
        case "${cls}" in
            canonical|fresh-current|missing)
                : ;;
            stale-symlink|stale-other)
                if _replace_one_stale "${p}" "${canonical_wrapper}"; then
                    replaced=$((replaced + 1))
                else
                    warned=$((warned + 1))
                fi
                ;;
            *)
                : ;;
        esac
    done < <(path_dedup_dirs)

    if [ "${replaced}" -gt 0 ]; then
        ok "Resolved ${replaced} stale jaisiu wrapper(s) on PATH"
    fi
    if [ "${warned}" -gt 0 ]; then
        warn "${warned} stale wrapper(s) require manual cleanup (sudo); see lines above"
    fi

    # Acceptance criterion: print `which -a jaisiu` so the operator can
    # confirm the precedence order is now correct.
    if command -v which >/dev/null 2>&1; then
        info "which -a jaisiu:"
        # shellcheck disable=SC2046  # word-splitting on which -a is intentional
        info "  $(which -a jaisiu 2>/dev/null | tr '\n' ' ')"
    fi
}
# <<< end lib/path_dedup.sh

# >>> begin lib/entrypoint.sh (inlined, sourced below)
# install/lib/entrypoint.sh — resolve the installed Jaisiu entrypoint + bundled jq.
#
# Background (cross-references):
#   - Operator report 2026-08-21 21:40 UTC (VM ubuntu install): tarball ships
#     a FLAT bundle (jaisiu.mjs at install root) but install.sh was wiring up
#     FHS-style paths (usr/bin/jaisiu, usr/lib/jaisiu/bin/jq) that don't
#     exist in the bundle. Result: a dangling symlink and `Error: Cannot find
#     module '/home/ubuntu/.jaisiu/app/jaisiu.mjs'`.
#   - Sibling decision: treat the flat layout as the contract and have every
#     lib resolve via these helpers. Do not introduce FHS-shaped references
#     in the installer — the tarball shape is the source of truth
#     (`scripts/jaisiu/build-gateway-tarball.sh:require_runtime_layout`).
#
# Public API:
#   jaisiu_entrypoint          — absolute path to jaisiu.mjs (always exits 0; check jaisiu_has_entrypoint first)
#   jaisiu_has_entrypoint      — 0 if entrypoint file exists, 1 otherwise
#   jaisiu_require_entrypoint  — die() if entrypoint is missing; use after install/extract
#   jaisiu_bundled_jq          — absolute path to bundled jq binary (may be missing on host jq fallback)
#   jaisiu_jq                  — path to a working jq (bundled or system) or empty string
#   jaisiu_install_wrapper     — write the `jaisiu` CLI wrapper to $JAISIU_BIN_DIR
#   jaisiu_install_wrapper_sudo — same, with sudo tee when target dir isn't user-writable
#
# The bundled jq path inside the flat tarball is `${JAISIU_INSTALL_DIR}/install/jq/jq-<arch>`
# (mirrors the staging layout under install/jq/). For non-root installs that
# point at ${HOME}/.local/jaisiu the path resolves naturally. For root installs
# pointing at /opt/jaisiu it also resolves. If the file isn't shipped for the
# host arch, jaisiu_jq falls back to `command -v jq` (system) and finally to
# python3 (for license writes).

# --- sibling-lib source: path_dedup.sh --------------------------------------
# Sourced lazily — only when this lib runs in the unit-test / dev harness
# path where each lib is `source`d independently. The bundled efss-portal
# install.sh inlines the libs in source order (log → preflight → … →
# entrypoint → plan), so path_dedup.sh gets inlined BEFORE us in that
# build; the guard makes both paths work.
if [ -z "${_JAISIU_PATH_DEDUP_SOURCED:-}" ]; then
    _pd_path="${BASH_SOURCE[0]:-$0}"
    _pd_dir="$(dirname "${_pd_path}")"
    # Path-dedup lives next to this file; fall back to the cwd (bundled
    # builds flatten everything into a single namespace).
    if [ -r "${_pd_dir}/path_dedup.sh" ]; then
        # shellcheck source=path_dedup.sh
        . "${_pd_dir}/path_dedup.sh"
    elif [ -r "./path_dedup.sh" ]; then
        # shellcheck source=path_dedup.sh
        . "./path_dedup.sh"
    fi
    _JAISIU_PATH_DEDUP_SOURCED=1
fi

# --- entrypoint resolution --------------------------------------------------

jaisiu_entrypoint() {
    # JAISIU-2894 (2026-08-28 12:25 UTC): honour both layouts:
    #   flat (legacy):   ${JAISIU_INSTALL_DIR}/jaisiu.mjs        (Node entrypoint)
    #   flat (legacy):   ${JAISIU_INSTALL_DIR}/openclaw.mjs     (Node entrypoint)
    #   bun (modern):    ${JAISIU_INSTALL_DIR}/usr/bin/jaisiu   (BUN-compiled binary)
    #                    ${JAISIU_INSTALL_DIR}/bin/jaisiu.exe    (Windows BUN binary)
    # Picking the bun binary first avoids the install-time race where
    # the BUN layout ships `usr/bin/jaisiu` but no flat `jaisiu.mjs`,
    # so the launcher would exec node on a missing file with
    # "Error: Cannot find module '/Users/.../.local/jaisiu/jaisiu.mjs'".
    # Pick the first layout that exists on disk; fall back to the flat
    # legacy default when no install has happened yet (the install
    # script will overwrite after extract anyway).
    local _dir="${JAISIU_INSTALL_DIR:-${HOME}/.local/jaisiu}"
    for _cand in \
        "${_dir}/usr/bin/jaisiu.exe" \
        "${_dir}/usr/bin/jaisiu" \
        "${_dir}/bin/jaisiu.exe" \
        "${_dir}/jaisiu.mjs" \
        "${_dir}/openclaw.mjs"; do
        if [ -e "$_cand" ] || [ -L "$_cand" ]; then
            printf '%s\n' "$_cand"
            return 0
        fi
    done
    # No install has happened yet — return the legacy default. The
    # install script will write the actual launcher after extract.
    printf '%s\n' "${_dir}/jaisiu.mjs"
}

jaisiu_has_entrypoint() {
    [ -n "${JAISIU_INSTALL_DIR:-}" ] || return 1
    # JAISIU-2008 follow-up: BUN-compiled tarballs ship the binary at
    # `usr/bin/jaisiu` rather than a `jaisiu.mjs` Node entrypoint. Accept
    # either layout.
    [ -f "${JAISIU_INSTALL_DIR}/jaisiu.mjs" ] \
        || [ -f "${JAISIU_INSTALL_DIR}/openclaw.mjs" ] \
        || [ -x "${JAISIU_INSTALL_DIR}/usr/bin/jaisiu" ] \
        || [ -x "${JAISIU_INSTALL_DIR}/usr/bin/jaisiu.exe" ] \
        || [ -x "${JAISIU_INSTALL_DIR}/bin/jaisiu.exe" ]
}

jaisiu_require_entrypoint() {
    if ! jaisiu_has_entrypoint; then
        # JAISIU-2008 follow-up: inlining the default inside $() trips
        # bash 5.2 with `JAISIU_API_BASE:-https://pryzm.at: No such
        # file or directory` on `set -u`. Assign to a local first so
        # the parameter-expansion happens in ordinary variable-assignment
        # context, then interpolate the local in the error string.
        local _api_base="${JAISIU_API_BASE:-https://pryzm.at}"
        error "Entrypoint not found at $(jaisiu_entrypoint) — tarball contents unexpected."
        error "Reinstall with the latest /efss/jaisiu/install.sh from ${_api_base}."
        return 1
    fi
}

# --- bundled jq resolution ---------------------------------------------------

# Map host arch to bundled-jq suffix used by install/jq/jq-<arch>.
_jaisiu_bundled_jq_arch() {
    local m
    m="$(uname -m 2>/dev/null || echo unknown)"
    case "${m}" in
        x86_64|amd64) echo "x86_64" ;;
        aarch64|arm64) echo "arm64" ;;
        *) echo "${m}" ;;
    esac
}

# Path inside the flat tarball where the bundled jq lives, when packed.
# Default follows the staging layout under install/jq/ — i.e. extracted to
# <install-dir>/install/jq/jq-<arch>. Falls back to a few historical spots
# for backwards compatibility with bundles published before 2026-08-21.
jaisiu_bundled_jq() {
    local arch
    arch="$(_jaisiu_bundled_jq_arch)"
    for cand in \
        "${JAISIU_INSTALL_DIR:-}/install/jq/jq-${arch}" \
        "${JAISIU_INSTALL_DIR:-}/install/jq/jq-${arch}.bin" \
        "${JAISIU_INSTALL_DIR:-}/usr/lib/jaisiu/bin/jq" \
        "${JAISIU_INSTALL_DIR:-}/jq" \
        "${JAISIU_INSTALL_DIR:-}/jq-${arch}"; do
        [ -x "${cand}" ] && printf '%s\n' "${cand}" && return 0
    done
    return 1
}

# Return a path to a working jq, preferring the bundled one. If none is
# available, print an empty string — callers must handle the no-jq case
# gracefully (most lib functions already do, e.g. license / state writes).
jaisiu_jq() {
    if jaisiu_bundled_jq >/dev/null 2>&1; then
        jaisiu_bundled_jq
        return 0
    fi
    if command -v jq >/dev/null 2>&1; then
        command -v jq
        return 0
    fi
    # No jq — empty.
    printf ''
    return 1
}

# --- CLI wrapper installation ------------------------------------------------

# Build the wrapper script content. Echoes to stdout; the caller writes it.
_jaisiu_wrapper_body() {
    local entry
    entry="$(jaisiu_entrypoint)"
    # JAISIU-2894 (2026-08-28 12:25 UTC): if the entrypoint is a BUN
    # binary (usr/bin/jaisiu, bin/jaisiu.exe) or a shebang'd script,
    # exec it directly. Only exec via node when the entrypoint is a
    # Node .mjs entrypoint. Picking the right interpreter here fixes
    # the "Cannot find module /.../jaisiu.mjs" error on BUN tarballs
    # where jaisiu.mjs does not exist but usr/bin/jaisiu does.
    local _exec_via_node=0
    case "${entry}" in
      *.mjs|*.cjs|*.js) _exec_via_node=1 ;;
    esac
    if [ "${_exec_via_node}" = "1" ]; then
        cat <<WRAPPER
#!/usr/bin/env bash
# Jaisiu CLI wrapper — installed by install.sh.
# Regenerated on every install. Do not edit by hand.
set -e

# Resolve node with fallbacks. JAISIU_NODE_BIN=<path> overrides everything;
# otherwise we walk a small list of explicit host locations, and only
# fall back to the bare 'node' on PATH as a last resort.
_resolve_node() {
  if [ -n "\${JAISIU_NODE_BIN:-}" ] && [ -x "\${JAISIU_NODE_BIN}" ]; then
    printf '%s\n' "\${JAISIU_NODE_BIN}"
    return 0
  fi
  for cand in /opt/homebrew/bin/node /usr/local/bin/node /opt/node/bin/node \
              /usr/bin/node /usr/lib/node_modules/node; do
    if [ -x "\$cand" ]; then printf '%s\n' "\$cand"; return 0; fi
  done
  if command -v node >/dev/null 2>&1; then
    command -v node
    return 0
  fi
  return 1
}

NODE_BIN="\$(JAISIU_NODE_BIN=\${JAISIU_NODE_BIN:-} _resolve_node 2>/dev/null || true)"

if [ -z "\${NODE_BIN}" ]; then
  echo "ERROR: node not found. Install Node.js >= 18 first." 1>&2
  echo "  macOS : brew install node" 1>&2
  echo "  Linux : see https://nodejs.org/" 1>&2
  echo "Or set JAISIU_NODE_BIN=/path/to/node before running." 1>&2
  exit 127
fi

exec "\${NODE_BIN}" "${entry}" "\$@"
WRAPPER
    else
        # BUN-compiled binary or shebang'd script — exec directly.
        cat <<WRAPPER
#!/usr/bin/env bash
# Jaisiu CLI wrapper — installed by install.sh.
# Regenerated on every install. Do not edit by hand.
set -e

exec "${entry}" "\$@"
WRAPPER
    fi
}

# Same as jaisiu_install_wrapper but writes through sudo tee when needed.
jaisiu_install_wrapper_sudo() {
    local bin_dir="${1:-${JAISIU_BIN_DIR:-${HOME}/.local/bin}}"
    local wrapper="${bin_dir}/jaisiu"
    local tmp; tmp="$(mktemp)"
    mkdir -p "${bin_dir}"
    _jaisiu_wrapper_body > "${tmp}"
    if [ -w "${bin_dir}" ]; then
        install -m 0755 "${tmp}" "${wrapper}"
    elif command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then
        sudo install -m 0755 -o root -g root "${tmp}" "${wrapper}"
    else
        error "Cannot write ${wrapper} — ${bin_dir} not user-writable and sudo -n failed."
    fi
    rm -f "${tmp}"
    printf '%s\n' "${wrapper}"
}

jaisiu_install_wrapper() {
    local bin_dir="${1:-${JAISIU_BIN_DIR:-${HOME}/.local/bin}}"
    local wrapper="${bin_dir}/jaisiu"
    mkdir -p "${bin_dir}"
    [ -w "${bin_dir}" ] || {
        jaisiu_install_wrapper_sudo "${bin_dir}"
        return $?
    }
    _jaisiu_wrapper_body > "${wrapper}"
    chmod 0755 "${wrapper}"
    _jaisiu_stale_wrapper_cleanup "$wrapper"
    printf '%s\n' "${wrapper}"
}

# JAISIU-2430: macOS /etc/paths (consumed by path_helper in /etc/zprofile)
# adds /usr/local/bin to the PATH BEFORE the user's rc files run. When a
# prior install left a root-owned /usr/local/bin/jaisiu behind, that stale
# wrapper took precedence in login shells over the fresh one we just wrote
# to ~/.local/bin/jaisiu. Symptom: `jaisiu --version` works (resolves to
# the new wrapper), `jaisiu tui` resolves the old one and dies with
#   TypeError: makeBuilder is not a function at dist-cjs/index.js:4982:17
# inside the previous bundle's broken @aws-sdk/client-bedrock.
#
# We delegate the dedup to install/lib/path_dedup.sh. The old
# MD5-comparison sweep had three latent bugs (couldn't catch broken
# symlinks, copied per-user wrapper into system dirs where it'd 404 for
# other users, and a `set -e` race after `cp -f` failure). The new
# helper classifies each candidate as canonical / fresh-current /
# stale-symlink / stale-other and replaces stale entries with a SYMLINK
# to the canonical wrapper (so the per-user indirection stays intact
# across logins). Honors JAISIU_SKIP_PATH_DEDUP=1 for hermetic tests.
_jaisiu_stale_wrapper_cleanup() {
    # Redirect the dedup's log stream to stderr so callers that capture
    # `jaisiu_install_wrapper` stdout (the wrapper path) don't accidentally
    # consume the audit log. The dedup itself writes to FD 1 because it
    # uses the canonical `info`/`warn`/`ok` funnel; we redirect FD 1 → 2
    # only for this call, then restore.
    {
        resolve_jaisiu_path_conflicts
    } 1>&2 || true
}

# Print the systemd ExecStart= line for the installed binary. Used by
# install/lib/systemd.sh. We call `node` directly because systemd-managed
# processes benefit from a stable, predictable exec line (no wrapper script
# involved in the service supervisor path).
jaisiu_systemd_execstart() {
    local entry node_bin
    entry="$(jaisiu_entrypoint)"
    node_bin="$(command -v node 2>/dev/null || echo /usr/bin/env)"
    printf 'ExecStart=%s %s gateway run\n' "${node_bin}" "${entry}"
}

# <<< end lib/entrypoint.sh

# >>> begin lib/plan.sh (inlined, sourced below)
# install/lib/plan.sh — broker-guided install plan loop (JAISIU-2016).
# Allowlisted step kinds only. Caller has set -euo pipefail.

_PLAN_ALLOWED_KINDS="preflight fetch_artifact extract link_binary await_credentials apply_config_command install_service probe_broker migrate_legacy"

_plan_json_escape() {
    local s="${1//\\/\\\\}"
    s="${s//\"/\\\"}"
    s="${s//$'\n'/\\n}"; s="${s//$'\r'/\\r}"; s="${s//$'\t'/\\t}"
    printf '%s' "${s}"
}

_plan_is_wsl() {
    [ -n "${JAISIU_PLATFORM_HINT:-}" ] && [[ "${JAISIU_PLATFORM_HINT}" == *wsl* ]] && return 0
    [ -f /proc/version ] && grep -qi microsoft /proc/version 2>/dev/null && return 0
    return 1
}

_plan_legacy_present() {
    [ -d /opt/jaisiu/install ] || [ -d "${HOME}/.jaisiu/app" ]
}

# sha256 verification that works on Linux + macOS + BSD. macOS / BSD ship
# `shasum -a 256`; Linux coreutils ships `sha256sum`. JAISIU-2430: the prior
# unconditional `sha256sum -c -` call aborted every macOS install with
# 'sha256sum: command not found' *after* the download succeeded, before
# extract — i.e. the operator saw a fresh tarball land in $TMPDIR and then
# the installer bailed.
_jaisiu_sha256_verify() {
    local file="${1:-}" expected="${2:-}"
    [ -n "${file}" ] && [ -n "${expected}" ] || return 1
    local actual cmd
    if command -v sha256sum >/dev/null 2>&1; then
        actual="$(sha256sum "${file}" | awk '{print $1}')"
    elif command -v shasum >/dev/null 2>&1; then
        actual="$(shasum -a 256 "${file}" | awk '{print $1}')"
    else
        echo "WARN: no sha256 tool found (sha256sum / shasum); skipping verification" >&2
        return 0
    fi
    [ "${actual}" = "${expected}" ]
}

# Collect host facts after preflight() has set JAISIU_* vars.
collect_install_facts_json() {
    local priv="user" wsl="false" legacy="false" systemd="false" launchd="false" hint=""
    [ "$(id -u)" = "0" ] && priv="root"
    _plan_is_wsl && wsl="true"
    _plan_legacy_present && legacy="true"
    [ "${JAISIU_HAS_SYSTEMD:-0}" = "1" ] && systemd="true"
    [ "${JAISIU_HAS_LAUNCHD:-0}" = "1" ] && launchd="true"
    hint="${JAISIU_PLATFORM_HINT:-}"
    [ -z "${hint}" ] && [ "${wsl}" = "true" ] && hint="linux-wsl"
    printf '{"os":"%s","arch":"%s","platformHint":"%s","privilege":"%s","hasSystemd":%s,"hasLaunchd":%s,"wsl":%s,"legacyPresent":%s,"existingLicenseKey":"%s","upstreamMode":"%s"}' \
        "$(_plan_json_escape "${JAISIU_OS}")" \
        "$(_plan_json_escape "${JAISIU_ARCH}")" \
        "$(_plan_json_escape "${hint}")" \
        "$(_plan_json_escape "${priv}")" \
        "${systemd}" "${launchd}" "${wsl}" "${legacy}" \
        "$(_plan_json_escape "${PRYZM_NODE_KEY:-${JAISIU_LICENSE_KEY:-}}")" \
        "$(_plan_json_escape "${JAISIU_UPSTREAM_MODE:-}")"
}

_plan_kind_allowed() {
    local k="$1" x
    for x in ${_PLAN_ALLOWED_KINDS}; do
        [ "${x}" = "${k}" ] && return 0
    done
    return 1
}

_plan_curl_json() {
    # _plan_curl_json METHOD URL BODY [HDR name:val ...]
    local method="$1" url="$2" body="${3:-}"
    shift 3 || true
    local args=(-sS -X "${method}" -H 'content-type: application/json')
    local h
    for h in "$@"; do args+=(-H "${h}"); done
    if [ -n "${body}" ]; then
        curl "${args[@]}" --data "${body}" "${url}"
    else
        curl "${args[@]}" "${url}"
    fi
}

_plan_field() {
    # python3 json path: _plan_field FILE '.key'
    local file="$1" expr="$2"
    python3 -c "import json,sys; d=json.load(open(sys.argv[1]));
v=d
for p in sys.argv[2].lstrip('.').split('.'):
  if not p: continue
  if isinstance(v,dict): v=v.get(p)
  else: v=None; break
print('' if v is None else v if not isinstance(v,(dict,list)) else json.dumps(v))" \
        "${file}" "${expr}" 2>/dev/null || true
}

open_install_session() {
    local api_base="${JAISIU_API_BASE:-https://pryzm.at}"
    api_base="${api_base%/}"
    local facts; facts="$(collect_install_facts_json)"
    local body email="${JAISIU_EMAIL:-}" intent="${JAISIU_INSTALL_INTENT:-}"
    local agent_hdr=()

    if [ -n "${PRYZM_INSTALL_AGENT_TOKEN:-}" ]; then
        agent_hdr=(-H "X-Install-Agent-Token: ${PRYZM_INSTALL_AGENT_TOKEN}")
        body="$(printf '{"email":"%s","hostId":"%s","agentName":"%s","releasesBase":"%s","facts":%s}' \
            "$(_plan_json_escape "${email}")" \
            "$(_plan_json_escape "${JAISIU_HOST_ID:-$(hostname 2>/dev/null || echo unknown)}")" \
            "$(_plan_json_escape "${JAISIU_AGENT_NAME:-install.sh}")" \
            "$(_plan_json_escape "${JAISIU_RELEASES:-}")" \
            "${facts}")"
    elif [ -n "${intent}" ]; then
        body="$(printf '{"installIntent":"%s","releasesBase":"%s","facts":%s}' \
            "$(_plan_json_escape "${intent}")" \
            "$(_plan_json_escape "${JAISIU_RELEASES:-}")" \
            "${facts}")"
    elif [ -n "${email}" ] && [ -n "${JAISIU_INSTALL_CODE:-}" ]; then
        body="$(printf '{"email":"%s","code":"%s","releasesBase":"%s","facts":%s}' \
            "$(_plan_json_escape "${email}")" \
            "$(_plan_json_escape "${JAISIU_INSTALL_CODE}")" \
            "$(_plan_json_escape "${JAISIU_RELEASES:-}")" \
            "${facts}")"
    else
        return 1
    fi

    local tmp; tmp="$(mktemp)"
    if [ "${#agent_hdr[@]}" -gt 0 ]; then
        curl -sS -X POST -H 'content-type: application/json' "${agent_hdr[@]}" \
            --data "${body}" "${api_base}/api/install/session" -o "${tmp}" || { rm -f "${tmp}"; return 1; }
    else
        curl -sS -X POST -H 'content-type: application/json' \
            --data "${body}" "${api_base}/api/install/session" -o "${tmp}" || { rm -f "${tmp}"; return 1; }
    fi

    JAISIU_SESSION_ID="$(_plan_field "${tmp}" '.sessionId')"
    JAISIU_SESSION_TOKEN="$(_plan_field "${tmp}" '.sessionToken')"
    JAISIU_PLAN_FILE="${tmp}"
    [ -n "${JAISIU_SESSION_ID}" ] && [ -n "${JAISIU_SESSION_TOKEN}" ]
}

_plan_report_status() {
    local step_id="$1" result="$2" code="${3:-}" message="${4:-}"
    local api_base="${JAISIU_API_BASE:-https://pryzm.at}"
    api_base="${api_base%/}"
    local body
    body="$(printf '{"sessionId":"%s","stepId":"%s","result":"%s","code":"%s","message":"%s"}' \
        "$(_plan_json_escape "${JAISIU_SESSION_ID}")" \
        "$(_plan_json_escape "${step_id}")" \
        "$(_plan_json_escape "${result}")" \
        "$(_plan_json_escape "${code}")" \
        "$(_plan_json_escape "${message}")")"
    local tmp; tmp="$(mktemp)"
    curl -sS -X POST -H 'content-type: application/json' \
        -H "X-Install-Session-Token: ${JAISIU_SESSION_TOKEN}" \
        --data "${body}" "${api_base}/api/install/status" -o "${tmp}" || true
    printf '%s' "${tmp}"
}

_plan_step_fetch() {
    local manifest_url="$1" target="$2"
    JAISIU_TARGET="${target:-${JAISIU_TARGET}}"
    local _manifest; _manifest="$(mktemp)"
    curl -fsSL "${manifest_url}" -o "${_manifest}" || return 1
    _field() {
        local k="$1"
        python3 -c "import json; a=json.load(open('${_manifest}')).get('artifacts',{}).get('${JAISIU_TARGET}',{}); print(a.get('${k}',''))"
    }
    local _url _sha _fmt
    _url="$(_field url)"; _sha="$(_field sha256)"; _fmt="$(_field format)"
    [ -n "${_url}" ] || return 1
    [ -n "${_sha}" ] || return 1
    # Derive temp-file suffix from the format (default tar.gz for older
    # bundles). The actual compression is decided in _plan_step_extract.
    case "${_fmt}" in
        tar)        JAISIU_TGZ="$(mktemp -t jaisiu.XXXXXX.tar)" ;;
        tar.bz2)    JAISIU_TGZ="$(mktemp -t jaisiu.XXXXXX.tar.bz2)" ;;
        tar.xz)     JAISIU_TGZ="$(mktemp -t jaisiu.XXXXXX.tar.xz)" ;;
        zip)        JAISIU_TGZ="$(mktemp -t jaisiu.XXXXXX.zip)" ;;
        *)          JAISIU_TGZ="$(mktemp -t jaisiu.XXXXXX.tgz)" ;;
    esac
    JAISIU_TARBALL_FORMAT="${_fmt:-tar.gz}"
    # -C - auto-resumes from where the previous partial download left
    # off (so a flaky-network retry doesn't restart from 0 bytes).
    # --retry-all-errors covers HTTP 5xx / TLS resets / connection drops
    # that the plain --retry flag ignores. Operator trace 2026-08-24:
    # Mac install hit "sha256 mismatch" 6× in a row because curl was
    # completing with truncated bytes; -C - keeps the partial bytes
    # between retries so we eventually assemble the full file.
    curl -fSL -C - --retry 5 --retry-delay 2 --retry-all-errors \
        --connect-timeout 20 --max-time 1800 \
        "${_url}" -o "${JAISIU_TGZ}"
    _jaisiu_sha256_verify "${JAISIU_TGZ}" "${_sha}" \
        || error "sha256 mismatch — refusing to extract (legacy plan path)"
}

_plan_step_extract() {
    # Honour the `format` from the manifest; default to gzip for legacy
    # bundles (older ci0..ci4 shipped .tar.gz; ci5+ ship raw .tar to dodge
    # gzip-truncation in the prod pipeline). Without this `tar -xzf` fails
    # with "not in gzip format" on the new plain-tar bundles, which is
    # exactly the failure we saw on jaisiu-vm (192.168.122.117) when
    # testing against pryzm.at on 2026-08-24.
    local fmt="${JAISIU_TARBALL_FORMAT:-tar.gz}"
    case "${fmt}" in
        tar)    tar -xf  "${JAISIU_TGZ}" -C "${JAISIU_INSTALL_DIR}" ;;
        tar.gz) tar -xzf "${JAISIU_TGZ}" -C "${JAISIU_INSTALL_DIR}" ;;
        tar.bz2) tar -xjf "${JAISIU_TGZ}" -C "${JAISIU_INSTALL_DIR}" ;;
        tar.xz) tar -xJf "${JAISIU_TGZ}" -C "${JAISIU_INSTALL_DIR}" ;;
        zip)    ( cd "${JAISIU_INSTALL_DIR}" && unzip -qo "${JAISIU_TGZ}" ) ;;
        *)      tar -xzf "${JAISIU_TGZ}" -C "${JAISIU_INSTALL_DIR}" ;;
    esac
    # The prod-published bundle (since ci5) wraps the tree in a single
    # top-level `jaisiu-gateway/` directory. Older bundles shipped a
    # `jaisiu/` wrapper. Flatten whichever is present so the entrypoint
    # lives at ${JAISIU_INSTALL_DIR}/jaisiu.mjs as the rest of the
    # installer expects.
    #
    # Edge case: BSD tar (macOS) leaves a literal `./` directory from
    # tarballs built with `tar -czf -C STAGING .`. The resulting archive
    # contains entries like `./jaisiu.mjs` next to a `.` directory entry.
    # GNU tar strips both down to a flat layout; BSD tar creates a real
    # `./` directory inside ${JAISIU_INSTALL_DIR} and the entrypoint
    # lands at `${JAISIU_INSTALL_DIR}/./jaisiu.mjs`. Without this guard,
    # `link_binary` then errors with "Entrypoint not found at
    # ${JAISIU_INSTALL_DIR}/jaisiu.mjs — tarball contents unexpected."
    if [ ! -f "${JAISIU_INSTALL_DIR}/jaisiu.mjs" ]; then
        # Try wrapper directories first (the historic layout).
        # JAISIU-2626 (2026-08-26): use mv instead of `cp -a` because BSD cp
        # rejects `cp .../jaisiu/. .../jaisiu/.` with "are identical (not
        # copied)" once the wrapper has been flattened once already, and
        # also refuses identical src/dst pairs. mv is atomic on the same
        # filesystem and never trips the "are identical" guard.
        for sub in jaisiu jaisiu-gateway jaisiu-cli; do
            if [ -d "${JAISIU_INSTALL_DIR}/${sub}" ]; then
                shopt -s dotglob nullglob
                for entry in "${JAISIU_INSTALL_DIR}/${sub}/."/*; do
                    _base="$(basename "${entry}")"
                    if [ -e "${JAISIU_INSTALL_DIR}/${_base}" ]; then
                        rm -rf "${entry}" 2>/dev/null || true
                    else
                        mv "${entry}" "${JAISIU_INSTALL_DIR}/${_base}" 2>/dev/null \
                            || rm -rf "${entry}" 2>/dev/null || true
                    fi
                done
                shopt -u dotglob nullglob
                rm -rf "${JAISIU_INSTALL_DIR}/${sub}"
                break
            fi
        done
        # Then handle BSD tar's literal `./` directory: if entrypoint is
        # still missing, the contents live one level deeper under `./`.
        # Detection: find a directory whose last component is a literal `.`
        # entry (not the `.` alias for the current directory). Without
        # this, the install loop iterates the install dir itself and
        # silently deletes files that were just moved by the wrapper-flatten.
        if [ ! -f "${JAISIU_INSTALL_DIR}/jaisiu.mjs" ]; then
            _literal_dot=""
            while IFS= read -r -d "" _d; do
                [ "${_d}" = "${JAISIU_INSTALL_DIR}/." ] && continue
                _literal_dot="${_d}"; break
            done < <(find "${JAISIU_INSTALL_DIR}" -maxdepth 1 -type d -name '.' -print0 2>/dev/null || true)
            if [ -n "${_literal_dot}" ] && [ -d "${_literal_dot}" ]; then
                shopt -s dotglob nullglob
                for entry in "${_literal_dot}/"*; do
                    _base="$(basename "${entry}")"
                    if [ -e "${JAISIU_INSTALL_DIR}/${_base}" ]; then
                        rm -rf "${entry}" 2>/dev/null || true
                    else
                        mv "${entry}" "${JAISIU_INSTALL_DIR}/${_base}" 2>/dev/null \
                            || rm -rf "${entry}" 2>/dev/null || true
                    fi
                done
                shopt -u dotglob nullglob
                rm -rf "${_literal_dot}" 2>/dev/null || true
            fi
        fi
    fi
    mkdir -p "${JAISIU_STATE_DIR}" "${JAISIU_LOG_DIR}"
}

_plan_step_link() {
    # Fail loudly if the bundle is missing the entrypoint — prevents a
    # dangling /usr/local/bin/jaisiu symlink and the misleading
    # `Cannot find module '/home/ubuntu/.jaisiu/app/jaisiu.mjs'` we saw on
    # 2026-08-21 when the install.sh precondition assumed an FHS tree the
    # tarball does not ship.
    jaisiu_require_entrypoint
    jaisiu_install_wrapper "${JAISIU_BIN_DIR}"
}

_plan_step_await_credentials() {
    local status_file="$1"
    local key broker cfg endpoint base jq_bin
    key="$(_plan_field "${status_file}" '.key')"
    broker="$(_plan_field "${status_file}" '.brokerKey')"
    cfg="$(_plan_field "${status_file}" '.configCommand')"
    endpoint="$(_plan_field "${status_file}" '.licenseEndpoint')"
    base="$(_plan_field "${status_file}" '.brokerBaseUrl')"
    jq_bin="$(jaisiu_jq 2>/dev/null || true)"
    # Unified JWT only. Retired pzk_ / yo-momma / pryzm_cu_ are dropped.
    if [ -n "${key}" ]; then
        if ! type _license_is_compact_jwt >/dev/null 2>&1 || ! _license_is_compact_jwt "${key}"; then
            print_message WARN "[plan/await_credentials] refusing to persist retired or malformed license key ($(printf '%s' "${key}" | head -c 12)…); expected a unified JWT"
            key=""
        fi
    fi
    # Dual-key broker slot is retired. Claim only when we still have no JWT.
    if [ -z "${key}" ]; then
        if claim_file="$(_plan_call_claim "${JAISIU_SESSION_TOKEN:-}")"; then
            if [ -n "${claim_file}" ] && [ -s "${claim_file}" ]; then
                key="$(_plan_field "${claim_file}" '.key')"
                endpoint="$(_plan_field "${claim_file}" '.licenseEndpoint')"
                base="$(_plan_field "${claim_file}" '.brokerBaseUrl')"
                JAISIU_KEY_PREFIX="$(_plan_field "${claim_file}" '.keyPrefix')"
                print_message OK "[plan/await_credentials] /api/install/claim minted a unified JWT"
            fi
            rm -f "${claim_file}" 2>/dev/null || true
        fi
        if [ -n "${key}" ]; then
            if ! type _license_is_compact_jwt >/dev/null 2>&1 || ! _license_is_compact_jwt "${key}"; then
                print_message WARN "[plan/await_credentials] /api/install/claim returned a retired credential; expected a unified JWT"
                key=""
            fi
        fi
    fi
    if [ -n "${key}" ] && [ -n "${jq_bin}" ]; then
        _license_write "${jq_bin}" "${key}" "" "${endpoint}" "${base}"
    fi
    JAISIU_CONFIG_COMMAND="${cfg}"
    JAISIU_BROKER_KEY_PREFIX="${JAISIU_BROKER_KEY_PREFIX:-$(_plan_field "${status_file}" '.brokerKeyPrefix')}"
    JAISIU_KEY_PREFIX="${JAISIU_KEY_PREFIX:-$(_plan_field "${status_file}" '.keyPrefix')}"
}

# JAISIU-2626 (2026-08-26): POST /api/install/claim — mint a fresh license
# + broker key pair against the entitlement the session established. Works
# unattended (no OTP). Returns the path to a JSON file with the response,
# or empty on failure. Gated by the same auth as /api/install/session:
# X-Install-Session-Token (the session token the plan loop got from
# /api/install/session). ensureFreeNodeEntitlement on the server is
# idempotent, so this works for new free-node accounts AND existing
# customer accounts (the latter returns the previously-issued keys
# without rotating — except issueJaisiuKeyForEmail always rotates, see
# src/efss/portal/install-routes.ts:519 comment).
_plan_call_claim() {
    local session_token="$1"
    local api_base="${JAISIU_API_BASE:-https://pryzm.at}"
    api_base="${api_base%/}"
    if [ -z "${session_token}" ]; then
        return 1
    fi
    local tmp; tmp="$(mktemp)"
    if ! curl -fsS --max-time 30 -X POST \
            -H 'content-type: application/json' \
            -H "X-Install-Session-Token: ${session_token}" \
            --data '{}' \
            "${api_base}/api/install/claim" -o "${tmp}"; then
        rm -f "${tmp}" 2>/dev/null || true
        return 1
    fi
    printf '%s' "${tmp}"
}

_plan_step_apply_config() {
    # configCommand is a jq pipeline from the broker — run via bash carefully,
    # then apply our clean state on top so the scaffold always wins over stale
    # broker-side fields (e.g. a stale workspace path from a previous install).
    state_install_after_config_command
}

_plan_step_probe() {
    local jq_bin
    jq_bin="$(jaisiu_jq 2>/dev/null || true)"
    local key=""
    if [ -n "${jq_bin}" ] && [ -f "${JAISIU_CONFIG}" ]; then
        key="$("${jq_bin}" -r '.models.providers["pryzm-at-broker"].apiKey // .license.brokerKey // empty' "${JAISIU_CONFIG}" 2>/dev/null || true)"
    fi
    [ -n "${key}" ] || return 0
    local base="${JAISIU_BROKER_BASE:-https://api.pryzm.at/v1}"
    local i
    for i in 1 2 3 4 5; do
        curl -fsSL "${base}/models" -H "Authorization: Bearer ${key}" >/dev/null 2>&1 && return 0
        sleep 1
    done
    return 1
}

_plan_execute_kind() {
    local kind="$1" args_json="$2" status_file="${3:-}"
    case "${kind}" in
        preflight) preflight ;;
        migrate_legacy) maybe_migrate ;;
        fetch_artifact)
            local mu tg
            mu="$(printf '%s' "${args_json}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('manifestUrl',''))" 2>/dev/null || true)"
            tg="$(printf '%s' "${args_json}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('target',''))" 2>/dev/null || true)"
            # JAISIU-2008 follow-up: the plan's args.releasesBase can be
            # "" (the broker doesn't auto-fill it). When that happens
            # the fallback URL was ${JAISIU_RELEASES}/latest.json —
            # but the actual served path is /releases/latest.json
            # (the install payload lives under a 'releases/'
            # subdir; the legacy direct /latest.json path was
            # removed when the releases/ namespace was added).
            # Without /releases/, the fetch_artifact step gets a 404
            # and the plan-flow falls back to the linear resolver.
            [ -n "${mu}" ] || mu="${JAISIU_RELEASES:-https://pryzm.at/efss/jaisiu}/releases/latest.json"
            _plan_step_fetch "${mu}" "${tg}"
            ;;
        extract) _plan_step_extract ;;
        link_binary) _plan_step_link ;;
        await_credentials) _plan_step_await_credentials "${status_file}" ;;
        apply_config_command) _plan_step_apply_config ;;
        install_service) install_service ;;
        probe_broker) _plan_step_probe ;;
        *) error "Refusing unknown plan step kind: ${kind}" ;;
    esac
}

run_install_plan_loop() {
    phase "plan" "Broker-guided install"
    open_install_session || error "Could not open install session (set JAISIU_EMAIL+PRYZM_INSTALL_AGENT_TOKEN, JAISIU_INSTALL_INTENT, or JAISIU_INSTALL_CODE)"

    local unsupported
    unsupported="$(_plan_field "${JAISIU_PLAN_FILE}" '.plan.unsupported')"
    if [ "${unsupported}" = "True" ] || [ "${unsupported}" = "true" ]; then
        warn "$(_plan_field "${JAISIU_PLAN_FILE}" '.plan.recoveryHint')"
        error "Platform unsupported by install plan"
    fi

    # Extract steps array to a temp file for iteration
    local steps_file; steps_file="$(mktemp)"
    python3 -c "import json; json.dump(json.load(open('${JAISIU_PLAN_FILE}')).get('plan',{}).get('steps',[]), open('${steps_file}','w'))"

    local n total
    total="$(python3 -c "import json; print(len(json.load(open('${steps_file}'))))")"
    n=0
    while [ "${n}" -lt "${total}" ]; do
        local sid kind args_json
        sid="$(python3 -c "import json; print(json.load(open('${steps_file}'))[${n}]['id'])")"
        kind="$(python3 -c "import json; print(json.load(open('${steps_file}'))[${n}]['kind'])")"
        args_json="$(python3 -c "import json; print(json.dumps(json.load(open('${steps_file}'))[${n}].get('args') or {}))")"
        n=$((n + 1))
        phase "${n}/${total}" "${sid} (${kind})"
        _plan_kind_allowed "${kind}" || error "Refusing unknown plan step kind: ${kind}"

        local stf
        stf="$(_plan_report_status "${sid}" "started")"
        if ! _plan_execute_kind "${kind}" "${args_json}" "${stf}"; then
            local failf
            failf="$(_plan_report_status "${sid}" "failed" "step_failed" "local execution failed")"
            warn "$(_plan_field "${failf}" '.recoveryHint')"
            rm -f "${stf}" "${failf}"
            error "Install step ${sid} failed"
        fi
        local okf
        okf="$(_plan_report_status "${sid}" "ok")"
        if [ "${kind}" = "await_credentials" ]; then
            _plan_step_await_credentials "${okf}"
        fi
        local status
        status="$(_plan_field "${okf}" '.status')"
        rm -f "${stf}" "${okf}"
        [ "${status}" = "done" ] && break
    done
    rm -f "${steps_file}" "${JAISIU_PLAN_FILE}"
    ok "Broker-guided install plan complete"
}

# Offline linear install (smoke / no broker auth).
run_install_offline() {
    phase "1/6" "Prerequisites"; preflight
    maybe_migrate

    phase "2/6" "Resolve install method"
    local _manifest _tgz
    _manifest="$(mktemp)"; _tgz="$(mktemp -t jaisiu.XXXXXX.tar)"
    trap 'rm -f "${_manifest}" "${_tgz}"' RETURN
    local _url="${JAISIU_RELEASES}${JAISIU_VERSION:+/${JAISIU_VERSION}}/latest.json"
    curl -fsSL "${_url}" -o "${_manifest}" || error "Fetch failed: ${_url}"
    _field() {
        local k="$1" tgt="${JAISIU_TARGET}"
        python3 -c "import json; a=json.load(open('${_manifest}')).get('artifacts',{}).get('${tgt}',{}); print(a.get('${k}',''))"
    }
    local url sha fmt
    url="$(_field url)"; sha="$(_field sha256)"; fmt="$(_field format)"
    [ -n "${url}" ] || error "No artifact for ${JAISIU_TARGET} in latest.json"
    [ -n "${sha}" ] || error "No sha256 for ${JAISIU_TARGET} in latest.json"
    ok "Manifest: ${JAISIU_TARGET} → ${url##*/} (format: ${fmt:-tar.gz})"
    step "Downloading…"; curl -fsSL "${url}" -o "${_tgz}"
    _jaisiu_sha256_verify "${_tgz}" "${sha}" \
        || error "sha256 mismatch — refusing to extract"
    JAISIU_TARBALL_FORMAT="${fmt:-tar.gz}"

    phase "3/6" "Extract bundle"
    JAISIU_TGZ="${_tgz}"; _plan_step_extract
    ok "Extracted to ${JAISIU_INSTALL_DIR}"

    phase "4/6" "Link binary"; _plan_step_link
    ok "Linked → ${JAISIU_BIN_DIR}/jaisiu"

    phase "5/6" "License"; license_install
    phase "5b/6" "Clean state"; state_install
    phase "6/6" "OS service"; install_service
}

# <<< end lib/plan.sh

# --- JAISIU-2794 inline-libs sentinel --------------------------------------
# The libs above are inlined as real code, not comments. _LIB_DIR is
# declared here so the plan-mode block in install/install.sh knows the
# libs are already sourced (it short-circuits when _LIB_DIR is non-empty
# in the calling shell). When this bundled script runs via curl|bash,
# BASH_SOURCE[0] is undefined and there is no on-disk lib/ to source.
_LIBS_INLINED=1
_LIB_DIR="${_LIB_DIR:-$(mktemp -d)}"
export _LIB_DIR
# Stub the lib/ files so any source-on-disk call from the inlined main
# would succeed but find the inlined functions already defined.
mkdir -p "${_LIB_DIR}"
for _f in log.sh preflight.sh migrate.sh userdirs.sh systemd.sh license.sh state.sh path_dedup.sh entrypoint.sh plan.sh; do
  echo "# stub for inlined lib/${_f} (real source inlined above)" > "${_LIB_DIR}/${_f}"
done
# --- main (from install/install.sh, lines 45..EOF) -------------------------
# We inline the canonical install.sh body starting at line 45 (right after
# the duplicated `set -euo pipefail`), which keeps the colors (lines 45-52)
# and the legacy print_message/info/warn/error/ok macros (lines 52-73) that
# the rest of the script calls into. The legacy standalone `log()` function
# (line 228) is replaced by the `log()` shim injected above (after the libs)
# which delegates to lib/log.sh's info/ok/warn/error/debug/step. The
# duplicated log-runtime setup (JAISIU_LOG_FILE=..., mkdir, exec 3>>...) is
# stripped because lib/log.sh's log_init() (called from the inlined main)
# opens the same FD 3 tee-to-file handle. Everything else (usage, CLI
# parser, OS/arch detection, email prompt, the broker-guided T1 dispatcher
# [_jaisiu_plan_triggered + _jaisiu_safe_await_credentials], version
# resolution, HEAD preflight, download, extraction, launcher install,
# service registration, deep smoke, banner) is taken verbatim from
# install.sh.
# ---- colors ----------------------------------------------------------------
NC="\033[0m"
MUTED="\033[2m"
RED="\033[31m"
ORANGE="\033[33m"
GREEN="\033[32m"

print_message() {
  # $1=level, $2=msg
  local level="$1"; shift
  case "$level" in
    INFO)  printf "${MUTED}%s${NC}\n" "$*" ;;
    OK)    printf "${GREEN}✓${NC} %s\n" "$*" ;;
    WARN)  printf "${ORANGE}!${NC} %s\n" "$*" ;;
    ERR)   printf "${RED}✗${NC} %s\n" "$*" >&2 ;;
    FATAL) printf "${RED}✗ %s${NC}\n" "$*" >&2; exit 1 ;;
  esac
}

# Short aliases used by sourced lib/* helpers (e.g. lib/path_dedup.sh).
# Without these, sourced libs that call `info`/`warn`/`error`/`ok` directly
# would hit "command not found" because the host script only exposes
# print_message. Keep the names lowercase + the same call shape so the
# lib/ tree can stay host-agnostic.
info()  { print_message INFO  "$*"; }
warn()  { print_message WARN  "$*"; }
error() { print_message ERR   "$*"; }
ok()    { print_message OK    "$*"; }

# ---- state extractors ------------------------------------------------------
# Pull a string value at a fixed JSON path from ~/.jaisiu/state/jaisiu.json
# without requiring jq or any other external tool. The state file is written
# by this installer in a known shape ({ "gateway": { "auth": { "token":
# "<hex>" }, "port": <int> } }), so a path-aware grep is enough. Returns the
# value on stdout (empty if the field is absent or unparseable).
#
# Used by the post-install banner to surface the WebUI deep link. Kept
# defensive: bad JSON, missing fields, or a value of the wrong shape all
# return empty rather than emitting a malformed URL into the terminal.
_state_extract_token() {
  # $1 = path to state/jaisiu.json
  local f="${1:-}"
  [ -r "$f" ] || return 0
  # 1. Match the literal key sequence, capture the value on the same line.
  #    Token is 32-byte hex (64 chars); grep -oE for the value class we wrote.
  local token
  token="$(grep -oE '"token"[[:space:]]*:[[:space:]]*"[0-9a-f]{64}"' "$f" 2>/dev/null | head -1 | sed -E 's/.*"([0-9a-f]{64})".*/\1/')"
  if [ -n "${token:-}" ]; then
    printf '%s\n' "$token"
    return 0
  fi
  # 2. Fallback for older state files where the token may have been written
  #    without the strict 64-char hex check (e.g. legacy 32-byte hex with
  #    dashes, or base64). Accept any reasonably-shaped bearer string.
  token="$(grep -oE '"token"[[:space:]]*:[[:space:]]*"[^"[:space:]]{16,128}"' "$f" 2>/dev/null | head -1 | sed -E 's/.*"([^"]+)".*/\1/')"
  if [ -n "${token:-}" ]; then
    printf '%s\n' "$token"
  fi
}
_state_extract_port() {
  local f="${1:-}"
  [ -r "$f" ] || return 0
  grep -oE '"port"[[:space:]]*:[[:space:]]*[0-9]{2,5}' "$f" 2>/dev/null | head -1 | sed -E 's/.*:[[:space:]]*([0-9]+).*/\1/'
}

# ---- usage -----------------------------------------------------------------
usage() {
  cat <<'USAGE'
Jaisiu gateway installer

Usage:
  install.sh [options]

Options:
  -v, --version <ver>     Install a specific version (e.g. 0.99-rc3-dev,
                          or "latest")
  -b, --binary <file>     Install from a local tarball (skips download)
  -k, --license-key <key> Bind a license key during install. Probes the
                          prod license endpoint; non-fatal on probe fail
                          (falls back to trial mode).
  -e, --email <addr>      User email (identity stored in state/jaisiu.json).
                          Required for the broker's auto-claim flow when
                          no --license-key is supplied.
                          NOTE: in piped non-interactive mode (curl|bash -s --)
                          the broker session bootstrap reads
                          $JAISIU_EMAIL from env, NOT from $1; export
                          JAISIU_EMAIL=… before invoking curl|bash.
      --no-email          Skip the interactive email prompt (non-interactive).
      --token <pzk>        Pre-supplied portal token (skips the email + OTP
                          signup exchange). Mirrors install-agent.sh's
                          --token flag for the customer path.
      --no-license        Skip license acquisition entirely (offline /
                          kiosk / fully trial installs).
      --otp-code <NNNNNN> Non-interactive OTP confirmation. The 6-digit
                          code from the email sent by /api/install/request
                          is piped in here so the installer does not block
                          on /dev/tty. Equivalent to setting
                          JAISIU_INSTALL_CODE.
      --skip-otp          Skip the email+OTP signup exchange entirely
                          (the operator already configured
                          JAISIU_PORTAL_TOKEN via /etc/jaisiu.env per
                          JAISIU-2889). Falls through to the auto-claim
                          path unchanged. Equivalent to JAISIU_SKIP_OTP=1.
      --env-file <path>   Source this file as a shell environment file
                          before flag parsing. Repeatable.
  --no-modify-path        Do not write to ~/.bashrc / ~/.zshrc / fish conf
  --system                Install to /usr/local/bin (default; requires sudo)
  --user                  Install to ~/.local/bin (no sudo)
  --no-service            Skip OS service registration (systemd/launchd)
  --help, -h              Show this help
  --debug                 Enable xtrace + verbose log
  --debug-state           Print state after each step

Environment:
  JAISIU_RELEASES         Base URL for releases (default: https://pryzm.at/efss/jaisiu)
  JAISIU_VERSION          Default -v value (e.g. "0.99-rc3-dev")
  JAISIU_LICENSE_KEY      License key (canonical; aliases: JAISIU_LICENSE,
                          PRYZM_NODE_KEY). Written to
                          ~/.jaisiu/state/jaisiu.json + exposed as env var
                          on the OS service.
  JAISIU_BROKER_KEY       Pryzm LLM broker key (alias: PRYZM_BROKER_KEY).
                          Required only for broker-routed LLM calls.
  JAISIU_EMAIL            User email (auto-claim flow when no key is set).
                          Always persisted to ~/.jaisiu/state/jaisiu.json
                          as identity.email, even in trial mode.
  JAISIU_NO_EMAIL=1       Skip the interactive email prompt.
  JAISIU_NO_LICENSE=1     Skip license acquisition entirely.
  JAISIU_NONINTERACTIVE=1 Skip ALL interactive prompts (CI / curl|bash).
                          JAISIU_EMAIL / --email still honored if set.
  JAISIU_PORTAL_TOKEN     Portal token (canonical; alias: PRYZM_INSTALL_AGENT_TOKEN).
                          When set, skips the email + OTP signup exchange.
  PRYZM_INSTALL_AGENT_TOKEN
                          Legacy alias for JAISIU_PORTAL_TOKEN.
  JAISIU_INSTALL_CODE     6-digit OTP confirmation code from the email
                          sent by /api/install/request. Used by the path-B
                          signup helper (install/lib/license.sh) so
                          non-interactive operators don't block on
                          /dev/tty. Same as `--otp-code <NNNNNN>`.
  JAISIU_SKIP_OTP=1       Skip the email+OTP signup exchange entirely
                          (auto-claim path runs alone). Same as `--skip-otp`.
  JAISIU_LICENSE_KEY_PREFIX
                          Portal-customer command marker (read-only).
  JAISIU_BIN_DIR          Override install dir (default: /usr/local/bin)
  JAISIU_LOG_FILE         Log path (default: /tmp/jaisiu-install.log)

Examples:
  # Public install, latest dev build (trial mode)
  curl -fsSL https://pryzm.at/efss/jaisiu/install.sh | bash

  # Pin a version
  curl -fsSL https://pryzm.at/efss/jaisiu/install.sh | bash -s -- -v 0.99-rc3-dev

  # Bind a unified license JWT + email:
  curl -fsSL https://pryzm.at/efss/jaisiu/install.sh \
    | JAISIU_LICENSE_KEY="eyJ..." \
      JAISIU_EMAIL="user@example.com" \
      bash

  # Air-gapped: install from local tarball
  bash install.sh -b ./jaisiu-gateway-linux-x64.tar.gz --no-service

  # User-local (no sudo):
  bash install.sh --user -v latest
USAGE
}

# ---- CLI flag parsing -------------------------------------------------------
JAISIU_VERSION_RAW=""
JAISIU_BINARY=""
JAISIU_NO_MODIFY_PATH=0
# Default to user-scope install (writes to ~/.local/bin + user LaunchAgent /
# systemd --user) when running as an unprivileged user with no explicit
# --system flag. Bug history (operator trace 2026-08-24): the previous
# default of 0 silently failed on stock macOS hosts because /usr/local/bin
# requires sudo, and the failure mode was "everything downloaded + extracted
# but nothing installed" — no error to the operator. Operators who genuinely
# want a system-wide install opt in with --system.
if [ "$(id -u)" != "0" ] && [ "${JAISIU_USER_INSTALL_OVERRIDE:-}" != "1" ]; then
  JAISIU_USER_INSTALL=1
else
  JAISIU_USER_INSTALL=0
fi
JAISIU_NO_SERVICE=0
JAISIU_DEBUG="${JAISIU_DEBUG:-0}"

# License key: env var first. Canonical = JAISIU_LICENSE_KEY (per
# src/daemon/service-env.ts). Aliases: JAISIU_LICENSE (legacy v1), PRYZM_NODE_KEY.
# Broker key: JAISIU_BROKER_KEY (Go backend /api/install/confirm contract) /
# PRYZM_BROKER_KEY (legacy v1). Email: JAISIU_EMAIL (auto-claim flow).
# Flag override: --license-key <key>.
JAISIU_LICENSE_KEY_VALUE="${JAISIU_LICENSE_KEY:-${JAISIU_LICENSE:-${PRYZM_NODE_KEY:-}}}"
JAISIU_BROKER_KEY_VALUE="${JAISIU_BROKER_KEY:-${PRYZM_BROKER_KEY:-}}"
JAISIU_EMAIL_VALUE="${JAISIU_EMAIL:-}"
JAISIU_NO_EMAIL_FLAG=0
JAISIU_NO_LICENSE_FLAG=0
JAISIU_TOKEN_VALUE="${JAISIU_PORTAL_TOKEN:-${PRYZM_INSTALL_AGENT_TOKEN:-}}"
JAISIU_EXTRA_ENV_FILES=""
# JAISIU-2887: OTP exchange flow additions. `--otp-code <NNNNNN>` lets
# non-interactive operators pipe the 6-digit confirmation code that
# `/api/install/request` emails them into the signup handshake without a
# blocking TTY read. `--skip-otp` is the escape hatch for operators who
# already configured `JAISIU_PORTAL_TOKEN` (or `--token`) via /etc/jaisiu.env
# (per JAISIU-2889) and want the email+OTP signup exchange to be skipped
# entirely so the auto-claim path runs alone. Both flags are inert when
# unset: existing behavior is preserved.
JAISIU_INSTALL_CODE_VALUE="${JAISIU_INSTALL_CODE:-}"
JAISIU_SKIP_OTP_FLAG="${JAISIU_SKIP_OTP:-0}"

# ---- env-file sourcing (Bug C / JAISIU-2889) --------------------------------
# Read /etc/jaisiu.env, ~/.config/jaisiu/env, ~/.jaisiu.env in order.
# Vars already set in the caller env win (so `JAISIU_FOO=1 curl | bash`
# always beats the operator's /etc/jaisiu.env). Idempotent. Files that
# are not readable are silently skipped; the legacy ~/.jaisiu.env path
# is honored with a deprecation WARN so operators can migrate.
#
# Custom paths can be appended via `--env-file <path>` (parsed below).
# source_jaisiu_env() — read /etc/jaisiu.env, ~/.config/jaisiu/env,
# ~/.jaisiu.env in priority order. Vars already set in the caller env
# win (so `JAISIU_FOO=1 curl | bash` always beats the operator's
# /etc/jaisiu.env). Idempotent. Files that are not readable are
# silently skipped; the legacy ~/.jaisiu.env path is honored with a
# deprecation WARN so operators can migrate.
#
# Custom paths can be appended via `--env-file <path>` (parsed later).
#
# Implementation note: we intentionally do NOT use a `local` declaration
# inside this block — `local` would scope the resulting _VALUE assignments
# to a subshell and silently fail to populate the caller's variables.
# We just run the body at top level and let bash global-assign naturally.
_source_jaisiu_env() {
    _jse_warn_legacy=0
    _jse_f=
    for _jse_f in /etc/jaisiu.env "${HOME}/.config/jaisiu/env" "${HOME}/.jaisiu.env" "${JAISIU_INSTALL_DIR:-}/install/launchd/jaisiu.env"; do
        [ -r "${_jse_f}" ] || continue
        case "${_jse_f}" in
            "${HOME}/.jaisiu.env") _jse_warn_legacy=1 ;;
        esac
        # shellcheck disable=SC1090
        set -a
        . "${_jse_f}" 2>/dev/null || true
        set +a
        print_message INFO "sourced env file: ${_jse_f}"
    done
    # Promote canonical license/broker vars into the installer's _VALUE
    # mirrors (set by flag parsing below). Caller env still wins because
    # we only fill the _VALUE slots if they are currently empty.
    if [ -z "${JAISIU_LICENSE_KEY_VALUE:-}" ]; then
        JAISIU_LICENSE_KEY_VALUE="${JAISIU_LICENSE_KEY:-${JAISIU_LICENSE:-${PRYZM_NODE_KEY:-}}}"
    fi
    if [ -z "${JAISIU_BROKER_KEY_VALUE:-}" ]; then
        JAISIU_BROKER_KEY_VALUE="${JAISIU_BROKER_KEY:-${PRYZM_BROKER_KEY:-}}"
    fi
    if [ -z "${JAISIU_TOKEN_VALUE:-}" ]; then
        JAISIU_TOKEN_VALUE="${JAISIU_PORTAL_TOKEN:-${PRYZM_INSTALL_AGENT_TOKEN:-}}"
    fi
    if [ "${_jse_warn_legacy}" = "1" ]; then
        print_message WARN "~/.jaisiu.env is deprecated; move to /etc/jaisiu.env (system-wide) or ~/.config/jaisiu/env (user)"
    fi
    unset _jse_warn_legacy _jse_f
}
# Run before flag parsing so flag values still override env-file values.
_source_jaisiu_env

# Broker-guided install plan flags (JAISIU-2016 / T2). Env-var aliases are
# canonical — the flag is just a convenience for `curl|bash` invocations.
# All three are inert when unset: the existing linear flow (download →
# extract → license → service) runs unchanged. When ANY of the four
# triggers fire (--agent-session / JAISIU_AGENT_SESSION=1,
# PRYZM_INSTALL_AGENT_TOKEN, JAISIU_INSTALL_INTENT, or JAISIU_EMAIL +
# JAISIU_INSTALL_CODE), the decision tree below opens a broker install
# session and runs the plan-driven flow via install/lib/plan.sh's
# `run_install_plan_loop`. Env wins if both env and flag are set, so
# portal-launched agents can override flag values at run time.
JAISIU_AGENT_SESSION_VALUE="${JAISIU_AGENT_SESSION:-0}"
JAISIU_INSTALL_PLAN_VALUE="${JAISIU_INSTALL_PLAN:-}"
JAISIU_AGENT_REQUIRED="${JAISIU_AGENT_REQUIRED:-0}"

# Match standard behaviour: allow long --flag=value and --flag value.
while [ $# -gt 0 ]; do
  case "$1" in
    -v|--version)        [ $# -ge 2 ] || FATAL_NO_ARGS=1; JAISIU_VERSION_RAW="${2:-}"; shift 2 ;;
    -v=*|--version=*)    JAISIU_VERSION_RAW="${1#*=}"; shift ;;
    -b|--binary)         [ $# -ge 2 ] || FATAL_NO_ARGS=1; JAISIU_BINARY="${2:-}"; shift 2 ;;
    -b=*|--binary=*)     JAISIU_BINARY="${1#*=}"; shift ;;
    -k|--license-key)    [ $# -ge 2 ] || FATAL_NO_ARGS=1; JAISIU_LICENSE_KEY_VALUE="${2:-}"; shift 2 ;;
    -k=*|--license-key=*) JAISIU_LICENSE_KEY_VALUE="${1#*=}"; shift ;;
    -e|--email)          [ $# -ge 2 ] || FATAL_NO_ARGS=1; JAISIU_EMAIL_VALUE="${2:-}"; JAISIU_EMAIL="${JAISIU_EMAIL_VALUE}"; export JAISIU_EMAIL; shift 2 ;;
    -e=*|--email=*)      JAISIU_EMAIL_VALUE="${1#*=}"; JAISIU_EMAIL="${JAISIU_EMAIL_VALUE}"; export JAISIU_EMAIL; shift ;;
    --no-email)          JAISIU_NO_EMAIL_FLAG=1; shift ;;
    # Portal token / interactive signup suppression (JAISIU-2887 / JAISIU-2889).
    # --token <pzk>      sets JAISIU_PORTAL_TOKEN to a pre-issued portal token
    #                    so we skip the email+OTP exchange entirely. Mirrors
    #                    install-agent.sh --token for the customer path.
    # --no-license       skips the email signup + trial-mode license prompt
    #                    entirely (offline / kiosk installs).
    # --env-file <path>  adds an extra env file to the source list.
    --token)             [ $# -ge 2 ] || FATAL_NO_ARGS=1; JAISIU_TOKEN_VALUE="${2:-}"; JAISIU_PORTAL_TOKEN="${JAISIU_TOKEN_VALUE}"; export JAISIU_PORTAL_TOKEN; shift 2 ;;
    --token=*)           JAISIU_TOKEN_VALUE="${1#*=}"; JAISIU_PORTAL_TOKEN="${JAISIU_TOKEN_VALUE}"; export JAISIU_PORTAL_TOKEN; shift ;;
    --no-license)        JAISIU_NO_LICENSE_FLAG=1; shift ;;
    # JAISIU-2887: OTP exchange flow (request → confirm). Non-interactive
    # operators pipe the 6-digit code from email into `--otp-code` so the
    # installer doesn't block on /dev/tty. The lib helper
    # install/lib/license.sh:_license_path_b_signup honors the
    # JAISIU_INSTALL_CODE env var (set below) before falling back to
    # `_read_tty` — see the comment at license.sh:_license_path_b_signup
    # line ~219. Mirrors install-agent.sh's require_arg guard so a bare
    # `--otp-code` (no value) fails loud instead of silently no-op'ing.
    --otp-code)          [ $# -ge 2 ] || FATAL_NO_ARGS=1; JAISIU_INSTALL_CODE_VALUE="${2:-}"; shift 2 ;;
    --otp-code=*)        JAISIU_INSTALL_CODE_VALUE="${1#*=}"; shift ;;
    # JAISIU-2887 / JAISIU-2889: skip the email+OTP signup exchange entirely
    # when the operator already wired JAISIU_PORTAL_TOKEN via /etc/jaisiu.env
    # (or via --token on the command line). With this flag the path-B
    # signup branch short-circuits and the script falls through directly
    # to the auto-claim block at line ~1583.
    --skip-otp)          JAISIU_SKIP_OTP_FLAG=1; shift ;;
    --env-file)          [ $# -ge 2 ] || FATAL_NO_ARGS=1; JAISIU_EXTRA_ENV_FILES="${JAISIU_EXTRA_ENV_FILES}:${2:-}"; shift 2 ;;
    --env-file=*)        JAISIU_EXTRA_ENV_FILES="${JAISIU_EXTRA_ENV_FILES}:${1#*=}"; shift ;;
    # Broker-guided install flags (JAISIU-2016 / T2). Long-only to avoid
    # collision with the existing -e/--email short flag. `--agent-session`
    # is a boolean; `--agent-email`/`--agent-token` set the broker plan
    # session credentials. Env-var aliases (JAISIU_AGENT_SESSION,
    # JAISIU_EMAIL, PRYZM_INSTALL_AGENT_TOKEN) win if both are set.
    --agent-session)     JAISIU_AGENT_SESSION_VALUE=1; shift ;;
    --agent-email)       [ $# -ge 2 ] || FATAL_NO_ARGS=1; JAISIU_EMAIL_VALUE="${2:-}"; JAISIU_EMAIL="${JAISIU_EMAIL_VALUE}"; export JAISIU_EMAIL; shift 2 ;;
    --agent-email=*)     JAISIU_EMAIL_VALUE="${1#*=}"; JAISIU_EMAIL="${JAISIU_EMAIL_VALUE}"; export JAISIU_EMAIL; shift ;;
    --agent-token)       [ $# -ge 2 ] || FATAL_NO_ARGS=1; PRYZM_INSTALL_AGENT_TOKEN="${2:-}"; export PRYZM_INSTALL_AGENT_TOKEN; shift 2 ;;
    --agent-token=*)     PRYZM_INSTALL_AGENT_TOKEN="${1#*=}"; export PRYZM_INSTALL_AGENT_TOKEN; shift ;;
    # Fail-fast when plan-mode is required but the broker doesn't expose
    # /api/install/session (older broker). Without this, the JAISIU-2016
    # fallback contract silently downgrades a strict agent-mode install
    # to the local resolver. Audit Gap 8 (2026-08-26). Env-var alias:
    # JAISIU_AGENT_REQUIRED=1.
    --require-agent|--agent-required) JAISIU_AGENT_REQUIRED=1; shift ;;
    --no-modify-path)    JAISIU_NO_MODIFY_PATH=1; shift ;;
    --user)              JAISIU_USER_INSTALL=1; shift ;;
    --system)            JAISIU_USER_INSTALL=0; shift ;;
    --no-service)        JAISIU_NO_SERVICE=1; shift ;;
    --debug)             JAISIU_DEBUG=1; shift ;;
    --debug-state)       JAISIU_DEBUG_STATE=1; shift ;;
    --help|-h)           usage; exit 0 ;;
    --)                  shift ;;  # ignore bare `--` (used as bash -s separator)
    -*)                  print_message ERR "Unknown option: $1"; usage; exit 64 ;;
    *)                   print_message ERR "Unexpected positional arg: $1"; usage; exit 64 ;;
  esac
done

[ "${FATAL_NO_ARGS:-0}" = "1" ] && { print_message ERR "missing argument"; usage; exit 64; }

[ "$JAISIU_DEBUG" = "1" ] && set -x

# ---- logging ---------------------------------------------------------------

# ---- pre-flight: required tools --------------------------------------------
require_cmd() {
  if ! command -v "$1" >/dev/null 2>&1; then
    print_message FATAL "missing required command: $1"
  fi
}
require_cmd bash
require_cmd curl
require_cmd tar

# unzip only when we'll need it (Windows path uses .zip).
case "$(uname -s 2>/dev/null || echo unknown)" in
  MINGW*|MSYS*|CYGWIN*) require_cmd unzip ;;
esac

print_message OK "pre-flight: bash/curl/tar present"

# ---- OS + arch detection ---------------------------------------------------
detect_os() {
  local raw
  raw="$(uname -s 2>/dev/null || echo unknown)"
  case "$raw" in
    Linux*)                  printf '%s' "linux" ;;
    Darwin*)                 printf '%s' "darwin" ;;
    MINGW*|MSYS*|CYGWIN*)    printf '%s' "windows" ;;
    *)                       printf '%s' "unknown" ;;
  esac
}
detect_arch() {
  local raw
  raw="$(uname -m 2>/dev/null || echo unknown)"
  case "$raw" in
    aarch64|arm64)   printf '%s' "arm64" ;;
    x86_64|x64|amd64) printf '%s' "x64" ;;
    *)               printf '%s' "$raw" ;;
  esac
}
OS_NAME="$(detect_os)"
ARCH_RAW="$(detect_arch)"

if [ "$OS_NAME" = "unknown" ]; then
  print_message FATAL "unsupported OS: $(uname -s)"
fi
if [ "$ARCH_RAW" != "arm64" ] && [ "$ARCH_RAW" != "x64" ]; then
  print_message FATAL "unsupported arch: $ARCH_RAW"
fi
print_message OK "detected OS=$OS_NAME arch=$ARCH_RAW"

# ---- Rosetta probe (darwin x64 → arm64 prefer) -----------------------------
needs_rosetta=0
is_native_arch=1
if [ "$OS_NAME" = "darwin" ] && [ "$ARCH_RAW" = "x64" ]; then
  if sysctl -n sysctl.proc_translated 2>/dev/null | grep -q '^1$'; then
    print_message INFO "running x64 under Rosetta on Apple Silicon; will install arm64"
    ARCH_RAW="arm64"
    is_native_arch=0
  fi
fi

# ---- AVX2 baseline probe (linux x64) ---------------------------------------
needs_baseline=0
if [ "$OS_NAME" = "linux" ] && [ "$ARCH_RAW" = "x64" ]; then
  if [ -r /proc/cpuinfo ]; then
    if ! grep -q '^flags' /proc/cpuinfo | head -1 | grep -q avx2; then
      # Cheap secondary check:
      if ! grep -qm1 ' avx2 ' /proc/cpuinfo; then
        needs_baseline=1
      fi
    fi
  fi
fi

# ---- musl probe (linux only) -----------------------------------------------
needs_musl=0
if [ "$OS_NAME" = "linux" ]; then
  if [ -f /etc/alpine-release ] || (ldd --version 2>&1 | head -1 | grep -qi musl); then
    needs_musl=1
  fi
fi

# ---- email prompt (required identity for broker auto-claim) ----------------
# Validates a user-supplied email address against a conservative RFC 5322-lite
# regex. Used by both this bash installer (install.sh) and the Windows native
# installer (install/install-native.ps1). Same shape on all platforms so the
# UX matches. Re-prompts until a valid value is supplied (or the caller opts
# out via --no-email / JAISIU_NO_EMAIL=1 / JAISIU_NONINTERACTIVE=1).
#
# The email is the identity used to:
#   1. Provision a new free account on the pryzm.at API broker (auto-claim
#      /api/install/request + /api/install/confirm), or
#   2. Configure an existing broker account (just persisted into
#      ~/.jaisiu/state/jaisiu.json as identity.email so the daemon can
#      attribute usage / query / cancel-subscription endpoints).
#
# In trial mode (no JAISIU_LICENSE_KEY supplied) the email is still
# prompted + persisted — operators need identity attribution even without
# a paid license (see EFSS portal /api/install/claim contract).
#
# Output: sets JAISIU_EMAIL_VALUE to the validated email (always lowercase,
# trimmed). Empty when the operator skipped (--no-email, no TTY, etc.).

# _validate_email <string> — returns 0 if string looks like an email address.
# Same regex as install-native.ps1 so both platforms accept the same input.
# An empty string is rejected (caller must explicitly opt out).
_validate_email() {
    local s="${1:-}"
    [ -n "$s" ] || return 1
    # Conservative RFC 5322-lite: local@domain.tld where local allows
    # [A-Za-z0-9._%+-], domain allows [A-Za-z0-9.-], TLD >= 2 chars.
    # We intentionally do NOT try to fully parse RFC 5322 — the goal is to
    # reject obvious garbage (spaces, missing @, missing TLD, etc.) and
    # let the broker do its own validation when we hit /install/request.
    case "$s" in
        *@*) ;;
        *) return 1 ;;
    esac
    case "$s" in
        *' '*|*"	"*|*$'\n'*|*$'\r'*) return 1 ;;
    esac
    # POSIX character classes (no \w / \d in POSIX ERE without -E).
    echo "$s" | grep -Eq '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'
}

# Read a single line from the controlling TTY (interactive) or stdin (pipe).
# Used by both email + license prompts. Mirrors _read_tty in
# install/lib/license.sh but lives here so the prompt is always available,
# even on the bash-only path that doesn't source the lib (and so it doesn't
# depend on lib/log.sh being sourced first).
_read_tty_line() {
    local prompt="$1" var="$2"
    # Prefer /dev/tty (the controlling terminal) whenever it's readable.
    # This handles `curl ... | bash` on a real Terminal.app session where
    # stdin is a pipe (not a TTY) but /dev/tty is still the operator's
    # keyboard — without this the prompt silently no-ops because `read`
    # reads the closed pipe and returns empty. Tenet: every install must
    # prompt for email, even from curl|bash on a TTY.
    if [ -r /dev/tty ]; then
        printf '%s' "$prompt" >&2
        IFS= read -r "$var" < /dev/tty || true
    else
        # Genuinely non-interactive (CI, no controlling TTY). Print the
        # prompt to stderr so it surfaces in logs but don't block on stdin.
        printf '%s' "$prompt" >&2
        IFS= read -r "$var" || true
    fi
}

prompt_for_email() {
    # If already provided via --email / -e / JAISIU_EMAIL, validate + accept.
    if [ -n "${JAISIU_EMAIL_VALUE:-}" ]; then
        if _validate_email "${JAISIU_EMAIL_VALUE}"; then
            # Normalize: lowercase + strip whitespace.
            JAISIU_EMAIL_VALUE="$(printf '%s' "${JAISIU_EMAIL_VALUE}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
            print_message OK "using email: ${JAISIU_EMAIL_VALUE}"
            return 0
        fi
        print_message ERR "invalid email supplied: '${JAISIU_EMAIL_VALUE}'"
        return 1
    fi

    # Explicit opt-out: --no-email or JAISIU_NO_EMAIL=1.
    if [ "${JAISIU_NO_EMAIL_FLAG:-0}" = "1" ] || [ "${JAISIU_NO_EMAIL:-0}" = "1" ]; then
        print_message INFO "email prompt skipped (--no-email / JAISIU_NO_EMAIL=1)"
        JAISIU_EMAIL_VALUE=""
        return 0
    fi

    # Non-interactive (no TTY and JAISIU_NONINTERACTIVE=1): keep silent.
    # Trial mode allows missing email; license-bound installs get a WARN.
    if [ "${JAISIU_NONINTERACTIVE:-0}" = "1" ] || { [ ! -t 0 ] && [ ! -r /dev/tty ]; }; then
        if [ -n "${JAISIU_LICENSE_KEY_VALUE:-}" ]; then
            print_message WARN "no TTY and no --email supplied; running without identity attribution"
        else
            print_message INFO "no TTY and no --email supplied; trial mode will continue without identity"
        fi
        JAISIU_EMAIL_VALUE=""
        return 0
    fi

    # License-key override: when the operator has supplied a node key
    # (PRYZM_NODE_KEY / JAISIU_LICENSE_KEY / --license-key) we already have
    # a fully wired installation — the broker auto-claim flow won't run, no
    # portal claim is needed, and the email is optional attribution only.
    # Suppress the interactive prompt so a `curl ... | JAISIU_LICENSE_KEY=...
    # bash` invocation doesn't block on Read-Host in the middle of the install.
    # Acceptance criterion: "Setting PRYZM_NODE_KEY env var suppresses the
    # prompt and uses the key for non-interactive installs." Operators who
    # still want identity attribution in license mode can set JAISIU_EMAIL
    # explicitly (handled by the pre-fill branch above).
    if [ -n "${JAISIU_LICENSE_KEY_VALUE:-}" ]; then
        print_message INFO "email prompt skipped (license key supplied via env / flag; using key for non-interactive install)"
        JAISIU_EMAIL_VALUE=""
        return 0
    fi

    # Interactive prompt. Email is required (OTP is sent to this address).
    # Empty is not a skip — Ctrl-C aborts.
    local attempt=0
    while : ; do
        _read_tty_line "Email for pryzm.at license (OTP sent to inbox): " input
        attempt=$((attempt + 1))
        if [ -z "${input:-}" ]; then
            print_message INFO "Email is required. Enter your address, or Ctrl-C to abort."
            continue
        fi
        if _validate_email "${input}"; then
            JAISIU_EMAIL_VALUE="$(printf '%s' "${input}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
            JAISIU_EMAIL="${JAISIU_EMAIL_VALUE}"
            export JAISIU_EMAIL
            print_message OK "email: ${JAISIU_EMAIL_VALUE}"
            return 0
        fi
        print_message WARN "invalid email '${input}' — expected user@domain.tld"
        [ "$attempt" -ge 3 ] && {
            print_message ERR "too many invalid attempts; aborting"
            return 1
        }
    done
}
prompt_for_email

# ---- Path B (email → OTP → license) before download ------------------------
# Customer report 2026-09-02 (Dawid / Mac): download proceeded with no OTP
# prompt because Path B ran only after the tarball step and was skipped when
# stock macOS had no jq on PATH. Run signup here (after email, before any
# artifact fetch) so operators see the OTP prompt before bandwidth is spent.
_JAISIU_PATH_B_ACQUIRED=0

_jaisiu_ensure_install_libs() {
    type _license_path_b_signup >/dev/null 2>&1 && return 0
    [ "${_LIBS_INLINED:-0}" = "1" ] && return 0
    local _script_dir _lib_dir
    _script_dir="${BASH_SOURCE[0]:-$0}"
    _lib_dir="$(cd "$(dirname "${_script_dir}")" 2>/dev/null && pwd)/lib"
    [ -d "${_lib_dir}" ] || _lib_dir="/usr/local/lib/jaisiu/install/lib"
    [ -d "${_lib_dir}" ] || return 1
    _LIB_DIR="${_lib_dir}"
    export _LIB_DIR
    # shellcheck source=lib/log.sh
    source "${_lib_dir}/log.sh"
    # shellcheck source=lib/entrypoint.sh
    source "${_lib_dir}/entrypoint.sh"
    # shellcheck source=lib/license.sh
    source "${_lib_dir}/license.sh"
    # shellcheck source=lib/state.sh
    source "${_lib_dir}/state.sh"
    return 0
}

_jaisiu_try_path_b_signup() {
    [ "${_JAISIU_PATH_B_ACQUIRED:-0}" = "1" ] && return 0
    [ -n "${JAISIU_LICENSE_KEY_VALUE:-}" ] && return 0
    if [ "${JAISIU_NO_LICENSE_FLAG:-0}" = "1" ] \
       || [ "${JAISIU_NO_LICENSE:-0}" = "1" ] \
       || [ -z "${JAISIU_EMAIL_VALUE:-}" ] \
       || [ "${JAISIU_SKIP_PORTAL_CLAIM:-0}" = "1" ] \
       || [ "${JAISIU_SKIP_PORTAL_CLAIM_EFFECTIVE:-0}" = "1" ] \
       || [ "${JAISIU_SKIP_OTP_FLAG:-0}" = "1" ]; then
        return 0
    fi
    _jaisiu_ensure_install_libs || true
    _jaisiu_ensure_install_libs || true
    if [ -n "${JAISIU_INSTALL_CODE_VALUE:-}" ]; then
        export JAISIU_INSTALL_CODE="${JAISIU_INSTALL_CODE_VALUE}"
    fi
    local _path_b_ok=0
    type _license_path_b_signup >/dev/null 2>&1 || _path_b_ok=1
    type _read_tty >/dev/null 2>&1 || _path_b_ok=1
    if [ "${_path_b_ok}" != "0" ]; then
        print_message FATAL "License signup helpers unavailable (bundled install.sh corrupt?). Re-download from https://pryzm.at/efss/jaisiu/install.sh"
    fi
    local _path_b_jq_bin=""
    _path_b_jq_bin="$(jaisiu_jq 2>/dev/null || true)"
    print_message INFO "License signup: POST /api/install/request (email=${JAISIU_EMAIL_VALUE})"
    if _license_path_b_signup "${JAISIU_EMAIL_VALUE}" "https://pryzm.at" "${_path_b_jq_bin}"; then
        if [ -n "${KEY:-}" ]; then
            JAISIU_LICENSE_KEY_VALUE="${KEY}"
            [ -n "${BROKER_KEY:-}" ] && JAISIU_BROKER_KEY_VALUE="${BROKER_KEY}"
            [ -n "${ENDPOINT:-}" ] && JAISIU_LICENSE_ENDPOINT_VALUE="${ENDPOINT}"
            [ -n "${BROKER_BASE:-}" ] && JAISIU_BROKER_BASE_URL_VALUE="${BROKER_BASE}"
            # Unified JWT: pooled installs return the same credential as key + brokerKey.
            if [ -z "${JAISIU_BROKER_KEY_VALUE:-}" ] && _license_is_compact_jwt "${JAISIU_LICENSE_KEY_VALUE}" 2>/dev/null; then
                JAISIU_BROKER_KEY_VALUE="${JAISIU_LICENSE_KEY_VALUE}"
            fi
            print_message OK "license + broker keys acquired via Path B signup"
            _JAISIU_PATH_B_ACQUIRED=1
            return 0
        fi
    fi
    print_message FATAL "License signup failed for ${JAISIU_EMAIL_VALUE}. Check your inbox for the confirmation code and re-run. Pre-supply: JAISIU_INSTALL_CODE=<code> curl -fsSL https://pryzm.at/efss/jaisiu/install.sh | bash"
}

# Refuse to download artifacts until license is minted (or operator opted out).
# Customer report 2026-09-02: tarball shipped without OTP/license → broken harness.
_jaisiu_require_license_before_download() {
    [ -n "${JAISIU_LICENSE_KEY_VALUE:-}" ] && return 0
    [ "${JAISIU_NO_LICENSE_FLAG:-0}" = "1" ] || [ "${JAISIU_NO_LICENSE:-0}" = "1" ] && {
        print_message INFO "JAISIU_NO_LICENSE=1 — continuing without license (local inspection only)"
        return 0
    }
    [ -z "${JAISIU_EMAIL_VALUE:-}" ] && {
        print_message WARN "no email supplied — continuing without license (set JAISIU_EMAIL or pass --email for a licensed install)"
        return 0
    }
    _jaisiu_ensure_install_libs || true
    _jaisiu_try_path_b_signup
}
_jaisiu_require_license_before_download

# ---- target naming (matches build artifact naming) -------------------------
TARGET_TRIPLE="${OS_NAME}-${ARCH_RAW}"
[ "$needs_baseline" = "1" ] && TARGET_TRIPLE="${TARGET_TRIPLE}-baseline"
[ "$needs_musl"     = "1" ] && TARGET_TRIPLE="${TARGET_TRIPLE}-musl"

# Supported matrix:
case "$TARGET_TRIPLE" in
  linux-x64|linux-x64-baseline|linux-x64-musl|linux-arm64|darwin-arm64|darwin-x64|windows-x64) ;;
  *) print_message FATAL "no artifact for $TARGET_TRIPLE" ;;
esac

# ---- install dir -----------------------------------------------------------
if [ "${JAISIU_BIN_DIR:-}" ]; then
  BIN_DIR="$JAISIU_BIN_DIR"
elif [ "$JAISIU_USER_INSTALL" = "1" ]; then
  BIN_DIR="${HOME}/.local/bin"
else
  BIN_DIR="/usr/local/bin"
fi

mkdir -p "$BIN_DIR" 2>/dev/null || {
  if [ "$JAISIU_USER_INSTALL" = "0" ] && command -v sudo >/dev/null 2>&1; then
    sudo mkdir -p "$BIN_DIR" || print_message FATAL "cannot create $BIN_DIR"
  else
    print_message FATAL "cannot create $BIN_DIR (try --user)"
  fi
}

# ---- broker-guided install plan (JAISIU-2016 / T2) --------------------------
# Opt-in switch from the linear latest.json resolver → broker-driven plan
# loop. Triggers: --agent-session, JAISIU_AGENT_SESSION=1,
# PRYZM_INSTALL_AGENT_TOKEN, JAISIU_INSTALL_INTENT,
# JAISIU_EMAIL + JAISIU_INSTALL_CODE, or JAISIU_INSTALL_PLAN={plan|1|...}.
# On 404 from /api/install/session (older broker), WARN + fall back to
# the linear path so the install continues and succeeds.
_jaisiu_plan_triggered() {
    [ "${JAISIU_AGENT_SESSION_VALUE:-0}" = "1" ] && return 0
    [ -n "${PRYZM_INSTALL_AGENT_TOKEN:-}" ] && return 0
    [ -n "${JAISIU_INSTALL_INTENT:-}" ] && return 0
    [ -n "${JAISIU_EMAIL:-}${JAISIU_INSTALL_CODE:-}" ] && [ -n "${JAISIU_EMAIL:-}" ] && [ -n "${JAISIU_INSTALL_CODE:-}" ] && return 0
    case "${JAISIU_INSTALL_PLAN_VALUE:-}" in
        1|true|yes|on|plan) return 0 ;;
    esac
    return 1
}

# Safe wrapper around plan.sh's _plan_step_await_credentials that only
# updates JAISIU_* variables when the response actually carries the
# field. The raw helper unconditionally runs `_plan_field` which prints
# empty strings for missing fields and clobbers any previously-set value
# — that breaks the older broker contract where credentials arrive in
# the dedicated `await_credentials` step and subsequent steps'
# responses intentionally omit keyPrefix.
_jaisiu_safe_await_credentials() {
    local resp_file="$1"
    [ -s "${resp_file}" ] || return 1
    local v
    v="$(_plan_field "${resp_file}" '.key')"
    [ -n "${v}" ] && JAISIU_LICENSE_KEY_VALUE="${v}"
    v="$(_plan_field "${resp_file}" '.brokerKey')"
    [ -n "${v}" ] && JAISIU_BROKER_KEY_VALUE="${v}"
    v="$(_plan_field "${resp_file}" '.keyPrefix')"
    [ -n "${v}" ] && JAISIU_KEY_PREFIX="${v}"
    v="$(_plan_field "${resp_file}" '.brokerKeyPrefix')"
    [ -n "${v}" ] && JAISIU_BROKER_KEY_PREFIX="${v}"
    v="$(_plan_field "${resp_file}" '.configCommand')"
    [ -n "${v}" ] && JAISIU_CONFIG_COMMAND="${v}"
    v="$(_plan_field "${resp_file}" '.licenseEndpoint')"
    [ -n "${v}" ] && JAISIU_LICENSE_ENDPOINT="${v}"
    v="$(_plan_field "${resp_file}" '.brokerBaseUrl')"
    [ -n "${v}" ] && JAISIU_BROKER_BASE="${v}"
    if [ -n "${JAISIU_LICENSE_KEY_VALUE:-}" ]; then
        local jq_bin; jq_bin="$(jaisiu_jq 2>/dev/null || true)"
        if [ -n "${jq_bin}" ]; then
            _license_write "${jq_bin}" "${JAISIU_LICENSE_KEY_VALUE}" "${JAISIU_BROKER_KEY_VALUE:-}" "${JAISIU_LICENSE_ENDPOINT:-}" "${JAISIU_BROKER_BASE:-}" || true
        fi
    fi
    return 0
}

if _jaisiu_plan_triggered; then
    # Map canonical install.sh facts → plan.sh fact names. The canonical
    # installer uses OS_NAME/ARCH_RAW; collect_install_facts_json() in
    # plan.sh reads JAISIU_OS/JAISIU_ARCH/JAISIU_HAS_SYSTEMD/etc.
    JAISIU_OS="${OS_NAME}"
    case "${ARCH_RAW}" in
        x64)    JAISIU_ARCH="x86_64" ;;
        arm64)  JAISIU_ARCH="arm64" ;;
        *)      JAISIU_ARCH="${ARCH_RAW}" ;;
    esac
    [ "${needs_systemd:-0}" = "1" ] && JAISIU_HAS_SYSTEMD=1 || JAISIU_HAS_SYSTEMD=0
    [ "${OS_NAME}" = "darwin" ] && JAISIU_HAS_LAUNCHD=1 || JAISIU_HAS_LAUNCHD=0
    JAISIU_TARGET="${OS_NAME}-${JAISIU_ARCH}"
    # JAISIU-2008 follow-up: the plan-flow's fetch_artifact step (in
    # plan.sh, lib/plan.sh line ~412) references JAISIU_RELEASES with
    # `${JAISIU_RELEASES%/}/latest.json`. The original "lazy" assignment
    # at line ~741 runs AFTER the plan-flow step, so under set -u the
    # plan-flow crashes with 'JAISIU_RELEASES: unset variable'. Set it
    # here at the same point as JAISIU_TARGET so the plan-flow can
    # resolve the manifest URL. (The : - default in plan.sh is the
    # belt-and-suspenders second line of defence.)
    JAISIU_RELEASES="${JAISIU_RELEASES:-https://pryzm.at/efss/jaisiu}"
    # Resolve the lib/ directory relative to this script. Works whether
    # install.sh was sourced, piped from curl, or invoked directly. The
    # fallback (`/usr/local/lib/jaisiu/install/lib`) covers distro package
    # installs that drop the script in /usr/local/bin alongside libs.
    _JAISIU_SCRIPT_DIR="${BASH_SOURCE[0]:-$0}"
    _JAISIU_LIB_DIR="$(cd "$(dirname "${_JAISIU_SCRIPT_DIR}")" 2>/dev/null && pwd)/lib"
    [ -d "${_JAISIU_LIB_DIR}" ] || _JAISIU_LIB_DIR="/usr/local/lib/jaisiu/install/lib"
    # JAISIU-2794 (2026-08-26): the EFSS-portal bundled install.sh
    # (https://pryzm.at/efss/jaisiu/install.sh) is fetched via curl|bash
    # where BASH_SOURCE[0] is unset, $0 is "bash", and there is no on-disk
    # lib/ sibling (lib/log.sh returns 404 on the CDN). The bundler emits
    # the libs as inlined executable code and sets _LIBS_INLINED=1 + a
    # sentinel _LIB_DIR. Short-circuit here so we don't fire the
    # "plan-mode requested but lib/ not found" warning and drop into a
    # non-plan linear flow. See install/ci/bundle-efss-install.sh for
    # the bundler side.
    if [ "${_LIBS_INLINED:-0}" = "1" ]; then
        print_message OK "plan-mode libs already inlined (JAISIU-2794)"
    elif [ -d "${_JAISIU_LIB_DIR}" ]; then
        # Source in dep order: log → preflight/entrypoint/migrate/license
        # → state → systemd → plan. plan.sh itself sources no further
        # libs, but its dispatchers (`preflight`, `install_service`,
        # `_plan_step_*`) call into the others above.
        # systemd.sh requires _LIB_DIR to source its sibling userdirs.sh.
        _LIB_DIR="${_JAISIU_LIB_DIR}"
        export _LIB_DIR
        # shellcheck source=lib/log.sh
        source "${_JAISIU_LIB_DIR}/log.sh"
        # shellcheck source=lib/preflight.sh
        source "${_JAISIU_LIB_DIR}/preflight.sh"
        # shellcheck source=lib/entrypoint.sh
        source "${_JAISIU_LIB_DIR}/entrypoint.sh"
        # shellcheck source=lib/migrate.sh
        source "${_JAISIU_LIB_DIR}/migrate.sh"
        # shellcheck source=lib/license.sh
        source "${_JAISIU_LIB_DIR}/license.sh"
        # shellcheck source=lib/state.sh
        source "${_JAISIU_LIB_DIR}/state.sh"
        # shellcheck source=lib/systemd.sh
        source "${_JAISIU_LIB_DIR}/systemd.sh"
        # shellcheck source=lib/plan.sh
        source "${_JAISIU_LIB_DIR}/plan.sh"

        # Honour an explicit `JAISIU_INSTALL_PLAN=plan|1` by skipping the
        # auto-decide. Falls through to the same dispatch below.
    fi

    # JAISIU-2008 follow-up: when libs are bundled inline (CDN curl|bash,
    # the JAISIU-2794 path above), the session-open block used to be
    # inside the `elif [ -d lib/ ]` branch, so the inlined flow skipped
    # `open_install_session` entirely and silently dropped into the
    # linear fallback. Move it out of the branch so it runs in BOTH
    # cases. The bundled libs already define `open_install_session` so
    # this is safe for the inlined path too.
    if open_install_session; then
        print_message OK "broker install session opened (session=${JAISIU_SESSION_ID:-?})"
        # JAISIU-LIC-UNIFY (2026-09-01): mint the unified license key now that
        # we have a session token. The T1 server contract (steps[] at top level)
        # does not include an await_credentials step, so credentials only arrive
        # via /api/install/claim (POSTed with the session token). Calling here
        # covers both the older and T1 contract shapes; in the older contract
        # it is a no-op (server returns the same key the plan would have minted)
        # and in T1 it is the only place the credentials land. The response is
        # fed into the same env vars _jaisiu_safe_await_credentials would set,
        # so downstream plan steps see JAISIU_BROKER_KEY / JAISIU_KEY_PREFIX /
        # JAISIU_CONFIG_COMMAND without needing to know the contract shape.
        if [ -n "${JAISIU_SESSION_TOKEN:-}" ] && _claim_resp="$(_plan_call_claim "${JAISIU_SESSION_TOKEN}")"; then
            if [ -n "${_claim_resp}" ] && [ -s "${_claim_resp}" ]; then
                _jaisiu_safe_await_credentials "${_claim_resp}"
                rm -f "${_claim_resp}" 2>/dev/null || true
            fi
        fi
        # Inline broker-guided dispatcher for the T1 server contract
        # (`steps[]` at top level). plan.sh's `run_install_plan_loop`
        # reads the older `.plan.steps[]` shape, so we don't reuse it
        # directly — we iterate the new contract ourselves using
        # plan.sh's helpers (allowlist + execute_kind + report_status).
        if [ -z "${JAISIU_PLAN_FILE:-}" ] || [ ! -s "${JAISIU_PLAN_FILE}" ]; then
            print_message WARN "open_install_session did not yield a plan file; falling back"
        else
            # The T1 contract may carry `steps[]` at top level OR a
            # wrapped `.plan.steps[]` (older brokers). Accept both.
            _steps_json="$(_plan_field "${JAISIU_PLAN_FILE}" '.steps')"
            if [ -z "${_steps_json}" ] || [ "${_steps_json}" = "null" ]; then
                _steps_json="$(_plan_field "${JAISIU_PLAN_FILE}" '.plan.steps')"
            fi
            _steps_file="$(mktemp)"
            if [ -z "${_steps_json}" ] || [ "${_steps_json}" = "null" ]; then
                print_message WARN "broker returned no steps[] in session response; falling back"
            else
                printf '%s' "${_steps_json}" > "${_steps_file}"
                _total="$(python3 -c "import json; print(len(json.load(open('${_steps_file}'))))")"
                _n=0
                _plan_ok=1
                while [ "${_n}" -lt "${_total}" ]; do
                    _sid="$(python3 -c "import json; print(json.load(open('${_steps_file}'))[${_n}]['id'])")"
                    _kind="$(python3 -c "import json; print(json.load(open('${_steps_file}'))[${_n}]['kind'])")"
                    _args_json="$(python3 -c "import json; print(json.dumps(json.load(open('${_steps_file}'))[${_n}].get('args') or {}))")"
                    _n=$((_n + 1))
                    print_message OK "plan step ${_n}/${_total}: ${_sid} (${_kind})"
                    _plan_kind_allowed "${_kind}" || { print_message WARN "refusing unknown plan step kind: ${_kind}"; _plan_ok=0; break; }
                    _stf="$(_plan_report_status "${_sid}" "started")"
                    # For await_credentials (older broker shape with
                    # a dedicated mint step) OR for apply_config_command
                    # (T1 server contract: the 7-step linux plan carries
                    # credentials in this step's status response), feed
                    # the response into await_credentials so
                    # JAISIU_KEY_PREFIX / JAISIU_BROKER_KEY_PREFIX /
                    # JAISIU_CONFIG_COMMAND get populated. Use a safe
                    # wrapper that only OVERWRITES each variable when
                    # the response actually carries the field — the
                    # raw plan.sh helper clobbers with empty strings
                    # when a step's response lacks the field (which
                    # would lose credentials minted by an earlier
                    # await_credentials step in the older contract).
                    case "${_kind}" in
                        await_credentials|apply_config_command)
                            _jaisiu_safe_await_credentials "${_stf}"
                            ;;
                    esac
                    if _plan_execute_kind "${_kind}" "${_args_json}" "${_stf}"; then
                        _okf="$(_plan_report_status "${_sid}" "ok")"
                        rm -f "${_stf}" "${_okf}"
                    else
                        print_message WARN "step ${_sid} (${_kind}) failed"
                        _failf="$(_plan_report_status "${_sid}" "failed" "step_failed" "local execution failed")"
                        rm -f "${_stf}" "${_failf}"
                        _plan_ok=0
                        break
                    fi
                done
                rm -f "${_steps_file}"
                if [ "${_plan_ok}" = "1" ]; then
                    # Emit the result block the test harness + portal expect.
                    cat <<EOF
PRYZM_INSTALL_RESULT
  install="ok"
  session_id="${JAISIU_SESSION_ID:-}"
  pzk_prefix="${JAISIU_KEY_PREFIX:-}"
  pryzm_cu_prefix="${JAISIU_BROKER_KEY_PREFIX:-}"
  service="$([ "${JAISIU_HAS_SYSTEMD}" = "1" ] && echo systemd || ([ "${JAISIU_HAS_LAUNCHD}" = "1" ] && echo launchd || echo none))"
PRYZM_INSTALL_RESULT_END
EOF
                    # Plan-mode owns its own complete install lifecycle
                    # (extract → link → apply_config → install_service
                    # → probe). The linear flow's deep-smoke gate is a
                    # no-op in plan mode (no BIOS-level service boot to
                    # verify from inside a unit test fixture, and the
                    # broker has already accepted the install via
                    # /api/install/status).
                    print_message OK "broker-guided install complete; skipping linear flow"
                    JAISIU_INSTALL_PLAN_VALUE="plan"
                    # Audit Gap 9 (2026-08-26): the post-install
                    # banner must point operators at the TUI and
                    # the web UI URLs. The old banner named only
                    # the log file; customers had no way to find
                    # the gateway surface without grepping the
                    # state file.
                    cat <<'PLAN_BANNER'

  ┌──────────────────────────────────────────────────────────────┐
  │  Jaisiu gateway installed (broker-guided)                    │
  │                                                              │
  │  Next steps:                                                 │
  │    • Terminal UI:  jaisiu tui                                │
  │    • Web UI:       http://127.0.0.1:18789/jaisiu             │
  │    • Diagnose:     jaisiu doctor                             │
  │                                                              │
  │  Session: see PRYZM_INSTALL_RESULT above.                    │
  │  Logs:    ${JAISIU_LOG_FILE:-/tmp/jaisiu-install.log}
  └──────────────────────────────────────────────────────────────┘
PLAN_BANNER
                    # Print the WebUI deep link with the gateway.auth.token
                    # already persisted to ~/.jaisiu/state/jaisiu.json. The
                    # token is 32-byte hex; we re-read from state so the URL
                    # reflects whatever's actually on disk (handles upgrades
                    # where the token was preserved across reinstalls).
                    if [ -r "$STATE_JSON" ]; then
                        webui_token="$(_state_extract_token "$STATE_JSON")"
                        if [ -n "${webui_token:-}" ]; then
                            print_message OK "Web UI (one-click login):  http://127.0.0.1:18789/jaisiu/#token=${webui_token}"
                        fi
                    fi
                    unset webui_token 2>/dev/null || true
                    log INFO "broker-guided install complete session=${JAISIU_SESSION_ID:-?}"
                    exit 0
                fi
                print_message WARN "broker plan loop failed; falling back to local latest.json resolver"
            fi
        fi
    else
        # Distinguish 404 (older broker) from network errors. The
        # older broker doesn't have T1's /api/install/{intent,
        # session, status} handlers, so we MUST warn + fall back
        # to keep the install working on out-of-date portals. JAISIU
        # 2016 fallback contract: the install continues and succeeds.
        # Exception: when JAISIU_AGENT_REQUIRED=1 (--require-agent /
        # JAISIU_AGENT_REQUIRED env), the operator has opted out of
        # the fallback — fail-fast so unattended agent/CI runs don't
        # silently downgrade to the local resolver (which lacks the
        # broker's credential mint + step machine). Audit Gap 8
        # (2026-08-26).
        if [ "${JAISIU_AGENT_REQUIRED:-0}" = "1" ]; then
            print_message FATAL "broker /api/install/session unavailable and JAISIU_AGENT_REQUIRED=1; refusing to fall back to the local resolver (set JAISIU_AGENT_REQUIRED=0 to allow JAISIU-2016 fallback)"
        fi
        print_message WARN "[JAISIU] broker /api/install/session returned 404; falling back to local latest.json resolver (no guided session this run)"
    fi
else
    # Same fail-fast contract — when plan-mode libs are missing AND
    # the operator has explicitly required plan mode, the install
    # cannot succeed in its declared mode.
    if [ "${JAISIU_AGENT_REQUIRED:-0}" = "1" ]; then
        print_message FATAL "plan-mode requested but lib/ not found at ${_JAISIU_LIB_DIR:-<unset>}; refusing to fall back to linear (set JAISIU_AGENT_REQUIRED=0 to allow fallback)"
    fi
    print_message WARN "plan-mode requested but lib/ not found at ${_JAISIU_LIB_DIR:-<unset>}; falling back to linear"
fi

# ---- version resolution ---------------------------------------------------
JAISIU_RELEASES="${JAISIU_RELEASES:-https://pryzm.at/efss/jaisiu}"

resolve_version() {
  # $1 = requested (may be empty / "latest" / explicit tag)
  if [ -n "$JAISIU_VERSION_RAW" ] && [ "$JAISIU_VERSION_RAW" != "latest" ]; then
    printf '%s' "$JAISIU_VERSION_RAW"; return 0
  fi
  if [ -n "${JAISIU_VERSION:-}" ]; then
    printf '%s' "$JAISIU_VERSION"; return 0
  fi
  # Fetch latest.json
  local url="$JAISIU_RELEASES/releases/latest.json"
  local body
  body="$(curl -fsSL --max-time 15 "$url" 2>/dev/null || true)"
  if [ -z "$body" ]; then
    print_message FATAL "could not reach $url (set JAISIU_VERSION=... to pin)"
  fi
  local v
  v="$(printf '%s' "$body" | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)"
  [ -n "$v" ] || print_message FATAL "latest.json missing 'version' field"
  printf '%s' "$v"
}

VERSION="$(resolve_version)"

# Resolve the artifact descriptor for the current arch (url, format, sha256).
# Reads artifacts.<arch>.{url,format,sha256} from latest.json. Falls back to
# deriving format from the URL extension if `format` is missing.
# Canonical arch names: manifest keys are "<os>-x64" / "<os>-arm64"
# (hyphenated, no underscore) — see efss-portal/jaisiu/releases/0.0.0-dev/
# latest.json for the actual keys. Prior installer revisions used the
# Node.js platform convention `x86_64` and the lookup silently missed;
# fix the mapping here so `windows-x64` resolves correctly.
case "$ARCH_RAW" in
  x86_64|x64)  ARTIFACT_ARCH="x64" ;;
  arm64)       ARTIFACT_ARCH="arm64" ;;
  *)           ARTIFACT_ARCH="$ARCH_RAW" ;;
esac

resolve_artifact() {
  local arch="$1"
  # Manifest key is "<os>-<arch>" (e.g. "darwin-arm64"), but the caller
  # passes just the arch. Compose the key explicitly.
  local manifest_key="${OS_NAME}-${arch}"
  local body url_field="" fmt_field="" sha_field=""
  body="$(curl -fsSL --max-time 15 "$JAISIU_RELEASES/releases/latest.json" 2>/dev/null || true)"
  if [ -n "$body" ]; then
    if command -v jq >/dev/null 2>&1; then
      url_field="$(printf '%s' "$body" | jq -r ".artifacts.\"$manifest_key\".url    // empty" 2>/dev/null)"
      fmt_field="$(printf '%s' "$body" | jq -r ".artifacts.\"$manifest_key\".format // empty" 2>/dev/null)"
      sha_field="$(printf '%s' "$body" | jq -r ".artifacts.\"$manifest_key\".sha256 // empty" 2>/dev/null)"
    elif command -v python3 >/dev/null 2>&1; then
      # Whitespace-separated `read` collapses empty fields: an unset
      # `format` in the manifest would steal the sha256 token. Use
      # NUL-delimited output (printf '\0') with `read -d ''` so empty
      # fields round-trip cleanly through bash.
      while IFS= read -r -d '' url_field && IFS= read -r -d '' fmt_field && IFS= read -r -d '' sha_field; do
        break
      done < <(printf '%s' "$body" | python3 -c "
import json,sys
d=json.load(sys.stdin)
a=d.get('artifacts',{}).get('$manifest_key',{})
sys.stdout.write(a.get('url',''))
sys.stdout.write('\0')
sys.stdout.write(a.get('format',''))
sys.stdout.write('\0')
sys.stdout.write(a.get('sha256',''))
sys.stdout.write('\0')
" 2>/dev/null)
    else
      # bash + sed only (stock macOS). Artifact blocks are flat JSON objects.
      local flat
      flat="$(printf '%s' "$body" | tr '\n' ' ')"
      url_field="$(printf '%s' "$flat" | sed -n "s/.*\"${manifest_key}\"[[:space:]]*:[[:space:]]*{[^}]*\"url\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" | head -1)"
      fmt_field="$(printf '%s' "$flat" | sed -n "s/.*\"${manifest_key}\"[[:space:]]*:[[:space:]]*{[^}]*\"format\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" | head -1)"
      sha_field="$(printf '%s' "$flat" | sed -n "s/.*\"${manifest_key}\"[[:space:]]*:[[:space:]]*{[^}]*\"sha256\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" | head -1)"
    fi
  fi

  # Honour the `format` field from latest.json verbatim. Older releases
  # shipped .tar.gz; the 2026-08-23 ci5 build switched to raw .tar to
  # avoid gzip truncation in the prod pipeline — Windows tar.exe then
  # ran `tar -xzf` on a plain tarball and silently corrupted the extract.
  # Same trap awaited the bash installer. Negotiating format from the
  # manifest (not the OS name) closes it on Linux, macOS, and Windows
  # uniformly.
  ARTIFACT_URL="$url_field"
  EXPECTED_SHA256="$sha_field"
  if [ -n "$fmt_field" ]; then
    ARCHIVE_EXT="$fmt_field"
  elif [ -n "$url_field" ]; then
    # Derive from URL extension. Handles .tar.gz / .tgz / .tar.bz2 /
    # .tbz2 / .tar.xz / .txz / .zip / .tar (the new ci5 default).
    case "$url_field" in
      *.tar.gz|*.tgz)     ARCHIVE_EXT="tar.gz" ;;
      *.tar.bz2|*.tbz2)   ARCHIVE_EXT="tar.bz2" ;;
      *.tar.xz|*.txz)     ARCHIVE_EXT="tar.xz" ;;
      *.zip)              ARCHIVE_EXT="zip" ;;
      *.tar)              ARCHIVE_EXT="tar" ;;
      *)                  ARCHIVE_EXT="tar.gz" ;;
    esac
  else
    # Manifest absent or arch missing — fall back to legacy default.
    ARCHIVE_EXT="tar.gz"
    [ "$OS_NAME" = "windows" ] && ARCHIVE_EXT="zip"
  fi

  TARBALL_NAME="jaisiu-gateway-${OS_NAME}-${ARTIFACT_ARCH}.${ARCHIVE_EXT}"
  if [ -z "$ARTIFACT_URL" ]; then
    # JAISIU-3000 (2026-09-02): the CDN layout serves tarballs at the
    # flat path $JAISIU_RELEASES/$TARBALL_NAME -- there is no
    # /releases/$VERSION/<tarball> segment on prod. The previous
    # /releases/$VERSION/ prefix was a stale guess that survived the
    # EFSS-portal rename; when resolve_artifact fell back to it
    # (e.g. Mac without jq/python3 -- jq is brew-only on macOS and
    # python3 is not shipped at all) the install died with HTTP 404
    # even though latest.json pointed at the correct URL one level
    # up. Use the flat path here so the legacy code path matches the
    # manifest-driven path.
    ARTIFACT_URL="$JAISIU_RELEASES/$TARBALL_NAME"
  fi
}
resolve_artifact "$ARTIFACT_ARCH"
print_message OK "target version: $VERSION"
print_message INFO "tarball format: $ARCHIVE_EXT ($TARBALL_NAME)"
[ -n "$EXPECTED_SHA256" ] && print_message INFO "expected sha256: ${EXPECTED_SHA256:0:16}..."

# ---- HEAD 404 preflight ----------------------------------------------------
if [ -z "$JAISIU_BINARY" ]; then
  http_code="$(curl -sILo /dev/null -w '%{http_code}' --max-time 10 "$ARTIFACT_URL" || echo 000)"
  case "$http_code" in
    200|301|302) print_message OK "$TARBALL_NAME reachable ($http_code)" ;;
    *) print_message FATAL "$ARTIFACT_URL returned $http_code (artifact missing?)" ;;
  esac
fi

# ---- existing version probe (idempotent re-run) ---------------------------
check_version() {
  if command -v jaisiu >/dev/null 2>&1; then
    local current
    current="$(jaisiu --version 2>/dev/null | head -1 | awk '{print $2}' || true)"
    if [ "$current" = "$VERSION" ]; then
      print_message OK "jaisiu $VERSION already installed — exiting 0"
      exit 0
    fi
  fi
}

# Begin run
check_version
log INFO "starting install version=$VERSION target=$TARGET_TRIPLE bin=$BIN_DIR"

# ---- download with progress (TTY only) -----------------------------------
TMP_DIR="$(mktemp -d 2>/dev/null || mktemp -d -t 'jaisiu-install')"
TMP_TARBALL="$TMP_DIR/$TARBALL_NAME"

download_with_progress() {
  local url="$1" dest="$2"
  # -C -: auto-resume from where a previous partial download left off.
  #   This survives truncated downloads on flaky links where curl -fL
  #   alone would restart from 0 bytes each retry. Pair with --retry
  #   and --retry-all-errors so HTTP >=500 / TLS resets / connection
  #   drops also retry. Operator trace 2026-08-24: Mac install hit
  #   "sha256 mismatch" 6 times in a row because curl was completing
  #   successfully with truncated bytes (the server was serving fine,
  #   but the Mac's path to pryzm.at was dropping TCP segments late).
  # -#: TTY-only progress bar. -fL: fail on HTTP >=400 + follow redirects.
  local common=(-fSL -C - --retry 5 --retry-delay 2 --retry-all-errors
                --connect-timeout 20 --max-time 1800)
  if [ ! -t 1 ]; then
    curl "${common[@]}" -o "$dest" "$url"
    return
  fi
  # TTY path: simple progress bar via curl -#
  curl -# "${common[@]}" -o "$dest" "$url"
}

extract_artifact() {
  local src="$1" dest_dir="$2"
  # Prefer the negotiated format from latest.json (set by resolve_artifact).
  # Fall back to the filename extension when ARCHIVE_EXT is unset.
  local fmt="${ARCHIVE_EXT:-}"
  if [ -z "$fmt" ]; then
    case "$src" in
      *.tar.gz|*.tgz)     fmt="tar.gz" ;;
      *.tar.bz2|*.tbz2)   fmt="tar.bz2" ;;
      *.tar.xz|*.txz)     fmt="tar.xz" ;;
      *.zip)              fmt="zip" ;;
      *.tar)              fmt="tar" ;;
      *)                  print_message FATAL "unknown archive: $src" ;;
    esac
  fi
  case "$fmt" in
    tar.gz)  tar -xzf "$src" -C "$dest_dir" ;;
    tar)     tar -xf  "$src" -C "$dest_dir" ;;
    tar.bz2) tar -xjf "$src" -C "$dest_dir" ;;
    tar.xz)  tar -xJf "$src" -C "$dest_dir" ;;
    zip)     unzip -q -o "$src" -d "$dest_dir" ;;
    *)       print_message FATAL "unsupported archive format: $fmt ($src)" ;;
  esac
}

if [ -n "$JAISIU_BINARY" ]; then
  print_message INFO "installing from local: $JAISIU_BINARY"
  cp -f "$JAISIU_BINARY" "$TMP_TARBALL"
else
  print_message INFO "downloading $ARTIFACT_URL"
  download_with_progress "$ARTIFACT_URL" "$TMP_TARBALL"

  # Verify sha256 against the latest.json manifest. If the download was
  # truncated (network blip, ISP throttling), retry up to 2 more times
  # before giving up. Operator feedback 2026-08-22: Linux host hit
  # 'gzip: stdin: unexpected end of file' because download was partial.
  # JAISIU-2430: macOS ships `shasum -a 256` instead of GNU `sha256sum`;
  # the unconditional sha256sum call below aborted every macOS install
  # before the retry loop could fire. Pick the available tool.
  _sha256_cmd=""
  if command -v sha256sum >/dev/null 2>&1; then
    _sha256_cmd="sha256sum"
  elif command -v shasum >/dev/null 2>&1; then
    _sha256_cmd="shasum -a 256"
  fi
  if [ -n "$_sha256_cmd" ] && [ -n "$EXPECTED_SHA256" ]; then
    actual_sha="$( $_sha256_cmd "$TMP_TARBALL" | awk '{print $1}')"
    if [ "$actual_sha" != "$EXPECTED_SHA256" ]; then
      print_message WARN "sha256 mismatch (download likely truncated); retrying 2 more times"
      for attempt in 1 2; do
        print_message INFO "  retry $attempt/2 ..."
        download_with_progress "$ARTIFACT_URL" "$TMP_TARBALL"
        actual_sha="$( $_sha256_cmd "$TMP_TARBALL" | awk '{print $1}')"
        [ "$actual_sha" = "$EXPECTED_SHA256" ] && break
      done
      if [ "$actual_sha" != "$EXPECTED_SHA256" ]; then
        print_message FATAL "sha256 mismatch after 3 attempts:
  expected: $EXPECTED_SHA256
  actual:   $actual_sha
This usually means the download was truncated by a flaky network. Re-run
on a stable connection (or use a local --source path via JAISIU_BINARY)."
      fi
    fi
    print_message OK "sha256 verified"
  fi
fi

EXTRACT_DIR="$TMP_DIR/extract"
mkdir -p "$EXTRACT_DIR"
extract_artifact "$TMP_TARBALL" "$EXTRACT_DIR"

# Auto-detect artifact prefix. The current published tarballs are FLAT
# (build-gateway-tarball.sh does `tar -czf -C $STAGING .` since ci5), but
# some older/staging bundles wrap the tree under a single top-level dir.
# Unwrap whichever wrapper is present (and only when it really is the
# single top-level dir) so the installer doesn't have to know which
# variant shipped. Mirrors the same logic in efss-portal/jaisiu/install.sh
# (lib/plan.sh::_plan_step_extract) so both paths behave consistently.
for _wrap in jaisiu-gateway jaisiu jaisiu-cli; do
  if [ -d "$EXTRACT_DIR/$_wrap" ] \
     && [ -z "$(find "$EXTRACT_DIR" -maxdepth 1 -mindepth 1 ! -name "$_wrap" 2>/dev/null)" ]; then
    EXTRACT_DIR="$EXTRACT_DIR/$_wrap"
    break
  fi
done

# Verify expected layout. Three valid layouts are accepted:
#   (A) bun-compiled: usr/bin/jaisiu [+ usr/lib/jaisiu/bin/jq]
#   (B) source-tree : openclaw.mjs at root [+ dist/ + node_modules/]
#   (C) dist-tree   : dist/index.mjs (or similar) at root, no jaisiu.mjs
EXTRACT_LAYOUT=""
EXTRACT_ENTRYPOINT=""
if [ -f "$EXTRACT_DIR/usr/bin/jaisiu" ] || [ -f "$EXTRACT_DIR/bin/jaisiu.exe" ]; then
  EXTRACT_LAYOUT="BUN"
elif [ -f "$EXTRACT_DIR/openclaw.mjs" ] || [ -f "$EXTRACT_DIR/jaisiu.mjs" ]; then
  EXTRACT_LAYOUT="SOURCE"
  [ -f "$EXTRACT_DIR/jaisiu.mjs" ] && EXTRACT_ENTRYPOINT="jaisiu.mjs" || EXTRACT_ENTRYPOINT="openclaw.mjs"
elif [ -f "$EXTRACT_DIR/dist/index.mjs" ] || [ -f "$EXTRACT_DIR/dist/cli-entry.mjs" ]; then
  # (C) JAISIU-2756 + JAISIU-2757: newer publishes ship the entrypoint under
  # dist/. Detect the actual file and treat as SOURCE with a custom entry.
  EXTRACT_LAYOUT="SOURCE"
  if [ -f "$EXTRACT_DIR/dist/cli-entry.mjs" ]; then
    EXTRACT_ENTRYPOINT="dist/cli-entry.mjs"
  elif [ -f "$EXTRACT_DIR/dist/index.mjs" ]; then
    EXTRACT_ENTRYPOINT="dist/index.mjs"
  fi
  print_message OK "dist layout detected, entrypoint=$EXTRACT_ENTRYPOINT"
else
  print_message FATAL "artifact has neither usr/bin/jaisiu nor jaisiu.mjs nor dist/{index,cli-entry}.mjs (broken publish?)"
fi
print_message OK "artifact extracted (layout=$EXTRACT_LAYOUT)"

# ---- install launcher ------------------------------------------------------
# Two paths:
#   BUN layout   : stage binary under ~/.jaisiu/workspace/jaisiu/usr/bin/
#                  (with a sibling package.json + usr/lib/jaisiu/bin/jq) and
#                  symlink to $BIN_DIR. The runtime resolves its project
#                  root via package.json walking up from argv1 (the symlink
#                  resolves through the symlink, so cwd /usr/local → walks up
#                  and finds the staged package.json at ~/.jaisiu/workspace/...).
#   SOURCE layout: write a single-file Node launcher to $BIN_DIR that
#                  exec's the bundled app at $JAISIU_APP_DIR/cli-entry.mjs.
JAISIU_WORKSPACE="${JAISIU_WORKSPACE:-$HOME/.jaisiu/workspace/jaisiu}"
if [ "$EXTRACT_LAYOUT" = "BUN" ]; then
  if [ "$OS_NAME" = "windows" ]; then
    LAUNCHER_SRC="$EXTRACT_DIR/bin/jaisiu.exe"
    LAUNCHER_DEST="$BIN_DIR/jaisiu.exe"
    BIN_STAGE_DIR="$JAISIU_WORKSPACE/bin"
    STAGE_SUBDIR=""
  else
    LAUNCHER_SRC="$EXTRACT_DIR/usr/bin/jaisiu"
    LAUNCHER_DEST="$BIN_DIR/jaisiu"
    BIN_STAGE_DIR="$JAISIU_WORKSPACE/usr/bin"
    STAGE_SUBDIR="usr/bin"
  fi
  [ -f "$LAUNCHER_SRC" ] || print_message FATAL "BUN artifact missing $LAUNCHER_SRC"
  # Stage the binary in a directory that the package-root resolver can find.
  # Layout:
  #   $JAISIU_WORKSPACE/
  #     package.json                      (name: 'jaisiu' marker for resolver)
  #     <stage_subdir>/jaisiu             (the binary)
  #     usr/lib/jaisiu/bin/jq             (jq shim)
  mkdir -p "$JAISIU_WORKSPACE"
  mkdir -p "$BIN_STAGE_DIR"
  # Carry the staged usr/lib/jaisiu/bin/jq from the tarball too if present.
  if [ -f "$EXTRACT_DIR/usr/lib/jaisiu/bin/jq" ]; then
    mkdir -p "$JAISIU_WORKSPACE/usr/lib/jaisiu/bin"
    cp -f "$EXTRACT_DIR/usr/lib/jaisiu/bin/jq" "$JAISIU_WORKSPACE/usr/lib/jaisiu/bin/jq" 2>/dev/null || true
    chmod 755 "$JAISIU_WORKSPACE/usr/lib/jaisiu/bin/jq" 2>/dev/null || true
  fi
  cp -f "$LAUNCHER_SRC" "$BIN_STAGE_DIR/jaisiu"
  chmod 755 "$BIN_STAGE_DIR/jaisiu"

  # JAISIU-2799 (2026-08-26): stage every sibling of the binary from the
  # tarball to $BIN_STAGE_DIR. The tarball ships extensions/, skills/,
  # docs/, assets/, LICENSE, README.md, CHANGELOG.md as siblings of
  # jaisiu inside usr/bin/ (see install/ci/build-native.sh). Without
  # this loop, those siblings are dropped on the floor at install time —
  # the gateway boots with an empty extensions/ + skills/ directory and
  # validation trips on the default plugins.slots.memory: 'memory-core'
  # slot vs the registry (JAISIU-2782, / 4). The JAISIU-2793 build fix
  # added them to the tarball; this is the install-side mirror.
  if [ "$OS_NAME" = "windows" ]; then
    _STAGE_SRC_DIR="$EXTRACT_DIR/bin"
  else
    _STAGE_SRC_DIR="$EXTRACT_DIR/usr/bin"
  fi
  if [ -d "$_STAGE_SRC_DIR" ]; then
    # Copy each sibling subdir of $EXTRACT_DIR/usr/bin/ EXCEPT 'jaisiu'
    # (already staged) and 'package.json' (we write our own marker below).
    for _sib in "$_STAGE_SRC_DIR"/*; do
      [ -e "$_sib" ] || continue
      _name="$(basename "$_sib")"
      case "$_name" in
        jaisiu|jaisiu.exe|package.json) continue ;;
      esac
      if [ -d "$_sib" ]; then
        cp -a "$_sib" "$BIN_STAGE_DIR/"
      elif [ -f "$_sib" ]; then
        cp -f "$_sib" "$BIN_STAGE_DIR/"
      fi
    done
    # Also stage top-level LICENSE/README/CHANGELOG.md/docs/etc if the
    # tarball packed them at $EXTRACT_DIR/ root (older build pipeline).
    for _root in LICENSE README.md CHANGELOG.md; do
      [ -f "$EXTRACT_DIR/$_root" ] && cp -f "$EXTRACT_DIR/$_root" "$BIN_STAGE_DIR/$_root" || true
    done
  fi
  # Write a marker package.json so resolveOpenClawPackageRoot walks up from
  # argv1 (the symlink target inside $BIN_STAGE_DIR) and finds a 'jaisiu'
  # package.json at $JAISIU_WORKSPACE.
  #
  # JAISIU-2627 (2026-08-26): the bun-compiled binary also does its own
  # `readFileSync('package.json')` relative to argv1 (path.dirname(argv1) =
  # $BIN_STAGE_DIR = .../usr/bin). The walker checks that dir first; without
  # a sibling package.json it crashes with ENOENT before the walker can
  # ascend to $JAISIU_WORKSPACE. Stage a sibling marker at the bin dir too
  # so both the bun runtime and the resolver agree on the install layout.
  # JAISIU-2799 (2026-08-26): the marker is now minimal. The BUN-compiled
  # binary is self-contained (built from ./dist/entry.js directly), so
  # it does NOT need a package.json#bin.jaisiu shim. Previously we wrote
  # `bin: { jaisiu: "./openclaw.mjs" }` but BUN's --compile does not honor
  # the bin field for runtime dispatch — that whole approach was a
  # misdiagnosis. The marker now only carries name/version for tooling
  # that walks up from argv1 to find package.json (some tools use it for
  # project-root resolution, even though we don't depend on it).
  _write_pkg_marker() {
    local _dir="$1"
    [ -s "$_dir/package.json" ] && return 0
    cat > "$_dir/package.json" <<EOF
{
  "name": "jaisiu",
  "version": "${VERSION}",
  "description": "Jaisiu gateway workspace marker (auto-installed by install.sh)",
  "private": true
}
EOF
  }
  _write_pkg_marker "$JAISIU_WORKSPACE"
  _write_pkg_marker "$BIN_STAGE_DIR"

  # JAISIU-fixes-round-2 (2026-08-26): the bun-compiled binary resolves
  # `./dist/entry.js` via the filesystem at runtime (cli-entry.mjs:120).
  # If the publisher forgot to ship dist/ as a sibling of the binary
  # (older build-native.sh revisions, pre-dist-sibling fix), the gateway
  # dies with "missing dist/entry.(m)js" or "Script not found 'gateway'".
  # Stage it here as a defensive backfill if the tarball packed dist/
  # at the root.
  if [ ! -d "$BIN_STAGE_DIR/dist" ]; then
    if [ -d "$EXTRACT_DIR/dist" ]; then
      print_message INFO "BUN layout missing dist/ sibling at usr/bin/ — backfilling from tarball root"
      cp -a "$EXTRACT_DIR/dist" "$BIN_STAGE_DIR/dist"
    else
      print_message WARN "BUN artifact shipped without dist/ — gateway may fail to boot. Update the publisher to include dist/ as a sibling of the binary."
    fi
  fi
else
  # SOURCE layout: write a shell launcher that locates node and exec's the
  # bundled app's entry. Shell launcher (not node shebang) because not every
  # host has node on PATH.
  LAUNCHER_DEST="$BIN_DIR/jaisiu"
  JAISIU_APP_DIR="${JAISIU_APP_DIR:-$HOME/.jaisiu/app}"
  cat > /tmp/jaisiu-launcher.tmp <<EOF
#!/usr/bin/env bash
# jaisiu CLI launcher — installed by install.sh.
# Locates node, then exec's the bundled app's cli-entry.mjs.

set -e

APP="\${JAISIU_APP_DIR:-${JAISIU_APP_DIR}}"
# Prefer cli-entry.mjs (canonical), fall back to dist/index.mjs for builds
# where the publish pipeline moved the entrypoint under dist/ (JAISIU-2756).
if [ -f "\$APP/cli-entry.mjs" ]; then
  ENTRY="\$APP/cli-entry.mjs"
elif [ -f "\$APP/dist/index.mjs" ]; then
  ENTRY="\$APP/dist/index.mjs"
else
  ENTRY="\$APP/cli-entry.mjs"
fi

# Find node: explicit path > PATH search > homebrew > system
NODE_BIN=""
for cand in "\$NODE_BIN_OVERRIDE" /opt/homebrew/bin/node /usr/local/bin/node /usr/bin/node; do
  if [ -x "\$cand" ]; then
    NODE_BIN="\$cand"
    break
  fi
done
if [ -z "\$NODE_BIN" ] && command -v node >/dev/null 2>&1; then
  NODE_BIN="\$(command -v node)"
fi

if [ -z "\$NODE_BIN" ]; then
  echo "ERROR: node not found. Install Node.js >= 18 first." >&2
  echo "  macOS : brew install node" >&2
  echo "  Linux : see https://nodejs.org/" >&2
  echo "Or set NODE_BIN_OVERRIDE=/path/to/node before running." >&2
  exit 127
fi

if [ ! -f "\$ENTRY" ]; then
  echo "ERROR: jaisiu app not found at \$ENTRY" >&2
  echo "  expected JAISIU_APP_DIR=\$APP" >&2
  exit 1
fi

exec "\$NODE_BIN" "\$ENTRY" "\$@"
EOF
  LAUNCHER_SRC="/tmp/jaisiu-launcher.tmp"
fi

write_launcher() {
  local dest="$1"
  # Remove any pre-existing wrapper / symlink / sibling files at $dest
  # BEFORE writing, so a half-state from a prior install can't mask the
  # new launcher. (E.g. /usr/local/bin/jaisiu as a 46-byte `import "./cli-entry.mjs"`
  # wrapper from a prior v1 install — if we don't remove it, the user
  # ends up running the old wrapper that points at a missing sibling.)
  if [ "$JAISIU_USER_INSTALL" = "0" ] && [ "$BIN_DIR" = "/usr/local/bin" ]; then
    sudo rm -f "$dest" 2>/dev/null || rm -f "$dest" 2>/dev/null || true
    # Try sudo copy first; fall back to plain copy; both must succeed.
    if ! sudo cp -f "$LAUNCHER_SRC" "$dest" 2>/dev/null; then
      if ! cp -f "$LAUNCHER_SRC" "$dest" 2>/dev/null; then
        # Bug history (operator trace 2026-08-24): when sudo isn't available
        # (e.g. passwordless sudo disabled, macOS default state), the
        # system-wide launcher write fails with "permission denied" and the
        # installer exits with set -e. Operator sees the download + extract
        # succeed but nothing else runs. We now fall back to the user-scope
        # launcher at ~/.local/bin and continue — the install finishes, the
        # gateway launches from launchd/systemd --user, and the operator
        # can later opt-in to system-wide with --system + passwordless sudo.
        print_message WARN "system-wide launcher write to $dest failed (no sudo) — falling back to user-scope install"
        FALLBACK_USER_INSTALL=1
        dest="$HOME/.local/bin/jaisiu"
        mkdir -p "$(dirname "$dest")" 2>/dev/null || true
        rm -f "$dest" 2>/dev/null || true
        if ! cp -f "$LAUNCHER_SRC" "$dest" 2>/dev/null; then
          print_message FATAL "cannot write launcher to $dest (user-scope fallback also failed)"
        fi
        chmod 755 "$dest" 2>/dev/null || true
      fi
    fi
    sudo chmod 755 "$dest" 2>/dev/null || chmod 755 "$dest" 2>/dev/null || true
  else
    rm -f "$dest" 2>/dev/null || true
    if ! cp -f "$LAUNCHER_SRC" "$dest" 2>/dev/null; then
      print_message FATAL "cannot write launcher to $dest"
    fi
    chmod 755 "$dest" 2>/dev/null || true
  fi
  # Verify the launcher is what we expect.
  if [ ! -s "$dest" ]; then
    print_message FATAL "launcher write to $dest produced an empty file"
  fi
  # Sanity-check: for SOURCE layout, the launcher should NOT be a 46-114
  # byte node wrapper that imports './cli-entry.mjs' — those import a
  # sibling that we never create at $dest.
  local size
  size=$(wc -c < "$dest" 2>/dev/null || echo 0)
  if [ "$EXTRACT_LAYOUT" = "SOURCE" ] && [ "$size" -lt 500 ]; then
    print_message WARN "launcher at $dest is only ${size}B — expected shell wrapper ~989B; pre-existing v1 file?"
  fi
}
write_launcher "$LAUNCHER_DEST"
[ "$EXTRACT_LAYOUT" = "SOURCE" ] && rm -f /tmp/jaisiu-launcher.tmp
print_message OK "launcher installed → $LAUNCHER_DEST"

# 449246c5: deconflict every `jaisiu` on PATH. The monolith's
# write_launcher() handles the destination directory (and root-vs-user
# fallback), but it doesn't sweep OTHER bin dirs that may shadow the
# freshly installed launcher (most common on macOS: /opt/homebrew/bin and
# /usr/local/bin both take precedence over ~/.local/bin in login shells).
# Delegate to the same library the bundled installer uses so behaviour
# stays consistent across installers. The monolith is typically fetched
# via `curl | bash` so BASH_SOURCE is unreliable; fall back to a
# well-known relative path.
_PDEDUP_CAND="${BASH_SOURCE[0]:-$0}"
case "${_PDEDUP_CAND}" in
    /*) _PDEDUP_DIR="$(dirname -- "${_PDEDUP_CAND}")" ;;
    *)  _PDEDUP_DIR="$(pwd)" ;;
esac
if [ -r "${_PDEDUP_DIR}/lib/path_dedup.sh" ]; then
    # shellcheck source=lib/path_dedup.sh
    . "${_PDEDUP_DIR}/lib/path_dedup.sh" 2>/dev/null \
        && resolve_jaisiu_path_conflicts 1>&2 \
        || print_message WARN "PATH dedup failed (non-fatal — see lines above)"
elif [ -r "./lib/path_dedup.sh" ]; then
    # shellcheck source=lib/path_dedup.sh
    . "./lib/path_dedup.sh" 2>/dev/null \
        && resolve_jaisiu_path_conflicts 1>&2 \
        || print_message WARN "PATH dedup failed (non-fatal — see lines above)"
else
    print_message INFO "PATH dedup lib not found at lib/path_dedup.sh — skipping"
fi

# For BUN layout, $BIN_DIR/jaisiu is a SYMLINK to the staged binary at
# $JAISIU_WORKSPACE/<subdir>/jaisiu. This is what lets the runtime's
# package-root resolver find the marker package.json (so updates, doctor,
# etc. can locate their repo root).
if [ "$EXTRACT_LAYOUT" = "BUN" ] && [ "$OS_NAME" != "windows" ]; then
  STAGED_BIN_PATH="$BIN_STAGE_DIR/jaisiu"
  # Replace the just-copied $BIN_DIR/jaisiu with a symlink (preserves the
  # staged one as the source of truth).
  if [ -L "$LAUNCHER_DEST" ]; then
    : # already a symlink
  else
    rm -f "$LAUNCHER_DEST" 2>/dev/null
    if ! ln -sf "$STAGED_BIN_PATH" "$LAUNCHER_DEST" 2>/dev/null; then
      # Fallback: keep the copied binary if symlink fails (older Mac, etc.)
      print_message WARN "could not symlink $LAUNCHER_DEST -> $STAGED_BIN_PATH; copied binary in place"
    fi
  fi
  print_message OK "binary symlinked: $LAUNCHER_DEST -> $STAGED_BIN_PATH"
fi

# Backwards-compat: keep an `openclaw` symlink that points to `jaisiu`.
# Remove any pre-existing broken openclaw wrappers too.
if [ "$OS_NAME" != "windows" ]; then
  if [ -e "$BIN_DIR/openclaw" ] || [ -L "$BIN_DIR/openclaw" ]; then
    if [ "$JAISIU_USER_INSTALL" = "0" ] && [ "$BIN_DIR" = "/usr/local/bin" ]; then
      sudo rm -f "$BIN_DIR/openclaw" 2>/dev/null || rm -f "$BIN_DIR/openclaw" 2>/dev/null || true
    else
      rm -f "$BIN_DIR/openclaw" 2>/dev/null || true
    fi
  fi
  ln -sf "$BIN_DIR/jaisiu" "$BIN_DIR/openclaw" 2>/dev/null || true
fi

# JAISIU-nanoagent: if the tarball shipped a sibling `jaisiu-nanoagent`
# binary alongside `jaisiu` (see scripts/build-nanoagent.mjs), expose it on
# $BIN_DIR too. The nanoagent is the minimal broker-session install driver —
# `jaisiu tui` users ignore it; `jaisiu-nanoagent install --email ...` users
# specifically want it on PATH so they can re-run or scripted-call it.
if [ "$OS_NAME" != "windows" ] && [ -x "$BIN_STAGE_DIR/jaisiu-nanoagent" ]; then
  NANOAGENT_DEST="$BIN_DIR/jaisiu-nanoagent"
  if [ -L "$NANOAGENT_DEST" ]; then
    : # already a symlink — leave it
  else
    rm -f "$NANOAGENT_DEST" 2>/dev/null
    if ln -sf "$BIN_STAGE_DIR/jaisiu-nanoagent" "$NANOAGENT_DEST" 2>/dev/null; then
      print_message OK "nanoagent symlinked: $NANOAGENT_DEST -> $BIN_STAGE_DIR/jaisiu-nanoagent"
    else
      print_message WARN "could not symlink $NANOAGENT_DEST; you can run nanoagent via $BIN_STAGE_DIR/jaisiu-nanoagent"
    fi
  fi
fi

# ---- workspace + app tree directories ---------------------------------------
# The jaisiu daemon expects ~/.jaisiu/workspace/jaisiu (or whatever
# JAISIU_WORKSPACE points to) to exist for agent runtime state. Create
# it for both BUN and SOURCE layouts so 'jaisiu --version' and 'jaisiu doctor'
# don't complain about a missing workspace root.
if [ "$EXTRACT_LAYOUT" != "BUN" ]; then
  JAISIU_WORKSPACE="${JAISIU_WORKSPACE:-$HOME/.jaisiu/workspace/jaisiu}"
  mkdir -p "$JAISIU_WORKSPACE"
fi
ALWAYS_CREATE_DIRS=("$HOME/.jaisiu" "$HOME/.jaisiu/workspace" "$HOME/.jaisiu/workspace/jaisiu" "$HOME/.jaisiu/logs")
for d in "${ALWAYS_CREATE_DIRS[@]}"; do
  mkdir -p "$d"
done

# ---- lay down app/ in ~/.jaisiu/app ---------------------------------------
# (only meaningful for SOURCE layout; BUN layout is a self-contained binary)
if [ "$EXTRACT_LAYOUT" = "SOURCE" ]; then
  JAISIU_APP_DIR="${JAISIU_APP_DIR:-$HOME/.jaisiu/app}"
  mkdir -p "$JAISIU_APP_DIR"

  # Always nuke the destination before extract, so a prior install (which
  # may have different filenames due to content-hash chunking — e.g.
  # dist/entry-OLDHASH.js vs dist/entry-NEWHASH.js) can't leave stale
  # files that the daemon may still load. This is critical when the prior
  # version is 0.99-rc1 and the new tarball is 0.99-rc2-ci1.
  # We keep $JAISIU_APP_DIR itself but remove its contents.
  rm -rf "${JAISIU_APP_DIR:?}/"* "${JAISIU_APP_DIR:?}/.[!.]"* 2>/dev/null || true
  mkdir -p "$JAISIU_APP_DIR"

  if command -v rsync >/dev/null 2>&1; then
    rsync -a \
          --exclude='.git' --exclude='.worktrees' --exclude='.cache' \
          --exclude='*.bak.*' --exclude='*.test.*' --exclude='__tests__' \
          "$EXTRACT_DIR/" "$JAISIU_APP_DIR/" \
      || print_message FATAL "rsync failed for app tree"
  else
    (cd "$EXTRACT_DIR" && tar cf - \
          --exclude='.git' --exclude='.worktrees' --exclude='.cache' \
          --exclude='*.bak.*' --exclude='*.test.*' --exclude='__tests__' \
          .) | (cd "$JAISIU_APP_DIR" && tar xf -) \
      || print_message FATAL "tar pipeline failed for app tree"
  fi
  print_message OK "app tree laid down → $JAISIU_APP_DIR"
  # ---- lay down prizm-desktop-controller (PDC) Rust binary -----------------
  # PDC ships at usr/libexec/jaisiu/pdc/ in every tarball (see
  # scripts/installer-e2e/build-clean-gateway.sh step 2.7). We copy it to
  # ~/.jaisiu/bin/ which is on $PATH (added by the launcher/PATH step
  # further below). For SOURCE layout, the binary lives under the staged
  # app tree; for BUN layout, it lives next to the launcher under the
  # workspace.
  PDC_SRC_DIR=""
  # Try the SOURCE-layout path first (EXTRACT_DIR is the staged tree).
  if [ -d "$EXTRACT_DIR/usr/libexec/jaisiu/pdc" ]; then
    PDC_SRC_DIR="$EXTRACT_DIR/usr/libexec/jaisiu/pdc"
  elif [ -d "$JAISIU_WORKSPACE/usr/libexec/jaisiu/pdc" ]; then
    # BUN layout — already laid down at the workspace root.
    PDC_SRC_DIR="$JAISIU_WORKSPACE/usr/libexec/jaisiu/pdc"
  fi

  if [ -z "$PDC_SRC_DIR" ]; then
    # Hard fail: PDC is a contract of "every install ships it" (JAISIU-PDC-bundling).
    # This is the first gate; the test-install-pryzm/05-desktop-controller.sh
    # runner will also re-assert post-install.
    print_message FATAL "no prizm-desktop-controller binary in this install (looked at usr/libexec/jaisiu/pdc/); rebuild via scripts/build-pdc-all.sh"
  fi

  PDC_BIN_NAME="prizm-desktop-controller"
  case "$OS_NAME" in
    windows) PDC_BIN_NAME="prizm-desktop-controller.exe" ;;
  esac
  PDC_BIN_SRC="$PDC_SRC_DIR/$PDC_BIN_NAME"
  if [ ! -x "$PDC_BIN_SRC" ]; then
    print_message FATAL "PDC binary not executable: $PDC_BIN_SRC"
  fi

  PDC_DEST_DIR="$HOME/.jaisiu/bin"
  mkdir -p "$PDC_DEST_DIR"
  PDC_DEST="$PDC_DEST_DIR/$PDC_BIN_NAME"
  if ! cp -f "$PDC_BIN_SRC" "$PDC_DEST"; then
    print_message FATAL "failed to copy PDC binary to $PDC_DEST"
  fi
  chmod 755 "$PDC_DEST"

  # Verify SHA against the per-arch manifest that ships with the binary.
  EXPECTED_SHA=""
  ACTUAL_SHA="$(sha256sum "$PDC_DEST" 2>/dev/null | awk '{print $1}')"
  if [ -f "$PDC_SRC_DIR/manifest.json" ] && command -v jq >/dev/null 2>&1; then
    EXPECTED_SHA="$(jq -r '.sha256 // empty' "$PDC_SRC_DIR/manifest.json" 2>/dev/null || true)"
  fi
  if [ -n "$EXPECTED_SHA" ] && [ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]; then
    print_message FATAL "PDC sha256 mismatch: expected $EXPECTED_SHA, got $ACTUAL_SHA — refuse to install"
  fi
  if [ -n "$EXPECTED_SHA" ]; then
    print_message OK "PDC binary installed → $PDC_DEST (sha256=${ACTUAL_SHA:0:16}… matches manifest)"
  else
    print_message WARN "PDC binary installed → $PDC_DEST (sha256=${ACTUAL_SHA:0:16}… — no manifest to verify against)"
  fi

  # Functional smoke: --version must return non-empty within 5s. Cheap,
  # catches linker / arch / GLIBC issues immediately.
  if ! PDC_VER="$(timeout 5 "$PDC_DEST" --version 2>&1 || true)"; then
    print_message FATAL "PDC --version timed out or crashed"
  fi
  if [ -z "$PDC_VER" ]; then
    print_message FATAL "PDC --version returned empty (binary broken)"
  fi
  print_message OK "PDC --version → $PDC_VER"

  # Stash the PDC path so the MCP setup step (further below) can register
  # it as an MCP server.
  export PDC_BINARY_PATH="$PDC_DEST"

  # ---- register PDC as a gateway MCP server --------------------------------
  # Spawns prizm-desktop-controller on localhost:8765 and the PDC MCP sidecar
  # on localhost:8766, then writes gateway.mcpClients.pdc into
  # ~/.jaisiu/state/jaisiu.json. We invoke the helper via Node so it works
  # identically on every supported layout (BUN + SOURCE). The helper is
  # idempotent — re-running overwrites the existing entry.
  #
  # In a non-interactive (curl|bash) install we skip the spawn but still
  # write the config — the operator can start PDC manually or via the OS
  # service after install completes.
  if [ "${JAISIU_NONINTERACTIVE:-0}" = "1" ] || [ "${JAISIU_INSTALL_PDC_AUTOSTART:-1}" != "1" ]; then
    print_message INFO "skipping PDC autostart (non-interactive or JAISIU_INSTALL_PDC_AUTOSTART=0); wiring MCP config only"
    JAISIU_INSTALL_PDC_AUTOSTART=0
  fi

  # Find the Node entry for the helper. SOURCE layout → dist/ exists with
  # the compiled mcp-setup module. BUN layout → no Node entry, skip with
  # a printed hint (operator wires via jaisiu mcp-setup --install pdc).
  JAISIU_PDC_MCP_NODE=""
  if [ -d "$JAISIU_APP_DIR/dist" ]; then
    JAISIU_PDC_MCP_NODE="$JAISIU_APP_DIR/dist/desktop/prizm-desktop-mcp-setup.js"
    if [ ! -f "$JAISIU_PDC_MCP_NODE" ]; then
      JAISIU_PDC_MCP_NODE=""
    fi
  fi
  if [ -n "$JAISIU_PDC_MCP_NODE" ]; then
    if [ "${JAISIU_INSTALL_PDC_AUTOSTART}" = "1" ]; then
      PDC_TOKEN="$(openssl rand -hex 32 2>/dev/null || head -c 64 /dev/urandom | xxd -p -c 64)"
      if ! node "$JAISIU_PDC_MCP_NODE" --install \
            --pdc-binary "$PDC_DEST" \
            --pdc-port 8765 \
            --mcp-port 8766 \
            --auth-token "$PDC_TOKEN" \
            --config-path "$HOME/.jaisiu/state/jaisiu.json" \
            >/tmp/jaisiu-pdc-install.log 2>&1; then
        print_message WARN "PDC MCP install-time wiring failed; see /tmp/jaisiu-pdc-install.log (operator can re-run with jaisiu mcp-setup --install pdc)"
      else
        print_message OK "PDC MCP registered → gateway.mcpClients.pdc"
      fi
    else
      # Config-only path: write the MCP entry without spawning the daemon.
      PDC_TOKEN="$(openssl rand -hex 32 2>/dev/null || head -c 64 /dev/urandom | xxd -p -c 64)"
      if ! node "$JAISIU_PDC_MCP_NODE" --install \
            --pdc-binary "$PDC_DEST" \
            --auth-token "$PDC_TOKEN" \
            --config-path "$HOME/.jaisiu/state/jaisiu.json" \
            --dry-run \
            >/dev/null 2>&1; then
        print_message WARN "PDC MCP dry-run failed (non-interactive)"
      else
        print_message OK "PDC MCP config wired (autostart deferred)"
      fi
    fi
  else
    print_message INFO "no compiled mcp-setup module found; skipping PDC MCP wiring (operator: run 'jaisiu mcp-setup --install pdc' after install)"
  fi

  # ---- macOS LaunchAgent for autostart --------------------------------------
  if [ "$OS_NAME" = "darwin" ] && [ "${JAISIU_INSTALL_PDC_AUTOSTART}" = "1" ]; then
    LAUNCH_AGENTS_DIR="$HOME/Library/LaunchAgents"
    mkdir -p "$LAUNCH_AGENTS_DIR"
    PLIST_TEMPLATE="$EXTRACT_DIR/usr/share/jaisiu/macos/ai.openclaw.pdc.installed.plist"
    PLIST_DEST="$LAUNCH_AGENTS_DIR/ai.openclaw.pdc.plist"
    if [ -f "$PLIST_TEMPLATE" ]; then
      PDC_TOKEN_FOR_PLIST="${PDC_TOKEN:-}"
      if [ -z "$PDC_TOKEN_FOR_PLIST" ]; then
        PDC_TOKEN_FOR_PLIST="$(openssl rand -hex 32 2>/dev/null || head -c 64 /dev/urandom | xxd -p -c 64)"
      fi
      NODE_BIN_DIR="$(dirname "$(command -v node 2>/dev/null || echo /usr/bin/node)")"
      sed -e "s|@USER_HOME@|$HOME|g" \
          -e "s|@NODE_BIN@|$NODE_BIN_DIR|g" \
          -e "s|@DISPLAY_NAME@|$(hostname)|g" \
          -e "s|@GATEWAY_TOKEN@|$PDC_TOKEN_FOR_PLIST|g" \
          "$PLIST_TEMPLATE" > "$PLIST_DEST"
      launchctl load -w "$PLIST_DEST" 2>/dev/null || print_message WARN "launchctl load failed (operator: launchctl load -w $PLIST_DEST)"
      print_message OK "macOS LaunchAgent installed → $PLIST_DEST"
    else
      print_message INFO "no PDC LaunchAgent template in install tree; skip (operator can wire manually)"
    fi
  fi

  # Stash the PDC path so downstream installers (post-install scripts, OS
  # service hooks) can reference it without re-running the resolver.
  export PDC_BINARY_PATH="$PDC_DEST"
  # Verify version changed by reading package.json from the new app tree.
  NEW_VER="$(jq -r .version "$JAISIU_APP_DIR/package.json" 2>/dev/null \
             || python3 -c "import json,sys;print(json.load(open('$JAISIU_APP_DIR/package.json'))['version'])" 2>/dev/null \
             || grep -m1 '"version"' "$JAISIU_APP_DIR/package.json" | sed -E 's/.*"version": *"([^"]+)".*/\1/')"
  print_message OK "app version: ${NEW_VER:-unknown}"
else
  print_message INFO "BUN artifact — launcher is self-contained, skipping ~/app/"
fi

# ---- bind license (trial if not provided) --------------------------------
# Canonical contract per packages/pryzm.at/backend/api/jaisiu_keys.go and
# src/daemon/service-env.ts:
#   - ~/.jaisiu/state/jaisiu.json contains { license: { key, endpoint, ... } }
#     and (if a broker key is supplied) { "pryzm-at-broker": { key, baseUrl } }
#   - The user email (always prompted up-front) lands at identity.email so
#     the daemon can attribute usage / hit /install/claim endpoints
#     without re-prompting (see EFSS portal efss-portal-api.ts install-claim).
#   - OS service env vars (LaunchAgent / systemd) carry JAISIU_LICENSE_KEY,
#     JAISIU_BROKER_KEY, JAISIU_EMAIL so the daemon picks them up at first
#     launch even before reading the config file.
#   - JAISIU_LICENSE_KEY_PREFIX is honored (portal-customer commands embed
#     the prefix as a placeholder; we don't write it to the config because
#     it's not a valid key).
STATE_JSON="${HOME}/.jaisiu/state/jaisiu.json"
mkdir -p "$(dirname "$STATE_JSON")"

# write_identity_block — emits ONLY the { identity: { email, capturedAt } }
# block to stdout. Used both by the license path and the trial path so
# identity.email is persisted regardless of whether a license was supplied.
# Output: JSON object. Returns 0 always (jq/python3 are best-effort; if
# neither is available, returns a printf-only block — caller decides how
# to merge).
# write_identity_block — emits a metadata-only block (the runtime's
# IdentitySchema only accepts name|theme|emoji|avatar, no email field).
# We persist `meta.lastTouchedAt` + `meta.lastTouchedVersion` so operators
# can audit when the install last ran without violating the schema.
# Email attribution is plumbed via JAISIU_EMAIL env var on the OS service
# (launchd plist / systemd --user unit).
#
# Bug history (operator trace 2026-08-24): earlier revisions emitted a
# top-level { identity: { email, capturedAt } }, which the runtime's
# legacy migration moves into agents.list[].identity. The migrated entry
# is then rejected by IdentitySchema (it doesn't declare email), so the
# gateway refuses to start with "Config invalid" + doctor suggestions.
_write_identity_block() {
    # Bug history (operator trace 2026-08-24, Mac-mini end-to-end):
    # OpenClawSchema declares `meta` as `.strict()` with ONLY
    # lastTouchedVersion + lastTouchedAt permitted (no email, no
    # capturedAt, no identity). Earlier revisions of this block emitted
    # `email` inside `meta` which the strict-mode validator rejected with
    # "Config invalid: meta: Unrecognized key: 'email'", causing the
    # gateway to refuse to start. Email attribution now travels through
    # the OS service env (JAISIU_EMAIL in the launchd plist's
    # EnvironmentVariables, or systemd --user Environment=). This block
    # emits only the schema-valid {meta:{lastTouchedAt,lastTouchedVersion}}.
    local captured_at
    captured_at="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "")"
    if command -v jq >/dev/null 2>&1; then
        jq -nc --arg t "${captured_at}" \
            '{meta: {lastTouchedAt: $t, lastTouchedVersion: "install.sh"}}'
    elif command -v python3 >/dev/null 2>&1; then
        T="${captured_at}" python3 -c "
import json, os
print(json.dumps({'meta': {'lastTouchedAt': os.environ['T'], 'lastTouchedVersion': 'install.sh'}}, indent=2))
"
    else
        printf '{\"meta\":{\"lastTouchedAt\":\"%s\",\"lastTouchedVersion\":\"install.sh\"}}\n' "$captured_at"
    fi
}

# merge_identity_into_state — reads $STATE_JSON (if any), deep-merges
# the metadata block into the existing state, writes back atomically with
# mode 600. Idempotent.
_merge_identity_into_state() {
    local state="$STATE_JSON" tmp; tmp="$(mktemp 2>/dev/null || echo "${state}.tmp")"
    local id_block; id_block="$(_write_identity_block)"
    if [ -f "$state" ] && command -v jq >/dev/null 2>&1; then
        # Deep-merge so we don't clobber existing keys. Same operator as
        # _merge_state_object — for the metadata block this matters less
        # (it's tiny), but we keep the semantics consistent across the
        # codebase so callers don't have to remember which path uses which.
        if jq -s '.[0] * .[1]' "$state" <(printf '%s' "$id_block") > "$tmp" 2>/dev/null; then
            mv "$tmp" "$state"; chmod 600 "$state" 2>/dev/null || true
            return 0
        fi
        rm -f "$tmp"
        return 1
    fi
    # No state file or no jq: write the metadata block as the seed state.
    # Later license-binding calls write_state_json below will replace this
    # atomically.
    printf '%s\n' "$id_block" > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }
    mv "$tmp" "$state"; chmod 600 "$state" 2>/dev/null || true
    return 0
}

# Merge an arbitrary JSON object into the existing state file without
# clobbering unrelated keys (wizard.lastRunAt, plugins.entries.*,
# gateway.auth.token, custom agent defaults, etc). Used by write_state_json
# below to make reinstall additive rather than destructive.
#
# jq deep-merges objects, replaces arrays — matches the operator's mental
# model: "the install refreshes the license + provider routing block, and
# leaves everything else alone."
_merge_state_object() {
    local state="$STATE_JSON" fragment="$1" tmp
    # No-op when there's nothing to merge. Returning 0 lets callers treat
    # _merge_state_object as idempotent — important for write_state_json
    # paths that may emit empty fragments when JAISIU_BROKER_KEY is unset.
    [ -n "$fragment" ] || return 0
    tmp="$(mktemp 2>/dev/null || echo "${state}.tmp")"
    # install.sh is intentionally standalone (no dependency on lib/
    # entrypoint.sh's jaisiu_jq) — see comment near _read_tty_line. Try
    # jq first, then python3 (re-implementing deep-merge semantics in
    # bash for the no-jq case is not worth the surface area).
    if [ -f "$state" ] && command -v jq >/dev/null 2>&1; then
        if printf '%s' "$fragment" | jq -e . >/dev/null 2>&1; then
            if jq -s '.[0] * .[1]' "$state" <(printf '%s' "$fragment") > "$tmp" 2>/dev/null; then
                mv "$tmp" "$state"; chmod 600 "$state" 2>/dev/null || true
                return 0
            fi
        fi
    elif [ -f "$state" ] && command -v python3 >/dev/null 2>&1; then
        # Python deep-merge: at each level, fragment overrides state for
        # overlapping scalar/array keys; nested dicts are merged
        # recursively. Same semantics as jq's `*` operator.
        if python3 -c "
import json, sys
state = json.load(open('${state}'))
frag = json.loads(sys.argv[1])
def deep_merge(a, b):
    out = dict(a) if isinstance(a, dict) else {}
    if isinstance(b, dict):
        for k, v in b.items():
            out[k] = deep_merge(out.get(k), v) if k in out and isinstance(out.get(k), dict) and isinstance(v, dict) else v
    return out
result = deep_merge(state, frag)
json.dump(result, sys.stdout)
" "$fragment" > "$tmp" 2>/dev/null; then
            mv "$tmp" "$state"; chmod 600 "$state" 2>/dev/null || true
            return 0
        fi
    fi
    # Fallback: no jq, no python3, or empty/corrupt fragment.
    # Last-resort overwrite — operator gets a fresh state file with
    # just the new fragment. Better than failing the install.
    printf '%s\n' "$fragment" > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }
    mv "$tmp" "$state"; chmod 600 "$state" 2>/dev/null || true
    return 0
}

# Persist identity.email regardless of license state (acceptance criterion
# "Email is persisted in state/jaisiu.json" — works in trial mode too).
if [ -n "${JAISIU_EMAIL_VALUE:-}" ]; then
    if _merge_identity_into_state; then
        print_message OK "identity persisted → $STATE_JSON (email=${JAISIU_EMAIL_VALUE})"
    else
        print_message WARN "could not persist identity to $STATE_JSON; email will live in env only"
    fi
fi

# Auto-claim from pryzm.at API portal when:
#   - email is provided (we have identity), AND
#   - no license key was passed manually (operator opted in to portal flow), AND
#   - JAISIU_SKIP_PORTAL_CLAIM is unset (escape hatch for offline installs),
#   - AND JAISIU_PORTAL_TOKEN is set (production pryzm.at rejects the
#     public fallback with 401 invalid_agent_token -- bf9f51d6ed).
# We POST directly to the CSRF-exempt upstream endpoint
# /api/install/agent-bootstrap (auth: X-Install-Agent-Token) instead of
# the EFSS-portal proxy /api/internal/install-claim. The proxy adds CSRF
# protection that operators running an unattended installer on a headless
# host can't satisfy -- they don't have a browser session to mint the
# cookie from. The upstream is the same Go backend (deployed via
# packages/pryzm.at/backend) and the response shape is identical. See
# packages/pryzm.at/backend/api/csrf.go:64 (csrfExemptInstallAgent) for
# the rationale.
#
# On success: populates JAISIU_LICENSE_KEY_VALUE + JAISIU_BROKER_KEY_VALUE
# so the existing write_state_json path picks them up and writes
# models.providers.pryzm-at-broker with agents.defaults.model.primary
# = 'pryzm-at-broker/MiniMax-M3' -- the operator's pryzm.at broker,
# NOT our internal MiniMax prod key.
#
# Bug history: 7b6b105c64 shipped this against the EFSS-portal proxy
# which has CSRF. bf9f51d6ed switched to the CSRF-exempt upstream and
# the X-Install-Agent-Token header. 7bfa31edcb (a publish-releases
# hardening fix) silently reverted both blocks during a sweeping
# install.sh diff. This restore re-applies bf9f51d6ed's flow so Mac +
# Windows installers can still auto-claim license + broker keys for
# the operator email when no JAISIU_LICENSE_KEY is passed.

if [ "${JAISIU_SKIP_PORTAL_CLAIM:-0}" != "1" ] \
   && [ "${JAISIU_SKIP_PORTAL_CLAIM_EFFECTIVE:-0}" != "1" ] \
   && [ -z "${JAISIU_LICENSE_KEY_VALUE:-}" ] \
   && [ -n "${JAISIU_EMAIL_VALUE:-}" ]; then
    if [ -z "${JAISIU_PORTAL_TOKEN:-}" ]; then
        print_message WARN "JAISIU_PORTAL_TOKEN not set; auto-claim will fail with 401 invalid_agent_token. Falling back to trial mode. Set JAISIU_PORTAL_TOKEN=\$PRYZM_INSTALL_AGENT_TOKEN to enable portal claim."
        JAISIU_SKIP_PORTAL_CLAIM_EFFECTIVE=1
    else
        portal_token="$JAISIU_PORTAL_TOKEN"
    fi
    # Re-guard: the outer guard above checked JAISIU_SKIP_PORTAL_CLAIM_EFFECTIVE
    # *before* we set it here, so the inner assignment can't re-skip this block
    # via the same flag. Skip explicitly when portal_token is missing to avoid
    # 'portal_token: unbound variable' under `set -u` (line 43). See
    # 44eac8f01 commit message for the original bug trace.
    if [ "${JAISIU_SKIP_PORTAL_CLAIM_EFFECTIVE:-0}" = "1" ] || [ -z "${portal_token:-}" ]; then
        JAISIU_SKIP_PORTAL_CLAIM_EFFECTIVE=1
        # Skip the auto-claim block entirely — fall through to trial mode
        # with the email already persisted to state/jaisiu.json by the
        # _merge_identity_into_state call above. This is the intended
        # acceptance criterion for the prompt regression: email is captured
        # and persisted even when portal claim is unavailable.
    else
        hostname_short="$(hostname -s 2>/dev/null || uname -n 2>/dev/null || echo unknown)"
        arch="$(uname -m 2>/dev/null || echo unknown)"
        os="$(uname -s 2>/dev/null | tr '[:upper:]' '[:lower:]' || echo unknown)"
        platform_hint=""
        if [ "${os}" = "darwin" ]; then platform_hint="macos"; fi
        if [ "${os}" = "linux" ] && [ -f /proc/version ] && grep -qiE 'microsoft|wsl' /proc/version 2>/dev/null; then
            platform_hint="linux-wsl"
        fi

        claim_body=$(printf '{"email":"%s","hostId":"%s","agentName":"%s","upstreamMode":"pooled"}' \
            "$JAISIU_EMAIL_VALUE" "$hostname_short" "Jaisiu install on ${hostname_short}")

        print_message INFO "auto-claim from pryzm.at API portal (email=${JAISIU_EMAIL_VALUE})..."
        claim_resp="$(curl -fsS --max-time 20 \
            -X POST \
            -H "Content-Type: application/json" \
            -H "X-Install-Agent-Token: ${portal_token:-}" \
            -d "$claim_body" \
            "https://pryzm.at/api/install/agent-bootstrap" 2>/dev/null || true)"

        if [ -n "$claim_resp" ]; then
            if command -v jq >/dev/null 2>&1; then
                # agent-bootstrap returns {key, brokerKey, brokerBaseUrl,
                # licenseEndpoint, keyPrefix, brokerKeyPrefix, upstreamMode, ...}
                claim_lk="$(printf '%s' "$claim_resp" | jq -r '.key // empty' 2>/dev/null || true)"
                claim_bk="$(printf '%s' "$claim_resp" | jq -r '.brokerKey // empty' 2>/dev/null || true)"
                claim_bb="$(printf '%s' "$claim_resp" | jq -r '.brokerBaseUrl // empty' 2>/dev/null || true)"
                claim_le="https://pryzm.at/api/node/license"
                claim_err="$(printf '%s' "$claim_resp" | jq -r '.error // empty' 2>/dev/null || true)"
            elif command -v python3 >/dev/null 2>&1; then
                claim_lk="$(printf '%s' "$claim_resp" | python3 -c "
import json, sys
try:
  d=json.load(sys.stdin)
  print(d.get('key',''), end='')
except: pass
" 2>/dev/null)"
                claim_bk="$(printf '%s' "$claim_resp" | python3 -c "
import json, sys
try:
  d=json.load(sys.stdin)
  print(d.get('brokerKey',''), end='')
except: pass
" 2>/dev/null)"
                claim_bb="$(printf '%s' "$claim_resp" | python3 -c "
import json, sys
try:
  d=json.load(sys.stdin)
  print(d.get('brokerBaseUrl',''), end='')
except: pass
" 2>/dev/null)"
                claim_le="https://pryzm.at/api/node/license"
                claim_err="$(printf '%s' "$claim_resp" | python3 -c "
import json, sys
try:
  d=json.load(sys.stdin)
  print(d.get('error',''), end='')
except: pass
" 2>/dev/null)"
            else
                print_message WARN "neither jq nor python3 found; skipping portal claim parse"
                claim_lk=""; claim_bk=""; claim_bb=""; claim_le=""; claim_err=""
            fi

            if [ -n "$claim_err" ]; then
                print_message WARN "portal claim failed: $claim_err (falling back to trial)"
            elif [ -z "$claim_lk" ]; then
                print_message WARN "portal claim returned no license key (falling back to trial)"
            else
                JAISIU_LICENSE_KEY_VALUE="$claim_lk"
                if [ -n "$claim_bk" ]; then
                    JAISIU_BROKER_KEY_VALUE="$claim_bk"
                    if [ -n "$claim_bb" ]; then
                        JAISIU_BROKER_BASE_URL_VALUE="$claim_bb"
                    fi
                    if [ -n "$claim_le" ]; then
                        JAISIU_LICENSE_ENDPOINT_VALUE="$claim_le"
                    fi
                    print_message OK "portal claim issued license + broker key for ${JAISIU_EMAIL_VALUE}"
                else
                    print_message OK "portal claim issued license for ${JAISIU_EMAIL_VALUE} (no broker key -- license_only mode)"
                fi
            fi
        else
            print_message WARN "portal claim unreachable; falling back to trial mode (set JAISIU_LICENSE_KEY manually to skip portal)"
        fi
    fi
fi
if [ -n "${JAISIU_LICENSE_KEY_VALUE:-}" ]; then

  # 1) probe the prod license endpoint (non-fatal on failure).
  PROBE_BODY=$(printf '{"key":"%s"}' "$JAISIU_LICENSE_KEY_VALUE")
  PROBE_RESP="$(curl -fsS --max-time 15 \
      -X POST -H "Content-Type: application/json" \
      -d "$PROBE_BODY" \
      "https://pryzm.at/api/node/license" 2>/dev/null || true)"
  case "$PROBE_RESP" in
    *'"valid":true'*|*'"valid": true'*)
      print_message OK "license key validated against pryzm.at"
      ;;
    *'"valid":false'*|*'"valid": false'*)
      print_message WARN "license endpoint rejected key (probe says invalid); binding anyway in trial"
      ;;
    *)
      print_message WARN "license probe inconclusive; binding key without validation"
      ;;
  esac

  # 2) write ~/.jaisiu/state/jaisiu.json (canonical config path per
  # src/config/paths.ts:166 — Jaisiu roots use $STATE_DIR/state/jaisiu.json).
  # The daemon reads this at boot. We must NOT skip writing if jq is missing
  # — that's the bug that broke the krzysztof install (jq absent on macOS,
  # license key never reached the config).
  # STATE_JSON is declared earlier (outside the license branch) so identity
  # is also persisted in trial mode.
write_state_json() {
  # Build the config in $tmp. Schema per
  # src/config/zod-schema.core.ts:ModelProviderSchema + integration test
  # scripts/install-sh-integration/install-sh-promise.test.mjs.
  #
  # Provider routing contract (per JAISIU-2510 / JAISIU-2016):
  #   - When JAISIU_BROKER_KEY is supplied:
  #       write models.providers.pryzm-at-broker AND set
  #       agents.defaults.model.primary = 'pryzm-at-broker/MiniMax-M3'
  #       (otherwise runtime falls back to the third-party minimax-portal/
  #       MiniMax-M3 — JAISIU-2510 fix).
  #   - When absent:
  #       skip with a printed warning. Runtime defaults are preserved
  #       (so tests/license_only users stay on minimax-portal/MiniMax-M3
  #       until they set JAISIU_BROKER_KEY).
  local k="$JAISIU_LICENSE_KEY_VALUE"
  local bk="${JAISIU_BROKER_KEY_VALUE:-}"
  local primary='pryzm-at-broker/MiniMax-M3'
  # Unified JWT (pooled default): license.key is also the broker apiKey.
  if [ -z "${bk}" ]; then
    case "${k}" in
      eyJ*.*.*) bk="${k}" ;;
    esac
  fi
  if command -v jq >/dev/null 2>&1; then
    if [ -n "$bk" ]; then
      jq -n --arg k "$k" --arg bk "$bk" --arg p "$primary" '
        {
          license: { key: $k, endpoint: "/api/node/license" },
          models: {
            providers: {
              "pryzm-at-broker": {
                baseUrl: "https://api.pryzm.at/v1",
                apiKey: $bk,
                models: [{
                  id: "MiniMax-M3",
                  name: "MiniMax M3",
                  contextWindow: 1000000,
                  maxTokens: 8192,
                  input: ["text", "image"]
                }]
              }
            }
          },
          agents: {
            defaults: {
              model: {
                primary: $p,
                fallbacks: [$p]
              }
            }
          }
        }
      '
    else
      print_message WARN "no broker credential — setting agents.defaults.model.primary only (provider entry skipped)"
      jq -n --arg k "$k" --arg p "$primary" '
        {
          license: { key: $k, endpoint: "/api/node/license" },
          agents: {
            defaults: {
              model: {
                primary: $p,
                fallbacks: [$p]
              }
            }
          }
        }
      '
    fi
  elif command -v python3 >/dev/null 2>&1; then
    if [ -n "$bk" ]; then
      K="$k" BK="$bk" python3 -c "
import json, os
d = {
  'license': {'key': os.environ['K'], 'endpoint': '/api/node/license'},
  'models': {
      'providers': {
          'pryzm-at-broker': {
              'baseUrl': 'https://api.pryzm.at/v1',
              'apiKey': os.environ['BK'],
              'models': [{
                  'id': 'MiniMax-M3',
                  'name': 'MiniMax M3',
                  'contextWindow': 1000000,
                  'maxTokens': 8192,
                  'input': ['text', 'image']
              }]
          }
      }
  },
  'agents': {
      'defaults': {
          'model': {
              'primary': '$primary',
              'fallbacks': ['$primary']
          }
      }
  }
}
print(json.dumps(d, indent=2))
"
    else
      print_message WARN "JAISIU_BROKER_KEY is not set; license_only mode — setting agents.defaults.model.primary"
      K="$k" P="$primary" python3 -c "
import json, os
d = {
  'license': {'key': os.environ['K'], 'endpoint': '/api/node/license'},
  'agents': {
      'defaults': {
          'model': {
              'primary': os.environ['P'],
              'fallbacks': [os.environ['P']]
          }
      }
  }
}
print(json.dumps(d, indent=2))
"
    fi
  else
    # printf JSON fallback (no jq + no python3). Escape values.
    local _k="${k//\\/\\\\}"; _k="${_k//\"/\\\"}"
    local _bk="${bk//\\/\\\\}"; _bk="${_bk//\"/\\\"}"
    if [ -n "$bk" ]; then
      printf '{\n  "license": {\n    "key": "%s",\n    "endpoint": "/api/node/license"\n  },\n  "models": {\n    "providers": {\n      "pryzm-at-broker": {\n        "baseUrl": "https://api.pryzm.at/v1",\n        "apiKey": "%s",\n        "models": [\n          {\n            "id": "MiniMax-M3",\n            "name": "MiniMax M3",\n            "contextWindow": 1000000,\n            "maxTokens": 8192,\n            "input": ["text", "image"]\n          }\n        ]\n      }\n    }\n  },\n  "agents": {\n    "defaults": {\n      "model": {\n        "primary": "pryzm-at-broker/MiniMax-M3",\n        "fallbacks": ["pryzm-at-broker/MiniMax-M3"]\n      }\n    }\n  }\n}\n' "$_k" "$_bk"
    else
      print_message WARN "JAISIU_BROKER_KEY is not set; license_only mode — setting agents.defaults.model.primary"
      printf '{\n  "license": {\n    "key": "%s",\n    "endpoint": "/api/node/license"\n  },\n  "agents": {\n    "defaults": {\n      "model": {\n        "primary": "pryzm-at-broker/MiniMax-M3",\n        "fallbacks": ["pryzm-at-broker/MiniMax-M3"]\n      }\n    }\n  }\n}\n' "$_k"
    fi
  fi
}

# Capture the license + provider fragment BEFORE any state write.
  # write_state_json writes a fresh {license, models, agents} object
  # on every install — operators expect it to refresh the license +
  # provider routing, but NOT to clobber wizard.lastRunAt, plugins,
  # gateway.auth.token, or any custom config they set via `jaisiu config
  # set` between installs. So we deep-merge the fragment into the
  # existing state instead of overwriting (see _merge_state_object).
  #
  # NB: This block sits at TOP-LEVEL (not inside any function). Bug
  # history (operator trace 2026-08-24, Mac-mini end-to-end): earlier
  # revisions declared these as `local`, which fails on macOS bash 3.2
  # with `bash: can only be used in a function`. Variables here are
  # global on purpose — they feed `_merge_state_object` and the
  # post-merge Redis patch which run at top level too.
  state_fragment=""
  if state_fragment="$(write_state_json 2>/dev/null)"; then
    : # captured
  fi
  if [ -n "$state_fragment" ]; then
    if _merge_state_object "$state_fragment"; then
      # Re-merge identity on top so the license write above doesn't
      # clobber the identity we wrote earlier (or vice-versa). Idempotent
      # and safe to call whether or not identity was previously seeded.
      _merge_identity_into_state || true

      # JAISIU-2889-bis (2026-09-07): do NOT unconditionally force
      # `gateway.prism.preferredAdapter = memory`. Pre-fix the installer
      # patched this on every install because the embedded default
      # PRISM_REDIS_HOST pointed at prod (185.25.148.67) and the
      # gateway logged noise when it couldn't reach it. That patch
      # silently downgraded every host (Macs that DID have a local
      # Redis to talk to included) to the in-memory adapter, which in
      # turn broke fleet routing features that depend on Redis pubsub.
      #
      # New default: leave `gateway.prism.preferredAdapter` alone
      # (whatever the bundled binary's runtime defaults to, which is
      # "redis" for daemon-installed operators). Operators whose
      # harness genuinely cannot reach the broker's Redis opt in
      # explicitly with:
      #     JAISIU_FORCE_MEMORY_ADAPTER=1 bash install.sh
      # Legacy JAISIU_USE_REDIS=1 still forces memory for backward
      # compatibility with ops scripts that pre-date this fix.
      if [ "${JAISIU_FORCE_MEMORY_ADAPTER:-${JAISIU_USE_REDIS:-0}}" = "1" ]; then
        if command -v jq >/dev/null 2>&1 && [ -f "$STATE_JSON" ]; then
          tmp_memory="$(mktemp 2>/dev/null || echo "${STATE_JSON}.tmp")"
          if jq '.gateway.prism.preferredAdapter = "memory"' "$STATE_JSON" > "$tmp_memory" 2>/dev/null; then
            mv "$tmp_memory" "$STATE_JSON"; chmod 600 "$STATE_JSON" 2>/dev/null || true
            print_message OK "gateway.prism.preferredAdapter=memory patched into state.json (Redis skipped, JAISIU_FORCE_MEMORY_ADAPTER)"
          else
            rm -f "$tmp_memory" 2>/dev/null || true
          fi
        elif command -v python3 >/dev/null 2>&1 && [ -f "$STATE_JSON" ]; then
          if python3 -c "
import json
with open('${STATE_JSON}') as f: d = json.load(f)
d.setdefault('gateway', {}).setdefault('prism', {})['preferredAdapter'] = 'memory'
with open('${STATE_JSON}','w') as f: json.dump(d, f, indent=2)
" 2>/dev/null; then
            print_message OK "gateway.prism.preferredAdapter=memory patched into state.json (Redis skipped, JAISIU_FORCE_MEMORY_ADAPTER)"
          fi
        fi
      else
        # Sentinel log so operators have proof the patch was deliberately
        # skipped (the previous silent-skip bug was the original
        # complaint).
        print_message INFO "gateway.prism.preferredAdapter left untouched (set JAISIU_FORCE_MEMORY_ADAPTER=1 to force memory)"
      fi

      print_message OK "config merged → ~/.jaisiu/state/jaisiu.json (license + provider routing; existing wizard/plugins/custom keys preserved)"
      if type state_install_provider_routing >/dev/null 2>&1; then
        JAISIU_CONFIG="${STATE_JSON}"
        export JAISIU_CONFIG
        state_install_provider_routing || true
      fi
      _JAISIU_LICENSE_STATE_WRITTEN=1
    else
      print_message WARN "writing state/jaisiu.json failed; daemon will rely on env vars only"
    fi
  fi

  # 3) export for the OS service env (LaunchAgent / systemd unit).
  export JAISIU_LICENSE_KEY="$JAISIU_LICENSE_KEY_VALUE"
  [ -n "${JAISIU_BROKER_KEY_VALUE:-}" ] && export JAISIU_BROKER_KEY="$JAISIU_BROKER_KEY_VALUE"
  [ -n "${JAISIU_EMAIL_VALUE:-}" ] && export JAISIU_EMAIL="$JAISIU_EMAIL_VALUE"
else
  # Post-download: only trial when operator explicitly skipped license.
  if [ "${_JAISIU_PATH_B_ACQUIRED:-0}" != "1" ] && [ -z "${JAISIU_LICENSE_KEY_VALUE:-}" ]; then
    print_message INFO "no JAISIU_LICENSE_KEY supplied — running in trial mode"
    # JAISIU-2008 follow-up: in trial mode the daemon still needs to start.
    # post_install_defaults() (which generates gateway.auth.token + sets
    # memory.backend=builtin) returns early when there's no license key AND
    # not non-interactive. Without those defaults the gateway crashes with
    # either "Gateway start blocked: set gateway.mode=local" or
    # "Gateway auth is set to token, but no token is configured".
    # Apply the minimum viable defaults inline so the user's first launch
    # works in trial mode.
    # JAISIU-2888 (Bug B fix 2026-08-27): do NOT wire
    # agents.defaults.model.primary or plugins.entries.minimax-portal-auth
    # in trial mode. Both reference a third-party provider we have no
    # license for. Leave model.primary unset (runtime has its own safe
    # default). The runtime will refuse any agent call until a real
    # provider is wired — that's the honest behavior for trial mode.
    if [ -f "$STATE_JSON" ] && command -v jq >/dev/null 2>&1; then
      tmp_mode="$(mktemp 2>/dev/null || echo "${STATE_JSON}.tmp")"
      auth_token=""
      if command -v openssl >/dev/null 2>&1; then
        auth_token="$(openssl rand -hex 32 2>/dev/null || true)"
      fi
      [ -z "${auth_token}" ] && auth_token="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' 2>/dev/null || true)"
      if jq --arg token "${auth_token:-}" '
          .gateway = (.gateway // {})
          | .gateway.mode = "local"
          | .gateway.auth = (.gateway.auth // {})
          | .gateway.auth.mode = (if (.gateway.auth.token // "") != "" then "token" else (.gateway.auth.mode // "token") end)
          | .gateway.auth.token = (if ($token // "") != "" then $token else (.gateway.auth.token // "") end)
          | .memory = (.memory // {})
          | .memory.backend = "builtin"
          | del(.plugins.entries["minimax-portal-auth"])
          | del(.agents.defaults.model)
        ' "$STATE_JSON" > "$tmp_mode" 2>/dev/null; then
        mv "$tmp_mode" "$STATE_JSON"; chmod 600 "$STATE_JSON" 2>/dev/null || true
        print_message OK "trial-mode defaults patched: gateway.mode=local, gateway.auth.token (32-byte random), memory.backend=builtin. NO LLM provider wired (see WARN below — JAISIU-2888)."
      else
        rm -f "$tmp_mode" 2>/dev/null || true
        print_message WARN "trial-mode defaults patch failed; gateway may not start cleanly"
      fi
    elif [ -f "$STATE_JSON" ] && command -v python3 >/dev/null 2>&1; then
      if python3 -c "
import json, os
with open('${STATE_JSON}') as f: d = json.load(f)
d.setdefault('gateway', {})
d['gateway']['mode'] = 'local'
d['gateway'].setdefault('auth', {})
if not d['gateway']['auth'].get('token'):
    token = os.urandom(32).hex()
    d['gateway']['auth']['token'] = token
    d['gateway']['auth']['mode'] = d['gateway']['auth'].get('mode', 'token')
d.setdefault('memory', {})['backend'] = 'builtin'
# JAISIU-2888: don't wire minimax-portal-auth or model.primary in trial
d.get('plugins', {}).get('entries', {}).pop('minimax-portal-auth', None)
d.get('agents', {}).get('defaults', {}).pop('model', None)
with open('${STATE_JSON}','w') as f: json.dump(d, f, indent=2)
" 2>/dev/null; then
        print_message OK "trial-mode defaults patched: gateway.mode=local, gateway.auth.token, memory.backend=builtin. NO LLM provider wired (see WARN below — JAISIU-2888)."
      else
        print_message WARN "trial-mode defaults patch failed; gateway may not start cleanly"
      fi
    fi
    # JAISIU-2888: ensure credentials dir exists so doctor stops warning.
    mkdir -p "${HOME}/.jaisiu/credentials" 2>/dev/null || true
    chmod 700 "${HOME}/.jaisiu/credentials" 2>/dev/null || true
    print_message WARN "trial mode: no LLM provider configured. Run \`jaisiu license add\` or set JAISIU_LICENSE_KEY env then restart. Gateway will boot for local inspection only."
    # Persist the WARN to a file in the state dir so the operator can
    # find it later (the install log gets rotated).
    cat > "${STATE_JSON%/*}/trial-mode-warning.txt" 2>/dev/null <<'TWARN' || true
trial mode active — no LLM provider configured
=============================================

This install ran in trial mode (no JAISIU_LICENSE_KEY / JAISIU_BROKER_KEY
were supplied, or the email+OTP signup path was either skipped or failed).

In trial mode the gateway boots for local inspection only — no agent
calls will succeed until you wire a real provider. To enable the full
gateway:

  1. Re-run the installer with credentials:
       curl -fsSL https://pryzm.at/efss/jaisiu/install.sh \\
         | JAISIU_LICENSE_KEY="eyJ..." \\
           bash

  2. Or paste them into /etc/jaisiu.env (system-wide) or
     ~/.config/jaisiu/env (user) before re-running the installer.

  3. Or run `jaisiu license add` after install (interactive).

JAISIU-2888 fix shipped 2026-08-27.
TWARN
  fi
fi

# If Path B succeeded inside the else branch, replay the license-branch
# writes so the new key lands in state/jaisiu.json + the OS service env.
# Early pre-download Path B success is handled by the main license branch
# above (_JAISIU_LICENSE_STATE_WRITTEN=1).
if [ "${_JAISIU_PATH_B_ACQUIRED:-0}" = "1" ] && [ "${_JAISIU_LICENSE_STATE_WRITTEN:-0}" != "1" ]; then
    print_message OK "Path B acquired — promoting license branch (writing state.json + service env)"
    # 1) probe the prod license endpoint (same as license branch)
    PROBE_BODY=$(printf '{"key":"%s"}' "$JAISIU_LICENSE_KEY_VALUE")
    PROBE_RESP="$(curl -fsS --max-time 15 \
        -X POST -H "Content-Type: application/json" \
        -d "$PROBE_BODY" \
        "https://pryzm.at/api/node/license" 2>/dev/null || true)"
    case "$PROBE_RESP" in
      *'"valid":true'*|*'"valid": true'*)
        print_message OK "license key validated against pryzm.at"
        ;;
      *'"valid":false'*|*'"valid": false'*)
        print_message WARN "license endpoint rejected key (probe says invalid); binding anyway in trial"
        ;;
      *)
        print_message WARN "license probe inconclusive; binding key without validation"
        ;;
    esac
    # 2) Set up broker + endpoint defaults for the write_state_json fragment
    : "${JAISIU_BROKER_KEY_VALUE:=${JAISIU_BROKER_KEY:-}}"
    : "${JAISIU_BROKER_BASE_URL_VALUE:=${JAISIU_BROKER_BASE:-https://api.pryzm.at/v1}}"
    : "${JAISIU_LICENSE_ENDPOINT_VALUE:=${JAISIU_LICENSE_ENDPOINT:-https://pryzm.at/api/node/license}}"
    # 3) Re-run the state.json write inline (same logic as license branch)
    state_fragment="$(write_state_json 2>/dev/null || true)"
    if [ -n "${state_fragment:-}" ] && _merge_state_object "${state_fragment}"; then
        _merge_identity_into_state || true
        # Mirror the Redis-skip patch from the license branch.
        # JAISIU-2889-bis (2026-09-07): opt-in via
        # JAISIU_FORCE_MEMORY_ADAPTER=1 (or legacy JAISIU_USE_REDIS=1);
        # default is now "leave preferredAdapter alone".
        if [ "${JAISIU_FORCE_MEMORY_ADAPTER:-${JAISIU_USE_REDIS:-0}}" = "1" ] && command -v jq >/dev/null 2>&1 && [ -f "$STATE_JSON" ]; then
            tmp_memory="$(mktemp 2>/dev/null || echo "${STATE_JSON}.tmp")"
            if jq '.gateway.prism.preferredAdapter = "memory"' "$STATE_JSON" > "$tmp_memory" 2>/dev/null; then
                mv "$tmp_memory" "$STATE_JSON"; chmod 600 "$STATE_JSON" 2>/dev/null || true
                print_message OK "gateway.prism.preferredAdapter=memory patched into state.json (Redis skipped, JAISIU_FORCE_MEMORY_ADAPTER)"
            else
                rm -f "$tmp_memory" 2>/dev/null || true
            fi
        else
            print_message INFO "gateway.prism.preferredAdapter left untouched (set JAISIU_FORCE_MEMORY_ADAPTER=1 to force memory)"
        fi
        print_message OK "config merged → ~/.jaisiu/state/jaisiu.json (license + provider routing)"
        if type state_install_provider_routing >/dev/null 2>&1; then
          JAISIU_CONFIG="${STATE_JSON}"
          export JAISIU_CONFIG
          state_install_provider_routing || true
        fi
    else
        print_message WARN "writing state/jaisiu.json failed; daemon will rely on env vars only"
    fi
    # 4) export for the OS service env (LaunchAgent / systemd unit)
    export JAISIU_LICENSE_KEY="$JAISIU_LICENSE_KEY_VALUE"
    [ -n "${JAISIU_BROKER_KEY_VALUE:-}" ] && export JAISIU_BROKER_KEY="$JAISIU_BROKER_KEY_VALUE"
    [ -n "${JAISIU_EMAIL_VALUE:-}" ] && export JAISIU_EMAIL="$JAISIU_EMAIL_VALUE"
fi

# ---- PATH injection (idempotent, shell-aware) -----------------------------
add_to_path() {
  [ "$JAISIU_NO_MODIFY_PATH" = "1" ] && { print_message INFO "skipping PATH write (--no-modify-path)"; return; }
  local dir="$1"
  case ":$PATH:" in
    *":$dir:"*) print_message OK "$dir already on PATH" ; return ;;
  esac
  local shell_name rc_file
  shell_name="$(basename "${SHELL:-/bin/sh}")"
  case "$shell_name" in
    bash) rc_file="$HOME/.bashrc" ;;
    zsh)  rc_file="$HOME/.zshrc" ;;
    fish)
      mkdir -p "${HOME}/.config/fish"
      rc_file="${HOME}/.config/fish/config.fish"
      ;;
    ash|sh) rc_file="$HOME/.profile" ;;
    *)     rc_file="$HOME/.profile" ;;
  esac
  if [ -f "$rc_file" ] && grep -Fxq "export PATH=\"$dir:\$PATH\"" "$rc_file" 2>/dev/null; then
    print_message OK "$rc_file already exports $dir"
  else
    {
      printf '\n# added by jaisiu installer\n'
      case "$shell_name" in
        fish) printf 'set -gx PATH %s $PATH\n' "$dir" ;;
        *)    printf 'export PATH="%s:$PATH"\n' "$dir" ;;
      esac
    } >> "$rc_file"
    print_message OK "PATH export appended to $rc_file"
  fi
  # Ensure this process can find it (so subsequent steps work even without
  # re-sourcing the rc).
  export PATH="$dir:$PATH"
}
add_to_path "$BIN_DIR"

# ---- OS service registration (systemd / launchd / scheduled task) --------
# Note: stale-wrapper sweep moved into install/lib/entrypoint.sh
# (jaisiu_install_wrapper → _jaisiu_stale_wrapper_cleanup) so it ships in
# the bundled install.sh, not just install/install.sh.
register_service() {
  if [ "$JAISIU_NO_SERVICE" = "1" ]; then
    print_message INFO "skipping service registration (--no-service)"
    return
  fi
  case "$OS_NAME" in
    linux)
      # Prefer systemd --user; fall back to no service.
      if command -v systemctl >/dev/null 2>&1; then
        local unit="$HOME/.config/systemd/user/jaisiu.service"
        mkdir -p "$(dirname "$unit")"
        # JAISIU-2799 (2026-08-26): disable + remove dead prior-install
        # systemd units. Earlier installs (v0.99-rc1, the legacy
        # openclaw-gateway.service) reference /home/$USER/.jaisiu/app/
        # which we now wipe on reinstall. Without this disable, systemd
        # keeps trying to start a non-existent binary in auto-restart
        # loop, which masks the new install's status and (worse) can
        # fight the new gateway for TCP 18789.
        for legacy_unit in openclaw-gateway jaisiu-rc1 jaisiu-v0 jaisiu-gateway; do
          if [ -f "$HOME/.config/systemd/user/${legacy_unit}.service" ]; then
            print_message WARN "removing legacy systemd unit ${legacy_unit}.service"
            systemctl --user disable --now "${legacy_unit}.service" 2>/dev/null || true
            rm -f "$HOME/.config/systemd/user/${legacy_unit}.service"
          fi
        done
        systemctl --user daemon-reload 2>/dev/null || true
        systemctl --user reset-failed jaisiu openclaw-gateway jaisiu-rc1 jaisiu-v0 jaisiu-gateway 2>/dev/null || true
        # Build Environment= lines for license ONLY. broker + email live
        # in ~/.jaisiu/state/jaisiu.json (mode 600) — DO NOT pass them via
        # the service unit (the unit file is plaintext on disk; we don't
        # want secrets readable by any user with shell access).
        local env_lines=""
        [ -n "${JAISIU_LICENSE_KEY:-}" ] && env_lines="${env_lines}Environment=JAISIU_LICENSE_KEY=$JAISIU_LICENSE_KEY
"
        cat >"$unit" <<EOF
[Unit]
Description=Jaisiu Gateway
After=network.target

[Service]
ExecStart=$BIN_DIR/jaisiu gateway run
Restart=on-failure
# JAISIU-2832 (2026-08-26): bumped from 5s to 10s. The gateway takes ~6s to
# spin up in degraded mode (quantum-palace fallback path is slower than
# the standard memory preflight). With RestartSec=5 the systemd unit
# restarts the gateway during its own startup, masking the actual bind
# signal in the install.sh deep-smoke gate.
RestartSec=10
$env_lines
[Install]
WantedBy=default.target
EOF
        systemctl --user daemon-reload || true
        systemctl --user enable --now jaisiu.service || \
          print_message WARN "systemd --user enable failed (run: loginctl enable-linger $USER)"
        print_message OK "systemd user unit installed ($unit)"
      else
        print_message WARN "no systemd detected — skipping service"
      fi
      ;;
    darwin)
      local plist="$HOME/Library/LaunchAgents/ai.jaisiu.gateway.plist"
      mkdir -p "$(dirname "$plist")"
      # JAISIU-2799 (2026-08-26): disable + remove dead prior-install
      # launchd plists. Earlier installs (v0.99-rc1, com.openclaw.gateway,
      # etc.) reference /Users/$USER/.jaisiu/app/ which we now wipe on
      # reinstall. Without unload, launchd keeps trying to start a
      # non-existent binary, masks the new install's status, and can
      # fight the new gateway for TCP 18789.
      for legacy_plist in com.openclaw.gateway ai.openclaw.gateway com.jaisiu.gateway.ai.jaisiu; do
        local legacy_path="$HOME/Library/LaunchAgents/${legacy_plist}.plist"
        if [ -f "$legacy_path" ]; then
          print_message WARN "unloading legacy launchd plist ${legacy_plist}.plist"
          launchctl bootout "gui/$(id -u)/${legacy_plist}" 2>/dev/null || true
          rm -f "$legacy_path"
        fi
      done
      # Build optional <key>EnvironmentVariables</key> block. LICENSE ONLY —
      # broker + email live in ~/.jaisiu/state/jaisiu.json (mode 600) so we
      # don't leak them via plaintext plist on disk. Even license could be
      # moved to state.json; we pass it here as a fallback for old daemon
      # versions that don't read it from config.
      local env_block=""
      if [ -n "${JAISIU_LICENSE_KEY:-}" ] || [ -n "${JAISIU_EMAIL:-}" ]; then
        env_block="
  <key>EnvironmentVariables</key>
  <dict>"
        if [ -n "${JAISIU_LICENSE_KEY:-}" ]; then
          env_block="${env_block}
    <key>JAISIU_LICENSE_KEY</key>
    <string>${JAISIU_LICENSE_KEY}</string>"
        fi
        if [ -n "${JAISIU_EMAIL:-}" ]; then
          env_block="${env_block}
    <key>JAISIU_EMAIL</key>
    <string>${JAISIU_EMAIL}</string>"
        fi
        env_block="${env_block}
  </dict>"
      fi
      cat >"$plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key><string>ai.jaisiu.gateway</string>
  <key>ProgramArguments</key>
  <array>
    <string>$BIN_DIR/jaisiu</string>
    <string>gateway</string>
    <string>--port</string>
    <string>18789</string>
  </array>
  <key>RunAtLoad</key><true/>
  <key>KeepAlive</key><true/>
  <key>StandardOutPath</key><string>$HOME/Library/Logs/jaisiu-gateway.log</string>
  <key>StandardErrorPath</key><string>$HOME/Library/Logs/jaisiu-gateway.log</string>$env_block
</dict>
</plist>
EOF
      # macOS 10.13+ deprecates launchctl load in favor of bootstrap.
      # Try bootstrap first; fall back to legacy load for older macOS.
      launchctl unload "$plist" 2>/dev/null || true
      launchctl bootstrap "gui/$UID" "$plist" 2>/dev/null \
        || launchctl load -w "$plist" 2>/dev/null \
        || print_message WARN "launchctl bootstrap failed (try: launchctl bootstrap gui/$UID $plist)"
      # Verify it's actually loaded.
      if launchctl print "gui/$UID/ai.jaisiu.gateway" 2>/dev/null | grep -q 'state ='; then
        print_message OK "LaunchAgent loaded (gui/$UID/ai.jaisiu.gateway)"
      else
        print_message OK "LaunchAgent installed ($plist) — may need manual: launchctl bootstrap gui/$UID $plist"
      fi
      ;;
    windows)
      # Minimal schtasks placeholder; PowerShell variant covers full install.
      print_message WARN "use install.ps1 on Windows for full service setup"
      ;;
  esac
}
register_service

# ---- post-install: zero-touch defaults --------------------------------------
# When a license key was provided, the operator's spec is "fully working
# from the start". Two defaults that achieve this:
#   - gateway.mode = local         (so jaisiu gateway can start)
#   - workspace at $JAISIU_WORKSPACE/jaisiu (created earlier)
# Plugins written into the state config:
#   - No third-party plugin entries (we use pryzm-at-broker, never auto-wire
#     third-party providers in trial mode — JAISIU-2888 fix 2026-08-27)
post_install_defaults() {
  # JAISIU-2832 (2026-08-26): post_install_defaults must run for ALL installs,
  # not just licensed ones. The memory.backend=builtin fallback is required
  # for the gateway to boot without node-llama-cpp + GGUF model. If we skip
  # this for unlicensed / non-interactive installs (curl|bash with
  # JAISIU_NONINTERACTIVE=1), the gateway crashes during memory preflight.
  if [ -z "${JAISIU_LICENSE_KEY_VALUE:-}" ] && [ "${JAISIU_NONINTERACTIVE:-0}" != "1" ]; then
    return 0
  fi
  local bin_cmd="$BIN_DIR/jaisiu"
  if [ ! -x "$bin_cmd" ]; then
    return 0
  fi
  # gateway.mode = local (so 'jaisiu gateway' has a valid mode)
  if "$bin_cmd" config set gateway.mode local >/dev/null 2>&1; then
    print_message OK "gateway.mode set to local"
  fi

  # gateway.auth.token — generate a 32-byte hex token if missing. Required by
  # the build's recommendation even on loopback. Stored in state.json, NOT in
  # the LaunchAgent plist (which previously stored it as plaintext).
  if ! "$bin_cmd" config get gateway.auth.token >/dev/null 2>&1; then
    local token
    token="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')"
    if "$bin_cmd" config set gateway.auth.token "$token" >/dev/null 2>&1; then
      print_message OK "gateway.auth.token generated (32-byte random)"
    fi
  fi

  # Create runtime-required dirs (sessions/, credentials/) with secure perms.
  # Doctor flags both as missing on a fresh install.
  for d in "$HOME/.jaisiu/agents/main/sessions" "$HOME/.jaisiu/credentials"; do
    [ -d "$d" ] || mkdir -p "$d"
  done
  chmod 700 "$HOME/.jaisiu" 2>/dev/null || true
  print_message OK "runtime dirs created (agents/main/sessions, credentials, mode 700)"

  # memory.backend = builtin (skips quantum palace which needs node-llama-cpp
  # + GGUF model download). Quantum palace is heavyweight and out-of-the-box
  # disable keeps the gateway bootable without operator setup.
  if "$bin_cmd" config set memory.backend builtin >/dev/null 2>&1; then
    print_message OK "memory.backend set to builtin (skips quantum palace)"
  fi

  # JAISIU-2888 (Bug B fix 2026-08-27): do NOT add or write the
  # minimax-portal-auth plugin entry. Earlier revisions inhibited the
  # third-party plugin by writing enabled=false; we now DEL the entry
  # entirely so it can never be enabled by mistake. The runtime's own
  # built-in model.primary default is a separate concern handled by
  # the runtime's schema — install.sh should not pollute state with
  # references to providers we have no license for.
  # This block intentionally does nothing — it's preserved here so the
  # diff against the prior revision is reviewable.
  : # JAISIU-2888: minimax-portal-auth inhibit removed

  # JAISIU-2794 (2026-08-26): bridge the gap between the default slot
  # (plugins.slots.memory = "memory-core" per src/plugins/slots.ts:17)
  # and the plugin registry. Two paths depending on whether memory-core
  # was bundled in the installed tarball.
  #
  # 1. BUNDLED: extensions/memory-core/ is on disk (JAISIU-2793 ships
  #    it). Register the entry so config/validation.ts:225-230 passes
  #    its known-ids check.
  #
  # 2. NOT BUNDLED: legacy tarballs (pre-#2517) ship 0 plugins. Set the
  #    slot to "none" so the default mapping is overridden and the
  #    doctor grep at install.sh:[doctor-grep] doesn't trip on
  #    "plugin not found: memory-core".
  #
  # Both paths use the same jq merge; idempotent.
  local state_json="${HOME}/.jaisiu/state/jaisiu.json"
  if [ -s "$state_json" ] && command -v jq >/dev/null 2>&1; then
    local mem_core_path=""
    case "$EXTRACT_LAYOUT" in
      BUN)
        # Find the staged binary dir — $JAISIU_WORKSPACE/usr/bin on
        # posix, $JAISIU_WORKSPACE/bin on windows.
        if [ -d "$JAISIU_WORKSPACE/usr/bin/extensions/memory-core" ]; then
          mem_core_path="extensions/memory-core"
        fi
        ;;
      SOURCE)
        if [ -d "$JAISIU_WORKSPACE/extensions/memory-core" ]; then
          mem_core_path="extensions/memory-core"
        fi
        ;;
    esac

    local tmp; tmp="$(mktemp)"
    if [ -n "$mem_core_path" ]; then
      # Path 1: register memory-core as enabled.
      # NOTE: do NOT emit a `path` key here — the plugins.entries[*] zod
      # schema is `.strict()` and rejects unknown keys with
      # "Unrecognized key: 'path'" (JAISIU-2835 follow-up). The plugin
      # loader already uses loadPaths / bundled auto-discovery, so the
      # path is informational at best. We only persist `enabled`.
      if jq '
          .plugins = (.plugins // {}) |
          .plugins.entries = (.plugins.entries // {}) |
          .plugins.entries["memory-core"] = (
            (.plugins.entries["memory-core"] // {}) |
            .enabled = true
          ) |
          .plugins.slots = (.plugins.slots // {}) |
          .plugins.slots.memory = "memory-core"
        ' "$state_json" > "$tmp" 2>/dev/null; then
        mv "$tmp" "$state_json"
        chmod 600 "$state_json"
        print_message OK "memory-core plugin registered (bundled at $mem_core_path)"
      else
        rm -f "$tmp"
        print_message WARN "failed to register memory-core in state config"
      fi
    else
      # Path 2: disable the default slot so validation passes.
      if jq '
          .plugins = (.plugins // {}) |
          .plugins.slots = (.plugins.slots // {}) |
          .plugins.slots.memory = "none"
        ' "$state_json" > "$tmp" 2>/dev/null; then
        mv "$tmp" "$state_json"
        chmod 600 "$state_json"
        print_message WARN "memory-core not bundled — set plugins.slots.memory=none (no memory plugin loaded)"
      else
        rm -f "$tmp"
        print_message WARN "failed to set plugins.slots.memory=none"
      fi
    fi
  fi
}
post_install_defaults

# JAISIU-2888 (Bug B fix 2026-08-27): post_install_defaults() invokes
# `jaisiu config set` which causes the bundled gateway binary to seed
# its built-in defaults into state/jaisiu.json. Those defaults include
# `agents.defaults.model.primary = minimax-portal/MiniMax-M3`, a third-
# party provider we have no license for. The license-branch
# `write_state_json` ABOVE wrote `pryzm-at-broker/MiniMax-M3` correctly,
# but `post_install_defaults` calls `jaisiu config set ...` on the bundled
# binary which triggers applyPluginAutoEnable -> re-enables the
# minimax-portal-auth plugin AND re-seeds the runtime's built-in
# `agents.defaults.model.primary = minimax-portal/MiniMax-M3` default.
# A subsequent JSON deep-merge leaves the runtime default as the
# winner (it was written last), undoing our pryzm-at-broker write.
#
# Fix (JAISIU-2888 follow-up / JAISIU-2889-bis 2026-09-07): ALWAYS scrub
# `.agents.defaults.model` and `.agents.defaults.subagents.model` here,
# regardless of trial vs licensed. For licensed installs the
# `write_state_json` block (lib/state.sh) was already written above and
# survived the deep-merge; the post_install_defaults re-seed is what
# we want gone. Deletion is safe because `applyPluginAutoEnable` then
# stops auto-enabling `minimax-portal-auth` (there is no
# `minimax-portal/...` model ref anywhere to match).
if [ -s "${HOME}/.jaisiu/state/jaisiu.json" ] && command -v jq >/dev/null 2>&1; then
    tmp_post="$(mktemp)"
    if jq 'del(.agents.defaults.model) | del(.agents.defaults.subagents.model)' \
        "${HOME}/.jaisiu/state/jaisiu.json" > "${tmp_post}" 2>/dev/null; then
        mv "${tmp_post}" "${HOME}/.jaisiu/state/jaisiu.json"
        chmod 600 "${HOME}/.jaisiu/state/jaisiu.json" 2>/dev/null || true
        print_message OK "scrubbed runtime-seeded minimax-portal model ref (JAISIU-2889-bis)"
    else
        rm -f "${tmp_post}" 2>/dev/null || true
        print_message WARN "failed to scrub runtime-seeded agents.defaults.model - operator should review state/jaisiu.json"
    fi
fi

# ---- smoke: did the launcher actually work? ------------------------------
# JAISIU-2490 (2026-09-17): A binary that returns a non-zero exit OR prints
# `error: ...` is a broken install (the most recent regression was the
# Bun-compiled standalone that printed "No such built-in module: node:sqlite"
# on every invocation). Don't paper over that with a green OK.
if command -v jaisiu >/dev/null 2>&1; then
  actual_version="$(jaisiu --version 2>/dev/null | head -1 || true)"
  actual_version="$(printf '%s' "$actual_version" | tr -d '\r')"
  if [ -z "$actual_version" ]; then
    print_message ERR "smoke: jaisiu --version returned empty (binary likely broken). Re-run with JAISIU_DEBUG=1 to see stderr."
    exit 1
  fi
  case "$actual_version" in
    error:*|Error:*)
      print_message ERR "smoke: jaisiu --version printed error: $actual_version"
      print_message ERR "binary/runtime is broken; refusing to mark install OK. See JAISIU-2490."
      exit 1
      ;;
  esac
  print_message OK "smoke: $actual_version"
else
  print_message ERR "jaisiu not on PATH (open a new shell, or run: export $BIN_DIR:\$PATH)"
  exit 1
fi

# ---- deeper smoke: verify the gateway actually boots and listens --------
# Per JAISIU-2510 / JAISIU-2016 finding #2: --version alone is too weak
# (a binary that prints a version but cannot boot passes). Now we:
#   1. Wait up to 10s for the service to start listening on 18789.
#   2. Require it to survive a 30s stability window without restart.
#   3. Run jaisiu doctor, fail on install-side complaints.
#   4. Verify the resolved agent primary is pryzm-at-broker/MiniMax-M3.
# Any failure ⇒ install exits non-zero with the tail of the gateway log.
#
# Skipped when --no-service was supplied or JAISIU_NO_SMOKE_DEEP=1.
if [ "$JAISIU_NO_SERVICE" != "1" ] && [ "${JAISIU_NO_SMOKE_DEEP:-0}" != "1" ]; then
  deep_smoke_ok=1
  # (1) Wait up to 30s for service to start listening.
  # JAISIU-2832 (2026-08-26): bumped from 10s. In degraded mode (no
  # node-llama-cpp), the gateway takes ~15-25s to reach "listening".
  waited=0
  listening=0
  while [ $waited -lt 30 ]; do
    if command -v nc >/dev/null 2>&1 && nc -z -w 1 127.0.0.1 18789 2>/dev/null; then
      listening=1; break
    fi
    if command -v curl >/dev/null 2>&1 && \
       curl -s -o /dev/null --max-time 1 http://127.0.0.1:18789/ 2>/dev/null; then
      listening=1; break
    fi
    sleep 1; waited=$((waited+1))
  done
  if [ "$listening" = "1" ]; then
    print_message OK "deep smoke: gateway listening on 127.0.0.1:18789 (after ${waited}s)"
  else
    print_message ERR "deep smoke: gateway NOT listening on 127.0.0.1:18789 (after 30s)"
    deep_smoke_ok=0
  fi

  # (2) 60s stability window — re-check the port every 5s, fail if it drops.
  # JAISIU-2832 (2026-08-26): bumped from 30s. In degraded mode the
  # stuck-session-monitor kicks in around 80-130s, so a 30s window would
  # miss the early SIGTERM pattern.
  if [ "$deep_smoke_ok" = "1" ]; then
    unstable=0
    for s in 5 10 15 20 25 30 35 40 45 50 55 60; do
      sleep 5
      if command -v nc >/dev/null 2>&1 && nc -z -w 1 127.0.0.1 18789 2>/dev/null; then
        :  # stable
      else
        unstable=1; break
      fi
    done
    if [ "$unstable" = "1" ]; then
      print_message ERR "deep smoke: gateway unstable during 60s stability window"
      deep_smoke_ok=0
    else
      print_message OK "deep smoke: gateway stable for 60s"
    fi
  fi

  # (3) jaisiu doctor — fail on install-side complaints.
  if [ "$deep_smoke_ok" = "1" ]; then
    doctor_out="$(jaisiu doctor 2>&1 || true)"
    if printf '%s' "$doctor_out" | grep -qE "Unrecognized config|plugin not found|missing workspace|Gateway not reachable"; then
      print_message ERR "deep smoke: jaisiu doctor reports install-side issues"
      printf '%s\n' "$doctor_out" | head -15
      deep_smoke_ok=0
    else
      print_message OK "deep smoke: jaisiu doctor clean"
    fi
  fi

  # (4) Verify resolved agent primary is pryzm-at-broker/MiniMax-M3.
  if [ "$deep_smoke_ok" = "1" ] && [ -s "${HOME}/.jaisiu/state/jaisiu.json" ]; then
    primary="$(jaisiu config get agents.defaults.model.primary 2>/dev/null | tr -d '\r' || true)"
    if [ "$primary" = "pryzm-at-broker/MiniMax-M3" ]; then
      print_message OK "deep smoke: agent primary = pryzm-at-broker/MiniMax-M3"
    else
      print_message WARN "deep smoke: agent primary = ${primary:-<unset>} (expected pryzm-at-broker/MiniMax-M3)"
    fi
  fi

  # Hard-fail exit on any deep smoke failure (with gateway log tail attached).
  if [ "$deep_smoke_ok" != "1" ]; then
    print_message ERR "DEEP SMOKE FAILED — gateway not healthy post-install"
    if [ "$OS_NAME" = "darwin" ]; then
      log_path="$HOME/Library/Logs/jaisiu-gateway.log"
    else
      log_path="${JAISIU_LOG_FILE:-/tmp/jaisiu-install.log}"
    fi
    if [ -r "$log_path" ]; then
      print_message INFO "tail of $log_path:"
      tail -20 "$log_path" | sed 's/^/    /' >&2
    fi
    exit 1
  fi
fi

# ---- final banner ---------------------------------------------------------
cat <<'BANNER'

  ┌──────────────────────────────────────────────┐
  │  Jaisiu gateway installed                    │
  │                                              │
  │  Next: open a new shell, then:               │
  │    jaisiu              # interactive TUI     │
  │    jaisiu doctor       # health check        │
  │    jaisiu acp          # ACP bridge (MCP-like │
  │                          for IDE integration)│
  │                                              │
  │  Service + gateway auto-started.             │
  │  Logs:                                       │
  │    linux   journalctl --user -u jaisiu      │
  │    darwin  ~/Library/Logs/jaisiu-gateway.log │
  │    windows Get-EventLog -LogName Application │
  └──────────────────────────────────────────────┘
BANNER

# Reflect the gateway.auth.token that was just persisted to
# ~/.jaisiu/state/jaisiu.json as a one-click WebUI deep link. Token is
# 32-byte hex; we re-read from state so the URL matches whatever the
# install actually wrote (handles upgrades where the token was kept).
if [ -r "$STATE_JSON" ]; then
  webui_token="$(_state_extract_token "$STATE_JSON")"
  if [ -n "${webui_token:-}" ]; then
    webui_port="$(_state_extract_port "$STATE_JSON")"
    webui_port="${webui_port:-18789}"
    print_message OK "Web UI (one-click login):  http://127.0.0.1:${webui_port}/jaisiu/#token=${webui_token}"
  fi
fi
unset webui_token webui_port 2>/dev/null || true

# Cleanup
rm -rf "$TMP_DIR" 2>/dev/null || true
log INFO "install complete"

# Final sanity check on the persisted state files (JAISIU-2751).
# A previous version wrote a stray 'e' character at line 2 col 1 in
# state/jaisiu.json, blocking jaisiu tui + jaisiu doctor. We verify
# both files parse cleanly before exiting so the user gets immediate
# feedback instead of a downstream crash.
final_state_check() {
  local f="$1"
  [ -f "$f" ] || return 0
  if command -v python3 >/dev/null 2>&1; then
    if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$f" 2>/dev/null; then
      print_message WARN "state file $f is not valid JSON — jaisiu tui/doctor will fail. Run: jaisiu config reset"
      return 1
    fi
  fi
  return 0
}
final_state_check "$STATE_JSON" || true
if [ -n "${CONFIG_JSON:-}" ]; then final_state_check "$CONFIG_JSON" || true; fi

exit 0
