#!/usr/bin/env python3
# Miracle Claw (miracle_claw)
# Copyright (c) 2026 Milagro Distribution Corp.
# All rights reserved. See LICENSE file for terms.
"""mclaw — Milagro Claw: a talk-to-LLM-and-run-code shell.

Usage:
    mclaw                       Start interactive REPL
    mclaw "list .txt files"     One-shot mode (single question, run, exit)
    mclaw --model NAME          Override model (default: milagro-dev)
    mclaw --url URL             Override MAIC endpoint
    mclaw --auto                Auto-run read-only (SAFE) commands without asking
    mclaw --yes / -y            Auto-run SAFE + NORMAL without asking
                             (DANGEROUS still requires YES-EXEC)
    mclaw --no-stream           Disable streaming response
    mclaw --no-context          Skip project context injection
    mclaw --no-memory           Skip cross-session memory injection
    mclaw --help                Show this help

Environment:
    MILAGRO_API_KEY    Bearer token for MAIC gateway (required)
    MIRACLE_CLAW_HOME  Override config dir (default: ~/.miracle_claw)

Architecture:
    mclaw (this file) — entry point + REPL loop only.
    ui.py           — ANSI color + banner.
    runtime.py      — language runners (python/bash/perl).
    llm.py          — MAIC gateway client + JSON envelope parsing.
    context.py      — dir snapshot + project context snippets.
    commands.py     — slash command dispatch + LLM turn.
    history.py      — JSONL log read/write + cross-session memory.
    repl.py         — line editor (readline replacement).
    safety.py       — risk classifier for shell commands.
    completion.py   — tab completion.
"""
from __future__ import annotations
import argparse
import atexit
import datetime as dt
import json
import os
import pathlib
import signal
import struct
import sys
import urllib.error
try:
    import fcntl
    import termios
    _HAVE_TIOCGWINSZ = True
except ImportError:
    _HAVE_TIOCGWINSZ = False
try:
    _TTY_FALLBACK_ROWS = int(os.environ.get("LINES", "24"))
    _TTY_FALLBACK_COLS = int(os.environ.get("COLUMNS", "80"))
except ValueError:
    _TTY_FALLBACK_ROWS, _TTY_FALLBACK_COLS = 24, 80

# Allow running `python3 mclaw` from anywhere.
HERE = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))

# If a voice_venv sits next to this script, prepend its site-packages to
# sys.path so the lazy `import faster_whisper` inside commands.cmd_voice
# works without re-execing mclaw inside the venv. Cheap and reversible.
_VOICE_VENV_SP = HERE / "voice_venv" / "lib" / f"python{sys.version_info.major}.{sys.version_info.minor}" / "site-packages"
if _VOICE_VENV_SP.is_dir() and str(_VOICE_VENV_SP) not in sys.path:
    sys.path.insert(0, str(_VOICE_VENV_SP))

from ui import color, C, draw_footer, footer_row1, footer_row2, box_top_with_title
from llm import (
    DEFAULT_URL, DEFAULT_MODEL, REQUEST_TIMEOUT,
    load_system_prompt, build_layered_system_prompt,
)
from commands import handle_turn, handle_repl_line, HELP_TEXT
from context import project_context
from history import recent_messages
from repl import LineEditor, CompletionAdapter, RawMode, CANCEL
import auth  # MAIC user-auth client (Phase 1)





def _term_size() -> tuple[int, int]:
    """Return (rows, cols) of the controlling terminal. Falls back to
    $LINES/$COLUMNS env vars, then 24x80."""
    if _HAVE_TIOCGWINSZ:
        try:
            buf = struct.pack("HHHH", 0, 0, 0, 0)
            buf = fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ, buf)
            rows, cols, _, _ = struct.unpack("HHHH", buf)
            if rows > 0 and cols > 0:
                return rows, cols
        except (OSError, ValueError):
            pass
    return _TTY_FALLBACK_ROWS, _TTY_FALLBACK_COLS


def _parse_server_chain(raw: str | None) -> list[tuple[str, str]] | None:
    """Parse a JSON-encoded failover chain from --server-chain.

    Format: '[["display_name", "model_id"], ...]'. Returns None if
    raw is None/empty/blank (so the caller can fall through to the
    default chain). Returns a list of tuples on success.

    This is the wire format miracle-claw-dashboard uses to pass a tier-appropriate
    chain to the spawned mclaw child. We keep parsing local to mclaw so
    the format can evolve without touching the server, and so the
    server doesn't have to know about mclaw's chain representation.

    Robust against:
      - missing argument (None) → None
      - empty/whitespace string → None
      - invalid JSON → None, with a stderr warning
      - wrong shape (not list of 2-tuples) → None, with a stderr warning
    The caller is expected to fall back to DEFAULT_FAILOVER_CHAIN
    on None. We never crash startup on a malformed --server-chain
    because miracle-claw-dashboard's spawn shouldn't be able to take down the
    whole chat experience.
    """
    if not raw or not raw.strip():
        return None
    try:
        parsed = json.loads(raw)
    except (json.JSONDecodeError, ValueError) as e:
        print(f"⚠️  --server-chain: invalid JSON ({e}); falling back to default",
              file=sys.stderr)
        return None
    if not isinstance(parsed, list):
        print(f"⚠️  --server-chain: expected list, got {type(parsed).__name__}; falling back",
              file=sys.stderr)
        return None
    out: list[tuple[str, str]] = []
    for i, entry in enumerate(parsed):
        if (not isinstance(entry, list) or len(entry) != 2
                or not all(isinstance(x, str) for x in entry)):
            print(f"⚠️  --server-chain: entry {i} is not [str, str]; falling back",
                  file=sys.stderr)
            return None
        display, model_id = entry
        if not display or not model_id:
            print(f"⚠️  --server-chain: entry {i} has empty string; falling back",
                  file=sys.stderr)
            return None
        out.append((display, model_id))
    if not out:
        return None
    return out


def _build_arg_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        prog="mclaw",
        description="Milagro Claw: talk-to-LLM-and-run-code shell.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=HELP_TEXT,
    )
    p.add_argument("question", nargs="*", help="One-shot question (skips REPL)")
    p.add_argument("--model", default=DEFAULT_MODEL, help=f"Model name (default: {DEFAULT_MODEL})")
    p.add_argument("--url",   default=DEFAULT_URL,   help=f"MAIC endpoint (default: {DEFAULT_URL})")
    p.add_argument("--auto", action="store_true", help="Auto-run read-only commands without asking")
    p.add_argument("--yes", "-y", action="store_true",
                   help="Auto-run SAFE and NORMAL commands without confirming. DANGEROUS still asks.")
    p.add_argument("--no-stream", action="store_true", help="Disable streaming response")
    p.add_argument("--no-color", action="store_true", help="Disable colored output")
    p.add_argument("--no-context", action="store_true", help="Skip project context injection")
    p.add_argument("--no-memory", action="store_true", help="Skip cross-session memory injection")
    p.add_argument("--no-bootstrap", action="store_true",
                   help="Skip persona + long-term memory bootstrap (SOUL/IDENTITY/USER/MEMORY). "
                        "Auto-disabled in chat mode (miracle-claw-dashboard spawns) where it's always on.")
    p.add_argument("--memory-turns", type=int, default=6,
                   help="Number of recent turns to inject as memory (default: 6)")
    p.add_argument("--timeout", type=int, default=REQUEST_TIMEOUT, help="API timeout (seconds)")
    p.add_argument("--no-session", action="store_true",
                   help="Skip loading/saving the persisted session file")
    p.add_argument("--reset-session", action="store_true",
                   help="Clear the persisted session file before starting")
    p.add_argument("--cwd", default=None,
                   help="Starting directory (default: load from session, else $PWD)")
    p.add_argument("--api-key", default=None,
                   help="Override the MAIC API key (escape hatch — bypasses login)")
    p.add_argument("--setup", action="store_true",
                   help="Run the first-run wizard (create admin + login)")
    p.add_argument("--tier", default=None,
                   help="Override active tier (local) for this run")
    p.add_argument("--server-chain", default=None,
                   help="JSON-encoded failover chain passed by miracle-claw-dashboard. "
                        "Format: '[[\"display\", \"model_id\"], ...]'. "
                        "Only used as the floor — if the user has a locally-saved "
                        "chain from /failover, that wins. This is the server's "
                        "way of saying 'your tier gets THIS chain by default' "
                        "without locking the user out of their own /failover customizations.")
    p.add_argument("--login", action="store_true",
                   help="Log in to MAIC (interactive; saves session)")
    p.add_argument("--logout", action="store_true",
                   help="Log out of MAIC and exit")
    p.add_argument("--register", action="store_true",
                   help="Create a new MAIC account (interactive)")
    p.add_argument("--change-password", action="store_true",
                   help="Change your MAIC account password (interactive; --force to skip current-password re-prompt when stdin is not a TTY)")
    p.add_argument("--update", action="store_true",
                   help="Upgrade to the latest miracle-claw release via pip")
    p.add_argument("--check-update", action="store_true",
                   help="Show available updates without upgrading")
    p.add_argument("--no-update", action="store_true",
                   help="Skip the automatic startup update check")
    p.add_argument("--no-alt-screen", action="store_true",
                   help="Don't switch to the alternate screen buffer on chat start "
                        "(for terminals/broken setups where 1049 doesn't work right)")
    p.add_argument("--update-channel", default=None, choices=("stable", "dev"),
                   help="Release channel for update checks (default: stable)")
    p.add_argument("--sync", action="store_true",
                   help="Upload unsynced mclaw turn logs to MAIC training_pairs, then exit")
    p.add_argument("--sync-status", action="store_true",
                   help="Show how many turns are waiting to be synced, then exit")
    p.add_argument("--sync-limit", type=int, default=200,
                   help="Max turns to upload in one --sync run (default: 200)")
    p.add_argument("--rate", nargs=2, metavar=("REQUEST_ID", "SCORE"),
                   help="Rate a training pair (1, -1, or 0 to clear) and exit")
    p.add_argument("--rate-list", type=int, nargs="?", const=20, metavar="N",
                   help="List the N most recent training pairs and exit (default 20)")

    # ─── Browser UI lifecycle (mclaw --serve-*) ────────────────────────
    from serve import add_serve_args
    add_serve_args(p)

    return p


def _check_api_key(args=None) -> str:
    """Resolve the MAIC API key.

    Order of precedence:
      1. --api-key CLI flag (escape hatch for headless / CI use)
      2. MILAGRO_API_KEY env var (legacy; warns loudly)
      3. Per-user mk_*** from the active tier's cached key
         (created at login time, chmod 600, 30-day rotation)
    """
    from tiers import get_active_tier
    tier = get_active_tier()

    # 1. Explicit --api-key flag (escape hatch).
    if args is not None and getattr(args, "api_key", None):
        return args.api_key

    # 3. Tier-cached per-user key (preferred).
    override = tier.api_key()
    if override:
        return override

    # 2. Legacy env var (with warning).
    api_key = os.environ.get("MILAGRO_API_KEY")
    if api_key:
        print("⚠️  MILAGRO_API_KEY is set in the environment.", file=sys.stderr)
        print("    This is a legacy auth path. Login-based auth is preferred.", file=sys.stderr)
        print("    Run `mclaw --setup` to create an admin account.", file=sys.stderr)
        return api_key

    # Nothing — explain the situation.
    print("❌  No MAIC API key available.", file=sys.stderr)
    print("    Run:  mclaw --setup       (first-run, creates admin)", file=sys.stderr)
    print("    Or:   mclaw --login       (log in with existing admin)", file=sys.stderr)
    print("    Or:   mclaw --api-key MK_xxx  (headless escape hatch)", file=sys.stderr)
    sys.exit(2)


def _auto_sync_training_data(home: pathlib.Path, url: str, api_key: str,
                              user_id: int, limit: int = 50) -> bool:
    """On REPL exit, push any unsynced turn logs to MAIC.

    Silently no-ops if:
      - MILAGRO_NO_AUTO_SYNC=1 is set
      - The user isn't logged in (no user_id)
      - There are no unsynced turns
      - Any network/import error occurs (we never want auto-sync
        to break the exit flow — it's a nice-to-have, not a contract)

    Returns True if a sync was attempted (whether or not it succeeded).
    Callers can use the return value to dedupe with the atexit handler
    (which fires on every exit, including the ones we explicitly call
    this from).
    """
    # Opt-out check.
    if os.environ.get("MILAGRO_NO_AUTO_SYNC", "").strip() in ("1", "true", "yes"):
        return False
    if not user_id:
        # Master-key / un-logged-in sessions don't have a user_id to
        # tag uploads with. Skip rather than upload anonymous rows.
        return False
    if not api_key:
        return False
    try:
        from training_sync import count_unsynced, sync_unsynced
        # BUGFIX 2026-07-09: this used to point at home / "logs" but the
        # real per-turn jsonl files are written to home / "history" by
        # turn.py::_log_exchange. The logs/ subdir is the cron's own
        # cron-sync.log plus a few stale hand-crafted test entries from
        # 2026-06-29..30 — that meant `mclaw --sync`, the REPL-exit
        # auto-sync, AND the daily 02:00 cron all reported "0 unsynced"
        # while 294+ real turns sat in history/ never reaching MAIC.
        history_dir = home / "history"
        if not history_dir.is_dir():
            return False  # No history yet, nothing to sync.
        n = count_unsynced(history_dir)
        if n == 0:
            return False
        result = sync_unsynced(history_dir, url, api_key, user_id, limit=limit)
        uploaded = result.get("uploaded", 0)
        if uploaded > 0:
            print(color(
                f"⚡  synced {uploaded} turn{'s' if uploaded != 1 else ''} "
                f"to MAIC training_pairs",
                C.DIM,
            ), file=sys.stderr)
        return True
    except Exception as e:
        # Never crash the exit flow on a sync error.
        # Surface the error only if debug is on, otherwise stay silent.
        if os.environ.get("MILAGRO_DEBUG", "").strip() in ("1", "true", "yes"):
            print(color(f"  (auto-sync failed: {type(e).__name__}: {e})",
                        C.YELLOW), file=sys.stderr)
        return False


def _build_context(args, cwd, history_dir, tier: str | None = None) -> tuple[str, list[str], list[dict]]:
    """Build system prompt + project context + memory once at startup.

    Phase B1 (2026-07-05): if `tier` is provided, prepend the
    tier-specific prompt prefix (pro/enterprise get richer
    instructions). Customer and local tiers get no prefix.
    """
    base_prompt = load_system_prompt()
    # Apply tier-specific prompt prefix (pro/enterprise get enhanced
    # behavior instructions). For customer/local/unknown, this returns
    # base_prompt unchanged.
    # Then layer in the persona + memory bootstrap (SOUL/IDENTITY/USER/
    # MEMORY/today's memory file) so the browser chat surface gets the
    # same context the OpenClaw TUI gets. Gated by --no-bootstrap for
    # one-shot CLI invocations where the cost isn't worth it. In chat
    # mode (MILAGRO_CHAT_SURFACE set) the bootstrap is always included
    # — the session is long-lived, the user expects a persona, and the
    # token cost amortizes over many turns.
    chat_mode = bool(os.environ.get("MILAGRO_CHAT_SURFACE"))
    include_bootstrap = chat_mode or not args.no_bootstrap
    system_prompt = build_layered_system_prompt(
        base_prompt, tier, include_bootstrap=include_bootstrap,
    )

    # Decide whether to walk cwd for project context. The walker is
    # bounded (max 20KB output, max 50KB per file) but on a /tmp-style
    # dir it still produces 5-10KB of "here are 47 files I found"
    # noise that costs tokens on every turn. For known-junk-drawer
    # locations, skip the walk entirely — the system prompt still
    # works, the LLM just doesn't get a directory listing. The user
    # gets a clear warning telling them what to do.
    cwd_str = str(cwd)
    skip_context = (
        cwd_str == "/tmp"
        or cwd_str.startswith("/tmp/")
        or cwd_str == "/var/tmp"
        or cwd_str.startswith("/var/tmp/")
        or cwd_str == "/"
    )

    proj_ctx = ""
    if not args.no_context and not skip_context:
        try:
            proj_ctx = project_context(cwd)
        except Exception as e:
            print(color(f"  ⚠️  could not build project context: {e}", C.YELLOW))

    memory: list[dict] = []
    if not args.no_memory:
        try:
            memory = recent_messages(history_dir, limit=args.memory_turns * 2)
        except Exception as e:
            print(color(f"  ⚠️  could not load memory: {e}", C.YELLOW))

    # Warn if cwd looks like a junk-drawer or "ephemeral" location. These
    # dirs are common footguns — they accumulate large data dumps, debug
    # scripts, and accidentally-cached secrets, all of which get walked
    # and snippeted into the system prompt. A user who sees this banner
    # once and types /cd to a real project dir saves themselves
    # thousands of tokens per turn.
    # Skip in chat_mode: per-turn miracle-claw-dashboard spawns print to the LLM,
    # not the user, and warning clutter bloats the response. The
    # interactive REPL is where the user actually reads the warning.
    chat_mode = bool(os.environ.get("MILAGRO_CHAT_SURFACE"))
    if not args.no_context and not chat_mode and skip_context:
        label = (
            "the system temp dir" if cwd_str in ("/tmp", "/var/tmp")
            else "the filesystem root" if cwd_str == "/"
            else f"a temp-style path ({cwd_str!r})"
        )
        print(color(
            f"  ⚠️  cwd={cwd}  — {label} often contains large data dumps, "
            f"build artifacts, or stale debug files.\n"
            f"      Project-context walker SKIPPED for this dir (would "
            f"have shipped 5–20KB of directory noise on every turn).\n"
            f"      Run ` /cd ~/your-project ` to switch to a real project "
            f"dir and re-enable context injection.\n"
            f"      Tip: if you were in a real project dir before, "
            f"` /cwd ` jumps back to the last one /cd saved.",
            C.YELLOW,
        ))
    elif not args.no_context and not chat_mode:
        # Soft warning for non-dangerous-but-suspicious paths (home, etc.)
        if cwd_str == str(pathlib.Path.home()):
            print(color(
                f"  cwd={cwd} (your home dir — context walker will scan it)",
                C.DIM,
            ))

    return system_prompt, proj_ctx, memory


def main() -> None:
    p = _build_arg_parser()
    args = p.parse_args()

    # Backfill profile.user_id if it's missing. Catches users who
    # logged in before the auth.refresh_tier fix (2026-07-11) and
    # never had user_id written to profile.json. Without this, the
    # 02:00 cron silently fails because mclaw --sync bails on
    # user_id=0. The backfill walks on-disk sources (user_session,
    # customer_api_key) and is a no-op if user_id is already set.
    # Background: see profile.backfill_user_id docstring.
    try:
        import profile as _prof_boot
        _prof_boot.backfill_user_id()
    except Exception:
        pass  # never block startup on a backfill error

    # Browser chat mode: the client (miracle-claw-dashboard's web UI) provides its own
    # input box and renders output in a plain scrollable log. Cursor
    # positioning doesn't survive the journey, so mclaw's full-screen TUI
    # (box borders, footer, intermediate input renders) just looks like
    # noise. In this mode we:
    #   * skip the box UI / footer / scroll-region setup
    #   * skip _redraw_box() on every keystroke (one byte => one render)
    #   * skip echoing the user prompt (the input bar already shows it)
    #   * emit progress indicators (thinking…, per-hop ✓/⤼, per-tool ⚒)
    #     that show live activity while the LLM works, similar to
    #     OpenClaw's 'follow along' experience (2026-07-13 change)
    # Detected via the MILAGRO_CHAT_SURFACE env var that miracle-claw-dashboard sets
    # in the child process before exec'ing mclaw. We also fall back to
    # `not is_tty` for plain non-TTY invocations.
    #
    # NOTE: defined here at the very top of main() so the ctx dict and
    # every other reference in this giant function can use it. is_tty is
    # detected further down (the original location) so we have to
    # duplicate the env-var check; both will be used by the rest of the
    # code. The fallback to `not is_tty` kicks in below once is_tty is
    # known, by OR-ing in the second clause at the late definition site.
    chat_mode = bool(os.environ.get("MILAGRO_CHAT_SURFACE"))

    # Bootstrap (persona + memory layers) gating. Mirrors the logic
    # in _build_context; computed here so it's visible to the rest
    # of main() (ctx, one-shot path, etc).
    include_bootstrap = chat_mode or not args.no_bootstrap

    # ─── Serve subcommands (no login, no API key, no update check) ───
    from serve import dispatch_serve
    rc = dispatch_serve(args)
    if rc is not None:
        sys.exit(rc)

    # ─── Auto-update pre-flight (must run before tier/login to avoid
    #     upgrading mid-wizard) ─────────────────────────────────────────
    import update as _update
    # Honour the explicit CLI flags.
    if args.update:
        sys.exit(_update.perform_update())
    if args.check_update:
        sys.exit(_update.main(["--check-update"]))
    # Otherwise: lightweight lazy check, gated by env flag.
    if (not args.no_update
            and not os.environ.get("MILAGRO_NO_UPDATE_CHECK")
            and "--help" not in sys.argv):
        try:
            channel = args.update_channel or os.environ.get("MILAGRO_UPDATE_CHANNEL", "stable")
            info = _update.check_for_update(channel, use_cache=True)
            if info:
                msg = _update.format_update_message(info)
                if msg:
                    print(color(msg, C.YELLOW), file=sys.stderr)
        except Exception:
            # Update checks must never crash mclaw.
            pass

    # ─── Tier + auth pre-flight (handle flags before key check) ─────
    if args.tier:
        from tiers import set_active_tier
        try:
            set_active_tier(args.tier)
        except ValueError as e:
            print(f"❌  {e}", file=sys.stderr)
            sys.exit(2)
    if args.logout:
        import auth as _auth
        _auth.logout()
        print("✅  Logged out.")
        sys.exit(0)

    # ─── Sync training data to MAIC (no LLM call needed) ─────────
    if args.sync or args.sync_status:
        from training_sync import sync_unsynced, count_unsynced
        from pathlib import Path
        # BUGFIX 2026-07-09: this used to point at .../logs/ but the real
        # turn data lives in .../history/ (turn.py::_log_exchange writes
        # <date>.jsonl into history/, not logs/). Logs/ holds the cron's
        # own cron-sync.log plus a few stale 2026-06-29..30 test
        # entries — both were/are correctly synced, but everything in
        # history/ was invisible to the sync code. Symptom: "Uploaded 0
        # turns" every day while ~300 real turns piled up.
        history_dir = Path(os.environ.get(
            "MILAGRO_CLAW_HOME",
            str(Path.home() / ".miracle_claw")
        )) / "history"
        if args.sync_status:
            n = count_unsynced(history_dir)
            print(f"⚡  {n} unsynced turn{'s' if n != 1 else ''} waiting in {history_dir}")
            sys.exit(0)
        # args.sync:
        api_key = _check_api_key(args)
        url = args.url
        # user_id comes from profile (cached at login time).
        from profile import get as _profile_get, backfill_user_id
        # Best-effort: backfill user_id from on-disk sources if it's
        # missing. This catches users who logged in before the
        # auth.refresh_tier fix (2026-07-11) and never had user_id
        # written to profile. Idempotent — no-op if already set.
        # Background: the 02:00 cron was silently failing for ~24h
        # because user_id was 0 and the bail below fires. Backfill
        # runs before the bail so users get a working sync without
        # having to manually re-login.
        try:
            backfill_user_id()
        except Exception:
            pass  # never block sync on a backfill error
        user_id = _profile_get("user_id", 0)
        if not user_id:
            print("❌  Not logged in. Run `mclaw --login` first.", file=sys.stderr)
            sys.exit(1)
        print(f"⚡  Syncing up to {args.sync_limit} turns to {url}...")
        result = sync_unsynced(history_dir, url, api_key, user_id, limit=args.sync_limit)
        if result.get("consent") is False:
            # 2026-07-09 (Pass 3.2): training-consent gate. The user has
            # not opted in (or we couldn't reach MAIC to ask). Don't
            # print "uploaded 0" — that looks like a silent failure.
            # Print a clear, actionable message and exit 0 (this is not
            # a sync error, it's a "we deliberately didn't sync").
            print("⛔  Training data consent not granted — skipping sync.")
            print("   Opt in:  POST /v1/users/me/training-consent  (with acknowledge=true)")
            print("   Or:      open the dashboard → Settings → Training data")
            sys.exit(0)
        if "error" in result and "uploaded" in result and result["uploaded"] == 0:
            print(f"  → uploaded {result.get('uploaded', 0)}, "
                  f"failed {result.get('skipped', 0)}: {result.get('error', '')}",
                  file=sys.stderr)
            sys.exit(1)
        print(f"✅  Uploaded {result.get('uploaded', 0)} turn"
              f"{'s' if result.get('uploaded', 0) != 1 else ''}. "
              f"Failed: {result.get('skipped', 0)}.")
        sys.exit(0)

    if args.rate_list is not None:
        from rate import fetch_recent_pairs
        url = args.url
        if not url:
            from tiers import get_active_tier
            url = get_active_tier().maic_url()
        url = url.rstrip("/")
        if url.endswith("/v1"):
            url = url[:-3].rstrip("/")
        api_key = _check_api_key(args)
        try:
            pairs = fetch_recent_pairs(url, api_key, limit=args.rate_list)
        except urllib.error.HTTPError as e:
            body = e.read().decode("utf-8", errors="replace")
            print(f"❌  {body or e.reason}", file=sys.stderr)
            sys.exit(1)
        except Exception as e:
            print(f"❌  {e}", file=sys.stderr)
            sys.exit(1)
        if not pairs:
            print("  (no training pairs yet)")
        else:
            total_id = pairs[0].get("id", "?")
            print(f"Recent training pairs ({len(pairs)} shown, latest id={total_id}):")
            for i, p in enumerate(pairs, 1):
                q = (p.get("question") or "?")[:60]
                score = p.get("score", 0)
                badge = "👍" if score == 1 else ("👎" if score == -1 else "  ")
                rid = p.get("request_id", "?")
                ts = (p.get("created_at") or "")[:19]
                print(f"  {i:3}. {badge} [{ts}] {rid}  {q}")
        sys.exit(0)

    if args.rate:
        from rate import rate_pair
        request_id, score_str = args.rate
        try:
            score = int(score_str)
        except ValueError:
            print(f"❌  score must be an integer: -1, 0, or 1 (got {score_str!r})",
                  file=sys.stderr)
            sys.exit(2)
        if score not in (-1, 0, 1):
            print(f"❌  score must be -1, 0, or 1 (got {score})", file=sys.stderr)
            sys.exit(2)
        url = args.url
        if not url:
            from tiers import get_active_tier
            url = get_active_tier().maic_url()
        # llm.py's DEFAULT_URL has a /v1 suffix — strip it so we don't
        # double-up when we append '/v1/training/rate'.
        url = url.rstrip("/")
        if url.endswith("/v1"):
            url = url[:-3].rstrip("/")
        api_key = _check_api_key(args)
        try:
            r = rate_pair(request_id, score, url, api_key)
        except urllib.error.HTTPError as e:
            body = e.read().decode("utf-8", errors="replace")
            print(f"❌  {body or e.reason}", file=sys.stderr)
            sys.exit(1)
        except Exception as e:
            print(f"❌  {e}", file=sys.stderr)
            sys.exit(1)
        sign = "👍" if r["score"] == 1 else ("👎" if r["score"] == -1 else "∅")
        verb = "updated" if r["updated"] else "unchanged"
        print(f"✅  {sign} {r['request_id']}  (was score={r['previous_score']}, {verb})")
        sys.exit(0)

    if args.login or args.register:
        import auth as _auth
        from wizard import _login_flow, _register_flow
        try:
            if args.register:
                _register_flow()
            else:
                _login_flow()
        except SystemExit:
            raise
        except Exception as e:
            print(f"❌  {e}", file=sys.stderr)
            sys.exit(1)
        # After login/register: switch to local tier if not already.
        from tiers import get_active_tier, set_active_tier
        if get_active_tier().name != "local":
            set_active_tier("local")
        # No question → exit cleanly. Otherwise fall through.
        if not args.question:
            sys.exit(0)
    if args.setup:
        from wizard import run_setup_wizard
        try:
            run_setup_wizard()
        except SystemExit:
            raise
        except Exception as e:
            print(f"❌  setup wizard failed: {e}", file=sys.stderr)
            sys.exit(1)
        if not args.question:
            sys.exit(0)
    if args.change_password:
        import auth as _auth
        import getpass as _gp
        me = _auth.me() or _auth.current_user()
        if not me:
            print("❌  Not logged in. Run `mclaw --login` first.", file=sys.stderr)
            sys.exit(1)
        email = me.get("email") or "(unknown)"
        print(f"  Changing password for {email}")
        try:
            if sys.stdin.isatty():
                old = _gp.getpass("  Current password: ")
                new1 = _gp.getpass("  New password: ")
                new2 = _gp.getpass("  Confirm new password: ")
                if new1 != new2:
                    print("❌  Passwords don't match.", file=sys.stderr)
                    sys.exit(1)
                if old == new1:
                    print("❌  New password is the same as the old one.", file=sys.stderr)
                    sys.exit(1)
            else:
                # Non-TTY (piped / CI): read all three lines from stdin.
                lines = [l.rstrip("\n") for l in sys.stdin]
                if len(lines) < 3:
                    print("❌  Need 3 lines on stdin: old, new, confirm.",
                          file=sys.stderr)
                    sys.exit(2)
                old, new1, new2 = lines[0], lines[1], lines[2]
                if new1 != new2:
                    print("❌  Passwords don't match.", file=sys.stderr)
                    sys.exit(1)
                if old == new1:
                    print("❌  New password is the same as the old one.",
                          file=sys.stderr)
                    sys.exit(1)
            result = _auth.change_password(old, new1)
            if result.get("changed"):
                print("✅  Password changed. New session token saved.")
            else:
                print("❌  Server didn't confirm the change.", file=sys.stderr)
                sys.exit(1)
        except RuntimeError as e:
            msg = str(e)
            if "401" in msg or "Current password is incorrect" in msg:
                print("❌  Current password is incorrect.", file=sys.stderr)
                sys.exit(1)
            print(f"❌  {msg}", file=sys.stderr)
            sys.exit(1)
        if not args.question:
            sys.exit(0)

    # ─── Tier-aware setup ─────────────────────────────────────────────
    # Resolve the active tier (single tier: "local"). The tier's boot()
    # checks for a valid session and prompts for login if missing.
    from tiers import get_active_tier
    from wizard import needs_setup
    tier = get_active_tier()
    if needs_setup():
        # First run on this machine. Run the setup wizard.
        from wizard import run_setup_wizard
        run_setup_wizard()
    try:
        tier.boot({})
    except SystemExit:
        raise
    except Exception as e:
        print(f"⚠️  tier boot failed: {e}", file=sys.stderr)
        sys.exit(1)

    api_key = _check_api_key(args)
    color_on = not args.no_color and sys.stdout.isatty()

    # Phase B1 (2026-07-05): resolve the user's MAIC tier for
    # prompt-prefix selection. The mclaw "tier" is the local/dev
    # tier (always "local"); the MAIC tier (customer/pro/enterprise)
    # is what determines the prompt prefix and model chain. We
    # read it from auth.me() which calls GET /v1/users/me.
    #
    # As of 2026-07-10, we also call auth.refresh_tier() (which
    # internally calls /v1/users/me AND updates the local
    # profile mirror) so subsequent calls to profile.tier() and
    # the chain picker see the same value. The bare me() call
    # above is still useful for one-shot reads (e.g. prompt
    # prefix) but it doesn't update the persistent mirror, and
    # miracle-claw-dashboard / chain picker read from the mirror, not from
    # the in-process maic_tier variable.
    maic_tier = None
    try:
        import auth as _auth_tier
        me_info = _auth_tier.me()
        if me_info:
            maic_tier = me_info.get("tier")
    except Exception:
        pass  # customer/no-prefix is the safe default
    # Also persist the resolved tier to the local profile so the
    # chain picker (which reads profile.tier()) and miracle-claw-dashboard
    # (which reads profile.tier() as a fallback) get the same
    # value. Network blip here is fine — we keep the cached
    # value on failure.
    try:
        import auth as _auth_refresh_tier
        _auth_refresh_tier.refresh_tier()
    except Exception:
        pass

    home = pathlib.Path(os.environ.get("MIRAGRO_CLAW_HOME", pathlib.Path.home() / ".miracle_claw"))
    history_dir = home / "history"
    history_dir.mkdir(parents=True, exist_ok=True)

    # Resolved user_id (for training data attribution). 0 means
    # master-key / un-logged-in session, in which case auto-sync is
    # a no-op.
    try:
        from profile import get as _profile_get_user_id
        user_id = int(_profile_get_user_id("user_id", 0) or 0)
    except Exception:
        user_id = 0
    # Also set in env so handle_turn() + llm.py can pick it up for
    # X-User-Id headers on MAIC calls + user_id in jsonl entries.
    # miracle-claw-dashboard sets this too (from the JWT principal) — so both
    # the TTY path and the browser path get per-user attribution.
    if user_id:
        os.environ["MILAGRO_USER_ID"] = str(user_id)
    elif "MILAGRO_USER_ID" in os.environ:
        del os.environ["MILAGRO_USER_ID"]  # don't leak stale values

    # ─── Session persistence (cwd, model, url) ─────────────────────────
    from session import load as session_load, save as session_save, clear as session_clear
    saved = {} if args.reset_session else (session_load() if not args.no_session else {})
    if args.reset_session:
        session_clear()

    # cwd: explicit --cwd > saved cwd > current dir
    # In chat_mode (browser-driven REPL), always start in $HOME so the
    # browser session isn't tied to whatever the last TTY session's cwd
    # was. The browser has no concept of "what dir am I in" — the user
    # just opens localhost:7777 and chats. Without this, a stale saved
    # cwd like /tmp (or any other random dir) silently ships the wrong
    # project context on every chat turn. REPL mode (real TTY) keeps
    # the saved cwd so /cd's persist across sessions as designed.
    if args.cwd:
        cwd = pathlib.Path(args.cwd).expanduser().resolve()
    elif chat_mode:
        cwd = pathlib.Path.home()
    elif "cwd" in saved:
        cwd = pathlib.Path(saved["cwd"]).expanduser().resolve()
    else:
        cwd = pathlib.Path.cwd()

    system_prompt, proj_ctx, memory = _build_context(args, cwd, history_dir, tier=maic_tier)

    # Org CLAW.md (Phase 2): overrides personal CLAW.md if the org has
    # one set. Org admin owns the rules; personal CLAW.md is only used
    # when no org CLAW.md exists.
    import auth as _auth
    try:
        org_claw = _auth.org_get_setting("claw_md")
        if org_claw:
            system_prompt = org_claw
    except Exception:
        pass

    # Mutable state passed to slash commands.
    # Restored fields: model, url (if not overridden on command line).
    # URL default: tier-aware (customer → Milagro cloud).
    tier_url = tier.maic_url()
    state = {
        # 2026-07-13 fix: --model on the command line now wins over the
        # saved session model. Previously, "saved.get("model") or
        # args.model" meant a saved session with a different model
        # (e.g. a prior /model deepseek) would silently override the
        # user's current --model kimi. The chain init below already
        # gates on args.model == DEFAULT_MODEL; this init needs the
        # same gate so the two stay in sync.
        #
        # NOTE: we store the RAW name (e.g. "kimi" not
        # "milagro-oc-kimi") here on purpose. The actual canonical
        # resolution + transport selection happens in the
        # `resolve_model` call a few lines below (line ~908), which
        # also fills in model_transport correctly for the local
        # Ollama fast path. If we pre-normalized here, `local` would
        # resolve to "llama3.2" and lose its "ollama" transport.
        "model": args.model if args.model != DEFAULT_MODEL else (saved.get("model") or args.model),
        "url":   saved.get("url")   or args.url   or tier_url or "",
        "auto":  args.auto,
        "yes":   args.yes,
        "stream": not args.no_stream,
        "timeout": args.timeout,
        # Issue #6 fix: surface --no-memory to the per-turn memory
        # refresh in _run_llm_turn so a session that opted out of
        # cross-turn memory injection doesn't accidentally start
        # loading it on turn 2. (Default: memory is refreshed each
        # turn; users who passed --no-memory keep getting nothing.)
        "no_memory": args.no_memory,
        # Cross-turn memory window. The CLI default is 4 (× 2 messages),
        # the chat surface default is the same. We make it explicit on
        # the state dict so _run_llm_turn can read it without going back
        # to the args namespace.
        "memory_turns": getattr(args, "memory_turns", 4),
        # Tier (for the greeting and /tier command).
        "tier":  tier.name,
        "user":  auth.current_user() if tier.name != "personal" else None,
        # Token usage — accumulated across turns for the footer HUD.
        # Reset to 0 each session (not persisted — start fresh on reload).
        "total_tokens": 0,
        "last_turn_tokens": 0,
        "last_prompt_tokens": 0,
        "last_completion_tokens": 0,
        "total_prompt_tokens": 0,
        "total_completion_tokens": 0,
    }

    # Auto-sync training data on every exit path. Registered as atexit
    # so it fires from sys.exit(), unhandled exceptions, normal return,
    # and SIGTERM (all of which trigger Python's atexit chain). The
    # explicit calls in the REPL exit branches are belt-and-suspenders
    # for cases where atexit is suppressed (e.g. the WS-bridged REPL
    # calling os._exit()).
    if user_id and api_key:
        _sync_url = state["url"]
        _auto_sync_done = [False]  # mutable flag for dedup
        def _atexit_auto_sync() -> None:
            if _auto_sync_done[0]:
                return  # explicit call already ran
            _auto_sync_training_data(home, _sync_url, api_key, user_id)
        atexit.register(_atexit_auto_sync)
        # Stash on state so REPL exit branches can flip the flag.
        state["_auto_sync_done"] = _auto_sync_done
    # Resolve model: prefer saved resolved name if user didn't override.
    from llm import resolve_model, DEFAULT_FAILOVER_CHAIN
    if saved.get("model_resolved") and saved.get("model") == state["model"]:
        # The saved model name matches the resolved one in state; trust it.
        state["model_resolved"] = saved["model_resolved"]
        state["model_transport"] = saved.get("model_transport", "maic")
    else:
        _resolved, _transport = resolve_model(state["model"])
        state["model_resolved"] = _resolved
        state["model_transport"] = _transport
    # Failover chain: prefer the saved one; if the user has no saved
    # chain, seed with the OpenClaw-equivalent default so the dev
    # copy's first turn exercises the same 5-model failover as
    # OpenClaw (2026-07-02 — see memory/2026-07-02-miracle-claw-
    # openclaw-setup.md).
    #
    # Phase A2 (2026-07-05): miracle-claw-dashboard may pass a tier-appropriate
    # chain via --server-chain. We use it as the floor for the
    # default chain (when the user has NO local /failover
    # customization) but never overwrite a saved one — that's the
    # user's explicit choice from /failover and must be respected.
    # The user can still /failover restore to get back to the
    # server's chain, same as the customer chain worked before.
    if "fallback_chain" in saved:
        # 2026-07-17 fix: detect a stale saved chain. The user's
        # explicit /failover choice is sacred, but a chain that was
        # saved BEFORE a server-side chain swap (e.g. kimi became
        # primary on 2026-07-17) is not an explicit choice — it's
        # just old data. Without this check, a user who logged in
        # once before the kimi swap got a chain that doesn't
        # contain kimi, and their first call fell through to the
        # 2nd entry (minimax). The displayed "primary" was minimax,
        # not kimi. (2026-07-17 "Webchat hangs on thinking..." bug.)
        #
        # Staleness signal: the chain doesn't contain the current
        # tier's primary. We resolve the current primary via
        # get_tier_chain(tier)[0][1] (the canonical MAIC id) and
        # check whether it's in the saved chain's model_ids.
        from llm import get_tier_chain
        _user_tier = None
        try:
            from profile import tier as _profile_tier
            _user_tier = _profile_tier()
        except Exception:
            _user_tier = None
        _current_primary = None
        try:
            _current_primary = get_tier_chain(_user_tier)[0][1]
        except Exception:
            _current_primary = None
        _saved_ids = [m for _d, m in saved["fallback_chain"]] if saved["fallback_chain"] else []
        _is_stale = (
            _current_primary
            and _saved_ids
            and _current_primary not in _saved_ids
        )
        if _is_stale:
            # Re-derive from the current tier chain and let the
            # user keep using it. (They can still /failover to set
            # their own if they want.)
            from llm import filter_chain_for_tier
            _tier_default = get_tier_chain(_user_tier)
            _filtered = filter_chain_for_tier(_tier_default, _user_tier) or _tier_default
            state["fallback_chain"] = list(_filtered)
            # Sync model + model_resolved to the new chain[0].
            # The model field is the canonical id (used in MAIC
            # calls); the model_resolved is the same canonical id
            # (resolve_model below is a no-op on already-canonical
            # names). Without this, the banner would show the
            # stale model while the chain used the new primary.
            state["model"] = _filtered[0][1]
            state["model_resolved"] = _filtered[0][1]
            print(color(
                f"  ⚠️  saved chain pre-dated current primary swap; "
                f"re-derived from {_user_tier or 'customer'} tier (was: "
                f"{[d for d, m in saved['fallback_chain']][0]}, now: "
                f"{_filtered[0][0]})",
                C.YELLOW,
            ), file=sys.stderr)
        else:
            state["fallback_chain"] = saved["fallback_chain"]
            # Chain is fresh, but state["model"] (set from
            # saved.get("model") above) might still point to the
            # old model id if the user logged in once before a
            # primary swap and then never used the REPL. Force
            # model + model_resolved to the chain[0] so the
            # banner and the MAIC call agree. (2026-07-17
            # "Webchat hangs" follow-up: after session.py
            # normalized the display name to a model id, the
            # chain and the model could be from different
            # generations of the chain init.)
            if saved["fallback_chain"]:
                _chain_top_id = saved["fallback_chain"][0][1]
                if state.get("model") and state["model"] != _chain_top_id:
                    state["model"] = _chain_top_id
                    state["model_resolved"] = _chain_top_id
    else:
        _seed_chain = _parse_server_chain(args.server_chain)
        # Resolve the user's effective MAIC tier for the chain
        # filter. Priority:
        #   1. tiers.get_active_tier().name (the local "mclaw tier",
        #      always "local" in our single-tier install)
        #   2. profile.tier() (the cached MAIC tier from
        #      auth.refresh_tier, canonicalized by the shared
        #      resolver)
        # We need #2 because #1 is always "local" in our install
        # (the dev/admin escape hatch), and filter_chain_for_tier
        # treats "local" as "no filtering" — which would let a
        # customer use a pro chain if miracle-claw-dashboard mistakenly
        # passed one. Using the MAIC tier for filtering is the
        # correct security boundary.
        from llm import filter_chain_for_tier
        _user_tier = None
        try:
            from tiers import get_active_tier
            _user_tier = get_active_tier().name
        except Exception:
            _user_tier = None
        # If the local tier is "local" (always the case in our
        # single-tier install), fall back to the MAIC tier
        # for the filter check. The MAIC tier is what the
        # chain picker should be gating on.
        if _user_tier in (None, "local", "personal"):
            try:
                from profile import tier as _profile_tier_fn
                _user_tier = _profile_tier_fn() or "customer"
            except Exception:
                _user_tier = "customer"
        if _seed_chain is not None:
            # Server gave us a tier-appropriate chain (pro/enterprise).
            # Use it as the default. model state is set to chain[0] so
            # /model and the footer line up.
            #
            # Phase A3 (2026-07-05): even though miracle-claw-dashboard picks a
            # tier-appropriate chain, a malicious or curious user could
            # pass --server-chain directly on the TTY command line
            # claiming to be a pro user. We cross-check against the
            # active tier (resolved locally from the logged-in MAIC
            # account or the local dev tier) and filter out any models
            # the user isn't allowed to use. The fallback below covers
            # the case where filtering empties the chain.
            _filtered = filter_chain_for_tier(_seed_chain, _user_tier)
            if _filtered:
                state["fallback_chain"] = list(_filtered)
                state["model"]         = _filtered[0][1]
                state["model_resolved"] = _filtered[0][1]
                state["model_transport"] = "maic"
            else:
                # Filtering left nothing (e.g. server chain was all
                # pro models and we're a customer). Use the user's
                # tier default chain instead.
                _tier_default = _filtered or []
                from llm import get_tier_chain
                _tier_default = get_tier_chain(_user_tier)
                state["fallback_chain"] = list(_tier_default)
                state["model"]         = _tier_default[0][1]
                state["model_resolved"] = _tier_default[0][1]
                state["model_transport"] = "maic"
                print(color(
                    f"  ⚠️  --server-chain contained no models your tier can use; "
                    f"using {(_user_tier or 'customer')} default chain",
                    C.YELLOW,
                ), file=sys.stderr)
        else:
            # No server chain — pick a tier-appropriate chain based
            # on the locally-cached MAIC tier (profile.tier()).
            # This is the TTY/standalone path where there's no
            # miracle-claw-dashboard in the loop to pass --server-chain. A
            # logged-in pro/enterprise user running `mclaw` directly
            # should get their tier chain, not the customer chain.
            from llm import get_tier_chain
            _tier_chain = get_tier_chain(_user_tier)
            state["fallback_chain"] = list(_tier_chain)
            # Make the model state match chain[0] so /model shows the
            # correct primary.
            #
            # 2026-07-13 fix: only override state["model"] if the user
            # didn't pass --model on the command line. Without this
            # gate, `echo "..." | mclaw --model kimi` would initialize
            # the REPL chain and silently replace kimi with chain[0]
            # (typically minimax), which is exactly the silent-downgrade
            # bug we just fixed. The one-shot code at the bottom of
            # main() already had this guard; this brings the REPL path
            # in line.
            #
            # When --model is passed, use state["model_resolved"]
            # (the canonical form that resolve_model filled in just
            # above), NOT args.model directly — otherwise "kimi" would
            # clobber the canonical "milagro-oc-kimi" the earlier
            # resolve_model call stored. The transport comes from
            # state["model_transport"] so local aliases keep their
            # ollama transport.
            if args.model == DEFAULT_MODEL:
                state["model"] = _tier_chain[0][1]
                state["model_resolved"] = _tier_chain[0][1]
            else:
                # User explicitly overrode --model; respect it and
                # disable the failover chain (they want THIS model,
                # not the chain).
                state["model"] = state["model_resolved"]
                state["model_resolved"] = state["model_resolved"]
                state["fallback_chain"] = None
            state["model_transport"] = state.get("model_transport", "maic")

    # Persist on normal exit (Ctrl-C / SIGTERM / return). Best-effort.
    def _persist_session() -> None:
        if args.no_session:
            return
        try:
            # In chat mode, the saved cwd is meaningless (browser
            # sessions always start in $HOME on next load). Don't
            # let a stray /cd in the chat pollute the saved cwd —
            # always save $HOME so the next browser session starts
            # clean. TTY REPL saves the real cwd so /cd's persist.
            save_cwd = str(pathlib.Path.home()) if chat_mode else str(cwd)
            session_save(state, cwd=save_cwd)
        except Exception as e:
            import sys as _sys
            print(f"⚠️  session save failed: {type(e).__name__}: {e}",
                  file=_sys.stderr)

        # Session memory persistence (2026-07-06). Extracts today's
        # jsonl log into a markdown summary and appends to
        # ~/.openclaw/workspace/memory/YYYY-MM-DD.md. The next
        # morning's MEMORY.md distillation picks it up automatically,
        # so the assistant (Home Claw / me) carries context into the
        # next session even when it's a different model instance.
        # Gated by --no-memory-flag (separate from the cross-turn
        # memory injection that --no-memory controls). Users can opt
        # out with MILAGRO_NO_SESSION_MEMORY=1.
        if os.environ.get("MILAGRO_NO_SESSION_MEMORY") != "1":
            try:
                from session_memory import persist_session_memory
                mem_path = persist_session_memory(
                    session_label=dt.datetime.now().strftime("%H:%M"),
                )
                if mem_path and not chat_mode:
                    # TTY REPL only — chat mode is noisy and the
                    # browser already shows the session is ending.
                    print(color(
                        f"  💾  session memory appended to {mem_path.name}",
                        C.DIM,
                    ))
            except Exception as e:
                import sys as _sys
                print(f"⚠️  session memory persist failed: {type(e).__name__}: {e}",
                      file=_sys.stderr)
    atexit.register(_persist_session)

    # Shared context dict for handle_repl_line — anything that's expensive
    # to recompute per-line lives here.
    def _reader(limit: int) -> list[dict]:
        from history import recent_entries
        return recent_entries(history_dir, limit=limit)
    def _clear_chat_region() -> None:
        """Clear the chat scroll region (preserve box + footer).

        In TTY mode: uses DECSTBM scroll region + ED selective erase to
        wipe only the chat area (rows 1..scroll_bottom). The box at the
        bottom and the footer BELOW it are OUTSIDE the scroll region and
        therefore untouched.

        In non-TTY mode (one-shot `--question`): falls back to a full
        screen clear since there's no scroll region set.

        Late-binding lookups for `is_tty` and `layout` are required so
        this function can be registered in ctx before they are defined.
        """
        if not is_tty:  # noqa: F821
            sys.stdout.write("\033[2J\033[H")
            sys.stdout.flush()
            return
        sys.stdout.write("\033[1;1H")     # home (top of scroll region)
        sys.stdout.write("\033[J")        # erase to end of screen (within region)
        sys.stdout.write("\033[1;1H")     # re-home for next prompt
        sys.stdout.flush()

    ctx = {
        "state":         state,
        "system_prompt": system_prompt,
        "api_key":       api_key,
        "color_on":      color_on,
        "cwd":           cwd,
        "history_dir":   history_dir,
        "project_ctx":   proj_ctx,
        "memory":        memory,
        "history_reader": _reader,
        "clear_chat":    _clear_chat_region,
        "chat_mode":     chat_mode,
        # Tool protocol (inline <tool_call> blocks → read_file/run_bash).
        # Enabled when the bootstrap is on (chat surface always; REPL
        # follows the --no-bootstrap flag). When False, the LLM doesn't
        # see the tool schema and call_with_tools is bypassed entirely.
        "tools_enabled": include_bootstrap,
        # Pricing for /cost (USD per 1M tokens). Override with env vars
        # MILAGRO_INPUT_PRICE_PER_M / MILAGRO_OUTPUT_PRICE_PER_M if you're
        # running a different model (e.g. Claude Opus, local Ollama).
        "pricing": {
            "input":  float(os.environ.get("MILAGRO_INPUT_PRICE_PER_M",  "0.50")),
            "output": float(os.environ.get("MILAGRO_OUTPUT_PRICE_PER_M", "1.50")),
        },
    }

    # ─── One-shot mode ──────────────────────────────────────────────
    if args.question:
        question = " ".join(args.question)
        # Use the state-resolved model/URL, not args.* — the chain init
        # above may have updated state["model"] to the OpenClaw-equivalent
        # primary, and we want one-shot mode to honor the same failover
        # chain as REPL mode. Only fall back to args.model if the user
        # explicitly passed --model on the command line.
        one_shot_model = state["model"]
        one_shot_url = state["url"]
        one_shot_model_resolved = state.get("model_resolved") or state["model"]
        one_shot_model_transport = state.get("model_transport", "maic")
        one_shot_chain = state.get("fallback_chain")
        if args.model != DEFAULT_MODEL:
            # User explicitly overrode --model on the CLI. Disable the
            # chain so they get exactly the model they asked for.
            # 2026-07-13 fix: use the canonical resolved form
            # (filled in by resolve_model(state["model"]) a few lines
            # above), so "kimi" becomes "milagro-oc-kimi" before
            # reaching handle_turn / MAIC. Preserves model_transport
            # too (so `local` aliases keep their ollama transport).
            one_shot_model = one_shot_model_resolved
            one_shot_model_resolved = one_shot_model_resolved
            one_shot_chain = None
        if args.url != DEFAULT_URL:
            one_shot_url = args.url
        handle_turn(
            question=question,
            system_prompt=system_prompt,
            cwd=cwd,
            model=one_shot_model,
            url=one_shot_url,
            api_key=api_key,
            auto=args.auto,
            yes=args.yes,
            stream=not args.no_stream,
            timeout=args.timeout,
            color_on=color_on,
            history_dir=history_dir,
            project_ctx=proj_ctx,
            memory=memory,
            model_resolved=one_shot_model_resolved,
            model_transport=one_shot_model_transport,
            fallback_chain=one_shot_chain,
            chat_mode=chat_mode,
            tools_enabled=(not args.no_bootstrap) or chat_mode,
        )
        return

    # ─── Alt-screen handoff ─────────────────────────────────────────
    # Before drawing the chat UI over whatever was on the user's terminal,
    # switch to the alternate screen buffer (xterm 1049) so that on exit
    # the previous terminal content is restored *exactly* as we found it.
    # We also clear the new buffer, set a dark background, and reset all
    # SGR attributes — this is the "wipe + paint" that makes the transition
    # unmistakable, matching how OpenClaw and other TUI apps (vim, htop,
    # lazygit, etc.) start their full-screen modes.
    #
    # Skipped when:
    #   * stdout isn't a TTY (piped / CI / miracle-claw-dashboard — browser handles its
    #     own screen). The alt screen is a real-terminal feature; the
    #     browser path would just see escape soup it can't render.
    #   * `--no-alt-screen` was passed (escape hatch for broken terminals,
    #     or users who want the chat to scroll naturally with their shell
    #     history).
    use_alt = (not getattr(args, "no_alt_screen", False)
               and sys.stdout.isatty()
               and not os.environ.get("MILAGRO_CHAT_SURFACE"))
    if use_alt:
        # 1049h   — switch to alt screen (previous content saved)
        # ?25l    — hide cursor while we lay out
        # 2J      — clear the entire new buffer (any leftover from tmux
        #            pre-output, the user's last command, etc.)
        # 0m      — reset all SGR attributes (bold, underline, color)
        #            so nothing from the prior session bleeds into ours
        # ?1049h's 5h sequence is the "1:1 mapping" guard; 39/49 (default
        # fg/bg) would also work but 0m is more thorough
        # 48;5;236 — set background to #1f1f1f (very dark gray) for the
        #            brief moment before our own background-aware chars
        #            render. Pure black (48;5;16) is too harsh.
        sys.stdout.write(
            "\033[?1049h"      # alt screen
            "\033[?25l"        # hide cursor
            "\033[2J"          # clear
            "\033[0m"          # reset attrs
            "\033[48;5;236"    # dark gray bg
            "\033[H"           # home
        )
        sys.stdout.flush()

    # ChatGPT-style: input box pinned at the BOTTOM, transcript scrolls above.
    # Screen layout (rows numbered top-to-bottom):
    #   row 1..(H-BOX_TOTAL_ROWS):  scroll region (chat) — terminal auto-scrolls
    #                                here when content overflows. The box below
    #                                is OUTSIDE this region so it never gets
    #                                overwritten by auto-scroll.
    #   row H-BOX_TOTAL_ROWS+1:     ╭───╮  top border of input box
    #   row ...                     │⚡ ...│ viewport row 0
    #   row ...                     │     │ viewport row 1
    #   row H:                      ╰───╯  bottom border
    #
    # At the start of each turn we jump to the bottom of the scroll region
    # (row H-BOX_TOTAL_ROWS-FOOTER_ROWS) and print the question + response there.
    # The terminal handles scrolling within the region; the box + footer stay put.
    from multiline_repl import MultiLineEditor, RawMode as ML_RawMode
    # Banner info is rendered into the FOOTER below the box, not above it.
    # That way the model/url/cwd info stays visible no matter how much chat
    # history scrolls off the top.

    try:
        from completion import Completer as _RealCompleter  # noqa: F401
        completer = CompletionAdapter(cwd=cwd)
    except ImportError:
        completer = None

    prompt_text = "⚡ "
    BOX_INNER_ROWS = 2  # compact: just enough for one-line + a bit of overflow
    BOX_TOTAL_ROWS = BOX_INNER_ROWS + 2   # + top + bottom border
    FOOTER_ROWS = 2                        # model/url/cwd line + help hint

    editor = MultiLineEditor(
        prompt=prompt_text,
        completer=completer,
        stream=sys.stdout,
        left_margin=0,
        viewport_rows=BOX_INNER_ROWS,
        viewport_width=80,
        placeholder="Ask Anything",
    )

    # Byte stream for input. We read directly with os.read() so that
    # signals (SIGWINCH, SIGINT) interrupt the read with InterruptedError
    # instead of being silently retried by BufferedReader.
    _stdin_fd = None
    try:
        _stdin_fd = sys.stdin.fileno()
    except (OSError, ValueError):
        pass
    stream = sys.stdin.buffer if hasattr(sys.stdin, "buffer") else sys.stdin

    # TTY/PTY detection (covers miracle-claw-dashboard case)
    is_tty = False
    try:
        is_tty = (os.isatty(sys.stdin.fileno())
                  or os.isatty(sys.stdout.fileno()))
    except (OSError, ValueError):
        pass

    # chat_mode is defined at the top of main() so the ctx dict (line ~670)
    # can reference it. The is_tty-based fallback for non-TTY invocations
    # is OR'd in here: if neither env var is set, fall back to `not is_tty`.
    # In the browser chat case the env var is set, so this OR is a no-op
    # and we keep the value from the top of main().
    chat_mode = chat_mode or not is_tty

    # chat_mode is defined earlier (right after is_tty) so the ctx dict
    # built above can reference it. The full TUI-mode rationale comment
    # lives there. Do not redefine chat_mode here — a second definition
    # would shadow the earlier one and break the ctx binding.

    # Mutable layout state (updated by SIGWINCH handler when terminal is resized)
    layout = {
        "rows": 24,
        "cols": 80,
        "box_top_row": 19,
        "box_inner_first_row": 20,
        "box_bottom_row": 22,
        "footer_row1": 23,
        "footer_row2": 24,
        "scroll_bottom_row": 18,   # last row of the chat scroll region
    }

    def _do_layout(*, clear: bool = True) -> None:
        """Re-query terminal size and redraw the box at the new position.

        Sets a DECSTBM scroll region covering rows 1..(H-BOX_TOTAL_ROWS-FOOTER_ROWS)
        so the chat area auto-scrolls independently of the box + footer.
        Both are OUTSIDE the scroll region and stay pinned at the bottom
        no matter how much chat scrolls.

        `clear=True` wipes the screen first (used on resize when the
        new row count would orphan old content). On initial layout
        (`clear=False`) we leave the chat area empty (no banner).
        """
        # Browser chat mode has no screen layout — the client renders a
        # scrollable log. Recompute `rows`/`cols` only so any code that
        # reads layout still gets sensible numbers, then bail out.
        if chat_mode:
            rows, cols = _term_size()
            layout["rows"] = rows
            layout["cols"] = cols
            return
        rows, cols = _term_size()
        rows = max(rows, BOX_TOTAL_ROWS + FOOTER_ROWS + 5)
        layout["rows"] = rows
        layout["cols"] = cols
        layout["box_top_row"] = rows - BOX_TOTAL_ROWS - FOOTER_ROWS + 1
        layout["box_inner_first_row"] = layout["box_top_row"] + 1
        layout["box_bottom_row"] = layout["box_top_row"] + BOX_TOTAL_ROWS - 1
        layout["footer_row1"] = layout["box_bottom_row"] + 1
        layout["footer_row2"] = layout["footer_row1"] + 1
        layout["scroll_bottom_row"] = layout["box_top_row"] - 1

        if clear:
            # Reset scroll region to full screen, clear, home.
            sys.stdout.write("\033[r\033[2J\033[H")
        else:
            # Just reset scroll region (without clearing) and home.
            sys.stdout.write("\033[r\033[H")
        # Set scroll region: rows 1..scroll_bottom_row (chat area).
        # The box + footer rows are OUTSIDE this region and stay
        # pinned at the bottom even when the chat fills up.
        sys.stdout.write(
            f"\033[1;{layout['scroll_bottom_row']}r"
        )
        # Draw ALL box borders first (top + bottom). Then render the
        # viewport inside the borders. This ordering matters: render_absolute()
        # leaves the cursor at the user's logical typing position INSIDE
        # the viewport, which means drawing the bottom border AFTER it
        # would overwrite viewport content. So we draw the bottom border
        # first, while the cursor is still at row 1 col 1.
        sys.stdout.write("\033[" + str(layout["box_top_row"]) + ";1H")
        # Top border carries the model name as a sticky title so the user
        # always knows which model they're talking to (especially useful
        # when failover rotates the chain and the primary changed). The
        # footer row still shows url/cwd + token count for full context.
        title = state.get("model_resolved") or args.model or ""
        sys.stdout.write(color(
            box_top_with_title(f"⚡ miracle_claw · {title}"),
            C.DIM, enabled=color_on,
        ))
        sys.stdout.write("\033[" + str(layout["box_bottom_row"]) + ";1H")
        sys.stdout.write(color(
            "\u2570" + "\u2500" * 78 + "\u256f",
            C.DIM, enabled=color_on,
        ))
        # Now render the inner viewport. Its cursor will end up inside the
        # box at the typing position.
        editor.render_absolute(layout["box_inner_first_row"])
        # Draw the footer (model/url/cwd + help hint) at the pinned rows
        # BELOW the box. These rows are outside the scroll region so they
        # never get overwritten by chat output.
        _redraw_footer()
        # Move cursor to row 1 (top of scroll region) so the chat area
        # starts fresh.
        sys.stdout.write("\033[1;1H")
        sys.stdout.flush()

    def _redraw_box_title() -> None:
        """Redraw just the input box's top border with the current model.

        Called after every turn (via _redraw_footer) and whenever the
        model changes via /model or failover, so the title stays in
        sync with state["model_resolved"]. Skipped in chat_mode where
        there's no pinned box.
        """
        if not is_tty or chat_mode:
            return
        title = state.get("model_resolved") or args.model or ""
        sys.stdout.write("\033[" + str(layout["box_top_row"]) + ";1H")
        sys.stdout.write(color(
            box_top_with_title(f"⚡ miracle_claw · {title}"),
            C.DIM, enabled=color_on,
        ))
        sys.stdout.flush()

    def _redraw_footer() -> None:
        """Redraw the footer (outside the scroll region) with current state.

        Reads `state["total_tokens"]` / `state["last_turn_tokens"]` to
        show a token count when total >= 1000 (silent for short sessions).
        Pulls the latest cwd from `ctx["cwd"]` (set by /cd) so the footer
        reflects post-cd changes without needing a separate callback.

        Also refreshes the input box's top-border title so the model
        name stays current after /model or failover.
        """
        current_cwd = ctx.get("cwd", cwd)
        # Use the resolved model name when available (set by /model).
        current_model = state.get("model_resolved") or args.model
        row1_text, row1_style = footer_row1(
            current_model, args.url, str(current_cwd),
            tokens_total=state.get("total_tokens", 0),
            tokens_last=state.get("last_turn_tokens", 0),
            # Pass the per-turn request_id in chat mode so the
            # dashboard's feedback button can POST it. Omitted in
            # TTY mode (the pinned footer is small; no UI to use it).
            request_id=(state.get("last_request_id", "") if chat_mode else ""),
        )
        if chat_mode:
            # Browser log has no pinned footer — instead, emit a plain-text
            # footer line that pipes safely over the WS-PTY bridge. The
            # dashboard's chat page renders it as the first line of the
            # session, so the user always knows which model + MAIC URL
            # they're talking to. Re-emitted on /model and /cd so it
            # stays current. No cursor positioning, no TUI escapes —
            # just a dim line that scrolls with the rest of the chat.
            sys.stdout.write("\n" + color(f"  {row1_text}", row1_style, enabled=color_on) + "\n\n")
            sys.stdout.flush()
            return
        draw_footer(
            row1_text=row1_text,
            row1_style=row1_style,
            row2_text=footer_row2(),
            row1=layout["footer_row1"],
            color_on=color_on,
        )
        # Refresh the box-top title so the model name stays current
        # after /model or failover. Cheap (one line redraw) and only
        # runs in TTY mode (skipped in chat_mode above).
        _redraw_box_title()

    if is_tty:
        # Save cursor + hide it.
        sys.stdout.write("\033[?25l")
        sys.stdout.flush()

    def _cleanup_terminal() -> None:
        try:
            # Only leave the alt screen if we actually entered it. The
            # `use_alt` closure var is captured at chat-UI entry; the
            # atexit handler runs once at process exit so a clean run
            # of `mclaw "question"` (which never entered the alt screen)
            # won't try to leave a buffer we never entered. Without
            # this guard, a stray 1049l on a real terminal flips the
            # visible buffer back to nothing and confuses the user.
            leave_alt = use_alt
            seq = (
                "\033[0m"      # reset attrs (kill dark bg, color, bold)
                "\033[?25h"    # show cursor
                "\033[r"       # full-screen scroll region
            )
            if leave_alt:
                seq += "\033[?1049l"  # leave alt screen
            sys.stdout.write(seq)
            sys.stdout.flush()
        except Exception:
            pass
    atexit.register(_cleanup_terminal)

    # SIGWINCH handler — declared here (not inside the TTY branch) so
    # the non-TTY REPL path can also reference it.
    _sigwinch_pending = [False]
    def _on_sigwinch(signum, frame):
        _sigwinch_pending[0] = True
    try:
        signal.signal(signal.SIGWINCH, _on_sigwinch)
    except (AttributeError, ValueError):
        pass

    if is_tty:
        # Initial layout — don't clear the screen (nothing to wipe on startup).
        _do_layout(clear=False)

    else:
        # Non-TTY fallback — just print the prompt, no box.
        # Chat mode: also emit the model/URL footer line so the dashboard's
        # chat page shows what the user is talking to. The footer updates
        # after every turn (via _redraw_footer() below), so the line stays
        # current with model changes (/model) and token counts.
        _redraw_footer()
        sys.stdout.write(editor.prompt)
        sys.stdout.flush()

    def _redraw_box() -> None:
        """Walk cursor to the box's first inner row and re-render it.
        Leaves the cursor at the logical position inside the box (the
        caller's cursor before this call is NOT restored — wrap calls
        in DECSC/DECRC if you need to restore)."""
        if not is_tty or chat_mode:
            return
        editor.render_absolute(layout["box_inner_first_row"])

    # ─── Pre-drain cooked-mode input buffer ───────────────────────────
    # When mclaw runs under a PTY (miracle-claw-dashboard browser sessions, `script`
    # command), the PTY starts in canonical (cooked) mode. Input that
    # arrives while boot() is running (e.g. auth.me() HTTP call) is
    # buffered by the kernel's line discipline. When we call
    # tty.setraw() below, the kernel FLUSHES that cooked-mode buffer —
    # the data is lost forever. This is the root cause of the WS
    # lockup where miracle-claw-dashboard sends input, the PTY echoes it, but mclaw
    # never sees it.
    #
    # Fix: drain any pending data from the PTY's read buffer BEFORE
    # switching to raw mode, and pre-feed it to the editor. Complete
    # lines are queued for the read loop to process.
    _pre_drained_lines: list[str] = []
    if is_tty and _stdin_fd is not None:
        import select as _sel_drain
        _drain_buf = bytearray()
        while True:
            rdy, _, _ = _sel_drain.select([_stdin_fd], [], [], 0.0)
            if not rdy:
                break
            try:
                chunk = os.read(_stdin_fd, 4096)
            except (OSError, ValueError):
                break
            if not chunk:
                break
            _drain_buf.extend(chunk)
        if _drain_buf:
            # Feed each byte to the editor; collect complete lines.
            from repl import CANCEL as _CANCEL_SENTINEL
            for byte in _drain_buf:
                results = editor.feed(bytes([byte]))
                for result in results:
                    if result is None or result is _CANCEL_SENTINEL:
                        continue
                    if result == "":
                        # EOF (Ctrl-D on empty line) — skip it, don't
                        # treat as a real submission.
                        continue
                    _pre_drained_lines.append(result)

    # Local reference so SIGWINCH handler can flip it
    with ML_RawMode():
        while True:
            # Process any lines pre-drained from the cooked-mode buffer
            # before setraw was called. These were collected before
            # entering raw mode to prevent the kernel from flushing them.
            if _pre_drained_lines:
                submitted = _pre_drained_lines.pop(0)
                # Same handling as a normally-submitted line (see below).
                if submitted is not None:
                    if is_tty:
                        sys.stdout.write(
                            "\033[" + str(layout["scroll_bottom_row"]) + ";1H"
                        )
                        if submitted.strip():
                            if not chat_mode:
                                uicon = os.environ.get("MILAGRO_USER_ICON", "👤")
                                prompt_prefix = f"  {uicon} "
                                sys.stdout.write(color(
                                    prompt_prefix + submitted.replace("\n", "\n    "),
                                    C.BOLD, C.WHITE, enabled=color_on,
                                ) + "\n")
                        action = handle_repl_line(raw=submitted, cwd=cwd,
                                                   state=state, ctx=ctx)
                    else:
                        sys.stdout.write("\n")
                        if submitted.strip():
                            if not chat_mode:
                                uicon = os.environ.get("MILAGRO_USER_ICON", "👤")
                                prompt_prefix = f"  {uicon} "
                                sys.stdout.write(color(
                                    prompt_prefix + submitted.replace("\n", "\n    "),
                                    C.BOLD, C.WHITE, enabled=color_on,
                                ) + "\n")
                        action = handle_repl_line(raw=submitted, cwd=cwd,
                                                   state=state, ctx=ctx)
                    if action == "exit":
                        break
                    _redraw_footer()
                    if is_tty:
                        _redraw_box()
                    else:
                        sys.stdout.write(editor.prompt)
                    sys.stdout.flush()
                continue
            # Check for SIGWINCH (terminal resize) and relayout.
            if _sigwinch_pending[0]:
                _sigwinch_pending[0] = False
                _do_layout()
                continue
            try:
                if _stdin_fd is not None:
                    # Use select() with a short timeout so we periodically
                    # check the SIGWINCH flag even when no data is arriving.
                    import select as _sel
                    rdy, _, _ = _sel.select([_stdin_fd], [], [], 0.1)
                    if not rdy:
                        # No input ready — loop back and recheck the flag.
                        continue
                    b = os.read(_stdin_fd, 1)
                else:
                    b = stream.read(1)
            except InterruptedError:
                sys.stderr.write("[loop] read interrupted by signal\n")
                sys.stderr.flush()
                # Signal (e.g. SIGWINCH) interrupted the read. Loop back
                # so the flag check at the top can handle it.
                continue
            except (EOFError, KeyboardInterrupt):
                sys.stdout.write("\n")
                _redraw_box()
                # Hand the bye message over to the alt screen, then leave it
                print(color("👋 bye", C.DIM, enabled=color_on))
                _cleanup_terminal()
                if _auto_sync_training_data(home, state["url"], api_key, user_id):
                    state["_auto_sync_done"][0] = True
                break
            if not b:
                print(color("👋 bye", C.DIM, enabled=color_on))
                _cleanup_terminal()
                if _auto_sync_training_data(home, state["url"], api_key, user_id):
                    state["_auto_sync_done"][0] = True
                break

            lines = editor.feed(b)
            submitted = None
            cancelled = False
            for line in lines:
                from multiline_repl import CANCEL
                if line is CANCEL:
                    cancelled = True
                    continue
                if line is None:
                    continue
                submitted = line

            if cancelled:
                editor.clear()
                _redraw_box()
                continue

            if submitted is not None:
                # Jump to the bottom of the scroll region (just above the
                # box) and print the question there. The terminal handles
                # scrolling within the region naturally — the box below is
                # OUTSIDE the region and stays pinned at the bottom.
                if is_tty:
                    # Position cursor at the bottom of the scroll region
                    # (row scroll_bottom_row). Newlines here will scroll
                    # within the region without touching the box.
                    sys.stdout.write(
                        "\033[" + str(layout["scroll_bottom_row"]) + ";1H"
                    )
                    if submitted.strip():
                        # User prompt icon — defaults to 👤 (person, per David 2026-06-28).
                        # Override via MILAGRO_USER_ICON env var.
                        # Skip the echo in chat mode (input is already in the
                        # command bar at the bottom of the screen).
                        if not chat_mode:
                            uicon = os.environ.get("MILAGRO_USER_ICON", "👤")
                            prompt_prefix = f"  {uicon} "
                            sys.stdout.write(color(
                                prompt_prefix + submitted.replace("\n", "\n    "),
                                C.BOLD, C.WHITE, enabled=color_on,
                            ) + "\n")
                    action = handle_repl_line(raw=submitted, cwd=cwd,
                                               state=state, ctx=ctx)
                else:
                    sys.stdout.write("\n")
                    if submitted.strip():
                        # In chat mode, the input is already in the command
                        # bar at the bottom of the screen — echoing it into
                        # the log just duplicates the text. The user said
                        # "the 👤 /help echoes the user's command which they
                        # already see in the command bar". Skip the echo.
                        if not chat_mode:
                            uicon = os.environ.get("MILAGRO_USER_ICON", "👤")
                            prompt_prefix = f"  {uicon} "
                            sys.stdout.write(prompt_prefix + submitted + "\n")
                    action = handle_repl_line(raw=submitted, cwd=cwd,
                                               state=state, ctx=ctx)
                if action == "exit":
                    _cleanup_terminal()
                    if _auto_sync_training_data(home, state["url"], api_key, user_id):
                        state["_auto_sync_done"][0] = True
                    return
                # Clear the box + redraw it for the next question.
                editor.clear()
                if is_tty:
                    _redraw_box()
                    # Redraw footer too — tokens may have changed and the
                    # model/url/cwd line could need refreshing (e.g. after
                    # a /model or /cd command).
                    _redraw_footer()
                else:
                    sys.stdout.write(editor.prompt)
                    sys.stdout.flush()
                continue

            # Plain keystroke — redraw the box (cursor moves to logical pos).
            _redraw_box()


if __name__ == "__main__":
    main()