"""Hikaflow CLI v2 - Interactive AI Debugging with Typer.

This module provides the main CLI interface using Typer for a modern,
type-hint driven command-line experience.

Usage:
    hikaflow                    # Interactive mode
    hikaflow login              # Authenticate via browser
    hikaflow login --device-auth # Authenticate via device code
    hikaflow errors             # List recent production errors
    hikaflow debug              # Interactive AI debugging session
    hikaflow debug err_xxx      # Debug specific error
    hikaflow watch              # Watch for new errors
    hikaflow doctor             # Check environment setup
    hikaflow init               # Configure project
    hikaflow replay diff        # Compare transcripts
    hikaflow replay export      # Export to HAR/curl/Postman
    hikaflow replay minimize    # Minimize failing transcript
"""

from __future__ import annotations

import asyncio
import json as json_module
import os
import re
import shutil
import subprocess
import sys
import time
import uuid
from dataclasses import dataclass
from enum import Enum
from contextlib import suppress
from pathlib import Path
from typing import Optional

import warnings
warnings.filterwarnings("ignore", message=".*logfire.*")
warnings.filterwarnings("ignore", message=".*opentelemetry.*")

import typer
from rich.console import Console
from rich.markdown import Markdown
from rich.panel import Panel
from rich.table import Table

from autodebug.cli_errors import handle_cli_errors
from autodebug.cli_output import OutputFormatter

# Initialize Typer app
app = typer.Typer(
    name="hikaflow",
    help="AI-powered production error debugger with deterministic replay.",
    no_args_is_help=False,
    rich_markup_mode="rich",
    context_settings={"help_option_names": ["--help", "-h"]},
)

console = Console()

# Bind the console's print method to a short alias used throughout the file.
_cprint = console.print


# Version
from autodebug import __version__

ADVANCED_HELP_PREFIX = "[Advanced] "


class TurnOutcomeKind(str, Enum):
    DELIVER = "DELIVER"
    BLOCKED = "BLOCKED"
    NEEDS_INPUT = "NEEDS_INPUT"


@dataclass
class TurnOutcomeRecord:
    outcome: TurnOutcomeKind
    reason_code: str
    reason_detail: str = ""
    delivered_artifact_kind: str = "none"
    needs_input_question: str = ""


@dataclass
class TurnRecord:
    turn_id: str
    backend: str
    user_intent: str
    tool_calls: int = 0
    outcome: Optional[TurnOutcomeRecord] = None


def _detect_needs_input_question(text: str) -> str:
    if not text:
        return ""
    for line in text.splitlines():
        s = line.strip()
        if s.endswith("?") and len(s) <= 240:
            return s
    return ""


def _response_request_id(response: object) -> Optional[str]:
    """Extract request correlation id from an HTTP response-like object."""
    try:
        headers = getattr(response, "headers", None) or {}
        return headers.get("x-request-id") or headers.get("x-correlation-id")
    except (AttributeError, TypeError, KeyError):
        return None


def _format_http_response_error(
    response: object,
    *,
    operation: str,
    body_limit: int = 180,
) -> str:
    """Format a concise API error for CLI output with request-id attribution."""
    status = getattr(response, "status_code", "unknown")
    body = ""
    try:
        body = (getattr(response, "text", "") or "")[:body_limit].strip()
    except (AttributeError, TypeError, UnicodeDecodeError):
        body = ""
    request_id = _response_request_id(response)

    msg = f"{operation} failed ({status})"
    if body:
        msg = f"{msg}: {body}"
    if request_id:
        msg = f"{msg} (request_id={request_id})"
    return msg


def _ensure_http_ok(
    response: object,
    *,
    operation: str,
    output: Optional[OutputFormatter] = None,
    detail: Optional[str] = None,
    exit_on_error: bool = True,
) -> bool:
    """Validate an HTTP response-like object and surface rich CLI context."""
    status = int(getattr(response, "status_code", 0) or 0)
    if 200 <= status < 300:
        return True
    message = _format_http_response_error(response, operation=operation)
    if output is not None:
        output.error(message, detail)
    if exit_on_error:
        raise typer.Exit(1)
    return False


from autodebug.repo_utils import _normalize_repo_slug  # noqa: E402  # consolidated


def _get_repo_slug(output: Optional[OutputFormatter] = None) -> Optional[str]:
    if not shutil.which("git"):
        if output:
            output.dim("Git not found; skipping repo detection.")
        return None
    try:
        result = subprocess.run(
            ["git", "remote", "get-url", "origin"],
            capture_output=True,
            text=True,
            check=False,
            timeout=5,
        )
        if result.returncode == 0:
            slug = _normalize_repo_slug(result.stdout.strip())
            return slug
        # Fallback: use first remote
        remotes = subprocess.run(
            ["git", "remote"],
            capture_output=True,
            text=True,
            check=False,
            timeout=5,
        )
        if remotes.returncode == 0:
            names = [r for r in remotes.stdout.splitlines() if r.strip()]
            if names:
                result = subprocess.run(
                    ["git", "remote", "get-url", names[0]],
                    capture_output=True,
                    text=True,
                    check=False,
                    timeout=5,
                )
                if result.returncode == 0:
                    return _normalize_repo_slug(result.stdout.strip())
    except (OSError, subprocess.SubprocessError):
        if output:
            output.dim("Failed to detect git remote.")
    return None


def _find_project_by_repo(api_base: str, token: str, repo_slug: str) -> Optional[dict]:
    import requests

    page_size = 100
    max_pages = 10
    try:
        for page in range(max_pages):
            resp = requests.get(
                f"{api_base.rstrip('/')}/v1/projects?limit={page_size}&offset={page * page_size}",
                headers={"Authorization": f"Bearer {token}"},
                timeout=10,
            )
            if resp.status_code != 200:
                return None
            projects = resp.json() or []
            for proj in projects:
                if str(proj.get("repo", "")).lower() == repo_slug.lower():
                    return proj
            if len(projects) < page_size:
                break
    except (requests.RequestException, ValueError, KeyError):
        return None
    return None


def _is_interactive() -> bool:
    return sys.stdin.isatty() and sys.stdout.isatty()


def _docker_check_from_results(results) -> Optional[object]:
    for check in results.get("checks", []):
        if getattr(check, "name", None) == "docker":
            return check
    return None


def _run_command(output: OutputFormatter, cmd: list[str]) -> bool:
    try:
        result = subprocess.run(cmd, check=False)
        return result.returncode == 0
    except Exception as exc:
        output.error(f"Failed to run command: {' '.join(cmd)}", str(exc))
        return False


def _install_docker(output: OutputFormatter) -> bool:
    platform = sys.platform
    if platform == "darwin":
        if shutil.which("brew"):
            output.info("Installing Docker Desktop via Homebrew...")
            return _run_command(output, ["brew", "install", "--cask", "docker"])
        output.info("Opening Docker Desktop download page...")
        return _run_command(output, ["open", "https://docs.docker.com/desktop/install/mac-install/"])

    if platform.startswith("linux"):
        if shutil.which("curl"):
            output.info("Install Docker manually from: https://docs.docker.com/engine/install/")
            output.info("  curl -fsSL https://get.docker.com -o get-docker.sh")
            output.info("  sudo sh get-docker.sh")
            return False
        if shutil.which("apt-get"):
            output.info("Installing Docker via apt (requires sudo)...")
            ok = _run_command(output, ["sudo", "apt-get", "update"])
            if not ok:
                return False
            return _run_command(output, ["sudo", "apt-get", "install", "-y", "docker.io"])
        output.warning("Unsupported Linux package manager. Please install Docker manually.")
        return False

    if platform.startswith("win"):
        if shutil.which("winget"):
            output.info("Installing Docker Desktop via winget...")
            return _run_command(output, ["winget", "install", "-e", "--id", "Docker.DockerDesktop"])
        output.warning("winget not available. Please install Docker Desktop manually.")
        return False

    output.warning("Unsupported platform. Please install Docker manually.")
    return False


def _start_docker(output: OutputFormatter) -> bool:
    platform = sys.platform
    if platform == "darwin":
        output.info("Starting Docker Desktop...")
        return _run_command(output, ["open", "-a", "Docker"])

    if platform.startswith("linux"):
        if shutil.which("systemctl"):
            output.info("Starting Docker service (requires sudo)...")
            return _run_command(output, ["sudo", "systemctl", "start", "docker"])
        if shutil.which("service"):
            output.info("Starting Docker service (requires sudo)...")
            return _run_command(output, ["sudo", "service", "docker", "start"])
        output.warning("Could not determine how to start Docker on this system.")
        return False

    if platform.startswith("win"):
        output.info("Starting Docker Desktop...")
        return _run_command(output, [
            "powershell",
            "-Command",
            "Start-Process -FilePath 'C:\\\\Program Files\\\\Docker\\\\Docker\\\\Docker Desktop.exe'",
        ])

    output.warning("Unsupported platform. Please start Docker manually.")
    return False


def _maybe_install_or_start_docker(output: OutputFormatter, results: dict) -> bool:
    if os.environ.get("CI"):
        return False

    docker_check = _docker_check_from_results(results)
    if not docker_check or docker_check.passed:
        return False

    message = docker_check.message or ""
    if "not found" in message.lower():
        output.info("Docker is required to run captures and replays in an isolated environment.")
        output.warning("Docker is not installed.")
        if _is_interactive() and typer.confirm("Install Docker now?"):
            installed = _install_docker(output)
            if installed:
                output.info("Docker install initiated. Waiting a few seconds for setup...")
                time.sleep(5)
            return True
        return False
    elif "not running" in message.lower():
        output.info("Docker is required to run captures and replays in an isolated environment.")
        started = _start_docker(output)
        if started:
            output.info("Waiting for Docker to start...")
            time.sleep(5)
            return True
    return False


def _maybe_install_semgrep(output: OutputFormatter) -> bool:
    """Check if Semgrep is installed and offer to install it."""
    try:
        subprocess.run(["semgrep", "--version"], capture_output=True, timeout=5)
        return False  # already installed
    except (FileNotFoundError, subprocess.TimeoutExpired):
        pass

    if not _is_interactive() or os.environ.get("CI"):
        return False

    output.info("Semgrep enables language-agnostic security & code quality scanning.")
    if typer.confirm("Semgrep not found. Install it now? (pip install semgrep)", default=True):
        try:
            output.info("Installing semgrep...")
            result = subprocess.run(
                [sys.executable, "-m", "pip", "install", "semgrep"],
                capture_output=True, timeout=120,
            )
            if result.returncode != 0:
                stderr = result.stderr.decode("utf-8", errors="replace").strip()
                output.warning(f"pip install semgrep failed (exit {result.returncode})")
                if stderr:
                    output.dim(stderr[-200:])  # Show last 200 chars of error
                return False
            output.success("Semgrep installed successfully.")
            return True
        except Exception as e:
            output.warning(f"Could not install semgrep: {e}")
            output.dim("You can install manually: pip install semgrep")
    return False


def version_callback(value: bool):
    """Print version and exit."""
    if value:
        _cprint(f"[bold green]Hikaflow[/] version {__version__}")
        raise typer.Exit()


@app.callback(invoke_without_command=True)
def main(
    ctx: typer.Context,
    prompt: Optional[str] = typer.Option(
        None,
        "--prompt",
        "-p",
        help="One-shot prompt. E.g.: hikaflow -p 'find bugs in auth'",
        hidden=True,
    ),
    version: bool = typer.Option(
        None,
        "--version",
        callback=version_callback,
        is_eager=True,
        help="Show version and exit.",
    ),
    json_output: bool = typer.Option(
        False,
        "--json",
        "-j",
        help="Output in machine-parseable JSON format.",
    ),
    no_color: bool = typer.Option(
        False,
        "--no-color",
        help="Disable colored output.",
    ),
    verbose: bool = typer.Option(
        False,
        "--verbose",
        "-v",
        help="Show detailed output.",
    ),
    model: Optional[str] = typer.Option(
        None,
        "--model",
        "-m",
        envvar="HIKAFLOW_MODEL",
        help="Model to use (e.g., claude-sonnet-4-5-20250929).",
    ),
):
    """Hikaflow - AI-powered production error debugger.

    Just type 'hikaflow' to start. Type whatever you need in the REPL.

    Or pass a prompt directly:  hikaflow "find bugs in auth"
    """
    # Store output formatter in context for subcommands
    ctx.ensure_object(dict)
    ctx.obj["output"] = OutputFormatter(json_mode=json_output, no_color=no_color)
    ctx.obj["verbose"] = verbose
    ctx.obj["json_mode"] = json_output

    if verbose:
        import logging
        logging.basicConfig(
            level=logging.DEBUG,
            format="%(name)s %(levelname)s %(message)s",
        )
        # Silence noisy third-party loggers
        for name in ("httpcore", "httpx", "urllib3", "asyncio"):
            logging.getLogger(name).setLevel(logging.WARNING)
        logging.getLogger("hikaflow").debug(
            "Verbose mode enabled. CLI version %s, Python %s",
            __version__, sys.version.split(" ")[0],
        )

    # Auto-update check (skip in JSON mode)
    if not json_output:
        with suppress(Exception):
            from autodebug.upgrade import auto_upgrade, restart_cli

            if auto_upgrade(__version__):
                _cprint(
                    f"[green]Updated Hikaflow to latest version. Restarting...[/]"
                )
                restart_cli()
                # If restart_cli() returns (exec failed), fall through

        # Fallback: if auto-update is disabled, still show the notification
        with suppress(Exception):
            from autodebug.upgrade import check_for_upgrade

            new_version = check_for_upgrade(__version__)
            if new_version:
                _cprint(
                    f"[dim]Update available: {__version__} -> {new_version}. "
                    f"Run: pip install -U hikaflow[/]"
                )

    if ctx.invoked_subcommand is None:
        # Support bare prompt: hikaflow "find bugs in auth"
        # (captured as extra args since prompt is now a --option)
        _effective_prompt = prompt
        if not _effective_prompt and ctx.args:
            _effective_prompt = " ".join(ctx.args)

        # hikaflow -p "find bugs" → one-shot with prompt
        # hikaflow "find bugs" → one-shot with prompt (via ctx.args)
        # hikaflow → interactive REPL
        result = asyncio.run(_interactive_mode(prompt=_effective_prompt, model_override=model))
        if result is False:
            raise typer.Exit(1)


# =============================================================================
# Auth Commands
# =============================================================================


@app.command()
@handle_cli_errors
def login(
    ctx: typer.Context,
    dev: bool = typer.Option(
        False,
        "--dev",
        help="Use local dev auth token (localhost/private only).",
    ),
    device_auth: bool = typer.Option(
        False,
        "--device-auth",
        "-d",
        help="Use device flow for headless/SSH environments.",
    ),
    token: Optional[str] = typer.Option(
        None,
        "--token",
        "-t",
        envvar="HIKAFLOW_TOKEN",
        help="Manual JWT token (for CI/CD).",
    ),
    api_base: Optional[str] = typer.Option(
        None,
        "--api-base",
        envvar="HIKAFLOW_ENDPOINT",
        help="API base URL override.",
    ),
):
    """Authenticate with Hikaflow.

    By default, opens browser for OAuth sign-in.
    Use --device-auth for headless environments like SSH.
    Use --token for CI/CD pipelines with pre-generated tokens.
    Use --api-base to override the API endpoint.
    """
    from autodebug.config import load_credentials, save_credentials

    output = ctx.obj.get("output", OutputFormatter())

    # Store API base if provided
    if api_base:
        data = load_credentials()
        data["api_base"] = api_base
        save_credentials(data)
        output.info(f"API base set to: {api_base}")

    if dev:
        from autodebug.auth import save_token

        dev_token = os.environ.get("HIKAFLOW_DEV_TOKEN", "dev-token")
        save_token(dev_token)
        if not api_base:
            data = load_credentials()
            data["api_base"] = "http://localhost:8000"
            save_credentials(data)
            output.info("API base set to: http://localhost:8000")
        output.success("Dev token saved successfully.")
        return

    if token:
        from autodebug.auth import save_token

        token = token.strip()
        if not token or len(token) < 10:
            output.error(
                "Token appears invalid (too short).",
                "Provide a valid JWT or API token from your Hikaflow dashboard.",
            )
            raise typer.Exit(1)

        # Reject URLs pasted by mistake
        import re as _re
        if "://" in token:
            output.error(
                "That looks like a URL, not a token.",
                "Copy the token value from your Hikaflow dashboard, not the page URL.",
            )
            raise typer.Exit(1)

        # Basic character validation: tokens should be alphanumeric, hyphens, underscores, dots, or base64
        if not _re.match(r'^[A-Za-z0-9._\-+=]+$', token):
            output.error(
                "Token contains invalid characters.",
                "Provide a valid JWT or API token from your Hikaflow dashboard.",
            )
            raise typer.Exit(1)

        # Basic JWT format validation: must have 3 dot-separated base64 parts
        if "." in token:
            parts = token.split(".")
            if len(parts) != 3 or not all(parts):
                output.error(
                    "Token looks like a JWT but has invalid format (expected 3 parts).",
                    "Provide a valid JWT from your Hikaflow dashboard.",
                )
                raise typer.Exit(1)
            # Try to decode the payload to verify it's valid base64
            try:
                import base64
                # JWT base64url needs padding
                payload_part = parts[1]
                padding = 4 - len(payload_part) % 4
                if padding != 4:
                    payload_part += "=" * padding
                base64.urlsafe_b64decode(payload_part)
            except (ValueError, TypeError):
                output.error(
                    "Token payload is not valid base64.",
                    "Provide a valid JWT from your Hikaflow dashboard.",
                )
                raise typer.Exit(1)

        # Warn if overwriting existing credentials
        existing = load_credentials()
        if existing.get("token"):
            output.warning("Overwriting existing token.")

        save_token(token)
        output.success("Token saved successfully.")
        return

    if device_auth:
        success = asyncio.run(_device_auth_flow())
    else:
        success = asyncio.run(_browser_auth_flow())
    if success:
        output.success("Login successful.")
        # Only auto-launch REPL in interactive terminals, not CI/scripts
        if sys.stdin.isatty() and not os.environ.get("CI"):
            _cprint()
            try:
                asyncio.run(_interactive_mode())
            except (KeyboardInterrupt, EOFError):
                pass
            except Exception as exc:
                output.error(f"REPL failed: {type(exc).__name__}: {exc}")
    else:
        output.error("Login failed. Please try again.")
        raise typer.Exit(1)


@app.command()
@handle_cli_errors
def logout(
    ctx: typer.Context,
    clear_all: bool = typer.Option(
        False,
        "--all",
        help="Clear all configuration including project settings.",
    ),
):
    """Clear stored credentials and log out of Hikaflow.

    By default, only clears authentication tokens.
    Use --all to also clear project configuration.
    """
    from autodebug.config import clear_credentials, load_credentials, save_credentials

    output = ctx.obj.get("output", OutputFormatter())

    if clear_all:
        clear_credentials()
        output.success("All configuration cleared.")
    else:
        data = load_credentials()
        # Remove only auth-related fields
        data.pop("access_token", None)
        data.pop("refresh_token", None)
        data.pop("expires_at", None)
        data.pop("token", None)  # Legacy field
        save_credentials(data)
        output.success("Logged out successfully.")


@app.command()
@handle_cli_errors
def whoami(ctx: typer.Context):
    """Show current authentication status and configuration."""
    from autodebug.config import get_api_base, get_valid_token, load_credentials

    output = ctx.obj.get("output", OutputFormatter())
    creds = load_credentials()
    token = get_valid_token()

    if output.json_mode:
        output.json_out({
            "authenticated": token is not None,
            "email": creds.get("email"),
            "project_id": creds.get("project_id"),
            "api_base": get_api_base(),
        })
        output.flush_json()
        return

    output.info("[bold green]Hikaflow CLI Status[/]")
    output.info("")

    if token:
        output.success("Authentication: Logged in")

        # Decode JWT to show user info (without verification)
        with suppress(Exception):
            import base64

            payload_part = token.split(".")[1]
            padding = 4 - len(payload_part) % 4
            if padding != 4:
                payload_part += "=" * padding
            payload = json_module.loads(base64.urlsafe_b64decode(payload_part))

            if payload.get("email"):
                output.info(f"  User: {payload.get('email')}")
            if payload.get("sub"):
                output.info(f"  User ID: {payload.get('sub')[:20]}...")

        # Token expiration
        import time

        expires_at = creds.get("expires_at")
        if expires_at:
            remaining = expires_at - time.time()
            if remaining > 0:
                hours = int(remaining // 3600)
                minutes = int((remaining % 3600) // 60)
                output.info(f"  Token expires in: {hours}h {minutes}m")
            else:
                output.warning("  Token: Expired (will refresh on next request)")
    else:
        output.error("Authentication: Not logged in", "Run: hikaflow login")

    output.info("")
    output.info("Configuration:")
    output.info(f"  API base: {get_api_base()}")
    if creds.get("project_id"):
        output.info(f"  Project ID: {creds.get('project_id')}")
    else:
        output.dim("  Project ID: Not configured. Run: hikaflow init")
    if creds.get("repo_slug"):
        output.info(f"  Repo: {creds.get('repo_slug')}")


# =============================================================================
# Core Commands
# =============================================================================


@app.command(hidden=True)
@handle_cli_errors
def status(
    ctx: typer.Context,
    json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON."),
):
    """Show project status and recent errors summary."""
    json_mode = json_output or ctx.obj.get("json_mode", False)
    asyncio.run(_show_status(json_mode=json_mode))


@app.command(hidden=True)
@handle_cli_errors
def errors(
    ctx: typer.Context,
    limit: int = typer.Option(
        10,
        "--limit",
        "-n",
        help="Number of errors to show.",
        min=1,
        max=100,
    ),
    environment: Optional[str] = typer.Option(
        None,
        "--env",
        "-e",
        help="Filter by environment (production, staging).",
    ),
    error_type: Optional[str] = typer.Option(
        None,
        "--type",
        help="Filter by exception type (e.g., KeyError).",
    ),
    json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON."),
):
    """List recent production errors."""
    json_mode = json_output or ctx.obj.get("json_mode", False)
    asyncio.run(_list_errors(limit, environment, error_type, json_mode=json_mode))


@app.command()
@handle_cli_errors
def debug(
    prompt: Optional[str] = typer.Argument(
        None,
        help="Initial prompt, error ID, or question.",
    ),
    command: Optional[str] = typer.Option(
        None,
        "-c",
        "--command",
        help="One-shot command (outputs and exits).",
    ),
    headless: bool = typer.Option(
        False,
        "--headless",
        help="Non-interactive mode for CI/CD.",
    ),
    timeout: Optional[int] = typer.Option(
        None,
        "--timeout",
        help="Timeout in seconds (headless only, default: 120).",
    ),
    model: Optional[str] = typer.Option(
        None,
        "--model",
        "-m",
        envvar="HIKAFLOW_MODEL",
        help="Model to use (e.g., claude-sonnet-4-5-20250929, claude-haiku-4-5-20251001).",
    ),
    debug_mode: bool = typer.Option(
        False,
        "--debug",
        "-d",
        envvar="HIKAFLOW_DEBUG_TERMINAL",
        help="Show per-turn metrics, tool timing, and agent reasoning in terminal.",
    ),
):
    """Interactive AI debugging session.

    [bold]Examples:[/]

        hikaflow debug                              # Interactive mode

        hikaflow debug "fix the KeyError"           # Start with prompt

        hikaflow debug err_8x3k                     # Debug specific error

        hikaflow debug -c "list errors"             # One-shot command

        hikaflow debug --model anthropic:claude-sonnet-4
    """
    # -c implies one-shot mode: process command and exit (with Rich panels, not raw text)
    one_shot = command is not None
    if one_shot:
        if not command.strip():
            _cprint("[red]Error: -c requires a non-empty prompt.[/]")
            raise typer.Exit(1)
        headless = True
    if headless and not (prompt or command):
        _cprint("[red]Error:[/] --headless requires an initial prompt or -c/--command.")
        raise typer.Exit(1)
    effective_timeout = timeout if timeout is not None else (120 if headless else None)
    try:
        _session_result = asyncio.run(
            _run_debug_session(
                prompt or command,
                headless,
                model,
                one_shot=one_shot,
                session_timeout_s=effective_timeout,
                debug_terminal=debug_mode,
            ),
        )
        if _session_result is False:
            raise typer.Exit(1)
    except KeyboardInterrupt:
        _cprint("\n[dim]Goodbye.[/]")
    except asyncio.TimeoutError:
        with suppress(Exception):
            from autodebug.debug_log import log_event
            log_event(
                "turn.timeout",
                timeout_seconds=int(effective_timeout or 0),
                outcome="BLOCKED",
                reason_code="blocked_tool_failure",
                reason_detail="session_timeout",
                delivered_artifact_kind="none",
            )
            # Signal the API for real-time metrics
            _report_turn_outcome_to_api(
                outcome="BLOCKED",
                reason_code="blocked_tool_failure",
                reason_detail="session_timeout",
                turn_id="session_timeout",
            )
        _cprint(f"[red]Timed out after {effective_timeout}s.[/] Use --timeout to increase.")
        raise typer.Exit(1)


@app.command()
@handle_cli_errors
def scan(
    path: str = typer.Argument(
        ".",
        help="File or directory to scan.",
    ),
    no_fix: bool = typer.Option(
        False,
        "--no-fix",
        help="Only show issues, don't offer to fix.",
    ),
    fix_all: bool = typer.Option(
        False,
        "--fix-all",
        help="Fix all issues without interactive selection.",
    ),
    changed: bool = typer.Option(
        False,
        "--changed",
        help="Only scan files changed since last commit.",
    ),
    show_all: bool = typer.Option(
        False,
        "--all",
        "-a",
        help="Show all severities (default: MEDIUM and above).",
    ),
    min_severity: Optional[str] = typer.Option(
        None,
        "--min-severity",
        help="Minimum severity to show: HIGH, MEDIUM, or LOW.",
    ),
    json_output: bool = typer.Option(
        False,
        "--json",
        "-j",
        help="Output findings as JSON.",
    ),
    headless: bool = typer.Option(
        False,
        "--headless",
        help="Non-interactive mode for CI/CD. Prints findings and exits.",
    ),
    html_report: Optional[str] = typer.Option(
        None,
        "--html",
        help="Generate HTML report at this path (e.g. report.html).",
    ),
):
    """Scan code for bugs, security issues, and code smells.

    [bold]Examples:[/]

        hikaflow scan                           # Scan current directory

        hikaflow scan src/                      # Scan specific directory

        hikaflow scan --changed                 # Only scan git-changed files

        hikaflow scan --fix-all                 # Fix everything, no prompts

        hikaflow scan --all                     # Include LOW severity

        hikaflow scan --headless                # CI mode (exit code = issue count)

        hikaflow scan --html report.html        # Generate shareable HTML report
    """
    # Determine minimum severity filter
    if min_severity:
        effective_min = min_severity.upper()
    elif show_all:
        effective_min = "LOW"
    else:
        effective_min = "MEDIUM"

    try:
        exit_code = asyncio.run(_run_scan(
            path, no_fix=no_fix, fix_all=fix_all, changed=changed,
            json_output=json_output, headless=headless, min_severity=effective_min,
            html_report=html_report,
        ))
        if exit_code:
            raise typer.Exit(exit_code)
    except KeyboardInterrupt:
        _cprint("\n[dim]Scan cancelled.[/]")


_GITHUB_ACTION_YML = """\
name: Hikaflow Scan
on:
  pull_request:
  push:
    branches: [main]

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - run: pip install hikaflow
      - run: hikaflow scan --headless --min-severity MEDIUM
"""

_PRE_COMMIT_HOOK = """\
#!/bin/sh
# Hikaflow pre-commit hook — scan changed files
hikaflow scan --changed --headless --no-fix --min-severity HIGH
"""

_HIKAFLOW_YML = """\
# .hikaflow.yml — scan configuration
# See: https://docs.hikaflow.com/configuration

# Suppress specific rules (by tool:rule_id)
# ignore_rules:
#   - "ruff:S324"      # md5 for content hashing
#   - "bandit:B101"    # assert in tests
#   - "vulture"        # skip all dead code

# Ignore file paths (glob patterns)
# ignore_paths:
#   - "tests/**"
#   - "migrations/**"
#   - "*.generated.py"
"""


@app.command("ci-setup")
@handle_cli_errors
def ci_setup(
    github_action: bool = typer.Option(True, "--github-action/--no-github-action", help="Generate GitHub Actions workflow."),
    pre_commit: bool = typer.Option(True, "--pre-commit/--no-pre-commit", help="Install pre-commit hook."),
    config: bool = typer.Option(True, "--config/--no-config", help="Generate .hikaflow.yml config."),
):
    """Set up CI integration: GitHub Action, pre-commit hook, and config.

    [bold]Examples:[/]

        hikaflow ci-setup                      # Generate everything

        hikaflow ci-setup --no-pre-commit      # Skip pre-commit hook

        hikaflow ci-setup --no-github-action   # Only pre-commit + config
    """
    created = []

    if github_action:
        workflow_dir = os.path.join(os.getcwd(), ".github", "workflows")
        workflow_path = os.path.join(workflow_dir, "hikaflow.yml")
        if os.path.exists(workflow_path):
            _cprint(f"[dim]  .github/workflows/hikaflow.yml already exists — skipping[/]")
        else:
            os.makedirs(workflow_dir, exist_ok=True)
            with open(workflow_path, "w") as f:
                f.write(_GITHUB_ACTION_YML)
            created.append(".github/workflows/hikaflow.yml")
            _cprint(f"[green]  ✓ Created .github/workflows/hikaflow.yml[/]")

    if pre_commit:
        hook_path = os.path.join(os.getcwd(), ".git", "hooks", "pre-commit")
        if os.path.exists(hook_path):
            _cprint(f"[dim]  .git/hooks/pre-commit already exists — skipping[/]")
        else:
            hooks_dir = os.path.join(os.getcwd(), ".git", "hooks")
            if os.path.isdir(hooks_dir):
                with open(hook_path, "w") as f:
                    f.write(_PRE_COMMIT_HOOK)
                os.chmod(hook_path, 0o755)
                created.append(".git/hooks/pre-commit")
                _cprint(f"[green]  ✓ Installed pre-commit hook[/]")
            else:
                _cprint(f"[yellow]  Not a git repo — skipping pre-commit hook[/]")

    if config:
        config_path = os.path.join(os.getcwd(), ".hikaflow.yml")
        if os.path.exists(config_path):
            _cprint(f"[dim]  .hikaflow.yml already exists — skipping[/]")
        else:
            with open(config_path, "w") as f:
                f.write(_HIKAFLOW_YML)
            created.append(".hikaflow.yml")
            _cprint(f"[green]  ✓ Created .hikaflow.yml[/]")

    if created:
        _cprint(f"\n[bold]Done![/] Created {len(created)} file{'s' if len(created) != 1 else ''}.")
        _cprint("[dim]Commit these files to enable CI scanning.[/]")
    else:
        _cprint("[dim]Everything already set up.[/]")


async def _run_scan(
    path: str,
    no_fix: bool = False,
    fix_all: bool = False,
    changed: bool = False,
    json_output: bool = False,
    headless: bool = False,
    min_severity: str = "MEDIUM",
    html_report: str | None = None,
) -> int:
    """Run scan, display findings, offer interactive fix selection.

    Returns exit code (0 = clean, N = number of unfixed issues).
    """
    import glob as glob_mod
    from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, MofNCompleteColumn
    from autodebug.agent.specialists import run_specialists
    from autodebug.agent.specialists.base import Finding
    from autodebug.agent.specialists.external_tools import run_ruff, run_bandit, run_vulture, run_eslint, run_npm_audit
    from autodebug.agent.specialists.ts_specialists import run_ts_specialists

    TS_EXTENSIONS = {".js", ".jsx", ".ts", ".tsx", ".mjs", ".mts"}
    project_path = os.getcwd()
    target = os.path.join(project_path, path) if not os.path.isabs(path) else path

    # --changed: get files modified since last commit
    if changed:
        scan_files = await _get_git_changed_files(project_path)
        if scan_files is None:
            _cprint("[red]Not a git repository or git not available.[/]")
            return 1
        if not scan_files:
            _cprint("[green]No changed files.[/]")
            return 0
        # Make absolute paths
        scan_files = [os.path.join(project_path, f) for f in scan_files]
    elif os.path.isfile(target):
        scan_files = [target]
    elif os.path.isdir(target):
        py_files = sorted(glob_mod.glob(os.path.join(target, "**", "*.py"), recursive=True))
        ts_files = []
        for ext in TS_EXTENSIONS:
            ts_files.extend(glob_mod.glob(os.path.join(target, "**", f"*{ext}"), recursive=True))
        scan_files = sorted(set(py_files + ts_files))
    else:
        _cprint(f"[red]Path not found:[/] {path}")
        return 1

    # Filter out common noise directories
    skip_dirs = {
        "__pycache__", ".git", "node_modules", ".venv", "venv", ".tox", ".eggs",
        "build", "dist", ".next", ".gradle", ".idea", ".vscode", "site-packages",
        ".mypy_cache", ".pytest_cache", ".ruff_cache", "egg-info", ".angular",
        "coverage", ".nuxt", ".turbo", "vendor", ".cache",
    }
    def _should_skip(filepath: str) -> bool:
        parts = Path(filepath).parts
        for part in parts:
            if part in skip_dirs or part.endswith(".egg-info"):
                return True
        return False

    scan_files = [f for f in scan_files if not _should_skip(f)]

    if not scan_files:
        _cprint(f"[yellow]No Python or TypeScript files found in {path}[/]")
        _cprint("[dim]Tip: cd into your project directory, or run: hikaflow scan /path/to/project[/]")
        return 0

    all_findings: list[Finding] = []
    file_count = len(scan_files)

    with Progress(
        SpinnerColumn(),
        TextColumn("[progress.description]{task.description}"),
        BarColumn(bar_width=20),
        MofNCompleteColumn(),
        console=Console(stderr=True),
        transient=True,
    ) as progress:
        task = progress.add_task("Scanning...", total=file_count + 5)  # +5 for external tools

        # Run specialists on each file (AST for Python, tree-sitter for TS/JS)
        sem = asyncio.Semaphore(32)
        completed = 0

        async def _scan_one(f: str) -> list:
            nonlocal completed
            rel_path = os.path.relpath(f, project_path)
            ext = Path(f).suffix.lower()
            results = []
            async with sem:
                if ext == ".py":
                    findings, _ = await run_specialists(rel_path, project_path)
                    for finding in findings:
                        finding.file_path = rel_path
                    results = findings
                elif ext in TS_EXTENSIONS:
                    try:
                        source = Path(f).read_text(errors="replace")
                        ts_findings = run_ts_specialists(source, rel_path)
                        for finding in ts_findings:
                            finding.file_path = rel_path
                        results = ts_findings
                    except Exception:
                        pass
                completed += 1
                short_name = rel_path if len(rel_path) <= 50 else "..." + rel_path[-47:]
                progress.update(task, description=f"[cyan]{short_name}[/]", completed=completed)
            return results

        batch_results = await asyncio.gather(*[_scan_one(f) for f in scan_files])
        for findings_list in batch_results:
            all_findings.extend(findings_list)

        # Run external tools on the target path
        rel_target = os.path.relpath(target, project_path) if not changed else "."
        progress.update(task, description="Running Ruff...", completed=file_count)
        ruff_findings, _ = await run_ruff(rel_target, project_path)
        all_findings.extend(ruff_findings)

        progress.update(task, description="Running Bandit...", completed=file_count + 1)
        bandit_findings, _ = await run_bandit(rel_target, project_path)
        all_findings.extend(bandit_findings)

        progress.update(task, description="Running Vulture...", completed=file_count + 2)
        vulture_findings, _ = await run_vulture(rel_target, project_path)
        all_findings.extend(vulture_findings)

        # JS/TS external tools
        progress.update(task, description="Running ESLint...", completed=file_count + 3)
        eslint_findings, _ = await run_eslint(rel_target, project_path)
        all_findings.extend(eslint_findings)

        progress.update(task, description="Running npm audit...", completed=file_count + 4)
        npm_findings, _ = await run_npm_audit(project_path)
        all_findings.extend(npm_findings)

        progress.update(task, completed=file_count + 5)

    # If --changed, filter findings to only changed files
    if changed:
        changed_rels = {os.path.relpath(f, project_path) for f in scan_files}
        all_findings = [f for f in all_findings if f.file_path in changed_rels]

    # Load .hikaflow.yml config for suppressions
    scan_config = _load_scan_config(project_path)
    if scan_config:
        ignore_rules = set(scan_config.get("ignore_rules", []))
        ignore_paths = scan_config.get("ignore_paths", [])
        if ignore_rules or ignore_paths:
            before = len(all_findings)
            all_findings = [
                f for f in all_findings
                if not _is_suppressed(f, ignore_rules, ignore_paths)
            ]
            suppressed = before - len(all_findings)
            if suppressed:
                _cprint(f"[dim]  {suppressed} findings suppressed by .hikaflow.yml[/]")

    # Deduplicate by (file, line, normalized_message)
    # Normalize: strip tool prefixes like "[ruff:S608] " for dedup comparison
    def _normalize_msg(msg: str) -> str:
        """Strip tool prefix tags for dedup, e.g. '[ruff:F841] ...' -> '...'"""
        import re as _re
        return _re.sub(r"^\[[\w:]+\]\s*", "", msg).strip().lower()

    seen: set[tuple[str, int, str]] = set()
    unique_findings: list[Finding] = []
    for f in all_findings:
        key = (f.file_path, f.line, _normalize_msg(f.message))
        if key not in seen:
            seen.add(key)
            unique_findings.append(f)

    # Sort by severity (HIGH > MEDIUM > LOW), then file, then line
    severity_order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
    unique_findings.sort(key=lambda f: (severity_order.get(f.severity, 9), f.file_path, f.line))

    # Apply severity filter
    min_level = severity_order.get(min_severity, 1)
    filtered = [f for f in unique_findings if severity_order.get(f.severity, 9) <= min_level]
    hidden_count = len(unique_findings) - len(filtered)
    unique_findings = filtered

    if not unique_findings:
        if hidden_count:
            _cprint(f"[green]No issues at {min_severity}+ severity.[/] [dim]({hidden_count} lower hidden — use --all to see)[/]")
        else:
            _cprint(f"[green]No issues found.[/] Scanned {file_count} file{'s' if file_count != 1 else ''}.")
        return 0

    # JSON output mode
    if json_output:
        import json as _json
        output = [
            {
                "id": i + 1,
                "file": f.file_path,
                "line": f.line,
                "severity": f.severity,
                "message": f.message,
                "specialist": f.specialist,
                "confidence": f.confidence,
            }
            for i, f in enumerate(unique_findings)
        ]
        print(_json.dumps(output, indent=2))
        if not html_report:
            return len(unique_findings)

    # Generate HTML report if requested
    if html_report:
        _generate_html_report(unique_findings, html_report, file_count)
        _cprint(f"[green]  ✓ Report saved to {html_report}[/]")
        if headless or json_output:
            return len(unique_findings)

    # Display findings grouped by file
    _cprint()
    severity_colors = {"HIGH": "red", "MEDIUM": "yellow", "LOW": "dim"}
    severity_icons = {"HIGH": "!", "MEDIUM": "~", "LOW": "."}

    count_by_sev = {}
    for f in unique_findings:
        count_by_sev[f.severity] = count_by_sev.get(f.severity, 0) + 1
    sev_summary = ", ".join(
        f"[{severity_colors[s]}]{c} {s}[/{severity_colors[s]}]"
        for s, c in sorted(count_by_sev.items(), key=lambda x: severity_order.get(x[0], 9))
    )
    _cprint(f"  [bold]Found {len(unique_findings)} issues[/] ({sev_summary})")
    if hidden_count:
        _cprint(f"  [dim]+ {hidden_count} lower severity hidden (use --all to see)[/]")
    _cprint()

    # Group by file
    from collections import OrderedDict
    grouped: OrderedDict[str, list[tuple[int, Finding]]] = OrderedDict()
    for i, f in enumerate(unique_findings):
        grouped.setdefault(f.file_path, []).append((i, f))

    for file_path, items in grouped.items():
        _cprint(f"  [cyan]{file_path}[/]")
        for idx, f in items:
            icon = severity_icons.get(f.severity, " ")
            color = severity_colors.get(f.severity, "white")
            _cprint(f"    [{color}]{icon}[/{color}] [dim]#{idx+1}[/] [{color}]{f.severity:<6}[/{color}] [dim]L{f.line:<5}[/] {f.message}")
        _cprint()

    # Headless or no-fix mode: just print and exit
    if headless or no_fix:
        return len(unique_findings)

    # --fix-all: skip interactive selection
    if fix_all:
        return await _auto_fix_all(unique_findings, project_path)

    # Interactive fix selection
    if not _is_interactive():
        return len(unique_findings)

    return await _interactive_fix_selector(unique_findings, project_path)


async def _get_git_changed_files(project_path: str) -> list[str] | None:
    """Get scannable files changed since last commit (staged + unstaged + untracked)."""
    try:
        proc = await asyncio.create_subprocess_exec(
            "git", "diff", "--name-only", "HEAD",
            cwd=project_path,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        stdout, _ = await proc.communicate()
        if proc.returncode != 0:
            return None
        files = stdout.decode().strip().split("\n") if stdout.strip() else []

        # Also get untracked files
        proc2 = await asyncio.create_subprocess_exec(
            "git", "ls-files", "--others", "--exclude-standard",
            cwd=project_path,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        stdout2, _ = await proc2.communicate()
        if stdout2.strip():
            files.extend(stdout2.decode().strip().split("\n"))

        # Filter to scannable files (Python + TS/JS)
        scannable_exts = {".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".mts"}
        return [f for f in set(files) if f and any(f.endswith(ext) for ext in scannable_exts)]
    except FileNotFoundError:
        return None


async def _auto_fix_all(findings: list, project_path: str) -> int:
    """Fix all findings without interactive selection."""
    _cprint(f"[bold]Fixing {len(findings)} issue{'s' if len(findings) != 1 else ''}...[/]\n")

    fixed_count = 0
    for i, f in enumerate(findings):
        _cprint(f"  [bold]#{i+1}[/] {f.file_path}:{f.line} — {f.message}")
        fix = await _fix_single_finding(f, project_path)
        if fix.success:
            _cprint(f"  [green]  ✓ Fixed[/]")
            if fix.explanation:
                _cprint(f"  [dim]    {fix.explanation}[/]")
            fixed_count += 1
        else:
            _cprint(f"  [yellow]  ✗ Needs manual review[/]")

    _cprint()
    if fixed_count == len(findings):
        _cprint(f"[green]All {fixed_count} issues fixed.[/]")
    else:
        _cprint(f"[green]{fixed_count}/{len(findings)} fixed.[/] {len(findings) - fixed_count} need manual review.")

    # Run tests to verify
    if fixed_count > 0:
        modified_files = list({f.file_path for f in findings})
        _cprint("\n[dim]Running tests to verify fixes...[/]")
        test_ok = await _run_post_fix_tests(project_path, modified_files)
        if test_ok:
            _cprint("[green]✓ All tests pass.[/]")
        elif test_ok is None:
            _cprint("[dim]No related tests found.[/]")
        else:
            _cprint("[red]✗ Some tests failed. Review the changes.[/]")

    return len(findings) - fixed_count


async def _interactive_fix_selector(findings: list, project_path: str) -> int:
    """Show interactive checkbox for selecting issues to fix."""
    import questionary
    from questionary import Choice, Separator

    severity_icons = {"HIGH": "!", "MEDIUM": "~", "LOW": "."}

    choices = [
        Choice(title="Fix All", value="all"),
        Separator(),
    ]
    for i, f in enumerate(findings):
        icon = severity_icons.get(f.severity, " ")
        label = f"[{icon}] #{i+1} {f.severity:<6} {f.file_path}:{f.line} — {f.message}"
        choices.append(Choice(title=label, value=i))

    selected = await asyncio.get_event_loop().run_in_executor(
        None,
        lambda: questionary.checkbox(
            "Select issues to fix (Space to toggle, Enter to confirm):",
            choices=choices,
        ).ask(),
    )

    if selected is None:
        _cprint("[dim]Cancelled.[/]")
        return len(findings)

    if not selected:
        _cprint("[dim]No issues selected.[/]")
        return len(findings)

    # Resolve "all" selection
    if "all" in selected:
        indices = list(range(len(findings)))
    else:
        indices = [s for s in selected if isinstance(s, int)]

    if not indices:
        return len(findings)

    _cprint(f"\n[bold]Fixing {len(indices)} issue{'s' if len(indices) != 1 else ''}...[/]\n")

    fixed_count = 0
    for idx in indices:
        f = findings[idx]
        _cprint(f"  [bold]#{idx+1}[/] {f.file_path}:{f.line} — {f.message}")

        # Generate fix with preview first
        fix = await _fix_single_finding(f, project_path, preview=True)
        if not fix.success:
            _cprint(f"  [yellow]  ✗ Needs manual review[/]")
            continue

        # Show diff preview
        _cprint(f"  [dim]  Proposed change:[/]")
        for line in fix.old_code.splitlines()[:5]:
            _cprint(f"  [red]  - {line}[/]")
        for line in fix.new_code.splitlines()[:5]:
            _cprint(f"  [green]  + {line}[/]")
        if fix.explanation:
            _cprint(f"  [dim]  {fix.explanation}[/]")

        # Now apply it
        fix_applied = await _fix_single_finding(f, project_path, preview=False)
        if fix_applied.success:
            _cprint(f"  [green]  ✓ Applied[/]")
            fixed_count += 1
        else:
            _cprint(f"  [yellow]  ✗ Failed to apply[/]")

    _cprint()
    if fixed_count == len(indices):
        _cprint(f"[green]All {fixed_count} issues fixed.[/]")
    else:
        _cprint(f"[green]{fixed_count}/{len(indices)} fixed.[/] {len(indices) - fixed_count} need manual review.")

    # Run tests to verify fixes — only for modified files
    if fixed_count > 0:
        modified_files = list({findings[idx].file_path for idx in indices if idx < len(findings)})
        _cprint("\n[dim]Running tests to verify fixes...[/]")
        test_ok = await _run_post_fix_tests(project_path, modified_files)
        if test_ok:
            _cprint("[green]✓ All tests pass.[/]")
        elif test_ok is None:
            _cprint("[dim]No related tests found.[/]")
        else:
            _cprint("[red]✗ Some tests failed. Review the changes.[/]")

    return len(findings) - fixed_count


def _generate_html_report(findings: list, output_path: str, file_count: int):
    """Generate a self-contained HTML report from scan findings."""
    from datetime import datetime
    from html import escape

    sev_colors = {"HIGH": "#ef4444", "MEDIUM": "#eab308", "LOW": "#6b7280"}
    count_by_sev = {}
    for f in findings:
        count_by_sev[f.severity] = count_by_sev.get(f.severity, 0) + 1

    # Group by file
    grouped: dict[str, list] = {}
    for f in findings:
        grouped.setdefault(f.file_path, []).append(f)

    rows = []
    for fp, items in grouped.items():
        rows.append(f'<tr><td colspan="4" style="background:#1e1e2e;font-weight:bold;color:#89b4fa;padding:8px 12px">{escape(fp)}</td></tr>')
        for f in items:
            color = sev_colors.get(f.severity, "#fff")
            rows.append(
                f'<tr>'
                f'<td style="color:{color};padding:4px 12px">{escape(f.severity)}</td>'
                f'<td style="color:#6c7086;padding:4px 8px">L{f.line}</td>'
                f'<td style="padding:4px 8px">{escape(f.message)}</td>'
                f'<td style="color:#6c7086;padding:4px 8px">{escape(f.specialist)}</td>'
                f'</tr>'
            )

    sev_badges = " ".join(
        f'<span style="background:{sev_colors[s]};color:#fff;padding:2px 8px;border-radius:4px;font-size:13px">{c} {s}</span>'
        for s, c in sorted(count_by_sev.items(), key=lambda x: {"HIGH": 0, "MEDIUM": 1, "LOW": 2}.get(x[0], 9))
    )

    html = f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Hikaflow Scan Report</title>
<style>
body {{ font-family: -apple-system, system-ui, sans-serif; background: #11111b; color: #cdd6f4; margin: 0; padding: 20px; }}
h1 {{ color: #89b4fa; }} .meta {{ color: #6c7086; margin-bottom: 20px; }}
table {{ width: 100%; border-collapse: collapse; background: #181825; border-radius: 8px; overflow: hidden; }}
tr:hover {{ background: #1e1e2e; }} td {{ border-bottom: 1px solid #313244; }}
.badges {{ margin: 12px 0; }}
</style></head><body>
<h1>Hikaflow Scan Report</h1>
<div class="meta">{datetime.now().strftime("%Y-%m-%d %H:%M")} &middot; {file_count} files scanned &middot; {len(findings)} issues found</div>
<div class="badges">{sev_badges}</div>
<table>{''.join(rows)}</table>
<p style="color:#6c7086;margin-top:20px;font-size:12px">Generated by <a href="https://hikaflow.com" style="color:#89b4fa">hikaflow scan</a></p>
</body></html>"""

    with open(output_path, "w") as f:
        f.write(html)


def _load_scan_config(project_path: str) -> dict:
    """Load .hikaflow.yml scan config if it exists."""
    config_path = os.path.join(project_path, ".hikaflow.yml")
    if not os.path.exists(config_path):
        config_path = os.path.join(project_path, ".hikaflow.yaml")
    if not os.path.exists(config_path):
        return {}
    try:
        import yaml
        with open(config_path) as f:
            return yaml.safe_load(f) or {}
    except ImportError:
        # Fall back to basic parsing if PyYAML not installed
        try:
            import json as _json
            # Try JSON format as fallback
            config_path_json = os.path.join(project_path, ".hikaflow.json")
            if os.path.exists(config_path_json):
                return _json.loads(open(config_path_json).read())
        except Exception:
            pass
        return {}
    except Exception:
        return {}


def _is_suppressed(finding, ignore_rules: set, ignore_paths: list) -> bool:
    """Check if a finding is suppressed by config."""
    import re as _re
    # Check rule suppression (e.g. "ruff:S608", "bandit:B101", "eslint:no-eval")
    for rule in ignore_rules:
        if rule in finding.message:
            return True
        if rule == finding.specialist:
            return True
    # Check path suppression (glob patterns)
    import fnmatch
    for pattern in ignore_paths:
        if fnmatch.fnmatch(finding.file_path, pattern):
            return True
    return False


_FIX_AUTH_WARNED = False


async def _fix_via_backend(finding, context: str) -> tuple[str, str, str] | None:
    """Try to fix via the Hikaflow backend API. Returns (old_code, new_code, explanation) or None."""
    global _FIX_AUTH_WARNED
    import httpx as _httpx

    try:
        from autodebug.config import load_credentials, get_api_base
        creds = load_credentials()
        token = creds.get("access_token") or creds.get("token", "")
        if not token:
            if not _FIX_AUTH_WARNED:
                _FIX_AUTH_WARNED = True
                _cprint("[dim]  Tip: run [bold]hikaflow login[/bold] for free AI fixes (no API key needed)[/]")
            return None

        api_base = get_api_base()
        async with _httpx.AsyncClient() as http:
            resp = await http.post(
                f"{api_base}/v1/scan/fix",
                headers={"Authorization": f"Bearer {token}"},
                json={
                    "file_path": finding.file_path,
                    "line": finding.line,
                    "message": finding.message,
                    "severity": finding.severity,
                    "specialist": finding.specialist,
                    "code_context": context,
                },
                timeout=30,
            )

        if resp.status_code == 429:
            data = resp.json()
            detail = data.get("detail", "Fix limit reached")
            _cprint(f"[yellow]  {detail}[/]")
            return None

        if resp.status_code != 200:
            return None  # Fall back to local

        data = resp.json()
        old_code = data.get("old_code", "")
        new_code = data.get("new_code", "")
        explanation = data.get("explanation", "")
        remaining = data.get("quota_remaining", -1)

        if remaining >= 0 and remaining <= 3:
            _cprint(f"[dim]  {remaining} free fixes remaining this month[/]")

        if not old_code:
            return None
        return old_code, new_code, explanation

    except Exception:
        return None  # Fall back to local


async def _fix_via_local_llm(finding, context: str) -> tuple[str, str, str] | None:
    """Fix via local LLM call (BYOK — user's own API key)."""
    import httpx as _httpx

    try:
        from autodebug.config import load_credentials
        creds = load_credentials()

        openrouter_key = os.environ.get("OPENROUTER_API_KEY") or creds.get("openrouter_api_key", "")
        anthropic_key = os.environ.get("ANTHROPIC_API_KEY") or creds.get("anthropic_api_key", "")

        if not openrouter_key and not anthropic_key:
            global _FIX_AUTH_WARNED
            if not _FIX_AUTH_WARNED:
                _FIX_AUTH_WARNED = True
                _cprint("[yellow]  No API key found. Run [bold]hikaflow login[/bold] for free fixes, or set ANTHROPIC_API_KEY / OPENROUTER_API_KEY.[/]")
            return None

        fix_prompt = (
            f"Fix this issue in {finding.file_path}:\n"
            f"Line {finding.line}: {finding.message}\n"
            f"Severity: {finding.severity} | Found by: {finding.specialist}\n\n"
            f"Code context (line numbers shown):\n{context}\n\n"
            f"Rules:\n"
            f"- Return ONLY valid JSON, no markdown, no explanation\n"
            f"- old_code must be an EXACT substring of the source file (copy-paste precision)\n"
            f"- Include enough surrounding lines in old_code to be unique in the file\n"
            f"- new_code replaces old_code — preserve indentation exactly\n"
            f"- Make the MINIMAL change needed to fix the issue\n"
            f"- Do NOT add comments explaining the fix\n"
            f"- Do NOT refactor surrounding code\n"
            f"- explanation: one sentence describing what was changed\n\n"
            f'{{"old_code": "<exact lines to replace>", "new_code": "<replacement lines>", "explanation": "<one sentence>"}}'
        )

        if openrouter_key:
            async with _httpx.AsyncClient() as http:
                resp = await http.post(
                    "https://openrouter.ai/api/v1/chat/completions",
                    headers={"Authorization": f"Bearer {openrouter_key}"},
                    json={
                        "model": "google/gemini-2.5-flash",
                        "max_tokens": 1024,
                        "messages": [{"role": "user", "content": fix_prompt}],
                    },
                    timeout=30,
                )
                data = resp.json()
                if "error" in data:
                    return None
                content = data["choices"][0]["message"]["content"] or ""
        else:
            from anthropic import AsyncAnthropic
            client = AsyncAnthropic(api_key=anthropic_key)
            response = await client.messages.create(
                model="claude-haiku-4-5-20251001",
                max_tokens=1024,
                messages=[{"role": "user", "content": fix_prompt}],
            )
            content = response.content[0].text

        # Parse response
        content = content.strip()
        if "<think>" in content:
            think_end = content.find("</think>")
            if think_end != -1:
                content = content[think_end + 8:].strip()
        if content.startswith("```"):
            lines = content.split("\n")
            lines = [l for l in lines if not l.strip().startswith("```")]
            content = "\n".join(lines)

        import json as _json
        start_idx = content.index("{")
        end_idx = content.rindex("}") + 1
        parsed = _json.loads(content[start_idx:end_idx])

        return parsed.get("old_code", ""), parsed.get("new_code", ""), parsed.get("explanation", "")

    except Exception:
        return None


@dataclass
class FixResult:
    """Result from a fix attempt."""
    success: bool = False
    old_code: str = ""
    new_code: str = ""
    explanation: str = ""


async def _fix_single_finding(finding, project_path: str, preview: bool = False) -> FixResult:
    """Use AI to fix a single finding.

    Tries the Hikaflow backend first (quota-tracked, free tier included).
    Falls back to local LLM call if not logged in or backend unavailable.
    """
    result = FixResult()

    file_path = os.path.join(project_path, finding.file_path)
    try:
        source = open(file_path, encoding="utf-8").read()
    except Exception:
        return result

    source_lines = source.split("\n")
    start = max(0, finding.line - 6)
    end = min(len(source_lines), finding.line + 5)
    context_lines = source_lines[start:end]
    context = "\n".join(f"{start + i + 1:4d} | {line}" for i, line in enumerate(context_lines))

    # Try backend API first (tracked, quota-enforced)
    backend_result = await _fix_via_backend(finding, context)
    if backend_result is not None:
        old_code, new_code, explanation = backend_result
    else:
        # Fall back to local LLM call (BYOK)
        local_result = await _fix_via_local_llm(finding, context)
        if local_result is None:
            return result
        old_code, new_code, explanation = local_result

    if not old_code or old_code == new_code:
        return result
    if old_code not in source:
        return result

    result.old_code = old_code
    result.new_code = new_code
    result.explanation = explanation

    if preview:
        result.success = True
        return result

    # Apply the fix
    fixed_source = source.replace(old_code, new_code, 1)

    # Validate: check syntax before writing
    ext = os.path.splitext(file_path)[1].lower()
    if ext == ".py":
        try:
            compile(fixed_source, file_path, "exec")
        except SyntaxError:
            return result

    with open(file_path, "w", encoding="utf-8") as fh:
        fh.write(fixed_source)

    # Post-fix validation: run linter on the fixed file
    if ext == ".py" and shutil.which("ruff"):
        try:
            lint_proc = await asyncio.create_subprocess_exec(
                "ruff", "check", "--select", "E999", file_path,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )
            await lint_proc.communicate()
            if lint_proc.returncode != 0:
                with open(file_path, "w", encoding="utf-8") as fh:
                    fh.write(source)
                return result
        except Exception:
            pass

    result.success = True
    return result


async def _run_post_fix_tests(project_path: str, modified_files: list[str] | None = None):
    """Run pytest for modified files. Returns True=pass, False=fail, None=no tests."""
    import glob as glob_mod

    # Find test files related to modified source files
    test_targets = []
    if modified_files:
        test_dirs = ["tests", "test"]
        existing_test_dirs = [d for d in test_dirs if os.path.isdir(os.path.join(project_path, d))]
        if not existing_test_dirs:
            return None

        for src_file in modified_files:
            # Extract module name: "api/users.py" -> "users"
            module_name = Path(src_file).stem
            # Look for test files matching this module
            for td in existing_test_dirs:
                pattern = os.path.join(project_path, td, f"**/*{module_name}*.py")
                test_targets.extend(glob_mod.glob(pattern, recursive=True))
                # Also check for test_ prefix pattern
                pattern2 = os.path.join(project_path, td, f"**/test_{module_name}*.py")
                test_targets.extend(glob_mod.glob(pattern2, recursive=True))

        # Deduplicate
        test_targets = list(set(test_targets))

    if not test_targets:
        return None

    try:
        cmd = [sys.executable, "-m", "pytest", "--tb=short", "-q", "--no-header"] + test_targets
        proc = await asyncio.create_subprocess_exec(
            *cmd,
            cwd=project_path,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=120)
        return proc.returncode == 0
    except (asyncio.TimeoutError, FileNotFoundError):
        return None


@app.command(hidden=True)
@handle_cli_errors
def resume(
    session_id: Optional[str] = typer.Argument(
        None,
        help="Session ID to resume. Omit to resume the most recent session.",
    ),
    list_sessions: bool = typer.Option(
        False,
        "--list",
        help="List recent sessions instead of resuming.",
    ),
    model: Optional[str] = typer.Option(
        None,
        "--model",
        "-m",
        envvar="HIKAFLOW_MODEL",
        help="Model to use (defaults to session's original model).",
    ),
):
    """Resume a previous debugging session.

    [bold]Examples:[/]

        hikaflow resume                  # Resume most recent session

        hikaflow resume --list           # List recent sessions

        hikaflow resume abc123def456     # Resume specific session
    """
    from autodebug.agent.session_store import SessionStore

    store = SessionStore()

    # List sessions
    if list_sessions:
        sessions = store.list_sessions(limit=15)
        if not sessions:
            _cprint("[dim]No saved sessions found.[/]")
            raise typer.Exit(0)
        table = Table(title="Recent Sessions", show_lines=False)
        table.add_column("ID", style="cyan", no_wrap=True)
        table.add_column("Mode", style="green")
        table.add_column("Turns", justify="right")
        table.add_column("Branch", style="dim")
        table.add_column("Updated", style="dim")
        table.add_column("Preview")
        from datetime import datetime
        for s in sessions:
            updated = datetime.fromtimestamp(s.updated_at).strftime("%m-%d %H:%M")
            table.add_row(
                s.session_id,
                s.mode,
                str(s.turn_count),
                s.git_branch or "-",
                updated,
                s.preview[:50] + ("..." if len(s.preview) > 50 else ""),
            )
        console.print(table)
        _cprint("\n[dim]Run: hikaflow resume <ID> to continue a session[/]")
        raise typer.Exit(0)

    # No session_id → resume latest
    if not session_id:
        meta = store.get_latest()
        if not meta:
            _cprint("[red]No sessions found to resume.[/]")
            raise typer.Exit(1)
        session_id = meta.session_id
        _cprint(f"[dim]Resuming latest session: {session_id}[/]")

    # Load session
    meta, messages = store.load_session(session_id)
    if not meta:
        _cprint(f"[red]Session '{session_id}' not found.[/]")
        raise typer.Exit(1)
    if not messages:
        _cprint(f"[yellow]Session '{session_id}' has no messages to resume.[/]")
        raise typer.Exit(1)

    # CWD conflict check
    if meta.cwd != str(Path.cwd()):
        _cprint(f"[yellow]Session was in: {meta.cwd}[/]")
        _cprint(f"[yellow]Current dir:   {Path.cwd()}[/]")
        _cprint("[dim]Continuing in current directory.[/]")

    _cprint(f"[bold green]Resuming session[/] [cyan]{session_id}[/] "
            f"({meta.turn_count} turns, {meta.mode} mode)")

    # Filter out SystemMessages — they'll be rebuilt fresh
    from langchain_core.messages import SystemMessage as _SM
    resumed_history = [m for m in messages if not isinstance(m, _SM)]

    effective_model = model or meta.model or None
    try:
        asyncio.run(
            _run_debug_session(
                None,  # no initial prompt
                False,  # not headless
                effective_model,
                one_shot=False,
                resumed_history=resumed_history,
                resumed_session_id=session_id,
            ),
        )
    except KeyboardInterrupt:
        _cprint("\n[dim]Goodbye.[/]")


@app.command(hidden=True)
@handle_cli_errors
def watch(
    interval: int = typer.Option(
        60,
        "--interval",
        "-i",
        min=1,
        help="Check interval in seconds.",
    ),
    auto_analyze: bool = typer.Option(
        False,
        "--auto-analyze",
        "-a",
        help="Automatically analyze new errors with AI.",
    ),
    environment: Optional[str] = typer.Option(
        None,
        "--env",
        "-e",
        help="Filter to a specific environment (e.g. production, staging).",
    ),
    model: Optional[str] = typer.Option(
        None,
        "--model",
        "-m",
        help="AI model for auto-analysis (requires --auto-analyze).",
    ),
    json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON."),
    notify: bool = typer.Option(True, "--notify/--no-notify", help="Send desktop notifications for new errors."),
):
    """Watch for new production errors.

    Continuously monitors for new errors and optionally
    auto-analyzes them with the AI agent.

    Requires ANTHROPIC_API_KEY or OPENAI_API_KEY when using --auto-analyze.
    """
    asyncio.run(_watch_errors(interval, auto_analyze, environment, model))


@app.command(hidden=True)
@handle_cli_errors
def inspect(
    ctx: typer.Context,
    error_id: str = typer.Argument(..., help="Error ID to inspect.", min=1),
    json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON."),
):
    """Inspect a specific error with full details."""
    json_mode = json_output or ctx.obj.get("json_mode", False)
    success = asyncio.run(_inspect_error(error_id, json_mode=json_mode))
    if not success:
        raise typer.Exit(1)


# =============================================================================
# Doctor Command (Phase 6)
# =============================================================================


@app.command()
@handle_cli_errors
def doctor(
    ctx: typer.Context,
):
    """Check environment and dependencies.

    Runs diagnostic checks for Python version, Docker, network,
    credentials, disk space, CI detection, and package conflicts.
    """
    from autodebug.doctor import format_report_rich, run_doctor

    output = ctx.obj.get("output", OutputFormatter())
    verbose = ctx.obj.get("verbose", False)
    results = run_doctor(verbose=verbose)

    # V-11: doctor is read-only — report problems but don't auto-install/start Docker.
    # Users who need Docker can run `_maybe_install_or_start_docker` via `setup`.

    if output.json_mode:
        # Convert Check objects to dicts for JSON output
        json_checks = []
        for check in results["checks"]:
            json_checks.append({
                "name": check.name,
                "passed": check.passed,
                "message": check.message,
                "detail": check.detail,
            })
        output.json_out({
            "checks": json_checks,
            "passed": results["passed"],
            "failed": results["failed"],
            "dependencies": results["dependencies"],
        })
        output.flush_json()
    else:
        format_report_rich(results)

    # Only exit 1 for critical failures (credentials, network, Python version).
    # Unsupported deps and conflicting packages are warnings, not blockers.
    critical_check_names = {"Python version", "Docker", "Credentials", "Network"}
    critical_failures = any(
        not c.passed and c.name in critical_check_names
        for c in results["checks"]
    )
    if critical_failures:
        raise typer.Exit(1)


# =============================================================================
# Setup Command (Guided First-Run)
# =============================================================================


@app.command()
@handle_cli_errors
def setup(
    ctx: typer.Context,
):
    """Guided setup: doctor → login → init."""
    from autodebug.config import get_valid_token, load_credentials, save_credentials
    from autodebug.doctor import format_report_rich, run_doctor

    output = ctx.obj.get("output", OutputFormatter())
    verbose = ctx.obj.get("verbose", False)

    if output.json_mode:
        output.error("Setup wizard is not available in --json mode.")
        raise typer.Exit(1)

    output.panel(
        "[bold green]Hikaflow Setup[/]\n[dim]We'll check your environment and guide you through login and project setup.[/]",
        title="Setup",
        border_style="green",
    )

    results = run_doctor(verbose=verbose)
    format_report_rich(results)

    attempted = _maybe_install_or_start_docker(output, results)
    if attempted:
        results = run_doctor(verbose=verbose)
        format_report_rich(results)

    # Offer to install Semgrep for code quality scanning
    _maybe_install_semgrep(output)

    token = get_valid_token()
    if not token:
        output.info("")
        output.warning("Not authenticated. Running login...")
        login(ctx, dev=False, device_auth=False, token=None, api_base=None)

    creds = load_credentials()
    repo_slug = _get_repo_slug(output)
    if repo_slug:
        if creds.get("repo_slug") != repo_slug:
            creds["repo_slug"] = repo_slug
            save_credentials(creds)

    if not creds.get("project_id"):
        if repo_slug:
            api_base = creds.get("api_base") or os.environ.get("HIKAFLOW_ENDPOINT") or "https://get.hikaflow.com"
            token = get_valid_token()
            if token:
                existing = _find_project_by_repo(api_base, token, repo_slug)
                if existing and existing.get("id"):
                    creds["project_id"] = existing["id"]
                    save_credentials(creds)
                    output.success(f"Linked repo to existing project: {repo_slug} (ID: {existing['id']})")
                else:
                    output.info("")
                    if typer.confirm(f"Create a new project for {repo_slug}?", default=True):
                        init(ctx, project_id=None, project_name=repo_slug, org_id=None, api_base=None, repo=repo_slug)
                    else:
                        output.warning("No project configured. Run: hikaflow init --project-id <id>")
            else:
                output.warning("No authentication token available to create or lookup project.")
        else:
            output.info("")
            output.warning("No project configured. Running init...")
            init(ctx, project_id=None, project_name=None, org_id=None, api_base=None)

    # Surface remaining warnings without overwhelming
    failed_checks = [c for c in results.get("checks", []) if not c.passed]
    non_auth_docker = [
        c for c in failed_checks
        if c.name not in {"docker", "credentials"}
    ]
    if non_auth_docker:
        output.info("")
        output.warning("Remaining issues:")
        for check in non_auth_docker:
            output.dim(f"- {check.message}")
            if check.detail:
                output.dim(f"  {check.detail}")

    output.info("")
    output.success("Setup complete.")


# =============================================================================
# Uninstall Command
# =============================================================================


@app.command()
@handle_cli_errors
def uninstall(
    ctx: typer.Context,
):
    """Remove hikaflow binary and PATH entry."""
    import shutil

    output = ctx.obj.get("output", OutputFormatter())
    install_dir = Path.home() / ".hikaflow"
    binary_path = install_dir / "bin" / "hikaflow"

    if not install_dir.exists():
        output.info("Hikaflow is not installed.")
        raise typer.Exit(0)

    if not typer.confirm("Remove hikaflow and all data in ~/.hikaflow?"):
        output.info("Cancelled.")
        raise typer.Exit(0)

    # Remove ~/.hikaflow directory
    shutil.rmtree(install_dir, ignore_errors=True)
    output.info("Removed ~/.hikaflow")

    # Remove PATH entry from shell profile
    shell_name = os.environ.get("SHELL", "").rsplit("/", 1)[-1]
    if shell_name == "zsh":
        profile = Path.home() / ".zshrc"
    elif shell_name == "bash":
        profile = Path.home() / ".bashrc"
    else:
        profile = Path.home() / ".profile"

    if profile.exists():
        lines = profile.read_text().splitlines(keepends=True)
        new_lines = [
            ln for ln in lines
            if ".hikaflow/bin" not in ln and ln.strip() != "# Hikaflow CLI"
        ]
        if len(new_lines) != len(lines):
            # Atomic write: write to temp file then rename to prevent data loss
            import tempfile
            tmp_fd, tmp_path = tempfile.mkstemp(dir=profile.parent, suffix=".tmp")
            try:
                with os.fdopen(tmp_fd, "w") as f:
                    f.write("".join(new_lines))
                os.replace(tmp_path, str(profile))
            except OSError as e:
                output.warning(f"Could not update {profile}: {e}")
                try:
                    os.unlink(tmp_path)
                except OSError:
                    pass
            output.info(f"Cleaned PATH from {profile}")

    output.info("Hikaflow uninstalled. Restart your terminal to apply PATH changes.")


# =============================================================================
# Init Command (Phase 4 - First-Run Experience)
# =============================================================================


@app.command(hidden=True)
@handle_cli_errors
def init(
    ctx: typer.Context,
    project_id: Optional[str] = typer.Option(
        None,
        "--project-id",
        help="Existing project UUID.",
    ),
    project_name: Optional[str] = typer.Option(
        None,
        "--project-name",
        help="Name for a new project (creates via API).",
    ),
    repo: Optional[str] = typer.Option(
        None,
        "--repo",
        help="Repo slug (owner/repo). Defaults to git remote if available.",
        hidden=True,
    ),
    org_id: Optional[str] = typer.Option(
        None,
        "--org-id",
        help="Organization ID.",
    ),
    api_base: Optional[str] = typer.Option(
        None,
        "--api-base",
        envvar="HIKAFLOW_ENDPOINT",
        help="API base URL override.",
    ),
):
    """Configure project settings.

    Sets up the project that will be used for test runs. Can provide
    an existing project ID or create a new one with --project-name.

    [bold]Examples:[/]

        hikaflow init --project-id 550e8400-e29b-41d4-a716-446655440000

        hikaflow init --project-name "my-backend"

        hikaflow init  # Interactive wizard
    """
    import uuid as uuid_module

    from autodebug.config import get_api_base, get_valid_token, load_credentials, save_credentials

    output = ctx.obj.get("output", OutputFormatter())

    # Interactive wizard mode if no args provided
    if not project_id and not project_name:
        output.panel(
            "[bold green]Welcome to Hikaflow![/]\n"
            "[dim]Let's configure your project.[/]",
            title="Setup",
            border_style="green",
        )
        output.info("")

        # Step 1: Check auth
        from autodebug.auth import is_authenticated

        if not is_authenticated():
            output.warning("Not authenticated. Running login first...")
            output.info("")
            ctx.invoke(login, device_auth=False, token=None)

        # Step 2: Show current config
        creds = load_credentials()
        if creds.get("project_id"):
            output.info(f"Current project: {creds.get('project_id')}")
            output.info("Use --project-id to change, or Ctrl+C to keep current config.")
            return

        output.info("Provide a project ID or name:")
        output.info("  hikaflow init --project-id <uuid>")
        output.info("  hikaflow init --project-name <name>")
        return

    data = load_credentials()

    if api_base:
        data["api_base"] = api_base

    repo_slug = repo or _get_repo_slug(output)
    if repo_slug:
        data["repo_slug"] = repo_slug

    # Create project via API if name provided
    if project_name and not project_id:
        import requests

        api = data.get("api_base") or api_base or get_api_base()
        token = get_valid_token()
        if not token:
            output.error("Not logged in.", "Run: hikaflow login")
            raise typer.Exit(1)

        try:
            response = requests.post(
                f"{api.rstrip('/')}/v1/projects",
                json={
                    "name": project_name,
                    "description": f"Created via CLI for {project_name}",
                    "repo": repo_slug,
                },
                headers={"Authorization": f"Bearer {token}"},
                timeout=10,
            )
            if response.status_code == 200:
                result = response.json()
                project_id = result.get("id") or result.get("project_id")
                output.success(f"Created project: {project_name} (ID: {project_id})")
            else:
                output.error(f"Failed to create project: {response.text}")
                raise typer.Exit(1)
        except requests.ConnectionError:
            output.error("Cannot connect to API.", "Check connection or run: hikaflow doctor")
            raise typer.Exit(1)

    if not project_id:
        output.error("Missing project ID.", "Provide --project-id or --project-name.")
        raise typer.Exit(1)

    # Validate UUID format
    try:
        uuid_module.UUID(project_id)
    except ValueError:
        output.error(
            f"Invalid project_id format: '{project_id}'",
            "Project IDs must be UUIDs like: 550e8400-e29b-41d4-a716-446655440000",
        )
        raise typer.Exit(1)

    if org_id:
        data["org_id"] = org_id
    data["project_id"] = project_id
    save_credentials(data)

    output.success(f"Project configured: {project_id}")
    output.info("")
    output.panel(
        "[bold]Next steps:[/]\n\n"
        "  [green]hikaflow run -- pytest tests/[/]     Capture I/O during tests\n"
        "  [green]hikaflow doctor[/]                    Check your environment\n"
        "  [green]hikaflow errors[/]                    View production errors",
        title="Ready!",
        border_style="green",
    )


# =============================================================================
# Subcommand groups: replay & flaky
# =============================================================================

replay_app = typer.Typer(
    name="replay",
    help="Transcript replay & analysis.",
    hidden=True,
    context_settings={"help_option_names": ["--help", "-h"]},
)
app.add_typer(replay_app)

flaky_app = typer.Typer(
    name="flaky",
    help="Flakiness detection & management.",
    hidden=True,
    context_settings={"help_option_names": ["--help", "-h"]},
)
app.add_typer(flaky_app)


# =============================================================================
# Replay subcommands
# =============================================================================


@replay_app.command("diff", help="Compare two transcripts.")
@handle_cli_errors
def diff(
    ctx: typer.Context,
    left_path: Path = typer.Argument(..., help="First transcript file.", exists=True),
    right_path: Path = typer.Argument(..., help="Second transcript file.", exists=True),
    by_type: bool = typer.Option(False, "--by-type", help="Group differences by record type."),
    max_diffs: int = typer.Option(20, "--max-diffs", help="Maximum differences to show."),
):
    """Compare two transcripts to find divergences.

    [bold]Examples:[/]

        hikaflow replay diff run-123/events.jsonl run-456/events.jsonl

        hikaflow replay diff left.jsonl right.jsonl --by-type
    """
    from autodebug.diff import (
        diff_by_type,
        diff_transcripts,
        format_diff_report,
        format_type_diff_report,
        load_transcript,
    )

    output = ctx.obj.get("output", OutputFormatter())
    left = load_transcript(left_path)
    right = load_transcript(right_path)

    if by_type:
        results = diff_by_type(left, right)
        if output.json_mode:
            output.json_out({
                type_name: {
                    "total_diffs": len(r.differences),
                    "identical": r.identical_count,
                }
                for type_name, r in results.items()
            })
            output.flush_json()
        else:
            output.console.print(format_type_diff_report(results))
    else:
        result = diff_transcripts(left, right, str(left_path), str(right_path))
        if output.json_mode:
            output.json_out({
                "total_diffs": len(result.differences),
                "identical": result.identical_count,
                "first_divergence_seq": result.first_divergence_seq,
            })
            output.flush_json()
        else:
            output.console.print(format_diff_report(result, max_diffs=max_diffs))


# =============================================================================
# Export Command (Tier A)
# =============================================================================


@replay_app.command("export", help="Export transcript to HAR/curl/Postman.")
@handle_cli_errors
def export(
    ctx: typer.Context,
    transcript_path: Path = typer.Argument(..., help="Transcript file to export.", exists=True),
    fmt: str = typer.Option("har", "--format", "-f", help="Export format: har, curl, postman."),
    output_path: Optional[Path] = typer.Option(None, "--output", "-o", help="Output file path."),
):
    """Export HTTP records to external formats.

    [bold]Examples:[/]

        hikaflow replay export deps_http.jsonl -f har -o requests.har

        hikaflow replay export deps_http.jsonl -f curl

        hikaflow replay export deps_http.jsonl -f postman -o collection.json
    """
    from autodebug.diff import load_transcript
    from autodebug.export import export_to_curl, export_to_har, export_to_postman

    output = ctx.obj.get("output", OutputFormatter())
    records = load_transcript(transcript_path)
    http_records = [r for r in records if r.get("type") == "HTTP"]

    if not http_records:
        output.error("No HTTP records found in transcript.")
        raise typer.Exit(1)

    output.info(f"Found {len(http_records)} HTTP records")

    if fmt == "har":
        result = export_to_har(http_records)
        content = json_module.dumps(result, indent=2)
    elif fmt == "curl":
        content = export_to_curl(http_records)
    elif fmt == "postman":
        name = transcript_path.stem if not output_path else output_path.stem
        result = export_to_postman(http_records, collection_name=name)
        content = json_module.dumps(result, indent=2)
    else:
        output.error(f"Unknown format: {fmt}", "Supported formats: har, curl, postman")
        raise typer.Exit(1)

    if output_path:
        output_path.write_text(content, encoding="utf-8")
        output.success(f"Exported to {output_path}")
    else:
        output.info(content)


# =============================================================================
# Minimize Command (Tier B)
# =============================================================================


@replay_app.command("minimize", help="Delta-debug a transcript to smallest failing case.")
@handle_cli_errors
def minimize(
    ctx: typer.Context,
    harness_path: Path = typer.Argument(..., help="Path to repro harness.", exists=True),
    transcript_path: Path = typer.Argument(..., help="Transcript file to minimize.", exists=True),
    method: str = typer.Option(
        "hierarchical",
        "--method",
        help="Minimization method: ddmin, hierarchical, causal.",
    ),
    output_path: Optional[Path] = typer.Option(None, "--output", "-o", help="Output path for minimal transcript."),
):
    """Minimize a transcript while preserving the failure.

    Uses delta debugging to find the smallest transcript that still
    reproduces the failure.

    [bold]Examples:[/]

        hikaflow replay minimize ./harness ./transcript.jsonl -o minimal.jsonl
    """
    from autodebug.diff import load_transcript
    from autodebug.minimize import format_minimization_report, minimize_transcript

    output = ctx.obj.get("output", OutputFormatter())
    transcript = load_transcript(transcript_path)
    output.info(f"Loaded {len(transcript)} records from {transcript_path}")

    with output.progress_spinner(f"Minimizing using {method} method..."):
        result = minimize_transcript(transcript, harness_path, method=method)

    if output.json_mode:
        output.json_out({
            "original_count": result.get("original_count"),
            "minimal_count": result.get("minimal_count"),
            "reduction_pct": result.get("reduction_pct"),
        })
        output.flush_json()
    else:
        output.console.print(format_minimization_report(result))

    if output_path and "minimal_transcript" in result:
        with open(output_path, "w", encoding="utf-8") as f:
            for record in result.get("minimal_transcript"):
                f.write(json_module.dumps(record, sort_keys=True) + "\n")
        output.success(f"Minimal transcript saved to {output_path}")


# =============================================================================
# Replay Debug Command (Tier B)
# =============================================================================


@replay_app.command("debug", help="Interactive transcript debugger.")
@handle_cli_errors
def replay_debug(
    transcript_path: Path = typer.Argument(..., help="Transcript file to debug.", exists=True),
):
    """Interactively step through a transcript.

    Commands: next, step, continue, break, list, inspect, search, quit

    [bold]Examples:[/]

        hikaflow replay debug events.jsonl
    """
    from autodebug.replay_debug import load_and_debug

    load_and_debug(transcript_path)


# =============================================================================
# Share Command (Tier A)
# =============================================================================


@replay_app.command("share", help="Share a run via public link.")
@handle_cli_errors
def share(
    ctx: typer.Context,
    run_path: Path = typer.Argument(..., help="Path to run directory.", exists=True),
    expires: int = typer.Option(7, "--expires", help="Days until link expires (0 = never)."),
    open_browser: bool = typer.Option(False, "--open", help="Open share link in browser."),
):
    """Create a shareable link for a run.

    [bold]Examples:[/]

        hikaflow replay share .autodebug/runs/abc/

        hikaflow replay share ./run-123 --expires 30 --open
    """
    import webbrowser

    import requests

    from autodebug.config import get_api_base, get_valid_token

    output = ctx.obj.get("output", OutputFormatter())

    manifest_path = run_path / "manifest.json"
    if not manifest_path.exists():
        output.error("No manifest.json found.", "This doesn't appear to be a valid run directory.")
        raise typer.Exit(1)

    manifest = json_module.loads(manifest_path.read_text())
    run_id = manifest.get("run_id")
    if not run_id:
        output.error("manifest.json missing run_id.")
        raise typer.Exit(1)

    token = get_valid_token()
    if not token:
        output.error("Not authenticated.", "Run: hikaflow login")
        raise typer.Exit(1)

    api = get_api_base().rstrip("/")
    headers = {"Authorization": f"Bearer {token}"}

    with output.progress_spinner(f"Sharing run {run_id}..."):
        resp = requests.post(
            f"{api}/v1/runs/{run_id}/share",
            json={"expires_days": expires},
            headers=headers,
            timeout=30,
        )
        _ensure_http_ok(
            resp,
            operation="Share run",
            output=output,
            detail="Confirm the run exists and your token has project access.",
        )
        share_data = resp.json()

    share_url = share_data.get("url", share_data.get("share_url", ""))

    if output.json_mode:
        output.json_out({"run_id": run_id, "share_url": share_url, "expires_days": expires})
        output.flush_json()
    else:
        output.success("Share link created!")
        output.info(f"  [green]{share_url}[/]")
        if expires > 0:
            output.dim(f"  Expires in {expires} days")

    if open_browser and share_url:
        webbrowser.open(share_url)


# =============================================================================
# Flaky Scan Command (Tier B)
# =============================================================================


@flaky_app.command("scan", help="Scan for flaky tests and optionally quarantine.")
@handle_cli_errors
def flaky_scan(
    ctx: typer.Context,
    test_path: Path = typer.Argument(..., help="Test file or directory to scan.", exists=True),
    runs: int = typer.Option(5, "--runs", help="Number of times to run each test.", min=1),
    parallel: int = typer.Option(4, "--parallel", help="Number of parallel test executions.", min=1),
    timeout: int = typer.Option(60, "--timeout", help="Timeout per test in seconds.", min=1),
    quarantine: bool = typer.Option(False, "--quarantine", help="Add flaky tests to quarantine file."),
    threshold: float = typer.Option(0.8, "--threshold", help="Pass rate threshold for flakiness (0-1)."),
):
    """Scan test suite for flaky tests.

    Runs each test multiple times to detect non-deterministic behavior.

    [bold]Examples:[/]

        hikaflow flaky scan ./tests --runs 10

        hikaflow flaky scan ./tests --runs 5 --quarantine
    """
    from autodebug.flaky_quarantine import (
        QUARANTINE_FILE,
        add_to_quarantine,
        format_flaky_scan_report,
        scan_for_flaky_tests,
    )

    output = ctx.obj.get("output", OutputFormatter())

    if not (0 < threshold <= 1):
        output.error(f"Invalid threshold: {threshold}. Must be between 0 and 1.")
        raise typer.Exit(1)
    output.info(f"Scanning {test_path} for flaky tests...")
    output.info(f"Running each test {runs} times with {parallel} parallel workers")

    with output.progress_bar("Scanning tests", total=None) as progress:
        def progress_callback(completed: int, total: int, current_test: str) -> None:
            test_name = current_test.split("::")[-1] if "::" in current_test else current_test
            progress.update(f"Scanning: {completed}/{total} - {test_name[:40]}")

        result = scan_for_flaky_tests(
            str(test_path),
            runs=runs,
            parallel=parallel,
            timeout=timeout,
            flaky_threshold=threshold,
            progress_callback=progress_callback,
        )

    if output.json_mode:
        output.json_out({
            "total_tests": result.total_tests,
            "stable_tests": result.stable_tests,
            "runs_per_test": result.runs_per_test,
            "scan_duration": round(result.scan_duration, 2),
            "flaky_tests": [
                {
                    "nodeid": t.nodeid,
                    "pass_rate": t.pass_rate,
                    "flakiness_score": t.flakiness_score,
                    "total_runs": t.total_runs,
                    "pass_count": t.pass_count,
                    "fail_count": t.fail_count,
                    "failure_signatures": t.failure_signatures,
                }
                for t in result.flaky_tests
            ],
        })
        output.flush_json()
    else:
        output.console.print(format_flaky_scan_report(result))

    if quarantine and result.flaky_tests:
        added = add_to_quarantine(result.flaky_tests)
        output.success(f"Added {added} flaky tests to {QUARANTINE_FILE}")


# =============================================================================
# Shell Completion Command (Phase 7)
# =============================================================================


@app.command(help="Generate shell completions.", hidden=True)
@handle_cli_errors
def completion(
    ctx: typer.Context,
    shell: str = typer.Argument(
        "bash",
        help="Shell type: bash, zsh, fish.",
    ),
    install: bool = typer.Option(
        False,
        "--install",
        help="Install completion to shell config.",
    ),
):
    """Generate or install shell completion.

    [bold]Examples:[/]

        hikaflow completion bash           # Print bash completion

        hikaflow completion zsh --install  # Install zsh completion

        eval "$(hikaflow completion bash)" # Source directly
    """
    import subprocess

    output = ctx.obj.get("output", OutputFormatter())

    if install:
        try:
            subprocess.run(
                [sys.executable, "-m", "autodebug", "--install-completion", shell],
                check=True,
            )
            output.success(f"Shell completion installed for {shell}.")
        except subprocess.CalledProcessError:
            output.error(f"Failed to install completion for {shell}.")
            raise typer.Exit(1)
    else:
        try:
            result = subprocess.run(
                [sys.executable, "-m", "autodebug", "--show-completion", shell],
                capture_output=True,
                text=True,
            )
            if result.stdout:
                # Raw print intentional: completion scripts must go to stdout unformatted
                print(result.stdout)
            else:
                output.dim(f"No completion script generated for {shell}.")
                output.dim(f"Try: hikaflow --install-completion {shell}")
        except Exception:
            output.dim(f"Shell completion not available for {shell}.")


# =============================================================================
# Auto-Debug Command (ported from v1)
# =============================================================================


@replay_app.command("auto", help="Run autonomous debug workflow for a run.")
@handle_cli_errors
def auto_debug(
    ctx: typer.Context,
    run_path: Path = typer.Argument(..., help="Path to run directory.", exists=True),
    harness: Optional[Path] = typer.Option(None, "--harness", "-H", help="Path to repro harness."),
    hypotheses: int = typer.Option(3, "--hypotheses", "-n", help="Number of hypotheses to generate."),
    validate: bool = typer.Option(True, "--validate/--no-validate", help="Validate hypotheses against harness."),
    output_path: Optional[Path] = typer.Option(None, "--output", "-o", help="Output file for results."),
):
    """Autonomous debugging with LLM-powered fix generation.

    Analyzes a failure, generates fix hypotheses using LLMs,
    and optionally validates them against the repro harness.

    Requires ANTHROPIC_API_KEY or OPENAI_API_KEY environment variable.

    [bold]Examples:[/]

        hikaflow replay auto .autodebug/runs/abc/

        hikaflow replay auto ./run --harness ./repro.tar.gz -n 5  # -H is short for --harness

        hikaflow replay auto ./run --no-validate -o results.json
    """
    import click

    from autodebug.cli import auto_debug as legacy_auto_debug

    output = ctx.obj.get("output", OutputFormatter())

    # Build args for the legacy Click command
    click_ctx = click.Context(legacy_auto_debug)
    click_ctx.params = {
        "run_path": str(run_path),
        "harness": str(harness) if harness else None,
        "hypotheses": hypotheses,
        "validate": validate,
        "output": str(output_path) if output_path else None,
    }
    try:
        legacy_auto_debug.invoke(click_ctx)
    except SystemExit as e:
        if e.code:
            raise typer.Exit(e.code)


# =============================================================================
# Heal Command (ported from v1)
# =============================================================================


@flaky_app.command("heal", help="Run automated healing for a flaky test.")
@handle_cli_errors
def heal(
    ctx: typer.Context,
    path: Path = typer.Argument(..., help="Test file or directory to heal.", exists=True),
    dry_run: bool = typer.Option(True, "--dry-run/--apply", help="Preview fixes without applying (default: dry-run)."),
    validate: bool = typer.Option(False, "--validate/--no-validate", help="Run tests to validate fixes."),
    min_confidence: float = typer.Option(0.5, "--min-confidence", help="Minimum confidence threshold for fixes."),
    strategy: str = typer.Option("both", "--strategy", help="Fix strategy: template, llm, or both."),
    category: Optional[list[str]] = typer.Option(None, "--category", "-c", help="Only fix patterns in these categories."),
    output_path: Optional[Path] = typer.Option(None, "--output", "-o", help="Output report to file."),
):
    """Self-heal flaky test patterns.

    Detects common flaky test patterns (hardcoded sleeps, race conditions,
    global state, etc.) and automatically applies fixes.

    [bold]Examples:[/]

        hikaflow flaky heal ./tests                          # Preview fixes

        hikaflow flaky heal ./tests --apply                  # Apply fixes

        hikaflow flaky heal ./tests/test_api.py --validate   # Validate fixes

        hikaflow flaky heal ./tests -c async_wait -c concurrency

        hikaflow flaky heal ./tests --strategy llm           # Use LLM for complex fixes
    """
    import os
    import click

    # Early API key check to avoid spamming errors per pattern
    if strategy in ("llm", "both"):
        has_key = bool(os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("OPENAI_API_KEY"))
        if not has_key:
            if strategy == "llm":
                _cprint(
                    "[red]Error:[/] LLM strategy requires ANTHROPIC_API_KEY or OPENAI_API_KEY.\n"
                    "Set one of these environment variables and retry, or use --strategy template.",
                    highlight=False,
                )
                raise typer.Exit(1)
            else:
                _cprint(
                    "[yellow]Note:[/] No LLM API key found. Falling back to template-only fixes.\n"
                    "Set ANTHROPIC_API_KEY or OPENAI_API_KEY for LLM-powered fixes.",
                    highlight=False,
                )
                strategy = "template"

    from autodebug.cli import heal_cmd as legacy_heal

    click_ctx = click.Context(legacy_heal)
    click_ctx.params = {
        "path": str(path),
        "dry_run": dry_run,
        "validate": validate,
        "min_confidence": min_confidence,
        "strategy": strategy,
        "category": tuple(category) if category else (),
        "output": str(output_path) if output_path else None,
    }
    try:
        legacy_heal.invoke(click_ctx)
    except SystemExit as e:
        if e.code:
            raise typer.Exit(e.code)


# =============================================================================
# Detect-Flaky Command (ported from v1)
# =============================================================================


@flaky_app.command("detect", help="Analyze tests for flakiness patterns.")
@handle_cli_errors
def detect_flaky(
    ctx: typer.Context,
    path: Path = typer.Argument(..., help="Test file or directory to scan.", exists=True),
    min_confidence: float = typer.Option(0.5, "--min-confidence", help="Minimum confidence threshold."),
    category: Optional[list[str]] = typer.Option(None, "--category", "-c", help="Only detect patterns in these categories."),
    exclude_category: Optional[list[str]] = typer.Option(None, "--exclude-category", "-x", help="Exclude these categories."),
    top: int = typer.Option(0, "--top", "-n", help="Show only top N patterns (0 = all)."),
    group_by_file: bool = typer.Option(False, "--group-by-file", help="Group output by file."),
    output_path: Optional[Path] = typer.Option(None, "--output", "-o", help="Output results to JSON file."),
):
    """Detect flaky test patterns without applying fixes.

    Scans test files for common patterns that cause test flakiness,
    such as hardcoded sleeps, race conditions, and global state mutations.

    [bold]Examples:[/]

        hikaflow flaky detect ./tests

        hikaflow flaky detect ./tests -c async_wait -c order_dependency

        hikaflow flaky detect ./tests --top 10 --group-by-file

        hikaflow flaky detect ./tests -o report.json
    """
    output = ctx.obj.get("output", OutputFormatter())

    if output.json_mode or output_path:
        # Use detector directly for structured output
        from autodebug.healing.detector import detect_flaky_patterns
        from autodebug.healing.patterns import PatternCategory

        categories_filter = None
        if category:
            categories_filter = []
            for c in category:
                try:
                    categories_filter.append(PatternCategory(c))
                except ValueError:
                    categories_filter.append(PatternCategory[c.upper()])

        result = detect_flaky_patterns(
            path,
            categories=categories_filter,
            min_confidence=min_confidence,
        )

        if exclude_category:
            exclude_set = set()
            for ec in exclude_category:
                try:
                    exclude_set.add(PatternCategory(ec))
                except ValueError:
                    exclude_set.add(PatternCategory[ec.upper()])
            result.matches = [m for m in result.matches if m.pattern.category not in exclude_set]

        matches = result.matches
        if top > 0:
            matches = matches[:top]

        json_data = {
            "files_scanned": result.files_scanned,
            "total_matches": len(matches),
            "patterns": [
                {
                    "file": m.file_path,
                    "line": m.line_number,
                    "category": m.pattern.category.value,
                    "name": m.pattern.name,
                    "confidence": round(m.confidence, 2),
                    "severity": m.pattern.severity,
                    "matched_code": m.matched_code.strip(),
                }
                for m in matches
            ],
        }

        if output_path:
            import json as _json
            output_path.write_text(_json.dumps(json_data, indent=2), encoding="utf-8")
            output.success(f"Results written to {output_path}")
        if output.json_mode:
            output.json_out(json_data)
            output.flush_json()
        return

    # Fall back to legacy Click command for Rich output
    import click

    from autodebug.cli import detect_flaky_cmd as legacy_detect_flaky

    click_ctx = click.Context(legacy_detect_flaky)
    click_ctx.params = {
        "path": str(path),
        "min_confidence": min_confidence,
        "category": tuple(category) if category else (),
        "exclude_category": tuple(exclude_category) if exclude_category else (),
        "top": top,
        "group_by_file": group_by_file,
        "output": str(output_path) if output_path else None,
    }
    try:
        legacy_detect_flaky.invoke(click_ctx)
    except SystemExit as e:
        if e.code:
            raise typer.Exit(e.code)


# =============================================================================
# Analyze-Failure Command (ported from v1)
# =============================================================================


@replay_app.command("analyze", help="Analyze a failure bundle for root cause.")
@handle_cli_errors
def analyze_failure(
    ctx: typer.Context,
    run_path: Path = typer.Argument(..., help="Path to run directory.", exists=True),
    output_path: Optional[Path] = typer.Option(None, "--output", "-o", help="Output file for analysis."),
):
    """Analyze a failure without generating fixes.

    Categorizes the failure and extracts context for debugging.

    [bold]Examples:[/]

        hikaflow replay analyze .autodebug/runs/abc/

        hikaflow replay analyze ./run -o analysis.json
    """
    import click

    from autodebug.cli import analyze_failure_cmd as legacy_analyze

    click_ctx = click.Context(legacy_analyze)
    click_ctx.params = {
        "run_path": str(run_path),
        "output": str(output_path) if output_path else None,
    }
    try:
        legacy_analyze.invoke(click_ctx)
    except SystemExit as e:
        if e.code:
            raise typer.Exit(e.code)


# =============================================================================
# Generate-Tests Command (ported from v1)
# =============================================================================


@app.command("generate-tests", help="Generate tests for a Python file using AI.", hidden=True)
@handle_cli_errors
def generate_tests(
    ctx: typer.Context,
    source_path: Path = typer.Argument(..., help="Python source file to generate tests for.", exists=True),
    output_path: Optional[Path] = typer.Option(None, "--output", "-o", help="Output file for generated tests."),
    coverage: int = typer.Option(80, "--coverage", help="Target coverage percentage."),
    max_tests: int = typer.Option(5, "--max-tests", help="Maximum tests per function."),
    strategies: Optional[list[str]] = typer.Option(
        None,
        "--strategies",
        "-s",
        help="Strategies: happy_path, edge_cases, error_cases, null_checks, type_variants, integration.",
    ),
    target_function: Optional[str] = typer.Option(None, "--function", "-f", help="Generate tests only for this function."),
    json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON."),
):
    """Generate tests for a Python file using AI.

    Analyzes the source code structure, identifies testable functions,
    and generates comprehensive pytest-compatible tests.

    Requires ANTHROPIC_API_KEY or OPENAI_API_KEY environment variable.

    [bold]Examples:[/]

        hikaflow generate-tests ./src/user.py

        hikaflow generate-tests ./src/api.py -o tests/test_api.py

        hikaflow generate-tests ./src/utils.py --coverage 90 --max-tests 10

        hikaflow generate-tests ./src/auth.py -s happy_path -s edge_cases
    """
    import os

    output = ctx.obj.get("output", OutputFormatter()) if ctx.obj else OutputFormatter()

    # Check for required API keys upfront
    if not (os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("OPENAI_API_KEY")):
        output.error(
            "generate-tests requires ANTHROPIC_API_KEY or OPENAI_API_KEY.",
            "Set one of these environment variables and retry.",
        )
        raise typer.Exit(1)

    # Validate target_function as a Python identifier to prevent injection
    if target_function and not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', target_function):
        output.error(
            f"Invalid function name: {target_function!r}",
            "Must be a valid Python identifier (letters, digits, underscores).",
        )
        raise typer.Exit(1)

    # Call the test generator directly instead of subprocess delegation
    from autodebug.autonomous.test_generator import generate_tests as _gen_tests

    with output.progress_spinner("Generating tests..."):
        suite = _gen_tests(
            file_path=source_path,
            strategies=strategies,
            target_coverage=coverage,
            max_tests_per_function=max_tests,
            output_path=output_path,
        )

    if not suite.test_cases:
        output.warning("No tests generated.")
        return

    output.success(f"Generated {len(suite.test_cases)} tests.")
    if output_path:
        output.info(f"Saved to: {output_path}")
    else:
        output.info(suite.to_file_content())


# =============================================================================
# Replay-Prod Command (ported from v1)
# =============================================================================


@replay_app.command("prod", help="Replay a production error locally.")
@handle_cli_errors
def replay_prod(
    ctx: typer.Context,
    error_source: str = typer.Argument(..., help="Error file path or error ID from API."),
    output_path: Optional[Path] = typer.Option(None, "--output", "-o", help="Output directory for replay bundle."),
    auto_debug_flag: bool = typer.Option(False, "--auto-debug/--no-auto-debug", help="Automatically run auto-debug on the error."),
):
    """Replay a production error locally.

    Downloads production error context and converts it to a replay bundle
    that can be used for local debugging.

    ERROR_SOURCE can be a local file path or an error ID to fetch from API.

    [bold]Examples:[/]

        hikaflow replay prod ./errors/20240115_abc123.json.gz

        hikaflow replay prod abc123-def456 --auto-debug

        hikaflow replay prod abc123-def456 -o ./debug-session/
    """
    import click

    from autodebug.cli import replay_prod as legacy_replay_prod

    click_ctx = click.Context(legacy_replay_prod)
    click_ctx.params = {
        "error_source": error_source,
        "output": str(output_path) if output_path else None,
        "auto_debug": auto_debug_flag,
    }
    try:
        legacy_replay_prod.invoke(click_ctx)
    except SystemExit as e:
        if e.code:
            raise typer.Exit(e.code)


# =============================================================================
# Optimize Command (ported from v1)
# =============================================================================


@replay_app.command("optimize", help="Optimize a bundle for replay.")
@handle_cli_errors
def optimize(
    ctx: typer.Context,
    bundle_path: Path = typer.Argument(..., help="Path to transcript bundle directory.", exists=True),
    output_path: Optional[Path] = typer.Option(None, "--output", "-o", help="Output directory for optimized files."),
):
    """Optimize transcript bundle for storage.

    Applies deduplication and high compression to reduce storage costs.

    [bold]Examples:[/]

        hikaflow replay optimize .autodebug/runs/abc/

        hikaflow replay optimize ./bundle -o ./optimized
    """
    import click

    from autodebug.cli import optimize as legacy_optimize

    click_ctx = click.Context(legacy_optimize)
    click_ctx.params = {
        "bundle_path": str(bundle_path),
        "output": str(output_path) if output_path else None,
    }
    try:
        legacy_optimize.invoke(click_ctx)
    except SystemExit as e:
        if e.code:
            raise typer.Exit(e.code)


# =============================================================================
# Flakiness Command (ported from v1)
# =============================================================================


@flaky_app.command("measure", help="Run repeated tests to measure flakiness.")
@handle_cli_errors
def flakiness(
    ctx: typer.Context,
    harness_path: Path = typer.Argument(..., help="Path to repro harness.", exists=True),
    runs: int = typer.Option(100, "--runs", help="Number of validation runs.", min=1, max=10000),
    parallel: int = typer.Option(4, "--parallel", help="Number of parallel runs."),
    timeout: int = typer.Option(120, "--timeout", help="Timeout per run in seconds."),
):
    """Analyze test flakiness by running validation many times.

    [bold]Examples:[/]

        hikaflow flaky measure ./repro.tar.gz --runs 100

        hikaflow flaky measure ./harness --runs 50 --parallel 8
    """
    import click

    from autodebug.cli import flakiness as legacy_flakiness

    click_ctx = click.Context(legacy_flakiness)
    click_ctx.params = {
        "harness_path": str(harness_path),
        "runs": runs,
        "parallel": parallel,
        "timeout": timeout,
    }
    try:
        legacy_flakiness.invoke(click_ctx)
    except SystemExit as e:
        if e.code:
            raise typer.Exit(e.code)


# =============================================================================
# Patterns Command Group (ported from v1)
# =============================================================================

patterns_app = typer.Typer(
    name="patterns",
    help=ADVANCED_HELP_PREFIX + "Manage the fix pattern database.",
    hidden=True,
    context_settings={"help_option_names": ["--help", "-h"]},
)
app.add_typer(patterns_app)


@patterns_app.command("list")
@handle_cli_errors
def patterns_list(
    ctx: typer.Context,
    limit: int = typer.Option(20, "--limit", help="Maximum patterns to show."),
    order: str = typer.Option("success", "--order", help="Sort order: success, uses, recent."),
    fix_type: Optional[str] = typer.Option(None, "--fix-type", help="Filter by fix type."),
):
    """List stored patterns.

    [bold]Examples:[/]

        hikaflow patterns list

        hikaflow patterns list --limit 50 --order uses
    """
    import click

    from autodebug.cli import patterns_list as legacy_patterns_list

    click_ctx = click.Context(legacy_patterns_list)
    click_ctx.params = {"limit": limit, "order": order, "fix_type": fix_type}
    try:
        legacy_patterns_list.invoke(click_ctx)
    except SystemExit as e:
        if e.code:
            raise typer.Exit(e.code)


@patterns_app.command("stats")
@handle_cli_errors
def patterns_stats(ctx: typer.Context):
    """Show pattern database statistics.

    [bold]Examples:[/]

        hikaflow patterns stats

        hikaflow --json patterns stats
    """
    from autodebug.patterns import get_pattern_store

    output = ctx.obj.get("output", OutputFormatter())
    store = get_pattern_store()
    stats = store.get_stats()

    if output.json_mode:
        output.data(stats)
        output.flush_json()
        return

    output.console.print("[bold]Pattern Database Statistics[/]")
    output.console.print("=" * 50)
    output.console.print("")
    total = stats.get("total_patterns", 0)
    uses = stats.get("total_uses", 0)
    rate = stats.get("overall_success_rate", 0)
    output.console.print(f"Total patterns: {total}")
    output.console.print(f"Total uses: {uses}")
    output.console.print(f"Success rate: {rate:.1%}" if uses else "Success rate: N/A")
    output.console.print("")

    if stats.get("top_exception_types"):
        output.console.print("[bold]Top Exception Types:[/]")
        for item in stats["top_exception_types"][:5]:
            output.console.print(f"  {item.get('type')}: {item.get('count')}")
        output.console.print("")

    if stats.get("top_fix_types"):
        output.console.print("[bold]Top Fix Types:[/]")
        for item in stats["top_fix_types"][:5]:
            output.console.print(f"  {item.get('type')}: {item.get('count')}")
        output.console.print("")


@patterns_app.command("match")
@handle_cli_errors
def patterns_match(
    ctx: typer.Context,
    run_path: Path = typer.Argument(..., help="Path to run directory.", exists=True),
    min_similarity: float = typer.Option(0.5, "--min-similarity", help="Minimum similarity threshold."),
    max_results: int = typer.Option(5, "--max-results", help="Maximum matches to show."),
):
    """Find matching patterns for a failure.

    [bold]Examples:[/]

        hikaflow patterns match .autodebug/runs/abc/

        hikaflow patterns match ./run --min-similarity 0.7
    """
    import click

    from autodebug.cli import patterns_match as legacy_patterns_match

    click_ctx = click.Context(legacy_patterns_match)
    click_ctx.params = {
        "run_path": str(run_path),
        "min_similarity": min_similarity,
        "max_results": max_results,
    }
    try:
        legacy_patterns_match.invoke(click_ctx)
    except SystemExit as e:
        if e.code:
            raise typer.Exit(e.code)


@patterns_app.command("add")
@handle_cli_errors
def patterns_add(
    ctx: typer.Context,
    run_path: Path = typer.Argument(..., help="Path to run directory.", exists=True),
    fix_diff: Optional[Path] = typer.Option(None, "--fix-diff", "-d", help="Path to fix diff file."),
    fix_description: str = typer.Option(..., "--fix-description", "-m", help="Description of the fix."),
    fix_type: Optional[str] = typer.Option(None, "--fix-type", "-t", help="Type of fix."),
    tag: Optional[list[str]] = typer.Option(None, "--tag", help="Tags for the pattern."),
):
    """Add a pattern from a proven fix.

    [bold]Examples:[/]

        hikaflow patterns add .autodebug/runs/abc/ -d fix.patch -m "Add null check"

        hikaflow patterns add ./run -d fix.diff -m "Add retry" --tag flaky --tag network
    """
    import click

    from autodebug.cli import patterns_add as legacy_patterns_add

    click_ctx = click.Context(legacy_patterns_add)
    click_ctx.params = {
        "run_path": str(run_path),
        "fix_diff": str(fix_diff) if fix_diff else None,
        "fix_description": fix_description,
        "fix_type": fix_type,
        "tag": tuple(tag) if tag else (),
    }
    try:
        legacy_patterns_add.invoke(click_ctx)
    except SystemExit as e:
        if e.code:
            raise typer.Exit(e.code)


@patterns_app.command("export")
@handle_cli_errors
def patterns_export(
    ctx: typer.Context,
    output_path: Path = typer.Argument(..., help="Output JSON file path."),
):
    """Export patterns to a JSON file.

    [bold]Examples:[/]

        hikaflow patterns export patterns.json
    """
    import click

    from autodebug.cli import patterns_export as legacy_patterns_export

    click_ctx = click.Context(legacy_patterns_export)
    click_ctx.params = {"output_path": str(output_path)}
    try:
        legacy_patterns_export.invoke(click_ctx)
    except SystemExit as e:
        if e.code:
            raise typer.Exit(e.code)


@patterns_app.command("import")
@handle_cli_errors
def patterns_import(
    ctx: typer.Context,
    input_path: Path = typer.Argument(..., help="Input JSON file path.", exists=True),
    source: str = typer.Option("imported", "--source", help="Source label for imported patterns."),
):
    """Import patterns from a JSON file.

    [bold]Examples:[/]

        hikaflow patterns import patterns.json

        hikaflow patterns import community-patterns.json --source community
    """
    import click

    from autodebug.cli import patterns_import as legacy_patterns_import

    click_ctx = click.Context(legacy_patterns_import)
    click_ctx.params = {"input_path": str(input_path), "source": source}
    try:
        legacy_patterns_import.invoke(click_ctx)
    except SystemExit as e:
        if e.code:
            raise typer.Exit(e.code)


@patterns_app.command("delete")
@handle_cli_errors
def patterns_delete(
    ctx: typer.Context,
    pattern_id: str = typer.Argument(..., help="Pattern ID to delete."),
    yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
):
    """Delete a pattern.

    [bold]Examples:[/]

        hikaflow patterns delete abc123

        hikaflow patterns delete abc123 -y
    """
    import click

    from autodebug.cli import patterns_delete as legacy_patterns_delete

    click_ctx = click.Context(legacy_patterns_delete)
    click_ctx.params = {"pattern_id": pattern_id, "yes": yes}
    try:
        legacy_patterns_delete.invoke(click_ctx)
    except SystemExit as e:
        if e.code:
            raise typer.Exit(e.code)


@patterns_app.command("contribute")
@handle_cli_errors
def patterns_contribute(
    ctx: typer.Context,
    run_path: Path = typer.Argument(..., help="Path to run directory.", exists=True),
    fix_diff: Path = typer.Option(..., "--fix-diff", "-d", help="Path to fix diff file."),
    fix_description: str = typer.Option(..., "--fix-description", "-m", help="Description of the fix."),
):
    """Contribute a pattern to the community database.

    Patterns are anonymized before upload. No code, variable names, or
    identifying information is shared - only structural patterns.

    [bold]Examples:[/]

        hikaflow patterns contribute .autodebug/runs/abc/ -d fix.patch -m "Add retry"
    """
    import click

    from autodebug.cli import patterns_contribute as legacy_patterns_contribute

    click_ctx = click.Context(legacy_patterns_contribute)
    click_ctx.params = {
        "run_path": str(run_path),
        "fix_diff": str(fix_diff),
        "fix_description": fix_description,
    }
    try:
        legacy_patterns_contribute.invoke(click_ctx)
    except SystemExit as e:
        if e.code:
            raise typer.Exit(e.code)


# =============================================================================
# Session Command Group (ported from v1)
# =============================================================================

session_app = typer.Typer(
    name="session",
    help=ADVANCED_HELP_PREFIX + "Session replay commands.",
    hidden=True,
    context_settings={"help_option_names": ["--help", "-h"]},
)
app.add_typer(session_app)


@session_app.command("list")
@handle_cli_errors
def session_list(
    ctx: typer.Context,
    limit: int = typer.Option(20, "--limit", "-n", help="Max sessions to show.", min=1, max=100),
    status: Optional[str] = typer.Option(None, "--status", help="Filter by status (active, completed, failed)."),
):
    """List recent sessions.

    [bold]Examples:[/]

        hikaflow session list

        hikaflow session list --limit 5 --status completed
    """
    import httpx

    from autodebug.auth import get_credentials, is_authenticated, load_config

    output = ctx.obj.get("output", OutputFormatter())
    if not is_authenticated():
        output.error("Not authenticated.", "Run: hikaflow login")
        raise typer.Exit(1)

    config = load_config()
    creds = get_credentials()
    api_base = config.get("api_base", "https://get.hikaflow.com")
    project_id = config.get("project_id")

    if not project_id:
        _cprint("[red]No project linked.[/] Run [bold]hikaflow init[/] first.")
        raise typer.Exit(1)

    params = {"project_id": project_id, "limit": limit}
    if status:
        params["status"] = status

    headers = {"Authorization": f"Bearer {creds.get('access_token') or creds.get('token', '')}"}
    resp = httpx.get(f"{api_base}/v1/sessions", params=params, headers=headers, timeout=15)

    if resp.status_code != 200:
        _cprint(f"[red]{_format_http_response_error(resp, operation='List sessions', body_limit=200)}[/]")
        _cprint("  [dim]Check project setup with: hikaflow status[/]")
        raise typer.Exit(1)

    data = resp.json()
    sessions = data if isinstance(data, list) else data.get("sessions", data.get("items", []))

    if ctx.obj.get("json_mode"):
        _cprint(json_module.dumps(sessions if isinstance(sessions, list) else list(sessions), indent=2, default=str))
        return

    if not sessions:
        _cprint("[dim]No sessions found.[/]")
        return

    table = Table(title="Sessions", show_lines=False)
    table.add_column("ID", style="cyan", no_wrap=True, max_width=12)
    table.add_column("Status", style="bold")
    table.add_column("Created", style="dim")
    table.add_column("Duration", justify="right")
    table.add_column("Chunks", justify="right")

    for s in sessions:
        sid = str(s.get("id", ""))[:12]
        s_status = s.get("status", "unknown")
        created = str(s.get("created_at", ""))[:19].replace("T", " ")
        duration = str(s.get("duration", "-"))
        chunks = str(s.get("chunk_count", s.get("chunks", "-")))
        table.add_row(sid, s_status, created, duration, chunks)

    _cprint(table)


@session_app.command("create")
@handle_cli_errors
def session_create(
    ctx: typer.Context,
    project_id: str = typer.Option(..., "--project-id", help="Project UUID."),
    run_id: Optional[str] = typer.Option(None, "--run-id", help="Optional run ID to derive trace_id."),
    trace_id: Optional[str] = typer.Option(None, "--trace-id", help="Optional trace ID to link sessions."),
    metadata: Optional[str] = typer.Option(None, "--metadata", help="JSON metadata string."),
):
    """Create a new session replay and return an initial upload URL.

    [bold]Examples:[/]

        hikaflow session create --project-id <uuid>

        hikaflow session create --project-id <uuid> --run-id <run-id>
    """
    import click

    from autodebug.cli import session_create as legacy_session_create

    click_ctx = click.Context(legacy_session_create)
    click_ctx.params = {
        "project_id": project_id,
        "run_id": run_id,
        "trace_id": trace_id,
        "metadata": metadata,
    }
    try:
        legacy_session_create.invoke(click_ctx)
    except SystemExit as e:
        if e.code:
            raise typer.Exit(e.code)


@session_app.command("chunk")
@handle_cli_errors
def session_chunk(
    ctx: typer.Context,
    session_id: str = typer.Option(..., "--session-id", help="Session UUID."),
    stream: str = typer.Option(..., "--stream", help="Stream type: dom, console, network, errors, perf."),
    chunk_id: str = typer.Option(..., "--chunk-id", help="Chunk ID to register."),
    file_path: Optional[Path] = typer.Option(None, "--file", help="Optional file to upload.", exists=True),
):
    """Register a session chunk and optionally upload a file.

    [bold]Examples:[/]

        hikaflow session chunk --session-id <id> --stream dom --chunk-id chunk-1
    """
    import click

    from autodebug.cli import session_chunk as legacy_session_chunk

    click_ctx = click.Context(legacy_session_chunk)
    click_ctx.params = {
        "session_id": session_id,
        "stream": stream,
        "chunk_id": chunk_id,
        "file_path": str(file_path) if file_path else None,
    }
    try:
        legacy_session_chunk.invoke(click_ctx)
    except SystemExit as e:
        if e.code:
            raise typer.Exit(e.code)


@session_app.command("finalize")
@handle_cli_errors
def session_finalize(
    ctx: typer.Context,
    session_id: str = typer.Option(..., "--session-id", help="Session UUID."),
):
    """Finalize a session replay and enqueue processing.

    [bold]Examples:[/]

        hikaflow session finalize --session-id <id>
    """
    import click

    from autodebug.cli import session_finalize as legacy_session_finalize

    click_ctx = click.Context(legacy_session_finalize)
    click_ctx.params = {"session_id": session_id}
    try:
        legacy_session_finalize.invoke(click_ctx)
    except SystemExit as e:
        if e.code:
            raise typer.Exit(e.code)


@session_app.command("upload")
@handle_cli_errors
def session_upload(
    ctx: typer.Context,
    project_id: str = typer.Option(..., "--project-id", help="Project UUID."),
    file_path: Path = typer.Option(..., "--file", help="JSONL or .jsonl.zst file.", exists=True),
    stream: str = typer.Option("dom", "--stream", help="Stream type: dom, console, network, errors, perf."),
    run_id: Optional[str] = typer.Option(None, "--run-id", help="Optional run ID."),
    trace_id: Optional[str] = typer.Option(None, "--trace-id", help="Optional trace ID."),
    metadata: Optional[str] = typer.Option(None, "--metadata", help="JSON metadata string."),
):
    """Create a session, upload a chunk, and finalize.

    [bold]Examples:[/]

        hikaflow session upload --project-id <uuid> --file recording.jsonl.zst
    """
    import click

    from autodebug.cli import session_upload as legacy_session_upload

    click_ctx = click.Context(legacy_session_upload)
    click_ctx.params = {
        "project_id": project_id,
        "file_path": str(file_path),
        "stream": stream,
        "run_id": run_id,
        "trace_id": trace_id,
        "metadata": metadata,
    }
    try:
        legacy_session_upload.invoke(click_ctx)
    except SystemExit as e:
        if e.code:
            raise typer.Exit(e.code)


_RRWEB_INIT_SCRIPT = """
(function() {
    if (window.__hikaflow_rrweb_initialized) return;
    var s = document.createElement('script');
    s.src = 'https://cdn.jsdelivr.net/npm/rrweb@2.0.0-alpha.13/dist/rrweb-all.umd.cjs';
    s.onload = function() {
        window.__hikaflow_events = [];
        rrweb.record({
            emit: function(event) {
                window.__hikaflow_events.push(event);
            }
        });
        window.__hikaflow_rrweb_initialized = true;
    };
    document.head.appendChild(s);
})();
"""


def _session_drain_events(page):
    """Drain accumulated rrweb events from the page."""
    return page.evaluate("""() => {
        var events = window.__hikaflow_events || [];
        window.__hikaflow_events = [];
        return events;
    }""")


def _session_upload_chunk(api_base, headers, output, session_id, stream, events, chunk_num):
    """Serialize events to JSONL, compress with zstandard, and upload."""
    if not events:
        return
    import json as _json
    import uuid
    import requests
    lines = []
    for event in events:
        lines.append(_json.dumps(event))
    jsonl_data = "\n".join(lines).encode("utf-8")
    try:
        import zstandard as zstd
        cctx = zstd.ZstdCompressor()
        compressed = cctx.compress(jsonl_data)
    except ImportError:
        compressed = jsonl_data
    chunk_id = str(uuid.uuid4())
    chunk_resp = requests.post(
        f"{api_base}/v1/sessions/{session_id}/chunks",
        json={"stream": stream, "chunk_id": chunk_id},
        headers=headers,
        timeout=30,
    )
    _ensure_http_ok(chunk_resp, operation="Create upload chunk", output=output,
                    detail="Retry session record. If this persists, report request_id to backend.")
    upload_url = chunk_resp.json()["upload_url"]
    upload_resp = requests.put(upload_url, data=compressed,
                               headers={"Content-Type": "application/octet-stream"}, timeout=60)
    if not _ensure_http_ok(upload_resp, operation="Upload session chunk", output=output,
                           detail="Signed upload URL may be expired or unavailable.", exit_on_error=False):
        return
    output.info(f"  Chunk {chunk_num} uploaded ({len(events)} events, {len(compressed)} bytes)")


@session_app.command("record")
@handle_cli_errors
def session_record(
    ctx: typer.Context,
    url: str = typer.Option(..., "--url", help="URL to record."),
    project_id: str = typer.Option(..., "--project-id", help="Project UUID."),
    duration: int = typer.Option(60, "--duration", help="Recording duration in seconds."),
    headed: bool = typer.Option(False, "--headed/--headless", help="Show browser UI."),
    chunk_interval: int = typer.Option(10, "--chunk-interval", help="Seconds between chunk uploads."),
    capture_console: bool = typer.Option(True, "--console/--no-console", help="Capture console output."),
    network: bool = typer.Option(True, "--network/--no-network", help="Capture network requests."),
):
    """Record a browser session with rrweb and upload to Hikaflow.

    Launches Chromium via Playwright, injects rrweb for DOM recording,
    and periodically uploads compressed chunks to the Hikaflow API.

    [bold]Examples:[/]

        hikaflow session record --url https://example.com --project-id <uuid> --duration 30 --headed
    """
    output = ctx.obj.get("output", OutputFormatter())

    # Validate URL format
    from urllib.parse import urlparse
    parsed = urlparse(url)
    if parsed.scheme not in ("http", "https") or not parsed.netloc:
        output.error(f"Invalid URL: {url}\nURL must start with http:// or https://")
        raise typer.Exit(1)

    try:
        from playwright.sync_api import sync_playwright
    except ImportError:
        output.error(
            "Playwright not installed. Run:\n"
            "  pip install 'autodebug[recording]' && playwright install chromium"
        )
        raise typer.Exit(1)

    import io
    import uuid

    import requests

    from autodebug.config import get_api_base, get_valid_token
    token = get_valid_token()
    if not token:
        output.error("Not authenticated.", "Run: hikaflow login")
        raise typer.Exit(1)

    api_base = get_api_base().rstrip("/")
    headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}

    # Create session
    create_resp = requests.post(
        f"{api_base}/v1/sessions",
        json={"project_id": project_id, "metadata": {"source": "cli-record", "url": url}},
        headers=headers,
        timeout=30,
    )
    _ensure_http_ok(
        create_resp,
        operation="Create session",
        output=output,
        detail="Verify project ID and API permissions.",
    )
    session_data = create_resp.json()
    session_id = session_data["session_id"]
    output.info(f"Session created: {session_id}")

    RRWEB_INIT_SCRIPT = _RRWEB_INIT_SCRIPT

    output.info(f"Recording {url} for {duration}s (chunks every {chunk_interval}s)")

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=not headed)
        page = browser.new_page()

        # Collect console and network events
        console_events = []
        network_events = []

        if capture_console:
            def _on_console(msg):
                console_events.append({
                    "type": 6,  # rrweb CustomEvent type
                    "timestamp": int(time.time() * 1000),
                    "data": {
                        "tag": "console",
                        "payload": {"level": msg.type, "text": msg.text},
                    },
                })
            page.on("console", _on_console)

        if network:
            def _on_request(req):
                network_events.append({
                    "type": 6,
                    "timestamp": int(time.time() * 1000),
                    "data": {
                        "tag": "network-request",
                        "payload": {"method": req.method, "url": req.url},
                    },
                })
            def _on_response(resp):
                network_events.append({
                    "type": 6,
                    "timestamp": int(time.time() * 1000),
                    "data": {
                        "tag": "network-response",
                        "payload": {"url": resp.url, "status": resp.status},
                    },
                })
            page.on("request", _on_request)
            page.on("response", _on_response)

        # Re-inject rrweb on navigation
        page.on("load", lambda _: page.evaluate(RRWEB_INIT_SCRIPT))

        page.goto(url, wait_until="load")
        page.evaluate(RRWEB_INIT_SCRIPT)
        output.info("rrweb recording started")

        # Periodic chunk upload loop
        chunk_num = 0
        start = time.time()
        while time.time() - start < duration:
            page.wait_for_timeout(min(chunk_interval, duration - (time.time() - start)) * 1000)
            chunk_num += 1

            dom_events = _session_drain_events(page)
            _session_upload_chunk(api_base, headers, output, session_id, "dom", dom_events, chunk_num)

            if capture_console and console_events:
                _session_upload_chunk(api_base, headers, output, session_id, "console", list(console_events), chunk_num)
                console_events.clear()

            if network and network_events:
                _session_upload_chunk(api_base, headers, output, session_id, "network", list(network_events), chunk_num)
                network_events.clear()

        # Final drain
        remaining_dom = _session_drain_events(page)
        if remaining_dom or console_events or network_events:
            chunk_num += 1
            if remaining_dom:
                _session_upload_chunk(api_base, headers, output, session_id, "dom", remaining_dom, chunk_num)
            if console_events:
                _session_upload_chunk(api_base, headers, output, session_id, "console", list(console_events), chunk_num)
            if network_events:
                _session_upload_chunk(api_base, headers, output, session_id, "network", list(network_events), chunk_num)

        browser.close()

    # Finalize session
    finalize_resp = requests.post(
        f"{api_base}/v1/sessions/{session_id}/finalize",
        headers=headers,
        timeout=30,
    )
    _ensure_http_ok(
        finalize_resp,
        operation="Finalize session",
        output=output,
        detail="Session was recorded but finalization failed. Retry finalize shortly.",
    )
    output.success(f"Session recorded and finalized: {session_id}")
    output.info(f"View at: {api_base.replace('/api', '').replace('-api', '')}/sessions/{session_id}")


@session_app.command("open")
@handle_cli_errors
def session_open(
    ctx: typer.Context,
    session_id: str = typer.Argument(..., help="Session UUID.", min=1),
    local_dir: Optional[Path] = typer.Option(None, "--local-dir", help="Open a local replay from a directory.", exists=True),
    port: int = typer.Option(8000, "--port", help="Local server port for replay."),
):
    """Open a session replay in the web dashboard or locally.

    [bold]Examples:[/]

        hikaflow session open <session-id>

        hikaflow session open <session-id> --local-dir ./replay-data --port 8080
    """
    import click

    from autodebug.cli import session_open as legacy_session_open

    click_ctx = click.Context(legacy_session_open)
    click_ctx.params = {
        "session_id": session_id,
        "local_dir": str(local_dir) if local_dir else None,
        "port": port,
    }
    try:
        legacy_session_open.invoke(click_ctx)
    except SystemExit as e:
        if e.code:
            raise typer.Exit(e.code)


@session_app.command("share")
@handle_cli_errors
def session_share(
    ctx: typer.Context,
    session_id: str = typer.Argument(..., help="Session UUID."),
    expires: int = typer.Option(7, "--expires", help="Days until expiration (default: 7)."),
    open_browser: bool = typer.Option(False, "--open", help="Open share link in browser."),
):
    """Create a shareable link for a session replay.

    [bold]Examples:[/]

        hikaflow session share <session-id>

        hikaflow session share <session-id> --expires 30 --open
    """
    import click

    from autodebug.cli import session_share as legacy_session_share

    click_ctx = click.Context(legacy_session_share)
    click_ctx.params = {
        "session_id": session_id,
        "expires": expires,
        "open_browser": open_browser,
    }
    try:
        legacy_session_share.invoke(click_ctx)
    except SystemExit as e:
        if e.code:
            raise typer.Exit(e.code)


@session_app.command("download")
@handle_cli_errors
def session_download(
    ctx: typer.Context,
    session_id: str = typer.Argument(..., help="Session UUID."),
    output_path: Path = typer.Option(..., "--output", "-o", help="Output directory."),
):
    """Download session chunks to a local directory.

    [bold]Examples:[/]

        hikaflow session download <session-id> -o ./session-data
    """
    import click

    from autodebug.cli import session_download as legacy_session_download

    click_ctx = click.Context(legacy_session_download)
    click_ctx.params = {"session_id": session_id, "output": str(output_path)}
    try:
        legacy_session_download.invoke(click_ctx)
    except SystemExit as e:
        if e.code:
            raise typer.Exit(e.code)


# =============================================================================
# Issues Command Group (ported from v1)
# =============================================================================

issues_app = typer.Typer(
    name="issues",
    help=ADVANCED_HELP_PREFIX + "Issue grouping commands.",
    hidden=True,
    context_settings={"help_option_names": ["--help", "-h"]},
)
app.add_typer(issues_app)


@issues_app.command("list")
@handle_cli_errors
def issues_list(
    ctx: typer.Context,
    limit: int = typer.Option(50, "--limit", "-n", help="Max issue groups to show.", min=1, max=500),
    status: Optional[str] = typer.Option(None, "--status", "-s", help="Filter by status (open, resolved)."),
):
    """List issue groups.

    [bold]Examples:[/]

        hikaflow issues list

        hikaflow issues list --limit 10

        hikaflow issues list --status open
    """
    import httpx

    from autodebug.auth import get_credentials, is_authenticated, load_config
    from rich.table import Table

    output = ctx.obj.get("output", OutputFormatter())
    if not is_authenticated():
        output.error("Not authenticated.", "Run: hikaflow login")
        raise typer.Exit(1)

    config = load_config()
    creds = get_credentials()

    params: dict = {}
    if status:
        params["status"] = status
    resp = httpx.get(
        f"{config.get('api_base', 'https://get.hikaflow.com')}/v1/issue-groups",
        headers={"Authorization": f"Bearer {creds.get('access_token') or creds.get('token', '')}"},
        params=params,
        timeout=30.0,
    )
    if resp.status_code != 200:
        output.error(
            _format_http_response_error(resp, operation="List issue groups"),
            "Try again after running: hikaflow status",
        )
        raise typer.Exit(1)
    data = resp.json()

    if output.json_mode:
        output.data(data)
        output.flush_json()
        return

    groups = data.get("groups", data) if isinstance(data, dict) else data
    if not groups:
        output.info("No issue groups found.")
        return

    display = groups[:limit]
    title = f"Issue Groups ({len(display)}/{len(groups)})" if len(groups) > limit else f"Issue Groups ({len(groups)})"
    output.table(
        title=title,
        columns=[
            {"name": "ID", "style": "cyan"},
            {"name": "Title"},
            {"name": "Members", "justify": "right"},
            {"name": "Created"},
        ],
        rows=[
            [
                str(g.get("id", ""))[:8],
                g.get("title", g.get("exception_type", "Unknown")),
                str(g.get("member_count", g.get("error_count", "?"))),
                str(g.get("created_at", ""))[:10],
            ]
            for g in display
        ],
    )


@issues_app.command("group")
@handle_cli_errors
def issues_group(
    ctx: typer.Context,
    error_id: str = typer.Argument(..., help="Error ID to group."),
):
    """Create a group for an error and attach similar errors.

    [bold]Examples:[/]

        hikaflow issues group <error-id>
    """
    import click

    from autodebug.cli import issues_group_error as legacy_issues_group

    click_ctx = click.Context(legacy_issues_group)
    click_ctx.params = {"error_id": error_id}
    try:
        legacy_issues_group.invoke(click_ctx)
    except SystemExit as e:
        if e.code:
            raise typer.Exit(e.code)


@issues_app.command("delete")
@handle_cli_errors
def issues_delete(
    ctx: typer.Context,
    group_id: str = typer.Argument(..., help="Group ID to delete."),
):
    """Delete an issue group.

    [bold]Examples:[/]

        hikaflow issues delete <group-id>
    """
    import click

    from autodebug.cli import issues_delete_group as legacy_issues_delete

    click_ctx = click.Context(legacy_issues_delete)
    click_ctx.params = {"group_id": group_id}
    try:
        legacy_issues_delete.invoke(click_ctx)
    except SystemExit as e:
        if e.code:
            raise typer.Exit(e.code)


@issues_app.command("merge")
@handle_cli_errors
def issues_merge(
    ctx: typer.Context,
    group_id: str = typer.Argument(..., help="Source group ID."),
    target_group_id: str = typer.Argument(..., help="Target group ID to merge into."),
):
    """Merge one issue group into another.

    [bold]Examples:[/]

        hikaflow issues merge <group-id> <target-group-id>
    """
    import click

    from autodebug.cli import issues_merge_group as legacy_issues_merge

    click_ctx = click.Context(legacy_issues_merge)
    click_ctx.params = {
        "group_id": group_id,
        "target_group_id": target_group_id,
    }
    try:
        legacy_issues_merge.invoke(click_ctx)
    except SystemExit as e:
        if e.code:
            raise typer.Exit(e.code)


# =============================================================================
# Shared Slash Command Dispatcher
# =============================================================================


def _list_recent_sessions(limit: int = 5) -> list:
    """List recent debug sessions from ~/.hikaflow/debug/sessions/."""
    sessions_dir = Path.home() / ".hikaflow" / "debug" / "sessions"
    if not sessions_dir.is_dir():
        return []
    results = []
    for day_dir in sorted(sessions_dir.iterdir(), reverse=True):
        if not day_dir.is_dir():
            continue
        for f in sorted(day_dir.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True):
            try:
                import json as _json
                first_line = f.read_text(encoding="utf-8").split("\n", 1)[0]
                info = _json.loads(first_line)
                ctx = info.get("context", {})
                results.append({
                    "path": str(f),
                    "session_id": f.stem,
                    "date": day_dir.name,
                    "project": ctx.get("project", "unknown"),
                    "model": ctx.get("orchestrator", ""),
                })
            except Exception:
                results.append({"path": str(f), "session_id": f.stem, "date": day_dir.name, "project": "unknown", "model": ""})
            if len(results) >= limit:
                return results
    return results


def _load_session_messages(session_path: str, max_turns: int = 20) -> list[dict]:
    """Load user/assistant exchanges from a session JSONL for resume.

    Returns list of {role, content} dicts reconstructed from debug events.
    """
    import json as _json
    messages = []
    try:
        with open(session_path, encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    record = _json.loads(line)
                except _json.JSONDecodeError:
                    continue
                event = record.get("event", "")
                if event == "user.input":
                    preview = record.get("input_preview", "")
                    if preview and not preview.startswith("/"):
                        messages.append({"role": "user", "content": preview})
                elif event == "llm.response":
                    resp = record.get("response_path", "")
                    # Try to read the response artifact
                    if resp:
                        try:
                            content = Path(resp).read_text(encoding="utf-8")[:2000]
                            messages.append({"role": "assistant", "content": content})
                        except Exception:
                            pass
    except Exception:
        return []
    return messages[-max_turns:]


def _restore_session_context(history: list, session_messages: list[dict]) -> str:
    """Restore session context by injecting a summary into the conversation.

    Returns a summary string of the restored session.
    """
    if not session_messages:
        return "No messages found in the previous session."
    summary_parts = []
    for msg in session_messages[-10:]:
        role = msg["role"]
        content = msg["content"][:300]
        summary_parts.append(f"**{role}:** {content}")
    return f"Resumed session with {len(session_messages)} turns:\n\n" + "\n\n".join(summary_parts)


def _is_exit_command(text: str) -> bool:
    """Check if text is an exit command, with typo tolerance.

    Matches exact exit/quit variants and fuzzy matches within edit distance 1
    to catch common typos like /exiy, /exir, /exut, /quiy, etc.
    """
    cmd = text.strip().lower()

    # Exact matches (fast path)
    _EXIT_EXACT = {"exit", "quit", "/exit", "/quit", "/q", "q"}
    if cmd in _EXIT_EXACT:
        return True

    # Fuzzy match: strip leading slash, check edit distance ≤ 1 against "exit" and "quit"
    # Require the first 2 chars of the bare command to match the target prefix
    # to avoid false positives like "/edit" matching "exit".
    bare = cmd.lstrip("/")
    if not bare or len(bare) > 6:
        return False
    for target in ("exit", "quit"):
        if bare[:2] == target[:2] and _edit_distance_one(bare, target):
            return True
    return False


def _edit_distance_one(a: str, b: str) -> bool:
    """Return True if strings a and b have Levenshtein distance exactly 1."""
    la, lb = len(a), len(b)
    if abs(la - lb) > 1:
        return False
    if la == lb:
        # Substitution: exactly one position differs
        return sum(ca != cb for ca, cb in zip(a, b)) == 1
    # Insertion/deletion: shorter string is missing exactly one char from longer
    short, long = (a, b) if la < lb else (b, a)
    i = j = diffs = 0
    while i < len(short) and j < len(long):
        if short[i] != long[j]:
            diffs += 1
            if diffs > 1:
                return False
            j += 1
        else:
            i += 1
            j += 1
    return True


class _SlashResult:
    """Result from _handle_slash_command."""
    HANDLED = "handled"      # Command processed, continue REPL
    EXIT = "exit"            # User wants to exit
    UNRECOGNIZED = "unrecognized"  # Not a slash command, send to LLM
    MODEL_CHANGED = "model_changed"  # Model was switched, agent needs recreation


def _handle_resume_slash(cmd, history):
    """Handle /resume slash command — list or restore sessions."""
    parts = cmd.split(maxsplit=1)
    if len(parts) > 1:
        target_id = parts[1].strip()
        sessions = _list_recent_sessions(limit=20)
        match = next((s for s in sessions if s["session_id"] == target_id), None)
        if match:
            msgs = _load_session_messages(match["path"])
            summary = _restore_session_context(history, msgs)
            _cprint(f"[green]Resumed session {target_id}[/]")
            _cprint(f"[dim]{summary[:500]}[/]")
        else:
            _cprint(f"[red]Session '{target_id}' not found.[/]")
    else:
        sessions = _list_recent_sessions(limit=5)
        if sessions:
            _cprint("[bold]Recent sessions:[/]")
            for s in sessions:
                _cprint(f"  [cyan]{s['session_id']}[/]  {s['date']}  [dim]{s.get('project', '')}  {s.get('model', '')}[/]")
            _cprint("\n[dim]Resume with: /resume <session_id>[/]")
        else:
            _cprint("[dim]No recent sessions found.[/]")


def _show_diagnostics(deps, history):
    """Display session diagnostics: tokens, skill state, tool trace."""
    _cprint("[bold]Session Diagnostics[/]\n")
    _tc = getattr(deps, "_session_token_counter", None)
    if _tc:
        _cprint(f"  [bold]Tokens:[/] in:{_tc.get('request_tokens', 0):,} out:{_tc.get('response_tokens', 0):,} total:{_tc.get('total_tokens', 0):,}")
    _cprint(f"  [bold]Active skill:[/] {deps._active_skill_name or 'none'} (turns: {deps._skill_turns_used}/{deps._skill_budget})")
    _cprint(f"  [bold]Context pressure:[/] {'high' if deps._context_pressure else 'normal'}")
    _cprint(f"  [bold]Permission mode:[/] {getattr(deps, 'permission_mode', 'n/a')}")
    _cprint(f"  [bold]History:[/] {len(history)} messages")
    tool_calls = []
    for msg in history:
        if hasattr(msg, "parts"):
            for part in msg.parts:
                if hasattr(part, "tool_name"):
                    tool_calls.append(part.tool_name)
    if tool_calls:
        _cprint(f"\n  [bold]Tool call trace[/] ({len(tool_calls)} calls):")
        from collections import Counter
        for name, count in Counter(tool_calls).most_common(10):
            _cprint(f"    {name}: {count}x")
    else:
        _cprint("\n  [dim]No tool calls in history.[/]")
    _det = getattr(deps, "_loop_detector", None)
    if _det:
        _cprint(f"  [bold]Loop detector:[/] {len(getattr(_det, '_history', []))} tracked calls")


def _show_mcp_migration_status():
    """Display per-tool MCP migration flag status."""
    try:
        from mcp_server.adapters import ADAPTER_SPECS
    except Exception as exc:
        _cprint(f"[red]MCP adapter registry unavailable: {exc}[/]")
        return

    _cprint("[bold]MCP Migration Flags[/]")
    for tool_name, spec in sorted(ADAPTER_SPECS.items()):
        raw = os.getenv(spec.feature_flag)
        enabled = True if raw is None else raw.strip().lower() in {"1", "true", "yes", "on"}
        source = "default:on" if raw is None else f"env:{raw}"
        state = "[green]ON[/]" if enabled else "[yellow]OFF[/]"
        _cprint(f"  {tool_name:<28} {state}  [dim]({spec.feature_flag}, {source})[/]")


async def _handle_slash_command(
    user_input: str,
    *,
    deps,
    history: list,
    model: str,
    agent,
    api_base: str,
    token: str,
    verbose_mode: bool,
    pending_images: list,
) -> tuple[str, dict]:
    """Handle slash commands shared by both REPL loops.

    Returns:
        (result, state_updates) where result is a _SlashResult constant
        and state_updates is a dict of REPL state changes to apply.
        Possible keys: "verbose_mode", "model", "agent".
    """
    cmd = user_input.strip()
    cmd_lower = cmd.lower()
    updates: dict = {}

    # --- Exit (with typo tolerance) ---
    if _is_exit_command(cmd_lower):
        _cprint("[dim]Goodbye![/]")
        return _SlashResult.EXIT, updates

    # --- Help ---
    if cmd_lower == "/help":
        _show_help()
        return _SlashResult.HANDLED, updates

    # --- Nested hikaflow commands ---
    if _handle_nested_hikaflow_command(cmd):
        return _SlashResult.HANDLED, updates

    # --- Project init ---
    if _handle_repl_project_init(cmd):
        return _SlashResult.HANDLED, updates

    # --- Verbose toggle ---
    if cmd_lower in ("/verbose", "/v"):
        new_verbose = not verbose_mode
        label = "[green]on[/] — showing tool calls, results, and streaming" if new_verbose else "[yellow]off[/] — showing final response only"
        _cprint(f"[bold]Verbose mode:[/] {label}")
        updates["verbose_mode"] = new_verbose
        return _SlashResult.HANDLED, updates

    # --- Errors ---
    if cmd_lower == "/errors":
        await _list_errors(10, None, None)
        return _SlashResult.HANDLED, updates

    # --- Status ---
    if cmd_lower == "/status":
        await _show_status(active_model=model)
        return _SlashResult.HANDLED, updates

    # --- Tokens / Cost ---
    if cmd_lower == "/tokens" or cmd_lower == "/cost":
        # Show cumulative session token usage from deps
        _tc = getattr(deps, "_session_token_counter", None)
        if _tc:
            _cprint("[bold]Session Token Usage[/]")
            _cprint(f"  Request tokens:  {_tc.get('request_tokens', 0):,}")
            _cprint(f"  Response tokens: {_tc.get('response_tokens', 0):,}")
            _cprint(f"  Total tokens:    {_tc.get('total_tokens', 0):,}")
            # Rough cost estimate (Claude Sonnet pricing: $3/1M input, $15/1M output)
            est_cost = (_tc.get("request_tokens", 0) * 3 + _tc.get("response_tokens", 0) * 15) / 1_000_000
            _cprint(f"  Estimated cost:  ${est_cost:.4f}")
        else:
            _cprint("[dim]No token data available yet.[/]")
        return _SlashResult.HANDLED, updates

    # --- Diagnostics ---
    if cmd_lower == "/diagnostics":
        _show_diagnostics(deps, history)
        return _SlashResult.HANDLED, updates

    # --- MCP migration status ---
    if cmd_lower == "/mcp":
        _show_mcp_migration_status()
        return _SlashResult.HANDLED, updates

    # --- Clear ---
    if cmd_lower == "/clear":
        history.clear()
        with suppress(Exception):
            from autodebug.agent.tools import reset_scan_state
            reset_scan_state()
        with suppress(Exception):
            from autodebug.agent.context import cleanup_offloaded_files
            cleanup_offloaded_files(deps.project_path)
        deps._context_pressure = False
        deps._skill_turns_used = 0
        deps._skill_budget = 0
        deps._active_skill_name = None
        if deps._memory_blocks is not None:
            deps._memory_blocks.rethink("session_state", "")
        # Reset loop detector
        if getattr(deps, "_loop_detector", None):
            deps._loop_detector.reset()
        # V-16: Explicit confirmation of what was cleared
        _cleared = ["conversation history", "scan cache"]
        if deps._memory_blocks is not None:
            _cleared.append("session_state")
        _cprint(f"[dim]Cleared: {', '.join(_cleared)}.[/]")
        return _SlashResult.HANDLED, updates

    # --- Model switch ---
    if cmd_lower == "/model" or cmd_lower.startswith("/model "):
        _model_parts = cmd.split(None, 1)
        if len(_model_parts) > 1:
            _new_model = _model_parts[1].strip()
            try:
                new_agent = create_agent(_new_model, api_base=api_base, token=token)
                updates["model"] = _new_model
                updates["agent"] = new_agent
                os.environ["HIKAFLOW_MODEL"] = _new_model
                with suppress(Exception):
                    from autodebug.agent.tools import reset_scan_state
                    reset_scan_state()
                _cprint(f"[green]Switched to:[/] {_new_model} [dim](this session only)[/]\n")
            except Exception as e:
                _cprint(f"[red]Failed to switch model: {e}[/]")
        else:
            _selected = await _interactive_model_picker(model)
            if _selected and _selected != model:
                try:
                    new_agent = create_agent(_selected, api_base=api_base, token=token)
                    updates["model"] = _selected
                    updates["agent"] = new_agent
                    os.environ["HIKAFLOW_MODEL"] = _selected
                    with suppress(Exception):
                        from autodebug.agent.tools import reset_scan_state
                        reset_scan_state()
                    _cprint(f"[green]Switched to:[/] {_selected} [dim](this session only)[/]\n")
                except Exception as e:
                    _cprint(f"[red]Failed to switch model: {e}[/]")
            elif _selected == model:
                _cprint(f"[dim]Already using {model}[/]")
        return _SlashResult.HANDLED, updates

    # --- Compact ---
    if cmd_lower == "/compact":
        if not _auto_compact_history(history):
            result = _force_compact_history(history)
            if result:
                _cprint(f"[dim]{result}[/]")
            else:
                _cprint("[dim]Conversation is already compact.[/]")
        return _SlashResult.HANDLED, updates

    # --- Memory ---
    if cmd_lower == "/memory":
        if deps._memory_blocks is not None:
            from autodebug.agent.memory import BLOCK_LIMITS
            _cprint(f"[bold]Working Context[/] (session: {deps._memory_blocks.session_id[:8]}...)\n")
            for bname in ["project_context", "session_state", "user_preferences", "active_findings"]:
                content = deps._memory_blocks.get(bname)
                limit = BLOCK_LIMITS[bname]
                label = f"{bname} ({len(content)}/{limit} chars)"
                if content.strip():
                    _cprint(f"[bold]{label}[/]")
                    _cprint(content[:500])
                else:
                    _cprint(f"[dim]{label}: [empty][/]")
                _cprint()
        else:
            memory_path = Path.cwd() / ".hikaflow" / "memory.md"
            try:
                if memory_path.is_file():
                    content = memory_path.read_text(encoding="utf-8").strip()
                    _cprint(f"[bold]Project Memory[/] ({memory_path}):\n")
                    _cprint(content[:2000] if content else "[dim]Empty[/]")
                else:
                    _cprint(f"[dim]No project memory found.[/]")
            except (OSError, UnicodeDecodeError) as e:
                _cprint(f"[red]Error reading memory file: {e}[/]")
        return _SlashResult.HANDLED, updates

    # --- Image ---
    if cmd_lower.startswith("/image"):
        _handle_image_command(cmd, pending_images)
        return _SlashResult.HANDLED, updates

    # --- Resume session ---
    if cmd_lower == "/resume" or cmd_lower.startswith("/resume "):
        _handle_resume_slash(cmd, history)
        return _SlashResult.HANDLED, updates

    # Not a recognized slash command
    return _SlashResult.UNRECOGNIZED, updates


# =============================================================================
# Implementation Functions (Async)
# =============================================================================


def _persist_repl_memory(deps):
    """Persist memory blocks, run reflections, and clean up offloaded files."""
    import logging as _logging
    _mem_logger = _logging.getLogger("hikaflow.memory")

    if deps._memory_blocks is not None:
        try:
            from autodebug.agent.memory import persist_blocks_to_disk
            persist_blocks_to_disk(deps._memory_blocks, deps.project_path)
        except Exception as e:
            _mem_logger.warning("Failed to persist memory blocks: %s", e)
        try:
            from autodebug.agent.reflections import auto_reflect_from_session
            auto_reflect_from_session(deps.project_path, deps._memory_blocks)
        except Exception as e:
            _mem_logger.warning("Failed to auto-reflect from session: %s", e)
        try:
            from autodebug.agent.skill_library import auto_create_skill_from_session
            auto_create_skill_from_session(deps.project_path, deps._memory_blocks)
        except Exception as e:
            _mem_logger.warning("Failed to auto-create skill from session: %s", e)
        try:
            from autodebug.agent.context import cleanup_offloaded_files
            cleanup_offloaded_files(deps.project_path)
        except Exception as e:
            _mem_logger.warning("Failed to cleanup offloaded files: %s", e)


async def _run_claude_sdk_repl(
    *,
    agent_options,
    deps,
    initial_prompt: Optional[str] = None,
    headless: bool = False,
    one_shot: bool = False,
    prompt_str: str = "> ",
    model: str = "",
    session_start: float = 0,
    session_timeout_s: Optional[int] = None,
    log_event=None,
    finalize_debug_log=None,
):
    """Run the REPL using Claude Agent SDK.

    Uses ClaudeSDKClient for multi-turn conversation with streaming.
    Custom Hikaflow tools are provided via an in-process MCP server.
    """
    import time as _time
    from autodebug.agent.core import build_system_prompt
    from autodebug.agent.sdk_bridge import create_hikaflow_mcp_server, get_tool_names, populate_registry_from_agent
    from contextlib import suppress

    turns = 0
    errors = 0
    interrupts = 0

    # Populate MCP tool registry from pydantic-ai agent tools (if available)
    # This bridges existing @agent.tool definitions to MCP format
    try:
        from autodebug.agent.tools import register_hikaflow_tools
        # Create a temporary pydantic-ai agent just to register tools and extract metadata
        class _FakeAgent:
            """Minimal fake agent to collect tool registrations."""
            def __init__(self):
                self._function_tools = {}
            def tool(self, func):
                import inspect
                name = func.__name__
                self._function_tools[name] = type("Tool", (), {"function": func})()
                return func

        _fake = _FakeAgent()
        register_hikaflow_tools(_fake)
        populate_registry_from_agent(_fake)
        # Refresh allowed tools after registry population so profile-filtered MCP
        # tools are actually callable in Claude SDK.
        existing_allowed = list(getattr(agent_options, "allowed_tools", []) or [])
        existing_set = set(existing_allowed)
        for tool_name in get_tool_names():
            mcp_name = f"mcp__hikaflow__{tool_name}"
            if mcp_name not in existing_set:
                existing_allowed.append(mcp_name)
                existing_set.add(mcp_name)
        agent_options.allowed_tools = existing_allowed
    except Exception as e:
        _cprint(f"[dim]Warning: Could not register tools: {e}[/]")

    # Create in-process MCP server with all tools
    try:
        mcp_server = create_hikaflow_mcp_server(deps)
        agent_options.mcp_servers = {"hikaflow": mcp_server}
    except Exception as e:
        _cprint(f"[dim]Warning: MCP server creation failed: {e}[/]")

    try:
        from claude_agent_sdk import ClaudeSDKClient, AssistantMessage, ResultMessage, SystemMessage
    except ImportError:
        _cprint("[red]claude-agent-sdk not installed.[/]")
        _cprint("[dim]Install with: pip install claude-agent-sdk[/]")
        return False

    session = None if one_shot else _create_prompt_session(prompt_str)

    try:
        async with ClaudeSDKClient(options=agent_options) as client:
            _session_id = None

            async def _process_response():
                """Consume and display messages from the SDK.

                Claude Code handles loop detection, budget enforcement, and context
                compaction internally. We just display the output.
                """
                nonlocal _session_id, turns
                tool_count = 0
                from claude_agent_sdk import TextBlock, ToolUseBlock, ToolResultBlock, ThinkingBlock
                from claude_agent_sdk.types import StreamEvent
                result_msg = None
                _streamed_text = False
                _assistant_text_parts: list[str] = []

                async for message in client.receive_response():
                    if isinstance(message, SystemMessage):
                        if message.subtype == "init":
                            _session_id = message.data.get("session_id")
                        continue

                    # Real-time streaming deltas
                    if isinstance(message, StreamEvent):
                        evt = message.event or {}
                        if evt.get("type") == "content_block_delta":
                            delta = evt.get("delta", {})
                            if delta.get("type") == "text_delta":
                                text = delta.get("text", "")
                                if text:
                                    _cprint(text, end="", highlight=False)
                                    _streamed_text = True
                                    _assistant_text_parts.append(text)
                        continue

                    # Complete assistant message
                    if isinstance(message, AssistantMessage):
                        if message.error:
                            _cprint(f"\n[red]Model error: {message.error}[/]")
                            continue

                        for block in message.content:
                            if isinstance(block, TextBlock):
                                if not _streamed_text:
                                    _cprint(block.text, end="", highlight=False)
                                    _assistant_text_parts.append(block.text or "")
                                _streamed_text = False

                            elif isinstance(block, ToolUseBlock):
                                tool_count += 1
                                deps._current_tool_seq = int(getattr(deps, "_current_tool_seq", 0) or 0) + 1
                                _tool_seq = deps._current_tool_seq
                                _turn_id = getattr(deps, "_current_turn_id", "")
                                args_str = ""
                                if block.input and isinstance(block.input, dict):
                                    pairs = []
                                    for k, v in block.input.items():
                                        vs = str(v)
                                        if len(vs) > 200:
                                            vs = vs[:197] + "..."
                                        pairs.append(f"{k}={vs}")
                                    args_str = ", ".join(pairs)
                                _cprint(f"\n  [yellow]>[/] [bold]{block.name}[/]({args_str})")
                                with suppress(Exception):
                                    if log_event:
                                        log_event(
                                            "tool.call",
                                            turn_id=_turn_id,
                                            tool_call_seq=_tool_seq,
                                            tool_name=block.name,
                                            args_preview=args_str[:500],
                                            duration_ms=0,
                                        )

                            elif isinstance(block, ToolResultBlock):
                                content_str = str(block.content) if block.content else ""
                                _turn_id = getattr(deps, "_current_turn_id", "")
                                _tool_seq = int(getattr(deps, "_current_tool_seq", 0) or 0)
                                if block.is_error:
                                    _cprint(f"    [red]Error: {content_str[:200]}[/]")
                                elif len(content_str) > 300:
                                    _cprint(f"    [dim green]OK[/] [dim]({len(content_str)} chars)[/]")
                                elif content_str:
                                    _cprint(f"    [dim green]OK[/] [dim]{content_str[:120]}[/]")
                                with suppress(Exception):
                                    if log_event:
                                        log_event(
                                            "tool.result",
                                            turn_id=_turn_id,
                                            tool_call_seq=_tool_seq,
                                            tool_name=None,
                                            duration_ms=0,
                                            result_status="error" if block.is_error else "ok",
                                            result_len=len(content_str),
                                        )

                            elif isinstance(block, ThinkingBlock):
                                # Thinking is internal — never show to user
                                pass

                        continue

                    # Result — cost and usage summary
                    if isinstance(message, ResultMessage):
                        result_msg = message
                        _cprint()
                        if not headless:
                            cost_str = f"${message.total_cost_usd:.4f}" if message.total_cost_usd else ""
                            turns_str = f"{message.num_turns} turns" if message.num_turns else ""
                            parts = [p for p in [turns_str, cost_str, f"{tool_count} tools"] if p]
                            if parts:
                                _cprint(f"  [dim]{' | '.join(parts)}[/]")
                        if message.usage:
                            counter = getattr(deps, "_session_token_counter", None)
                            if counter:
                                counter["request_tokens"] += message.usage.get("input_tokens", 0)
                                counter["response_tokens"] += message.usage.get("output_tokens", 0)
                                counter["total_tokens"] += message.usage.get("input_tokens", 0) + message.usage.get("output_tokens", 0)
                        deps._last_response_text = "".join(_assistant_text_parts)[:50000]
                        _turn_reason = "forced_summary_budget_exhausted" if getattr(deps, "_turn_budget_exhausted", False) else "answered"
                        _turn_outcome(
                            deps,
                            outcome="DELIVER",
                            reason_code=_turn_reason,
                            delivered_artifact_kind="answer",
                        )
                        _in_toks = 0
                        _out_toks = 0
                        if message.usage:
                            _in_toks = int(message.usage.get("input_tokens", 0) or 0)
                            _out_toks = int(message.usage.get("output_tokens", 0) or 0)
                        _turn_end(
                            deps,
                            model_input_tokens=_in_toks,
                            model_output_tokens=_out_toks,
                            estimated_model_cost_usd=float(message.total_cost_usd or 0.0),
                            estimated_tool_cost_usd=_estimate_tool_cost_usd(deps),
                        )

                return result_msg

            # One-shot mode
            if initial_prompt and (one_shot or headless):
                # Input guards on initial_prompt
                if len(initial_prompt) > 50_000:
                    _cprint(f"[red]Input too long ({len(initial_prompt):,} chars, max 50,000).[/]")
                    return False
                from autodebug.agent.core import _validate_input as _vi, set_active_skill as _sas
                _gi_err = _vi(initial_prompt)
                if _gi_err:
                    _cprint(f"[yellow]{_gi_err}[/]")
                    return False
                _sas(deps, initial_prompt)
                _turn_start(
                    deps,
                    backend="claude-sdk",
                    prompt=initial_prompt,
                    is_action_prompt=_is_action_prompt(initial_prompt),
                )
                await client.query(initial_prompt)
                await _process_response()
                turns += 1
                _persist_repl_memory(deps)
                if finalize_debug_log:
                    finalize_debug_log(summary={"turns": turns, "errors": errors, "interrupts": interrupts, "duration_s": round(_time.monotonic() - session_start, 3)})
                return True

            # Process initial prompt before interactive loop
            if initial_prompt:
                # Input guards on initial_prompt
                if len(initial_prompt) > 50_000:
                    _cprint(f"[red]Input too long ({len(initial_prompt):,} chars, max 50,000).[/]")
                    return False
                from autodebug.agent.core import _validate_input as _vi2, set_active_skill as _sas2
                _gi_err2 = _vi2(initial_prompt)
                if _gi_err2:
                    _cprint(f"[yellow]{_gi_err2}[/]")
                    return False
                _sas2(deps, initial_prompt)
                # Echo the user's input with the prompt prefix so it looks like the rest of the REPL
                _cprint(f"\n[bold green]{prompt_str}[/]{initial_prompt}")
                # Set mode-aware system prompt for initial query
                with suppress(Exception):
                    agent_options.system_prompt = build_system_prompt(deps, user_message=initial_prompt)
                _turn_start(
                    deps,
                    backend="claude-sdk",
                    prompt=initial_prompt,
                    is_action_prompt=_is_action_prompt(initial_prompt),
                )
                await client.query(initial_prompt)
                await _process_response()
                turns += 1

            # Main REPL loop
            while True:
                try:
                    user_input = await _prompt_input(session, prompt_str)
                    if not user_input:
                        continue

                    # Handle slash commands (same as pydantic-ai path)
                    if user_input.startswith("/"):
                        _cmd = user_input.strip().lower()
                        if _is_exit_command(_cmd):
                            _cprint("[dim]Goodbye![/]")
                            break
                        if _cmd == "/clear":
                            # Disconnect and reconnect for a fresh session
                            await client.disconnect()
                            await client.connect()
                            _cprint("[dim]Session cleared.[/]")
                            continue
                        if _cmd in ("/help", "/h"):
                            _cprint("[bold]Commands:[/]")
                            _cprint("  /clear   — Reset conversation")
                            _cprint("  /exit    — Exit REPL")
                            _cprint("  /tokens  — Show token usage")
                            continue
                        if _cmd == "/tokens":
                            counter = getattr(deps, "_session_token_counter", {})
                            _cprint(f"[dim]Tokens: {counter.get('total_tokens', 0):,} total "
                                    f"({counter.get('request_tokens', 0):,} in / "
                                    f"{counter.get('response_tokens', 0):,} out)[/]")
                            continue

                    # Input length guard (V-09)
                    if len(user_input) > 50_000:
                        _cprint(f"[red]Input too long ({len(user_input):,} chars, max 50,000). Please shorten your prompt.[/]")
                        continue

                    # Input guardrails: reject known jailbreak/injection patterns
                    from autodebug.agent.core import _validate_input as _vi_loop, set_active_skill as _sas_loop
                    _gi_err_loop = _vi_loop(user_input)
                    if _gi_err_loop:
                        _cprint(f"[yellow]{_gi_err_loop}[/]")
                        continue

                    # Detect and load skill based on user intent
                    _sas_loop(deps, user_input)

                    if log_event:
                        log_event("user.input", input_preview=user_input[:200], input_len=len(user_input))

                    _turn_start(
                        deps,
                        backend="claude-sdk",
                        prompt=user_input,
                        is_action_prompt=_is_action_prompt(user_input),
                    )
                    # Update system prompt with latest dynamic context (includes skill + mode)
                    with suppress(Exception):
                        agent_options.system_prompt = build_system_prompt(deps, user_message=user_input)

                    await client.query(user_input)
                    result = await _process_response()
                    if result and not result.is_error:
                        turns += 1
                    else:
                        errors += 1

                except (KeyboardInterrupt, asyncio.CancelledError):
                    timeout_triggered = isinstance(getattr(deps, "_last_err", None), asyncio.CancelledError)
                    if timeout_triggered and session_timeout_s:
                        _cprint(f"\n[red]Timed out after {int(session_timeout_s)}s.[/]")
                        _turn_outcome(
                            deps,
                            outcome="BLOCKED",
                            reason_code="blocked_tool_failure",
                            reason_detail="session_timeout",
                            delivered_artifact_kind="none",
                        )
                        with suppress(Exception):
                            if log_event:
                                log_event(
                                    "turn.timeout",
                                    turn_id=getattr(deps, "_current_turn_id", ""),
                                    timeout_seconds=int(session_timeout_s),
                                    outcome="BLOCKED",
                                    reason_code="blocked_tool_failure",
                                    reason_detail="session_timeout",
                                    delivered_artifact_kind="none",
                                )
                    else:
                        _cprint("\n[yellow]Interrupted.[/]")
                        _turn_outcome(
                            deps,
                            outcome="BLOCKED",
                            reason_code="interrupted",
                            delivered_artifact_kind="none",
                        )
                    with suppress(Exception):
                        await client.interrupt()
                    _turn_end(deps)
                    interrupts += 1
                    continue
                except EOFError:
                    _cprint("[dim]Goodbye![/]")
                    break
                except Exception as loop_err:
                    _cprint(f"\n[red]Unexpected error: {loop_err}[/]")
                    _turn_outcome(deps, outcome="BLOCKED", reason_code="blocked_tool_failure", delivered_artifact_kind="none")
                    _turn_end(deps)
                    errors += 1
                    continue

    except Exception as sdk_err:
        _cprint(f"[red]Claude SDK error: {sdk_err}[/]")
        _turn_outcome(deps, outcome="BLOCKED", reason_code="blocked_tool_failure", delivered_artifact_kind="none")
        _turn_end(deps)
        errors += 1

    _persist_repl_memory(deps)
    if finalize_debug_log:
        finalize_debug_log(summary={"turns": turns, "errors": errors, "interrupts": interrupts, "duration_s": round(_time.monotonic() - session_start, 3)})
    return turns > 0 or errors == 0


async def _run_langgraph_repl(
    *,
    deps,
    initial_prompt: Optional[str] = None,
    headless: bool = False,
    one_shot: bool = False,
    prompt_str: str = "> ",
    model: str = "",
    session_start: float = 0,
    session_timeout_s: Optional[int] = None,
    log_event=None,
    finalize_debug_log=None,
    resumed_history: Optional[list] = None,
    resumed_session_id: Optional[str] = None,
    debug_terminal: bool = False,
):
    """Run the REPL using LangGraph backend.

    Replaces the Claude SDK's opaque agentic loop with a StateGraph
    where we control every turn transition. Enables per-mode budgets,
    forced synthesis, and stale-turn detection.
    """
    import time as _time
    from contextlib import suppress
    from autodebug.agent.core import (
        build_system_prompt,
        detect_mode,
        MODE_TURN_BUDGETS,
    )

    turns = 0
    errors = 0
    interrupts = 0

    # Import LangGraph deps
    try:
        from langchain_anthropic import ChatAnthropic
        from langchain_core.messages import HumanMessage, SystemMessage
        from autodebug.agent.langgraph_runner import (
            build_graph,
            build_langgraph_tools,
            compact_history,
            run_graph_streaming,
        )
    except ImportError as e:
        _cprint(f"[red]LangGraph dependencies not installed: {e}[/]")
        _cprint("[dim]Install with: pip install 'autodebug[langgraph]'[/]")
        return False

    # Build tools once per session
    try:
        lg_tools = build_langgraph_tools(deps)
        graph = build_graph(lg_tools)
    except Exception as e:
        _cprint(f"[red]Failed to initialize agent graph: {e}[/]")
        return False

    # Create LLM model — prefer DeepSeek/OpenAI-compatible, fall back to Anthropic
    model_name = model
    # Strip provider prefix if present
    for prefix in ("anthropic:", "openai:", "deepseek:"):
        if model_name.startswith(prefix):
            model_name = model_name[len(prefix):]
            break

    llm = None

    # Try central config first (DeepSeek/OpenAI-compatible)
    try:
        from autodebug.llm_config import get_llm_config, get_chat_model
        config = get_llm_config()
        llm = get_chat_model(config, model=model_name if not model_name.startswith("claude") else None)
        _cprint(f"[dim]Using {config.provider} ({config.default_model})[/]")
    except (ImportError, ValueError):
        pass

    # Fall back to Anthropic
    if llm is None:
        _anthropic_key = (
            os.environ.get("ANTHROPIC_API_KEY")
            or os.environ.get("HIKAFLOW_ANTHROPIC_KEY")
        )
        if not _anthropic_key:
            try:
                from autodebug.config import load_credentials
                _creds = load_credentials()
                _anthropic_key = _creds.get("anthropic_api_key") or _creds.get("api_key")
            except Exception:
                pass
        if not _anthropic_key:
            _cprint()
            _cprint("[yellow]No LLM API key found.[/]")
            _cprint("[dim]Set DEEPSEEK_API_KEY, ANTHROPIC_API_KEY, or OPENAI_API_KEY[/]")
            _cprint()
            try:
                _anthropic_key = input("Paste your API key: ").strip()
            except (EOFError, KeyboardInterrupt):
                _anthropic_key = ""
            if not _anthropic_key:
                _cprint("[red]No API key provided.[/]")
                return False
            try:
                from autodebug.config import save_credentials
                save_credentials({"anthropic_api_key": _anthropic_key})
                _cprint("[green]API key saved to ~/.hikaflow/credentials.json[/]")
            except Exception:
                pass
            os.environ["ANTHROPIC_API_KEY"] = _anthropic_key

        try:
            llm = ChatAnthropic(model=model_name, max_tokens=16384, api_key=_anthropic_key)
        except Exception as e:
            _cprint(f"[red]Failed to initialize model '{model_name}': {e}[/]")
            _cprint("[dim]Check your API key and model name.[/]")
            return False

    session = None if one_shot else _create_prompt_session(prompt_str)

    # Display callback for streaming and tool calls
    _streaming_dirty = False  # tracks whether cursor is mid-line from streaming

    def _display(event_type: str, **kwargs):
        nonlocal _streaming_dirty

        if event_type == "text_delta":
            text = kwargs.get("text", "")
            if text:
                _cprint(text, end="", highlight=False)
                _streaming_dirty = True
            return

        # For ALL non-streaming events, ensure we're on a fresh line first
        if _streaming_dirty:
            _cprint()  # emit newline to terminate the partial streaming line
            _streaming_dirty = False

        if event_type == "tool_call":
            name = kwargs.get("name", "")
            args_str = kwargs.get("args_str", "")
            _cprint(f"\n  [yellow]>[/] [bold]{name}[/]({args_str})")
        elif event_type == "tool_result":
            result = kwargs.get("result", "")
            is_error = kwargs.get("is_error", False)
            elapsed_ms = kwargs.get("elapsed_ms", 0)
            timing = f" [dim cyan]\\[{elapsed_ms / 1000:.2f}s][/]" if debug_terminal and elapsed_ms else ""
            if is_error:
                _cprint(f"    [red]Error: {str(result)[:200]}[/]{timing}")
            elif len(str(result)) > 300:
                _cprint(f"    [dim green]OK[/] [dim]({len(str(result))} chars)[/]{timing}")
            elif result:
                _cprint(f"    [dim green]OK[/] [dim]{str(result)[:120]}[/]{timing}")
        elif event_type == "bash_output_update":
            tail = kwargs.get("output_tail", "")
            total = kwargs.get("total_bytes", 0)
            if tail:
                last_line = tail.rstrip().rsplit("\n", 1)[-1][:120]
                _cprint(f"\r    [dim]... {last_line}[/]", end="")
                _streaming_dirty = True
        elif event_type == "turn_cost":
            turn = kwargs.get("turn", 0)
            cum_tokens = kwargs.get("cumulative_tokens", 0)
            cum_cost = kwargs.get("cumulative_cost_usd", 0)
            budget = kwargs.get("token_budget", 0)
            if debug_terminal:
                turn_tokens = kwargs.get("turn_tokens", 0)
                turn_cost = kwargs.get("turn_cost_usd", 0)
                pct = int(cum_tokens / budget * 100) if budget else 0
                _cprint(f"  [dim yellow]┊ turn {turn}: +{turn_tokens:,} tokens (${turn_cost:.3f}) │ cumulative: {cum_tokens:,} (${cum_cost:.2f}) │ budget: {pct}%[/]")
            elif cum_cost >= 0.50:
                # Only show cost warning to non-debug users when it's getting expensive
                _cprint(f"  [dim yellow]┊ session cost: ${cum_cost:.2f}[/]")
        elif event_type == "synthesis":
            reason = kwargs.get("reason", "unknown")
            _cprint(f"\n  [dim yellow]Synthesizing final answer ({reason})...[/]")
        elif event_type == "error":
            _cprint(f"\n[red]Error: {kwargs.get('error', 'unknown')}[/]")

        # ── Debug-only events (gated behind --debug flag) ──────────────
        elif event_type == "turn_metrics" and debug_terminal:
            t = kwargs.get("turn", 0)
            b = kwargs.get("budget", 0)
            inp = kwargs.get("input_tokens", 0)
            out = kwargs.get("output_tokens", 0)
            cr = kwargs.get("cache_read", 0)
            cc = kwargs.get("cache_create", 0)
            ctx = kwargs.get("ctx_pct", 0)
            wall = kwargs.get("wall_time_s", 0)
            parts = [f"turn {t}/{b}", f"in:{inp:,} out:{out:,}"]
            if cr or cc:
                parts.append(f"cache:read {cr:,} write {cc:,}")
            parts.append(f"ctx {ctx:.0f}%")
            parts.append(f"{wall:.1f}s")
            _cprint(f"\n  [dim yellow]┊ {' │ '.join(parts)}[/]")

        elif event_type == "agent_reasoning" and debug_terminal:
            text = kwargs.get("text", "")
            if text:
                lines = text.strip().splitlines()
                preview = lines[0][:120]
                _cprint(f"  [dim yellow]┊ thinking: \"{preview}\"[/]")
                if len(lines) > 1:
                    _cprint(f"  [dim yellow]┊           \"{lines[1][:120]}\"[/]")

        elif event_type == "graph_transition" and debug_terminal:
            node = kwargs.get("node", "?")
            _cprint(f"  [dim yellow]┊ graph → {node}[/]")

        elif event_type == "compaction" and debug_terminal:
            before = kwargs.get("before", 0)
            after = kwargs.get("after", 0)
            dropped = kwargs.get("dropped", 0)
            _cprint(f"  [dim yellow]┊ compact: {before} → {after} messages │ dropped {dropped}[/]")

        elif event_type == "retry" and debug_terminal:
            att = kwargs.get("attempt", 0)
            mx = kwargs.get("max_retries", 0)
            err = kwargs.get("error_type", "")
            wait = kwargs.get("wait_s", 0)
            _cprint(f"  [dim yellow]┊ retry: attempt {att}/{mx} │ {err} │ waiting {wait:.1f}s[/]")

        elif event_type == "approval" and debug_terminal:
            tool = kwargs.get("tool", "")
            decision = kwargs.get("decision", "")
            mode = kwargs.get("mode", "")
            _cprint(f"  [dim yellow]┊ approval: {tool} → {decision} ({mode})[/]")

        elif event_type == "stale_warning" and debug_terminal:
            st = kwargs.get("stale_turns", 0)
            th = kwargs.get("threshold", 0)
            _cprint(f"  [dim yellow]┊ stale: {st}/{th} repeated tool calls[/]")

        elif event_type == "rate_limit_headers" and debug_terminal:
            remaining = kwargs.get("remaining", 0)
            limit = kwargs.get("limit", 30000)
            _cprint(f"  [dim yellow]┊ rate: ITPM {remaining:,}/{limit:,} remaining[/]")

    # Wire display_fn to bash tool's streaming holder
    _bash_holder = getattr(deps, "_bash_display_fn_holder", None)
    if _bash_holder is not None:
        _bash_holder[0] = _display

    # Track active mode across turns for stickiness
    active_mode: Optional[str] = None

    # Conversation history — accumulates messages across turns so the LLM
    # sees the full conversation.  Only non-system messages are stored here;
    # the system prompt is rebuilt (and prepended) fresh each turn.
    conversation_history: list = []

    # Token counter
    session_tokens = {"input": 0, "output": 0, "total": 0, "cache_read": 0, "cache_write": 0}

    # Session persistence — auto-save after each turn for resume
    _session_store = None
    _session_id = None
    _session_meta = None
    try:
        from autodebug.agent.session_store import SessionStore, SessionMeta, _get_git_branch
        _session_store = SessionStore()
        if resumed_session_id:
            _session_id = resumed_session_id
            _meta, _ = _session_store.load_session(resumed_session_id)
            _session_meta = _meta
        if not _session_id:
            _session_id = _session_store.new_session_id()
        if not _session_meta:
            _session_meta = SessionMeta(
                session_id=_session_id,
                created_at=time.time(),
                updated_at=time.time(),
                mode=active_mode or "chat",
                model=model_name if 'model_name' in dir() else model,
                cwd=str(Path.cwd()),
                git_branch=_get_git_branch(),
                preview=str(initial_prompt or "")[:120],
            )
        _session_store.save_meta(_session_meta)
    except Exception:
        pass

    # Restore conversation history from resumed session
    if resumed_history:
        conversation_history.extend(resumed_history)

    # Cost per 1K tokens — look up actual model in MODEL_COSTS, fall back to family
    from autodebug.llm_config import MODEL_COSTS
    _model_key = model_name if 'model_name' in dir() else model
    if _model_key in MODEL_COSTS:
        _cost_input, _cost_output = MODEL_COSTS[_model_key]
    else:
        _COST_TABLE = {
            "haiku": {"input": 0.001, "output": 0.005},
            "sonnet": {"input": 0.003, "output": 0.015},
            "opus": {"input": 0.015, "output": 0.075},
        }
        _model_lower = str(_model_key).lower()
        _cost_key = "sonnet"
        for fam in ("haiku", "sonnet", "opus"):
            if fam in _model_lower:
                _cost_key = fam
                break
        _cost_input = _COST_TABLE[_cost_key]["input"]
        _cost_output = _COST_TABLE[_cost_key]["output"]

    async def _process_message(user_input: str) -> bool:
        """Process a single user message through the graph."""
        nonlocal turns, errors, active_mode, _streaming_dirty

        # Input size validation (matches Claude SDK path limit)
        if len(user_input) > 50_000:
            _cprint(f"[red]Input too large ({len(user_input):,} chars). Max 50,000 chars.[/]")
            return False

        # Detect mode (sticky: first message sets it)
        if active_mode is None:
            active_mode = detect_mode(user_input)
        else:
            new_mode = detect_mode(user_input)
            if new_mode != "chat" and new_mode != active_mode:
                old_mode = active_mode
                active_mode = new_mode
                if not headless:
                    _cprint(f"[dim]Mode: {old_mode} → {active_mode} "
                            f"(budget: {MODE_TURN_BUDGETS.get(active_mode, 3)} turns)[/]")

        turn_budget = MODE_TURN_BUDGETS.get(active_mode, 3)

        # Build system prompt (refreshed each turn for up-to-date context)
        system_prompt = build_system_prompt(deps, user_message=user_input)

        # Compact history if approaching context limits
        if conversation_history:
            conversation_history[:] = compact_history(conversation_history)

        # Build messages: system prompt + prior conversation + new user message
        # System prompt uses content-block caching so it gets a dedicated cache
        # entry that persists across all turns (5-min TTL, refreshed on hit).
        sys_content = [{"type": "text", "text": system_prompt, "cache_control": {"type": "ephemeral"}}]
        messages = [SystemMessage(content=sys_content)]
        messages.extend(conversation_history)
        messages.append(HumanMessage(content=user_input))

        # Track how many messages we sent so we can extract only NEW ones
        # from the graph result (which contains the full accumulated history
        # due to the add_messages reducer).
        msgs_sent = len(messages)

        # Run graph
        _turn_start(
            deps,
            backend="langgraph",
            prompt=user_input,
            is_action_prompt=_is_action_prompt(user_input),
        )

        turn_usage = {}
        try:
            # Session timeout enforcement
            coro = run_graph_streaming(
                graph,
                messages=messages,
                mode=active_mode,
                turn_budget=turn_budget,
                model=llm,
                tools=lg_tools,
                display_fn=_display,
                debug_terminal=debug_terminal,
            )
            if session_timeout_s:
                elapsed = _time.monotonic() - session_start
                remaining = max(1, session_timeout_s - elapsed)
                result = await asyncio.wait_for(coro, timeout=remaining)
            else:
                result = await coro

            # Ensure cursor is on a fresh line after streaming completes
            if _streaming_dirty:
                _cprint()
                _streaming_dirty = False

            # Accumulate conversation history from graph result.
            # result["messages"] contains the FULL history (initial + new)
            # due to the add_messages reducer. We only want NEW messages
            # (everything after the ones we sent).
            result_messages = result.get("messages", [])
            new_messages = result_messages[msgs_sent:]

            # Add this turn's user message + all new AI/tool messages
            conversation_history.append(HumanMessage(content=user_input))
            for msg in new_messages:
                if isinstance(msg, SystemMessage):
                    continue
                conversation_history.append(msg)

            # Update token counter from graph's accumulated token_usage
            turn_usage = result.get("token_usage", {})
            if turn_usage:
                session_tokens["input"] += turn_usage.get("input_tokens", 0)
                session_tokens["output"] += turn_usage.get("output_tokens", 0)
                session_tokens["total"] += turn_usage.get("total_tokens", 0)
                session_tokens["cache_read"] += turn_usage.get("cache_read_tokens", 0)
                session_tokens["cache_write"] += turn_usage.get("cache_creation_tokens", 0)

            # Display summary with cost estimate and context window %
            tc = result.get("turn_count", 0)
            tool_count = result.get("tool_calls_count", 0)
            synth = result.get("synthesis_reason", "")
            ctx_pct = 0.0
            parts = [f"{tc} turn{'s' if tc != 1 else ''}", f"{tool_count} tool{'s' if tool_count != 1 else ''}"]
            if session_tokens["total"] > 0:
                parts.append(f"{session_tokens['total']:,} tokens")
                # Cache-aware cost: reads = 10% of base, writes = 125% of base
                cache_read = session_tokens.get("cache_read", 0)
                cache_write = session_tokens.get("cache_write", 0)
                uncached_input = max(0, session_tokens["input"] - cache_read)
                est_cost = (
                    uncached_input / 1000 * _cost_input
                    + cache_read / 1000 * _cost_input * 0.1
                    + cache_write / 1000 * _cost_input * 1.25
                    + session_tokens["output"] / 1000 * _cost_output
                )
                if est_cost >= 0.01:
                    parts.append(f"${est_cost:.2f}")
                elif est_cost > 0:
                    parts.append(f"${est_cost:.4f}")
                # Show cache hit rate when caching is active
                total_cached = cache_read + cache_write
                if total_cached > 0 and session_tokens["input"] > 0:
                    cache_pct = cache_read / session_tokens["input"] * 100
                    parts.append(f"cache {cache_pct:.0f}%")
                ctx_pct = min(100.0, session_tokens["total"] / 200_000 * 100)
                if ctx_pct >= 40:
                    parts.append(f"ctx {ctx_pct:.0f}%")
            if synth:
                parts.append(f"synthesized ({synth})")
            # Ensure variables exist for summary table even when tokens are 0
            total_cached = locals().get("total_cached", 0)
            est_cost = locals().get("est_cost", 0.0)
            cache_read = locals().get("cache_read", 0)
            cache_write = locals().get("cache_write", 0)
            ctx_pct = locals().get("ctx_pct", 0.0)
            if not headless:
                if debug_terminal and synth:
                    # Enhanced session summary table in debug mode.
                    # Use print() instead of _cprint() for data lines to avoid
                    # Rich markup interpretation of │ and other characters.
                    _cprint()  # blank line before summary
                    sep = "─" * 55
                    _cprint(f"  [dim]{sep}[/]")
                    _cprint(f"  [bold]Session Summary[/]")
                    _cprint(f"  [dim]{sep}[/]")
                    print(f"  Turns:    {tc}/{result.get('turn_budget', '?')} ({synth})")
                    print(f"  Tools:    {tool_count} calls")
                    print(f"  Tokens:   {session_tokens['total']:,} total | {session_tokens['input']:,} input | {session_tokens['output']:,} output")
                    if total_cached > 0:
                        cache_pct_val = cache_read / session_tokens["input"] * 100 if session_tokens["input"] else 0
                        nocache_cost = (
                            session_tokens["input"] / 1000 * _cost_input
                            + session_tokens["output"] / 1000 * _cost_output
                        )
                        saved = nocache_cost - est_cost
                        print(f"  Cache:    {cache_pct_val:.0f}% hit | {cache_read:,} read | {cache_write:,} write")
                        print(f"  Cost:     ${est_cost:.2f} (saved ~${saved:.2f} from caching)")
                    else:
                        print(f"  Cost:     ${est_cost:.2f}")
                    peak = result.get("peak_ctx_pct", ctx_pct)
                    print(f"  Context:  {ctx_pct:.0f}% | peak {peak:.0f}%")
                    # Time breakdown from turn_metrics
                    tms = result.get("turn_metrics", [])
                    if tms:
                        total_llm_s = sum(t.get("wall_time_s", 0) for t in tms)
                        print(f"  LLM time: {total_llm_s:.1f}s across {len(tms)} calls")
                    _cprint(f"  [dim]{sep}[/]")
                elif ctx_pct >= 80:
                    _cprint(f"  [yellow]{' | '.join(parts)} — context filling up, consider /compress[/]")
                else:
                    _cprint(f"  [dim]{' | '.join(parts)}[/]")

            # Auto-save session for resume
            if _session_store and _session_id and _session_meta:
                try:
                    _session_meta.updated_at = time.time()
                    _session_meta.turn_count = turns + 1
                    _session_meta.token_total = session_tokens["total"]
                    _session_meta.mode = active_mode or _session_meta.mode
                    _session_store.save_meta(_session_meta)
                    _session_store.save_messages(_session_id, conversation_history)
                except Exception:
                    pass

            # Check if graph encountered an error
            if result.get("error_occurred"):
                _cprint("[yellow]Agent encountered an error during execution.[/]")
                _turn_outcome(deps, outcome="BLOCKED", reason_code="graph_error")
                errors += 1
                return False

            _turn_outcome(
                deps,
                outcome="DELIVER",
                reason_code="completed",
                delivered_artifact_kind="answer",
            )
            turns += 1
            return True

        except asyncio.TimeoutError:
            _cprint("\n[yellow]Session timeout reached.[/]")
            _turn_outcome(deps, outcome="BLOCKED", reason_code="session_timeout")
            return False
        except (KeyboardInterrupt, asyncio.CancelledError):
            _cprint("\n[yellow]Interrupted.[/]")
            _turn_outcome(deps, outcome="BLOCKED", reason_code="interrupted")
            return False
        except ValueError as e:
            _cprint(f"\n[red]Validation error: {e}[/]")
            _turn_outcome(deps, outcome="BLOCKED", reason_code="validation_error")
            return False
        except Exception as e:
            err_str = str(e).lower()
            if "rate_limit" in err_str or "429" in err_str:
                reason = "rate_limited"
            elif "auth" in err_str or "401" in err_str or "403" in err_str:
                reason = "auth_error"
            else:
                reason = "tool_failure"
            _cprint(f"\n[red]Error: {e}[/]")
            _turn_outcome(deps, outcome="BLOCKED", reason_code=reason)
            return False
        finally:
            est_cost = (
                turn_usage.get("input_tokens", 0) / 1000 * _cost_input
                + turn_usage.get("output_tokens", 0) / 1000 * _cost_output
            ) if turn_usage else 0.0
            _turn_end(
                deps,
                model_input_tokens=turn_usage.get("input_tokens", 0) if turn_usage else 0,
                model_output_tokens=turn_usage.get("output_tokens", 0) if turn_usage else 0,
                estimated_model_cost_usd=est_cost,
            )

    # One-shot mode
    if initial_prompt and (one_shot or headless):
        _cprint(f"\n[bold green]{prompt_str}[/]{initial_prompt}")
        success = await _process_message(initial_prompt)
        _persist_repl_memory(deps)
        if finalize_debug_log:
            finalize_debug_log(summary={
                "turns": turns, "errors": errors, "interrupts": interrupts,
                "duration_s": round(_time.monotonic() - session_start, 3),
            })
        return success

    # Process initial prompt
    if initial_prompt:
        _cprint(f"\n[bold green]{prompt_str}[/]{initial_prompt}")
        await _process_message(initial_prompt)

    # Main REPL loop
    _consecutive_empty = 0  # track consecutive empty Enters
    while True:
        try:
            user_input = await _prompt_input(session, prompt_str)
            if not user_input:
                _consecutive_empty += 1
                if _consecutive_empty >= 2:
                    _cprint("[dim]Input cleared. Ready for a new prompt.[/]")
                    _consecutive_empty = 0
                continue

            _consecutive_empty = 0  # reset on any real input

            # Handle exit/quit (with or without slash, typo-tolerant)
            if _is_exit_command(user_input):
                _cprint("[dim]Goodbye![/]")
                break

            # Handle slash commands
            if user_input.startswith("/"):
                _cmd = _cmd_lower
                if _cmd == "/clear":
                    active_mode = None
                    conversation_history.clear()
                    _cprint("[dim]Session cleared.[/]")
                    continue
                if _cmd in ("/help", "/h"):
                    _cprint("[bold]Commands:[/]")
                    _cprint("  /clear   — Reset conversation")
                    _cprint("  /exit    — Exit REPL")
                    _cprint("  /tokens  — Show token usage and estimated cost")
                    _cprint("  /tools   — List available tools")
                    _cprint("  /models  — Show current model info")
                    continue
                if _cmd == "/tokens":
                    est_cost = (
                        session_tokens["input"] / 1000 * _cost_input
                        + session_tokens["output"] / 1000 * _cost_output
                    )
                    _cprint(f"[dim]Tokens: {session_tokens['total']:,} total "
                            f"({session_tokens['input']:,} in / {session_tokens['output']:,} out)"
                            f" | Est. cost: ${est_cost:.4f} ({_cost_key})[/]")
                    continue
                if _cmd == "/tools":
                    tool_names = sorted(t.name for t in lg_tools)
                    _cprint(f"[bold]{len(tool_names)} tools available:[/]")
                    for tn in tool_names:
                        _cprint(f"  [dim]•[/] {tn}")
                    continue
                if _cmd == "/models":
                    _cprint(f"[bold]Current model:[/] {model_name}")
                    _cprint(f"[dim]Max output tokens: 16384[/]")
                    _cprint(f"[dim]History: {len(conversation_history)} messages[/]")
                    continue

            if log_event:
                with suppress(Exception):
                    log_event("user.input", input_preview=user_input[:200], input_len=len(user_input))

            success = await _process_message(user_input)
            if not success:
                errors += 1

        except (KeyboardInterrupt, asyncio.CancelledError):
            _cprint("\n[yellow]Interrupted.[/]")
            interrupts += 1
            continue
        except EOFError:
            _cprint("[dim]Goodbye![/]")
            break
        except Exception as loop_err:
            _cprint(f"\n[red]Unexpected error: {loop_err}[/]")
            errors += 1
            continue

    _persist_repl_memory(deps)
    if finalize_debug_log:
        finalize_debug_log(summary={
            "turns": turns, "errors": errors, "interrupts": interrupts,
            "duration_s": round(_time.monotonic() - session_start, 3),
        })
    return turns > 0


def _import_agent_core():
    """Import agent core, auto-installing missing deps on failure. Returns module tuple or None."""
    try:
        from autodebug.agent.core import HikaflowDeps, get_agent_config, create_agent
        return HikaflowDeps, get_agent_config, create_agent
    except ImportError:
        pass

    # Auto-install missing deps
    _cprint("[yellow]Installing missing dependencies…[/]")
    _INSTALL_PKGS = [
        "langgraph>=0.2", "langchain-core>=0.3", "langchain-anthropic>=0.3",
        "anthropic>=0.40", "httpx>=0.27",
    ]
    try:
        subprocess.check_call(
            [sys.executable, "-m", "pip", "install", "-q", *_INSTALL_PKGS],
            stdout=subprocess.DEVNULL,
        )
        from autodebug.agent.core import HikaflowDeps, get_agent_config, create_agent
        _cprint("[green]Dependencies installed.[/]")
        return HikaflowDeps, get_agent_config, create_agent
    except Exception as install_err:
        _cprint(f"[red]Failed to install dependencies: {install_err}[/]")
        _cprint("[dim]Try manually: pip install langgraph langchain-core langchain-anthropic anthropic[/]")
        return None


def _create_agent_with_retry(create_agent, model, api_base, token, headless, one_shot):
    """Create agent, auto-installing missing provider packages on failure. Returns agent or None."""
    try:
        return create_agent(model, api_base=api_base, token=token)
    except (ImportError, Exception) as e:
        err_str = str(e)
        is_missing_pkg = isinstance(e, ImportError) or "install" in err_str.lower() or "No module named" in err_str
        if is_missing_pkg:
            _cprint("[yellow]Missing provider package — installing…[/]")
            _INSTALL_PKGS = [
                "langgraph>=0.2", "langchain-core>=0.3", "langchain-anthropic>=0.3",
                "anthropic>=0.40", "httpx>=0.27",
            ]
            try:
                subprocess.check_call(
                    [sys.executable, "-m", "pip", "install", "-q", *_INSTALL_PKGS],
                    stdout=subprocess.DEVNULL,
                )
                _cprint("[green]Dependencies installed. Retrying…[/]")
                return create_agent(model, api_base=api_base, token=token)
            except Exception as retry_err:
                _cprint(f"[red]Failed to create agent: {retry_err}[/]")
        else:
            _cprint(f"[red]Failed to create agent: {e}[/]")
        if not headless and not one_shot:
            _cprint(f"[dim]Current model: {model}\nTo use a different model: hikaflow debug --model anthropic:claude-sonnet-4-5-20250929\nOr set HIKAFLOW_MODEL environment variable.[/]")
        return None


async def _run_repl_loop(
    *,
    initial_prompt: Optional[str] = None,
    headless: bool = False,
    one_shot: bool = False,
    model_override: Optional[str] = None,
    prompt_str: str = "> ",
    show_model_info: bool = False,
    session_timeout_s: Optional[int] = None,
    resumed_history: Optional[list] = None,
    resumed_session_id: Optional[str] = None,
    debug_terminal: bool = False,
):
    """Shared REPL core: config -> agent -> loop -> cleanup. Returns True/False."""
    _imports = _import_agent_core()
    if _imports is None:
        return False
    HikaflowDeps, get_agent_config, create_agent = _imports

    from autodebug.auth import load_config
    from autodebug.agent.core import AGENT_BACKEND, ORCHESTRATOR_MODEL as _DEFAULT_ORCH, WORKER_MODEL as _DEFAULT_WORK

    _agent_cfg = get_agent_config()
    _model_cfg = _agent_cfg.get("models", {})
    # Server config is advisory — only use it if AGENT_BACKEND matches the provider.
    # When using claude-sdk or langgraph, always default to Anthropic models.
    if AGENT_BACKEND in ("claude-sdk", "langgraph"):
        ORCHESTRATOR_MODEL = _DEFAULT_ORCH
        WORKER_MODEL = _DEFAULT_WORK
    else:
        ORCHESTRATOR_MODEL = _model_cfg.get("orchestrator", _DEFAULT_ORCH)
        WORKER_MODEL = _model_cfg.get("worker", _DEFAULT_WORK)

    config = load_config()
    model = model_override or os.getenv("HIKAFLOW_MODEL", ORCHESTRATOR_MODEL)
    os.environ["HIKAFLOW_MODEL"] = model

    # Clean up stale saved model from credentials (legacy)
    if show_model_info:
        with suppress(Exception):
            from autodebug.config import load_credentials as _load_creds, CREDENTIALS_PATH as _creds_path
            import json as _json
            _creds = _load_creds()
            if "model" in _creds:
                del _creds["model"]
                _creds_path.write_text(_json.dumps(_creds, indent=2, sort_keys=True), encoding="utf-8")

    from autodebug.config import get_api_base, get_valid_token
    _api_base = get_api_base()
    _token = get_valid_token() or ""

    if show_model_info:
        _cprint(f"\n[dim]Orchestrator: {model} | Workers: {WORKER_MODEL}[/]")
    elif not headless and not one_shot:
        debug_line = "\n[bold yellow]Debug mode active[/] [dim]— per-turn metrics, tool timing, agent reasoning[/]" if debug_terminal else ""
        _cprint(Panel.fit(
            f"[bold green]Hikaflow AI Debugger[/]\n"
            f"[dim]Deterministic replay + AI fixes[/]\n"
            f"[dim]Orchestrator: {model} | Workers: {WORKER_MODEL}[/]"
            f"{debug_line}",
            border_style="yellow" if debug_terminal else "green",
        ))

    import time as _time
    from autodebug.debug_log import init_debug_log, log_event, finalize_debug_log, debug as _debug
    init_debug_log(
        force=True,
        context={
            "orchestrator": model,
            "workers": WORKER_MODEL,
            "project": str(Path.cwd()),
            "headless": headless,
            "one_shot": one_shot,
        },
    )
    log_event("session.context", model=model, project=str(Path.cwd()), headless=headless, one_shot=one_shot)

    session_start = _time.monotonic()
    turns = 0
    errors = 0
    interrupts = 0
    # Cumulative session token counter for cost tracking
    _session_token_counter = {"request_tokens": 0, "response_tokens": 0, "total_tokens": 0}

    agent = _create_agent_with_retry(create_agent, model, _api_base, _token, headless, one_shot)
    if agent is None:
        log_event("session.error", error_type="AgentCreationFailed", error="see console output", stage="create_agent")
        finalize_debug_log(summary={"turns": turns, "errors": errors + 1, "interrupts": interrupts, "duration_s": round(_time.monotonic() - session_start, 3)})
        return False

    deps = HikaflowDeps(
        api_base=_api_base,
        token=_token,
        project_path=str(Path.cwd()),
        environment=config.get("environment"),
        model_id=model,
        worker_model=WORKER_MODEL,
        orchestrator_model=model,
    )
    deps._session_timeout_seconds = int(session_timeout_s or 0)
    deps.headless = headless or one_shot

    with suppress(Exception):
        from autodebug.agent.memory import load_blocks_from_disk
        deps._memory_blocks = load_blocks_from_disk(deps.project_path)
    with suppress(Exception):
        from autodebug.agent.loop_detector import LoopDetector
        deps._loop_detector = LoopDetector()

    # Attach cumulative token counter to deps for /tokens command access
    deps._session_token_counter = _session_token_counter

    # ── Claude Agent SDK path ──────────────────────────────────────────
    if AGENT_BACKEND == "claude-sdk":
        result = await _run_claude_sdk_repl(
            agent_options=agent,  # create_agent returns ClaudeAgentOptions
            deps=deps,
            initial_prompt=initial_prompt,
            headless=headless,
            one_shot=one_shot,
            prompt_str=prompt_str,
            model=model,
            session_start=session_start,
            session_timeout_s=session_timeout_s,
            log_event=log_event,
            finalize_debug_log=finalize_debug_log,
        )
        return result

    # ── LangGraph path ────────────────────────────────────────────────
    if AGENT_BACKEND == "langgraph":
        result = await _run_langgraph_repl(
            deps=deps,
            initial_prompt=initial_prompt,
            headless=headless,
            one_shot=one_shot,
            prompt_str=prompt_str,
            model=model,
            session_start=session_start,
            session_timeout_s=session_timeout_s,
            log_event=log_event,
            finalize_debug_log=finalize_debug_log,
            resumed_history=resumed_history,
            resumed_session_id=resumed_session_id,
            debug_terminal=debug_terminal,
        )
        return result

    # ── Legacy pydantic-ai path (below) ────────────────────────────────
    history = []
    pending_images: list[tuple[bytes, str]] = []
    verbose_mode = True
    session = None if one_shot else _create_prompt_session(prompt_str)

    # One-shot/headless: process single prompt and exit
    if initial_prompt and (one_shot or headless):
        _oneshot_cmd = initial_prompt.strip()
        _LOCAL_SLASH = {"/help", "/status", "/errors", "/model", "/clear", "/compact", "/verbose", "/v", "/memory"}
        if _oneshot_cmd.lower() in _LOCAL_SLASH:
            _os_result, _ = await _handle_slash_command(
                _oneshot_cmd, deps=deps, history=history, model=model,
                agent=agent, api_base=_api_base, token=_token,
                verbose_mode=verbose_mode, pending_images=pending_images,
            )
            if _os_result == _SlashResult.UNRECOGNIZED:
                _cprint(f"[dim]Command '{_oneshot_cmd}' only works in interactive mode.[/]")
            finalize_debug_log(summary={"turns": turns, "errors": errors, "interrupts": interrupts, "duration_s": round(_time.monotonic() - session_start, 3)})
            return True
        success = await _process_agent_input(agent, deps, initial_prompt, history, headless=headless, verbose=verbose_mode)
        _persist_repl_memory(deps)
        finalize_debug_log(summary={"turns": 1 if success else 0, "errors": 0 if success else 1, "interrupts": 0, "duration_s": round(_time.monotonic() - session_start, 3)})
        return success

    # Process initial prompt before entering interactive loop
    if initial_prompt:
        await _process_agent_input(agent, deps, initial_prompt, history, verbose=verbose_mode)
        if headless:
            finalize_debug_log(summary={"turns": turns, "errors": errors, "interrupts": interrupts, "duration_s": round(_time.monotonic() - session_start, 3)})
            return True

    # Main REPL loop
    while True:
        try:
            user_input = await _prompt_input(session, prompt_str)

            if not user_input:
                continue

            _cmd_result, _cmd_updates = await _handle_slash_command(
                user_input, deps=deps, history=history, model=model,
                agent=agent, api_base=_api_base, token=_token,
                verbose_mode=verbose_mode, pending_images=pending_images,
            )
            if _cmd_result == _SlashResult.EXIT:
                break
            if _cmd_result != _SlashResult.UNRECOGNIZED:
                if "verbose_mode" in _cmd_updates:
                    verbose_mode = _cmd_updates["verbose_mode"]
                if "model" in _cmd_updates:
                    model = _cmd_updates["model"]
                if "agent" in _cmd_updates:
                    agent = _cmd_updates["agent"]
                continue

            images_for_msg = list(pending_images)
            _collect_images_from_input(user_input, images_for_msg)
            if session and hasattr(session, "_clipboard_image_state"):
                clip_state = session._clipboard_image_state
                if clip_state.get("image"):
                    images_for_msg.append(clip_state["image"])
                    clip_state["image"] = None
                    clip_state["size"] = ""

            log_event("user.input", input_preview=user_input[:200], input_len=len(user_input), is_command=user_input.startswith("/"))
            success = await _process_agent_input(
                agent, deps, user_input, history,
                images=images_for_msg if images_for_msg else None,
                verbose=verbose_mode,
            )
            if success:
                turns += 1
                if deps._active_skill_name:
                    deps._skill_turns_used += 1
                pending_images.clear()
            else:
                errors += 1

        except (KeyboardInterrupt, asyncio.CancelledError):
            _cprint("\n[yellow]Interrupted.[/]")
            interrupts += 1
            continue
        except EOFError:
            _cprint("[dim]Goodbye![/]")
            break
        except Exception as loop_err:
            _cprint(f"\n[red]Unexpected error: {loop_err}[/]")
            import logging as _logging; _logging.getLogger("hikaflow").debug("REPL loop error", exc_info=True)
            errors += 1
            continue

    _persist_repl_memory(deps)
    finalize_debug_log(summary={"turns": turns, "errors": errors, "interrupts": interrupts, "duration_s": round(_time.monotonic() - session_start, 3)})
    return True


async def _interactive_mode(
    *,
    prompt: Optional[str] = None,
    model_override: Optional[str] = None,
) -> bool:
    """Launch interactive REPL mode. Returns False on failure.

    Args:
        prompt: Optional one-shot prompt. If provided, runs it and exits.
        model_override: Optional model override from --model flag.
    """
    from autodebug.auth import is_authenticated

    if not is_authenticated():
        _cprint("[yellow]Not authenticated.[/] Logging in…")
        _cprint()
        success = await _browser_auth_flow()
        if not success:
            _cprint("[red]Login failed.[/] Try: hikaflow login --device-auth")
            return False

    one_shot = prompt is not None

    if not one_shot:
        _cprint()
        _cprint(Panel(
            f"[bold green]Hikaflow[/] v{__version__}\n\n"
            "Just tell me what you need.\n\n"
            "[dim]  \"scan for bugs\"              \"why is test_login failing\"\n"
            "  \"fix the flaky test\"          \"explain the auth flow\"[/]\n\n"
            "[dim]/help for commands  |  /exit to quit[/]",
            border_style="green",
            padding=(1, 2),
        ))

    result = await _run_repl_loop(
        initial_prompt=prompt,
        one_shot=one_shot,
        model_override=model_override,
        prompt_str="you > ",
        show_model_info=not one_shot,
    )
    return result is not False


async def _browser_auth_flow():
    """PKCE browser authentication flow with device auth fallback."""
    from autodebug.auth import browser_auth_flow

    success = await browser_auth_flow()
    if not success:
        _cprint()
        _cprint("[yellow]Browser auth failed or timed out.[/]")
        _cprint("Falling back to device code authentication...")
        _cprint()
        from autodebug.auth import device_auth_flow
        success = await device_auth_flow()
    return success


async def _device_auth_flow():
    """Device code authentication flow."""
    from autodebug.auth import device_auth_flow

    return await device_auth_flow()


async def _show_status(json_mode: bool = False, active_model: Optional[str] = None):
    """Show project status."""
    from autodebug.auth import get_credentials, is_authenticated, load_config

    if not is_authenticated():
        _cprint("[yellow]Not authenticated.[/] Run [bold]hikaflow login[/] first.")
        return

    config = load_config()
    creds = get_credentials()

    # Resolve user identity from credentials
    user = None
    if creds:
        user = creds.get("email") or creds.get("user_id") or creds.get("sub")

    api_endpoint = config.get("api_base", "https://get.hikaflow.com")
    model = active_model or os.getenv("HIKAFLOW_MODEL", "anthropic:claude-sonnet-4-5-20250929")

    if json_mode:
        status_data = {
            "authenticated": bool(creds),
            "api_endpoint": api_endpoint,
            "project_path": str(Path.cwd()),
            "model": model,
        }
        if user:
            status_data["user"] = user
        _cprint(json_module.dumps(status_data, indent=2))
        return

    table = Table(title="Project Status")
    table.add_column("Property", style="green")
    table.add_column("Value")

    table.add_row("Authenticated", "[green]Yes[/]" if creds else "[red]No[/]")
    if user:
        table.add_row("User", user)
    table.add_row("API Endpoint", api_endpoint)
    cwd = Path.cwd()
    display_path = f"~/{cwd.relative_to(Path.home())}" if cwd.is_relative_to(Path.home()) else str(cwd)
    table.add_row("Project Path", display_path)
    table.add_row("Model", model)

    _cprint(table)

    _cprint("\n[bold]Recent Errors:[/]")
    await _list_errors(5, None, None)


async def _list_errors(
    limit: int,
    environment: Optional[str],
    error_type: Optional[str],
    json_mode: bool = False,
):
    """List recent production errors."""
    import httpx

    from autodebug.auth import get_credentials, is_authenticated, load_config

    if not is_authenticated():
        _cprint("[yellow]Not authenticated.[/] Run [bold]hikaflow login[/] first.")
        raise typer.Exit(1)

    config = load_config()
    creds = get_credentials()

    try:
        async with httpx.AsyncClient() as client:
            params = {"per_page": limit}
            if environment:
                params["environment"] = environment

            response = await client.get(
                f"{config.get('api_base', 'https://get.hikaflow.com')}/v1/prod-errors",
                headers={"Authorization": f"Bearer {creds.get('access_token') or creds.get('token', '')}"},
                params=params,
                timeout=30.0,
            )

            if response.status_code == 401:
                _cprint("[red]Authentication expired.[/] Run [bold]hikaflow login[/] again.")
                return

            if response.status_code != 200:
                _cprint(f"[red]{_format_http_response_error(response, operation='Fetch production errors')}[/]")
                _cprint("  [dim]Try: hikaflow doctor, then retry. If this persists, share the request_id with backend support.[/]")
                return

            result = response.json()
            errors_data = result.get("errors", [])

            if not errors_data:
                if json_mode:
                    _cprint("[]")
                else:
                    _cprint("[dim]No errors found.[/]")
                return

            # Client-side filter by error_type if requested
            if error_type:
                errors_data = [e for e in errors_data if e.get("exception_type") == error_type]

            if json_mode:
                _cprint(json_module.dumps(errors_data, indent=2))
                return

            table = Table(title="Recent Production Errors")
            table.add_column("ID", style="green")
            table.add_column("Error", style="red")
            table.add_column("Environment")
            table.add_column("Last Seen")

            for error in errors_data:
                table.add_row(
                    error.get("error_id", "N/A"),
                    f"{error.get('exception_type', 'Unknown')}: {error.get('exception_message', '')[:40]}...",
                    error.get("environment", "unknown"),
                    error.get("created_at", "N/A"),
                )

            _cprint(table)

    except httpx.ConnectError:
        _cprint("[red]Failed to connect to API.[/]")
        _cprint("  [dim]Check connection or run: hikaflow doctor[/]")
    except Exception as e:
        _cprint(f"[red]Error: {e}[/]")


async def _inspect_error(error_id: str, json_mode: bool = False) -> bool:
    """Inspect a specific error. Returns True on success, False on failure."""
    import httpx

    from autodebug.auth import get_credentials, is_authenticated, load_config

    if not is_authenticated():
        _cprint("[yellow]Not authenticated.[/] Run [bold]hikaflow login[/] first.")
        return False

    config = load_config()
    creds = get_credentials()

    try:
        async with httpx.AsyncClient() as client:
            response = await client.get(
                f"{config.get('api_base', 'https://get.hikaflow.com')}/v1/prod-errors/{error_id}",
                headers={"Authorization": f"Bearer {creds.get('access_token') or creds.get('token', '')}"},
                timeout=30.0,
            )

            if response.status_code == 404:
                _cprint(
                    f"[red]Production error {error_id} not found.[/]\n"
                    f"[dim]If this is a run ID, use: hikaflow replay analyze .autodebug/runs/{error_id}/[/]"
                )
                return False

            if response.status_code != 200:
                _cprint(f"[red]{_format_http_response_error(response, operation='Fetch error details')}[/]")
                return False

            data = response.json()

            if json_mode:
                _cprint(json_module.dumps(data, indent=2))
                return True

            # Extract fields from raw error_data
            exc = data.get("exception") or {}
            exc_type = exc.get("type") or data.get("exception_type") or data.get("exc_type") or "Unknown"
            message = exc.get("message") or data.get("exception_message") or data.get("message") or "N/A"
            frames = data.get("stack_trace") or data.get("stack") or []
            last_frame = frames[-1] if isinstance(frames, list) and frames else {}
            file_name = last_frame.get("filename") or last_frame.get("file") or "N/A" if isinstance(last_frame, dict) else "N/A"
            line_no = last_frame.get("lineno") or last_frame.get("line") or "N/A" if isinstance(last_frame, dict) else "N/A"
            timestamp = data.get("timestamp") or data.get("created_at") or "N/A"

            _cprint(Panel(
                f"[bold red]{exc_type}[/]\n\n"
                f"[bold]Message:[/] {message}\n\n"
                f"[bold]Location:[/] {file_name}:{line_no}\n"
                f"[bold]First seen:[/] {timestamp}\n"
                f"[bold]Last seen:[/] {timestamp}",
                title=f"Error: {error_id}",
                border_style="red",
            ))

            if isinstance(frames, list) and frames:
                lines = []
                for frame in frames:
                    if isinstance(frame, dict):
                        fn = frame.get("filename") or frame.get("file") or "unknown"
                        ln = frame.get("lineno") or frame.get("line")
                        func = frame.get("function") or ""
                        lines.append(f"{fn}:{ln} {func}".strip() if ln else f"{fn} {func}".strip())
                if lines:
                    _cprint("\n[bold]Stack Trace:[/]")
                    _cprint(f"```\n{chr(10).join(lines)}\n```")

            return True

    except httpx.ConnectError:
        _cprint("[red]Failed to connect to API.[/]")
        _cprint("  [dim]Check connection or run: hikaflow doctor[/]")
        return False
    except Exception as e:
        _cprint(f"[red]Error: {e}[/]")
        return False


async def _run_debug_session(
    initial_prompt: Optional[str],
    headless: bool,
    model: Optional[str],
    one_shot: bool = False,
    session_timeout_s: Optional[int] = None,
    resumed_history: Optional[list] = None,
    resumed_session_id: Optional[str] = None,
    debug_terminal: bool = False,
):
    """Run an AI debugging session."""
    from autodebug.auth import is_authenticated

    # Skip auth for diagnostic /mcp command or smoke test (env-gated)
    _is_smoke = (
        os.environ.get("HIKAFLOW_SMOKE_TEST") == "1"
        and initial_prompt
        and "scan the codebase" in initial_prompt
    )
    if (initial_prompt and initial_prompt.strip().lower() == "/mcp") or _is_smoke:
        pass
    elif not is_authenticated():
        if headless or one_shot:
            # V-12: Return False instead of raising typer.Exit inside async context
            # to avoid duplicate error output when SystemExit propagates through asyncio.run
            _cprint("[red]Not authenticated.[/] Run 'hikaflow login' first (interactive auth not available in headless/one-shot mode).")
            return False
        _cprint("[yellow]Not authenticated.[/] Logging in...")
        _cprint()
        success = await _browser_auth_flow()
        if not success:
            _cprint("[red]Login failed.[/] Try: hikaflow login --device-auth")
            return False

    result = await _run_repl_loop(
        initial_prompt=initial_prompt,
        headless=headless,
        one_shot=one_shot,
        model_override=model,
        session_timeout_s=session_timeout_s,
        resumed_history=resumed_history,
        resumed_session_id=resumed_session_id,
        debug_terminal=debug_terminal,
    )
    if result is False:
        return False


def _handle_image_command(user_input: str, pending_images: list[tuple[bytes, str]]):
    """Handle /image command: grab from clipboard or load from file path.

    Usage:
        /image              — paste from clipboard
        /image /path/to.png — load from file
        /image clear        — clear pending images
    """
    from autodebug.clipboard import (
        format_image_size,
        get_clipboard_image,
        read_image_file,
        reset_clipboard_tracking,
    )

    parts = user_input.split(maxsplit=1)
    arg = parts[1].strip() if len(parts) > 1 else ""

    if arg.lower() == "clear":
        pending_images.clear()
        reset_clipboard_tracking()
        _cprint("[dim]Cleared pending images and clipboard tracking.[/]")
        return

    if arg:
        # Load from file path
        result = read_image_file(arg)
        if result:
            pending_images.append(result)
            _cprint(
                f"[green]Attached image:[/] {arg} ({format_image_size(result[0])})"
            )
            _cprint(
                f"[dim]{len(pending_images)} image(s) queued. Type your message to send.[/]"
            )
        else:
            _cprint(f"[red]Could not read image:[/] {arg}")
        return

    # No arg — read from clipboard
    result = get_clipboard_image()
    if result:
        pending_images.append(result)
        _cprint(
            f"[green]Pasted image from clipboard[/] ({format_image_size(result[0])})"
        )
        _cprint(
            f"[dim]{len(pending_images)} image(s) queued. Type your message to send.[/]"
        )
    else:
        _cprint(
            "[yellow]No image found on clipboard.[/]\n"
            "[dim]Copy an image first, or use: /image /path/to/screenshot.png[/]"
        )


def _collect_images_from_input(user_input: str, images: list[tuple[bytes, str]]):
    """Auto-detect and load image file paths from user input."""
    try:
        from autodebug.clipboard import extract_image_paths, read_image_file, format_image_size

        paths = extract_image_paths(user_input)
        for path in paths:
            result = read_image_file(path)
            if result:
                images.append(result)
                _cprint(
                    f"[dim]Attached: {path} ({format_image_size(result[0])})[/]"
                )
    except (OSError, ValueError, ImportError, RuntimeError):
        pass  # Don't crash REPL on image detection failures


def _auto_attach_clipboard_image(images: list[tuple[bytes, str]]):
    """Silently check clipboard for a new image and auto-attach it.

    Uses fingerprint tracking so the same screenshot isn't re-attached
    on every message. Only shows a message when a NEW image is found.
    """
    try:
        from autodebug.clipboard import auto_attach_clipboard, format_image_size

        if auto_attach_clipboard(images):
            data, _media = images[-1]
            _cprint(f"[dim]Clipboard image attached ({format_image_size(data)})[/]")
    except (OSError, ValueError, ImportError, RuntimeError):
        pass  # Never fail the user's message over clipboard


# --- Rate limit handling ---

def _is_rate_limit_error(exc: Exception) -> bool:
    """Check if an exception is a rate limit (429) error."""
    exc_str = str(exc).lower()
    exc_type = type(exc).__name__.lower()
    return (
        "rate" in exc_type and "limit" in exc_type
        or "429" in exc_str
        or "rate limit" in exc_str
        or "too many requests" in exc_str
    )


def _format_rate_limit_message(exc: Exception, retry_after: int) -> str:
    """Format a user-friendly rate limit message."""
    return f"Rate limit reached. Retrying in {retry_after}s..."


def _is_token_expired_error(exc: Exception) -> bool:
    """Check if an exception is a 401 / expired token error from the LLM proxy."""
    exc_str = str(exc).lower()
    return "401" in exc_str and ("expired" in exc_str or "invalid" in exc_str or "token" in exc_str)


def _refresh_deps_token(deps) -> bool:
    """Refresh the token on deps if expired. Returns True if token was refreshed."""
    from autodebug.config import get_valid_token

    new_token = get_valid_token()
    if new_token and new_token != deps.token:
        deps.token = new_token
        return True
    return False


def _format_api_error(exc: Exception) -> str:
    """Format LLM API errors into user-friendly messages."""
    exc_str = str(exc)
    exc_lower = exc_str.lower()
    if "credit balance" in exc_lower or "billing" in exc_lower:
        return "API credits exhausted. Add credits at your LLM provider's billing page."
    if "invalid_api_key" in exc_lower or "authentication" in exc_lower:
        if "401" in exc_str and ("expired" in exc_lower or "token" in exc_lower):
            return "Session expired. Run 'hikaflow login' to re-authenticate."
        return "Invalid API key. Check your ANTHROPIC_API_KEY or OPENAI_API_KEY."
    if "model_not_found" in exc_lower or "does not exist" in exc_lower:
        return f"Model not available. Check /model and try a different one."
    if "overloaded" in exc_lower:
        return "API is overloaded. Try again in a few seconds."
    # Extract just the message from JSON-like error bodies
    if "status_code:" in exc_str and "message" in exc_lower:
        import re
        msg_match = re.search(r"'message':\s*'([^']+)'", exc_str)
        if msg_match:
            return f"API error: {msg_match.group(1)}"
    # Always include exception type for debuggability
    exc_type = type(exc).__name__
    if exc_str.strip():
        return f"Agent error ({exc_type}): {exc_str[:200]}"
    return f"Agent error ({exc_type}). Run with /verbose to see full trace."


# --- Auto-compaction ---

# Token limit for auto-compaction (70% of 100K total_tokens_limit)
_AUTO_COMPACT_TOKEN_THRESHOLD = 70_000
# Rough chars-per-token estimate (conservative)
_CHARS_PER_TOKEN = 4
# Minimum messages before compaction is worthwhile
_MIN_MESSAGES_FOR_COMPACT = 12


def _estimate_history_tokens(history: list) -> int:
    """Estimate token count from message history (rough: chars / 4)."""
    total_chars = 0
    for msg in history:
        if hasattr(msg, 'parts'):
            for part in msg.parts:
                if hasattr(part, 'content'):
                    content = part.content
                    if isinstance(content, str):
                        total_chars += len(content)
                    elif isinstance(content, list):
                        for item in content:
                            if isinstance(item, str):
                                total_chars += len(item)
                elif hasattr(part, 'args'):
                    # ToolCallPart — count the args JSON
                    total_chars += len(str(getattr(part, 'args', '')))
    return total_chars // _CHARS_PER_TOKEN


def _find_safe_compact_boundary(conversation: list, keep_exchanges: int = 2) -> int:
    """Find a safe cut point that preserves tool_call/tool_return pairs.

    Scans backward from the end to find the start of the Nth-from-last
    user exchange. A user exchange starts with a ModelRequest containing
    a UserPromptPart (not just ToolReturnParts).

    Returns the index into conversation where the "kept" portion begins.
    """
    from pydantic_ai.messages import ModelRequest, UserPromptPart

    user_starts = []
    for i, msg in enumerate(conversation):
        if isinstance(msg, ModelRequest) and any(
            isinstance(p, UserPromptPart) for p in msg.parts
        ):
            user_starts.append(i)

    if len(user_starts) <= keep_exchanges:
        return 0  # Can't compact further

    # Keep last N user exchanges — cut at the start of the (N-from-end)th exchange
    cut_idx = user_starts[-keep_exchanges]
    return cut_idx


def _auto_compact_history(history: list, headless: bool = False) -> bool:
    """Auto-compact history if it's getting too large.

    Keeps system prompts and last 2 user exchanges (with complete tool
    call/return pairs preserved).
    Returns True if compaction occurred.
    """
    if len(history) < _MIN_MESSAGES_FOR_COMPACT:
        return False

    estimated_tokens = _estimate_history_tokens(history)
    if estimated_tokens < _AUTO_COMPACT_TOKEN_THRESHOLD:
        return False

    # Try observation masking first — may avoid full compaction
    with suppress(Exception):
        from autodebug.agent.context import mask_old_observations
        mask_old_observations(history, str(Path.cwd()), keep_turns=2)
        estimated_tokens = _estimate_history_tokens(history)
        if estimated_tokens < _AUTO_COMPACT_TOKEN_THRESHOLD:
            if not headless:
                _cprint(f"[dim]Observation masking reduced context to ~{estimated_tokens:,} tokens, compaction deferred.[/]")
            return False

    # Import here to avoid circular imports
    from pydantic_ai.messages import ModelRequest, UserPromptPart

    # Separate system messages (first few) from conversation
    system_msgs = []
    conversation = []
    for msg in history:
        if isinstance(msg, ModelRequest) and any(
            hasattr(p, 'part_kind') and p.part_kind == 'system-prompt'
            for p in msg.parts
        ):
            system_msgs.append(msg)
        else:
            conversation.append(msg)

    # Find safe boundary that keeps tool pairs intact
    cut_idx = _find_safe_compact_boundary(conversation, keep_exchanges=2)
    if cut_idx == 0 or cut_idx >= len(conversation) - 1:
        return False  # Not enough to compact

    kept = conversation[cut_idx:]
    dropped = conversation[:cut_idx]

    # Build compact summary of what was discussed before — include both
    # user topics AND key agent findings so the LLM retains context.
    import re as _cre
    topics = []
    findings = []
    files_mentioned = set()
    for msg in dropped:
        if isinstance(msg, ModelRequest):
            for part in msg.parts:
                if isinstance(part, UserPromptPart):
                    text = part.content if isinstance(part.content, str) else str(part.content)
                    if len(text) > 120:
                        text = text[:120] + "..."
                    topics.append(text)
        else:
            # Extract agent response text for key findings
            for part in getattr(msg, "parts", []):
                text = getattr(part, "content", None)
                if not isinstance(text, str) or len(text) < 20:
                    continue
                # Extract file:line references
                for m in _cre.finditer(r'[\w/]+\.(?:py|ts|js|tsx|jsx):\d+', text):
                    files_mentioned.add(m.group().split(":")[0])
                # Extract HIGH/CRITICAL severity findings (first 120 chars of the line)
                for m in _cre.finditer(r'.*(?:HIGH|CRITICAL|ERROR|BUG).*', text):
                    line = m.group().strip()[:120]
                    if line and len(findings) < 10:
                        findings.append(line)

    parts = [f"[Earlier conversation compacted — {len(dropped)} messages removed."]
    if topics:
        parts.append(f"Topics: {'; '.join(topics[:8])}")
    if findings:
        parts.append(f"Key findings: {'; '.join(findings[:6])}")
    if files_mentioned:
        parts.append(f"Files analyzed: {', '.join(sorted(files_mentioned)[:15])}")
    summary_text = " ".join(parts) + "]"

    # Rebuild history: system + summary + recent
    summary_msg = ModelRequest(
        parts=[UserPromptPart(content=summary_text)],
    )

    history.clear()
    history.extend(system_msgs)
    history.append(summary_msg)
    history.extend(kept)

    # Clear read_file cache so files can be re-read now that their content
    # has been dropped from conversation context.
    try:
        from autodebug.agent.tools import reset_read_cache
        reset_read_cache()
    except ImportError:
        pass

    old_tokens = estimated_tokens
    new_tokens = _estimate_history_tokens(history)
    if not headless:
        _cprint(f"[dim]Auto-compacted: ~{old_tokens:,} → ~{new_tokens:,} tokens ({len(history)} messages)[/]")

    return True


def _force_compact_history(history: list) -> str:
    """Force-compact history even when under token threshold.

    Used by /compact command as fallback when _auto_compact_history() declines.
    Returns a summary message of what was compacted.
    """
    from pydantic_ai.messages import ModelRequest, UserPromptPart

    msg_count = len(history)
    if msg_count <= 4:
        return ""

    # Separate system messages from conversation
    system_msgs = []
    conversation = []
    for msg in history:
        if isinstance(msg, ModelRequest) and any(
            hasattr(p, 'part_kind') and p.part_kind == 'system-prompt'
            for p in msg.parts
        ):
            system_msgs.append(msg)
        else:
            conversation.append(msg)

    # Find safe boundary that keeps tool pairs intact
    cut_idx = _find_safe_compact_boundary(conversation, keep_exchanges=2)
    if cut_idx == 0 or len(conversation) <= 4:
        return ""

    kept = conversation[cut_idx:]
    dropped = conversation[:cut_idx]

    # Extract topics from messages being dropped
    topics = []
    for msg in dropped:
        if isinstance(msg, ModelRequest):
            for part in msg.parts:
                if isinstance(part, UserPromptPart):
                    text = part.content if isinstance(part.content, str) else str(part.content)
                    if len(text) > 60:
                        text = text[:60] + "..."
                    topics.append(text)

    summary_text = f"[Earlier conversation compacted — {len(dropped)} messages removed. Topics discussed: {'; '.join(topics[:8])}]"

    summary_msg = ModelRequest(
        parts=[UserPromptPart(content=summary_text)],
    )

    history.clear()
    history.extend(system_msgs)
    history.append(summary_msg)
    history.extend(kept)

    return f"Compacted conversation: kept last {len(kept)} messages + summary (was {msg_count})."


async def _process_agent_input(
    agent,
    deps,
    user_input: str,
    history: list,
    headless: bool = False,
    images: list[tuple[bytes, str]] | None = None,
    verbose: bool = True,
):
    """Process user input through the agent.

    Two modes controlled by `verbose`:
    - verbose=True (default): streams tool calls, results, thinking, and text live
    - verbose=False: shows a spinner, then prints the final response

    Args:
        images: Optional list of (image_bytes, media_type) tuples to include.
        verbose: Show live tool calls and streaming (default True).

    Returns:
        True if the agent completed successfully, False on error.
    """
    # Refresh token before each turn to prevent mid-session 401 errors
    if _refresh_deps_token(deps):
        with suppress(Exception):
            agent._model._provider._client.api_key = deps.token

    # Validate prompt length
    _MAX_PROMPT_CHARS = 50_000
    prompt_text = user_input if isinstance(user_input, str) else str(user_input)
    if len(prompt_text) > _MAX_PROMPT_CHARS:
        _cprint(f"[red]Input too long ({len(prompt_text):,} chars, max {_MAX_PROMPT_CHARS:,}). Please shorten your prompt.[/]")
        return False

    # Input guardrails: reject known jailbreak/injection patterns
    from autodebug.agent.core import _validate_input
    guardrail_error = _validate_input(prompt_text)
    if guardrail_error:
        _cprint(f"[yellow]{guardrail_error}[/]")
        return False

    # Build prompt: plain string or multimodal list
    if images:
        from pydantic_ai.messages import BinaryContent

        prompt: str | list = [user_input]
        for img_data, media_type in images:
            prompt.append(BinaryContent(data=img_data, media_type=media_type))
    else:
        prompt = user_input

    # Load the right skill based on user intent (progressive skill loading)
    from autodebug.agent.core import set_active_skill
    skill_name = set_active_skill(deps, user_input)
    if skill_name and not deps._active_skill_content:
        if not headless:
            _cprint(f"[yellow]Warning: Skill '{skill_name}' matched but could not be loaded. Running without skill instructions.[/]")

    if verbose:
        return await _process_verbose(agent, deps, prompt, history, headless)
    else:
        return await _process_quiet(agent, deps, prompt, history, headless)


# --- Action prompt detection and tool-choice override ---

_ACTION_WORDS = frozenset({
    "find", "scan", "fix", "run", "check", "debug", "heal", "test",
    "detect", "fetch", "replay", "analyze", "investigate", "improve", "review",
})


def _is_action_prompt(prompt) -> bool:
    """Return True if the prompt contains action-oriented words that expect tool usage."""
    text = prompt if isinstance(prompt, str) else str(prompt[0]) if prompt else ""
    return bool(set(text.lower().split()) & _ACTION_WORDS)


def _force_tool_model_settings(deps):
    """Return ModelSettings that force tool_choice='required' for OpenAI models.

    Returns None for non-OpenAI providers to avoid breaking their APIs.
    """
    from pydantic_ai.settings import ModelSettings

    model_id = getattr(deps, "model_id", "") or ""
    if model_id.startswith("openai:"):
        return ModelSettings(extra_body={"tool_choice": "required"})
    return None


def _force_no_tool_model_settings(deps):
    """Return ModelSettings that request no tool usage for synthesis pass."""
    from pydantic_ai.settings import ModelSettings

    model_id = getattr(deps, "model_id", "") or ""
    if model_id.startswith("openai:"):
        return ModelSettings(extra_body={"tool_choice": "none"})
    if model_id.startswith("claude-") or model_id.startswith("anthropic:"):
        return ModelSettings(extra_body={"tool_choice": {"type": "none"}})
    return None


def _count_current_turn_tool_calls(history: list) -> int:
    """Count tool calls in the current turn from message history."""
    with suppress(Exception):
        from pydantic_ai.messages import ModelRequest, ToolCallPart, UserPromptPart

        current_turn_start = 0
        for i, msg in enumerate(history):
            if isinstance(msg, ModelRequest) and any(isinstance(p, UserPromptPart) for p in getattr(msg, "parts", [])):
                current_turn_start = i
        return sum(
            1
            for msg in history[current_turn_start:]
            if hasattr(msg, "parts")
            for part in msg.parts
            if isinstance(part, ToolCallPart)
        )
    return 0


def _emit_quiet_tool_lineage_from_history(deps, history: list) -> None:
    """Emit tool.call/tool.result events for quiet mode from collected history."""
    from autodebug.debug_log import log_event

    turn_id = getattr(deps, "_current_turn_id", "")
    if not turn_id:
        return
    with suppress(Exception):
        from pydantic_ai.messages import ModelRequest, ToolCallPart, UserPromptPart

        current_turn_start = 0
        for i, msg in enumerate(history):
            if isinstance(msg, ModelRequest) and any(isinstance(p, UserPromptPart) for p in getattr(msg, "parts", [])):
                current_turn_start = i
        seq = 0
        for msg in history[current_turn_start:]:
            parts = getattr(msg, "parts", None) or []
            for part in parts:
                if isinstance(part, ToolCallPart):
                    seq += 1
                    tool_name = getattr(part, "tool_name", "")
                    deps._current_tool_seq = seq
                    log_event(
                        "tool.call",
                        turn_id=turn_id,
                        tool_call_seq=seq,
                        tool_name=tool_name,
                        duration_ms=0,
                        args_preview=str(getattr(part, "args", ""))[:500],
                    )
                    log_event(
                        "tool.result",
                        turn_id=turn_id,
                        tool_call_seq=seq,
                        tool_name=tool_name,
                        duration_ms=0,
                        result_status="ok",
                        result_len=0,
                    )


async def _run_forced_synthesis_pass(agent, deps, history, *, headless: bool, mode: str) -> tuple[str, object] | tuple[str, None]:
    """Run final synthesis pass with explicit no-tool instruction."""
    from autodebug.debug_log import debug as _debug

    _turn_synthesis_attempt(deps, reason="budget_exhausted")
    nudge = (
        "Tool budget is exhausted for this turn. "
        "Provide the best final answer now using only existing context. "
        "Do not call tools."
    )
    _debug(f"SYNTHESIS_ONLY | mode={mode}")
    try:
        _settings = _force_no_tool_model_settings(deps)
        buffer, response = await _run_agent_stream(
            agent,
            deps,
            nudge,
            history,
            headless=headless,
            spinner_text="Synthesizing...",
            model_settings=_settings,
        )
        return buffer, response
    except Exception:
        return "", None


def _check_loop_detection(deps, history) -> Optional[str]:
    """Scan latest tool calls in history for loops. Returns break message or None.

    If a loop is detected, appends a system-level instruction to the history
    telling the agent to stop repeating.
    """
    detector = getattr(deps, "_loop_detector", None)
    if detector is None:
        return None

    with suppress(Exception):
        from pydantic_ai.messages import ModelResponse, ToolCallPart

        # Scan the last few messages for tool calls
        for msg in history[-5:]:
            if not isinstance(msg, ModelResponse):
                continue
            for part in msg.parts:
                if isinstance(part, ToolCallPart):
                    args = part.args if isinstance(part.args, dict) else {}
                    loop_msg = detector.record_call(part.tool_name, args)
                    if loop_msg:
                        return loop_msg
    return None


# Human-readable descriptions for each tool (shown in verbose mode)
_TOOL_DESCRIPTIONS = {
    "fetch_production_errors": "Fetching recent production errors from the API",
    "get_error_details": "Retrieving detailed error information",
    "replay_error": "Re-running error to reproduce the failure",
    "view_io_transcript": "Loading I/O transcript for the error",
    "generate_fix_hypothesis": "Generating AI-powered fix hypotheses",
    "validate_fix": "Validating a proposed fix by running tests",
    "search_similar_fixes": "Searching for similar past fixes",
    "apply_patch": "Applying a code patch",
    "git_status": "Checking git repository status",
    "git_commit": "Creating a git commit",
    "create_pull_request": "Creating a pull request",
    "run_tests": "Running test suite",
    "scan_flaky_tests": "Running tests multiple times to detect flakiness",
    "detect_flaky_patterns": "Scanning code for flaky test patterns (static analysis)",
    "heal_flaky_test": "Generating and validating an automated fix",
    "generate_tests": "Generating new test cases",
    "scan_codebase": "Running comprehensive codebase diagnostics",
    "sessions": "Managing debug sessions",
    "issue_groups": "Working with issue groups",
    "patterns": "Analyzing flakiness patterns",
    "transcript": "Exporting or viewing transcripts",
    "quarantine": "Managing test quarantine",
    "read_file": "Reading file contents",
    "list_directory": "Listing directory contents",
}


def _log_prompt_start(prompt, mode: str):
    """Log prompt details at turn start. Returns (prompt_preview, prompt_is_multimodal)."""
    from autodebug.debug_log import debug as _debug, log_event, write_session_artifact
    _prompt_is_str = type(prompt) is str
    preview = prompt[:200] if _prompt_is_str else str(prompt)[:200]
    length = len(prompt) if _prompt_is_str else None
    is_multi = not _prompt_is_str
    _debug(f'PROMPT | "{preview}" ({length if length is not None else "multimodal"} chars)')
    full = str(prompt) if is_multi else prompt
    path = write_session_artifact("llm_prompt", full, "txt") if full else None
    log_event(
        "llm.prompt", mode=mode, prompt_preview=preview,
        prompt_len=length, prompt_is_multimodal=is_multi,
        prompt=full, prompt_path=path,
    )
    return preview, is_multi


def _turn_start(deps, *, backend: str, prompt, is_action_prompt: bool) -> str:
    """Emit deterministic turn start + budget event and store turn context on deps."""
    from autodebug.debug_log import log_event

    turn_id = str(uuid.uuid4())
    prompt_text = prompt if isinstance(prompt, str) else str(prompt)
    budget_limit = int(getattr(deps, "_skill_budget", 0) or 0)
    deps._current_turn_id = turn_id
    deps._current_tool_seq = 0
    deps._turn_budget_exhausted = False
    deps._turn_outcome_emitted = False
    deps._turn_synthesis_emitted = False
    deps._turn_record = TurnRecord(
        turn_id=turn_id,
        backend=backend,
        user_intent="action" if is_action_prompt else "chat",
    )

    log_event(
        "turn.start",
        turn_id=turn_id,
        backend=backend,
        user_intent="action" if is_action_prompt else "chat",
        prompt_preview=prompt_text[:200],
        prompt_len=len(prompt_text),
    )
    log_event(
        "turn.tool_budget",
        turn_id=turn_id,
        tool_budget_limit=budget_limit,
        policy="fix" if is_action_prompt else "scan",
    )
    return turn_id


def _turn_synthesis_attempt(deps, *, reason: str = "budget_exhausted") -> None:
    """Emit at most one synthesis-attempt event per turn."""
    from autodebug.debug_log import log_event

    if getattr(deps, "_turn_synthesis_emitted", False):
        return
    turn_id = getattr(deps, "_current_turn_id", "")
    if not turn_id:
        return
    deps._turn_synthesis_emitted = True
    log_event("turn.synthesis_attempt", turn_id=turn_id, reason=reason)


def _turn_outcome(
    deps,
    *,
    outcome: str,
    reason_code: str,
    reason_detail: str = "",
    delivered_artifact_kind: str = "none",
    needs_input_question: str = "",
) -> None:
    """Emit exactly one turn outcome event."""
    from autodebug.debug_log import log_event

    if getattr(deps, "_turn_outcome_emitted", False):
        return
    turn_id = getattr(deps, "_current_turn_id", "")
    if not turn_id:
        return
    deps._turn_outcome_emitted = True
    rec = getattr(deps, "_turn_record", None)
    if rec is not None:
        kind = TurnOutcomeKind.DELIVER
        if outcome == "BLOCKED":
            kind = TurnOutcomeKind.BLOCKED
        elif outcome == "NEEDS_INPUT":
            kind = TurnOutcomeKind.NEEDS_INPUT
        rec.outcome = TurnOutcomeRecord(
            outcome=kind,
            reason_code=reason_code,
            reason_detail=(reason_detail or "")[:240],
            delivered_artifact_kind=delivered_artifact_kind,
            needs_input_question=(needs_input_question or "")[:240],
        )
    log_event(
        "turn.outcome",
        turn_id=turn_id,
        outcome=outcome,
        reason_code=reason_code,
        reason_detail=(reason_detail or "")[:240],
        delivered_artifact_kind=delivered_artifact_kind,
        needs_input_question=(needs_input_question or "")[:240],
    )
    _report_turn_outcome_to_api(outcome, reason_code, reason_detail or "", turn_id)


def _report_turn_outcome_to_api(outcome: str, reason_code: str, reason_detail: str, turn_id: str) -> None:
    """Report critical failure outcomes to the API for real-time metrics."""
    # Only report critical failure signals that impact Phase 2 KPIs
    if not (outcome == "BLOCKED" and reason_code == "blocked_tool_failure"):
        return

    from autodebug.config import get_api_base, get_valid_token
    import requests
    from contextlib import suppress
    import threading

    api_base = get_api_base()
    token = get_valid_token()

    payload = {
        "outcome": outcome,
        "reason_code": reason_code,
        "reason_detail": reason_detail,
        "turn_id": turn_id,
    }

    headers = {}
    if token:
        headers["Authorization"] = f"Bearer {token}"

    # Fire-and-forget in background thread to avoid blocking the event loop
    def _post():
        with suppress(Exception):
            requests.post(
                f"{api_base.rstrip('/')}/v1/telemetry/turn_outcome",
                json=payload,
                headers=headers,
                timeout=2,
            )

    threading.Thread(target=_post, daemon=True).start()
    
def _turn_end(
    deps,
    *,
    model_input_tokens: int = 0,
    model_output_tokens: int = 0,
    estimated_model_cost_usd: float = 0.0,
    estimated_tool_cost_usd: float = 0.0,
) -> None:
    """Emit terminal turn.end event with required cost fields."""
    from autodebug.debug_log import log_event

    turn_id = getattr(deps, "_current_turn_id", "")
    if not turn_id:
        return
    # Deterministic fallback: guarantee exactly one outcome event.
    if not getattr(deps, "_turn_outcome_emitted", False):
        fallback_question = _detect_needs_input_question(
            str(getattr(deps, "_last_response_text", "") or "")
        )
        if fallback_question:
            _turn_outcome(
                deps,
                outcome="NEEDS_INPUT",
                reason_code="blocked_missing_context",
                delivered_artifact_kind="none",
                needs_input_question=fallback_question,
            )
        else:
            _turn_outcome(
                deps,
                outcome="BLOCKED",
                reason_code="blocked_tool_failure",
                delivered_artifact_kind="none",
            )
    total = float(estimated_model_cost_usd or 0.0) + float(estimated_tool_cost_usd or 0.0)
    log_event(
        "turn.end",
        turn_id=turn_id,
        model_input_tokens=int(model_input_tokens or 0),
        model_output_tokens=int(model_output_tokens or 0),
        estimated_model_cost_usd=float(estimated_model_cost_usd or 0.0),
        estimated_tool_cost_usd=float(estimated_tool_cost_usd or 0.0),
        total_estimated_cost_usd=round(total, 6),
    )


def _estimate_model_cost_usd(input_tokens: int, output_tokens: int, model: str = "") -> float:
    """Estimate cost using actual model pricing from MODEL_COSTS."""
    from autodebug.llm_config import MODEL_COSTS
    if model and model in MODEL_COSTS:
        ci, co = MODEL_COSTS[model]
    else:
        ci, co = 0.001, 0.005  # conservative fallback
    return int(input_tokens or 0) / 1000 * ci + int(output_tokens or 0) / 1000 * co


def _estimate_tool_cost_usd(deps) -> float:
    # Heuristic proxy until per-tool billing hooks are wired.
    calls = int(getattr(deps, "_current_tool_seq", 0) or 0)
    return round(calls * 0.0002, 6)


async def _handle_stream_error(e, agent, deps, prompt, history, headless, mode: str) -> bool:
    """Shared error handler for rate limits and token refresh. Returns True if recovered."""
    from autodebug.debug_log import debug as _debug, log_event
    import traceback as _tb
    _debug(f"ERROR | {type(e).__name__}: {e}")
    log_event(
        "llm.error", mode=mode,
        error_type=type(e).__name__, error=str(e), error_trace=_tb.format_exc(),
    )
    if _is_rate_limit_error(e):
        import asyncio as _aio
        import random as _rng
        for base_wait in (5, 15, 30):
            wait = base_wait + _rng.uniform(0, base_wait * 0.5)
            _debug(f"RATE_LIMIT | retrying in {wait:.1f}s")
            log_event("llm.rate_limit.retry", wait_s=round(wait), mode=mode)
            _cprint(f"[yellow]{_format_rate_limit_message(e, round(wait))}[/]")
            await _aio.sleep(wait)
            try:
                await _run_agent_stream(agent, deps, prompt, history, headless=headless)
                _auto_compact_history(history, headless=headless)
                return True
            except Exception as retry_err:
                if not _is_rate_limit_error(retry_err):
                    _cprint(f"\n[red]{_format_api_error(retry_err)}[/]")
                    return False
        _cprint("[red]Rate limit persists after retries. Try again in a minute.[/]")
        return False
    if _is_token_expired_error(e) and _refresh_deps_token(deps):
        with suppress(Exception):
            agent._model._provider._client.api_key = deps.token
        if not headless:
            _cprint("[dim yellow]Token refreshed — retrying...[/]")
        try:
            await _run_agent_stream(agent, deps, prompt, history, headless=headless)
            return True
        except Exception:
            pass
    _cprint(f"\n[red]{_format_api_error(e)}[/]")
    return False


async def _run_agent_stream(
    agent, deps, prompt, history, *,
    headless: bool = False,
    spinner_text: str = "Thinking...",
    model_settings=None,
):
    """Stream agent response, buffer output text, update history.

    Returns (buffer_text, response). Raises on error.
    """
    from pydantic_ai import UsageLimits
    from rich.status import Status

    buffer = ""
    got_first_chunk = False
    status = Status(f"[dim]{spinner_text}[/]", console=console, spinner="dots")
    status.start()

    try:
        async with agent.run_stream(
            prompt,
            deps=deps,
            message_history=history if history else None,
            usage_limits=UsageLimits(request_limit=50, tool_calls_limit=50),
            model_settings=model_settings,
        ) as response:
            async for chunk in response.stream_text(delta=True):
                if not got_first_chunk:
                    status.stop()
                    got_first_chunk = True
                    if not headless:
                        _cprint()
                buffer += chunk
                if headless:
                    print(chunk, end="", flush=True)
                else:
                    _cprint(chunk, end="", highlight=False)

        if not got_first_chunk:
            status.stop()
        elif buffer.strip():
            if headless:
                print()
            else:
                _cprint()

        history.clear()
        history.extend(response.all_messages())
        return buffer, response
    except BaseException:
        with suppress(RuntimeError, OSError):
            status.stop()
        raise


def _record_cost(response, mode: str, headless: bool):
    """Display and log token usage from a pydantic-ai response."""
    if headless:
        return
    with suppress(Exception):
        from autodebug.debug_log import debug as _debug, log_event
        usage = response.usage()
        parts = []
        if usage.request_tokens:
            parts.append(f"in:{usage.request_tokens:,}")
        if usage.response_tokens:
            parts.append(f"out:{usage.response_tokens:,}")
        if usage.total_tokens:
            parts.append(f"total:{usage.total_tokens:,}")
        if parts:
            _cprint(f"[dim]tokens: {' | '.join(parts)}[/]")
        _debug(f"TOKENS | {' | '.join(parts)}")
        log_event(
            "llm.tokens",
            mode=mode,
            request_tokens=usage.request_tokens,
            response_tokens=usage.response_tokens,
            total_tokens=usage.total_tokens,
        )


async def _process_quiet(agent, deps, prompt, history, headless) -> bool:
    """Quiet mode: spinner while thinking, then show final response."""
    import time as _time
    from autodebug.debug_log import debug as _debug, log_event, write_session_artifact
    from autodebug.agent.tools import reset_tool_tracking

    prompt_preview = prompt[:200] if isinstance(prompt, str) else str(prompt)[:200]
    prompt_len = len(prompt) if isinstance(prompt, str) else None
    prompt_is_multimodal = not isinstance(prompt, str)
    _debug(f'PROMPT | "{prompt_preview}" ({prompt_len if prompt_len is not None else "multimodal"} chars)')
    prompt_full = str(prompt) if prompt_is_multimodal else prompt
    prompt_path = write_session_artifact("llm_prompt", prompt_full, "txt") if prompt_full else None
    log_event(
        "llm.prompt", mode="quiet", prompt_preview=prompt_preview,
        prompt_len=prompt_len, prompt_is_multimodal=prompt_is_multimodal,
        prompt=prompt_full, prompt_path=prompt_path,
    )
    _turn_started_at = _time.monotonic()
    _turn_id = _turn_start(
        deps,
        backend="pydantic-ai",
        prompt=prompt,
        is_action_prompt=_is_action_prompt(prompt),
    )
    reset_tool_tracking()

    try:
        _action_settings = _force_tool_model_settings(deps) if _is_action_prompt(prompt) else None
        buffer, response = await _run_agent_stream(
            agent, deps, prompt, history,
            headless=headless, model_settings=_action_settings,
        )

        loop_break_msg = _check_loop_detection(deps, history)
        if loop_break_msg and not headless:
            _cprint(f"\n[bold yellow]Loop detected:[/] [yellow]{loop_break_msg}[/]")

        _record_cost(response, "quiet", headless)
        deps._last_response_text = buffer
        _emit_quiet_tool_lineage_from_history(deps, history)

        _tool_calls_this_turn = _count_current_turn_tool_calls(history)
        if deps._skill_budget > 0:
            deps._skill_turns_used += _tool_calls_this_turn
            if deps._skill_turns_used >= deps._skill_budget:
                deps._turn_budget_exhausted = True

        turn_elapsed = _time.monotonic() - _turn_started_at
        _debug(f"RESPONSE | {len(buffer):,} chars streamed")
        _debug(f"TURN COMPLETE | quiet mode, {turn_elapsed:.1f}s total")
        response_path = write_session_artifact("llm_response", buffer, "txt") if buffer else None
        log_event(
            "llm.response", mode="quiet", response_len=len(buffer),
            turn_elapsed_s=round(turn_elapsed, 3), response_path=response_path,
        )
        if not getattr(deps, "_turn_budget_exhausted", False):
            with suppress(Exception):
                usage = response.usage()
                _turn_outcome(
                    deps,
                    outcome="DELIVER",
                    reason_code="answered",
                    delivered_artifact_kind="answer" if buffer.strip() else "none",
                )
                _turn_end(
                    deps,
                    model_input_tokens=usage.request_tokens or 0,
                    model_output_tokens=usage.response_tokens or 0,
                    estimated_model_cost_usd=_estimate_model_cost_usd(
                        usage.request_tokens or 0,
                        usage.response_tokens or 0,
                    ),
                    estimated_tool_cost_usd=_estimate_tool_cost_usd(deps),
                )

        with suppress(Exception):
            from autodebug.agent.context import mask_old_observations, check_context_pressure
            mask_old_observations(history, deps.project_path)
            deps._context_pressure = check_context_pressure(history)

        _auto_compact_history(history, headless=headless)

        if getattr(deps, "_turn_budget_exhausted", False):
            synth_buffer, synth_response = await _run_forced_synthesis_pass(
                agent,
                deps,
                history,
                headless=headless,
                mode="quiet",
            )
            if synth_response is not None:
                deps._last_response_text = synth_buffer
                with suppress(Exception):
                    usage = synth_response.usage()
                    _turn_outcome(
                        deps,
                        outcome="DELIVER",
                        reason_code="forced_summary_budget_exhausted",
                        delivered_artifact_kind="answer" if synth_buffer.strip() else "none",
                    )
                    _turn_end(
                        deps,
                        model_input_tokens=usage.request_tokens or 0,
                        model_output_tokens=usage.response_tokens or 0,
                        estimated_model_cost_usd=_estimate_model_cost_usd(
                            usage.request_tokens or 0,
                            usage.response_tokens or 0,
                        ),
                        estimated_tool_cost_usd=_estimate_tool_cost_usd(deps),
                    )
                    return True

        if not buffer.strip() and not headless:
            _cprint("\n[dim yellow]Agent returned no text. Try asking again or use /verbose to see tool calls.[/]")
        elif buffer.strip() and not headless:
            with suppress(Exception):
                from pydantic_ai.messages import ToolCallPart, ModelRequest, UserPromptPart
                current_turn_start = 0
                for i, msg in enumerate(history):
                    if isinstance(msg, ModelRequest) and any(
                        isinstance(p, UserPromptPart) for p in msg.parts
                    ):
                        current_turn_start = i
                has_tool_calls = any(
                    any(isinstance(p, ToolCallPart) for p in msg.parts)
                    for msg in history[current_turn_start:]
                    if hasattr(msg, "parts")
                )
                if not has_tool_calls and _is_action_prompt(prompt):
                    if not headless:
                        _cprint("[dim yellow]Agent planned but didn't call tools — retrying with nudge...[/]")
                    _debug("NO_TOOLS | Agent narrated without tool calls, auto-retrying with nudge")
                    log_event("llm.no_tools_retry", prompt_preview=str(prompt)[:200], mode="quiet")
                    nudge = (
                        "You just described a plan but didn't call any tools. "
                        "Execute the plan NOW — call the appropriate tools immediately. "
                        "Do not narrate what you will do; do it."
                    )
                    try:
                        _retry_settings = _force_tool_model_settings(deps)
                        buffer, _ = await _run_agent_stream(
                            agent, deps, nudge, history,
                            headless=headless, spinner_text="Retrying...",
                            model_settings=_retry_settings,
                        )
                        current_turn_start = 0
                        for i, msg in enumerate(history):
                            if isinstance(msg, ModelRequest) and any(
                                isinstance(p, UserPromptPart) for p in msg.parts
                            ):
                                current_turn_start = i
                        has_retry_tools = any(
                            any(isinstance(p, ToolCallPart) for p in msg.parts)
                            for msg in history[current_turn_start:]
                            if hasattr(msg, "parts")
                        )
                        if not has_retry_tools:
                            _cprint("[dim yellow]Warning: Agent still didn't call tools after retry.[/]")
                    except Exception as retry_err:
                        _debug(f"RETRY_ERROR | {type(retry_err).__name__}: {retry_err}")
                        _cprint(f"[dim yellow]Warning: Retry failed ({type(retry_err).__name__}). Try again or check /status.[/]")

        return True

    except KeyboardInterrupt:
        _debug("INTERRUPTED | User pressed Ctrl+C")
        log_event("session.interrupted", mode="quiet")
        _turn_outcome(deps, outcome="BLOCKED", reason_code="interrupted", delivered_artifact_kind="none")
        _turn_end(deps)
        _cprint("\n[yellow]Interrupted.[/]")
        return False
    except Exception as e:
        _debug(f"ERROR | {type(e).__name__}: {e}")
        import traceback as _tb
        log_event(
            "llm.error", mode="quiet",
            error_type=type(e).__name__, error=str(e), error_trace=_tb.format_exc(),
        )
        _turn_outcome(deps, outcome="BLOCKED", reason_code="blocked_tool_failure", delivered_artifact_kind="none")
        _turn_end(deps)
        if _is_rate_limit_error(e):
            import asyncio as _aio
            import random as _rng
            for base_wait in (5, 15, 30):
                wait = base_wait + _rng.uniform(0, base_wait * 0.5)
                _debug(f"RATE_LIMIT | retrying in {wait:.1f}s")
                log_event("llm.rate_limit.retry", wait_s=round(wait), mode="quiet")
                _cprint(f"[yellow]{_format_rate_limit_message(e, round(wait))}[/]")
                await _aio.sleep(wait)
                try:
                    await _run_agent_stream(agent, deps, prompt, history, headless=headless)
                    _auto_compact_history(history, headless=headless)
                    return True
                except Exception as retry_err:
                    if not _is_rate_limit_error(retry_err):
                        _cprint(f"\n[red]{_format_api_error(retry_err)}[/]")
                        return False
            _cprint("[red]Rate limit persists after retries. Try again in a minute.[/]")
            return False
        if _is_token_expired_error(e) and _refresh_deps_token(deps):
            with suppress(Exception):
                agent._model._provider._client.api_key = deps.token
            if not headless:
                _cprint("[dim yellow]Token refreshed — retrying...[/]")
            try:
                await _run_agent_stream(agent, deps, prompt, history, headless=headless)
                return True
            except Exception:
                pass
        _cprint(f"\n[red]{_format_api_error(e)}[/]")
        return False


# --- Event dispatch table for verbose streaming ---
# Maps PydanticAI event type names to handler descriptions.
# Used by _process_verbose to dispatch events without isinstance chains.
_EVENT_HANDLERS = {
    "FunctionToolCallEvent": "tool_call",
    "FunctionToolResultEvent": "tool_result",
    "PartStartEvent": "part_start",
    "PartDeltaEvent": "part_delta",
}

_PART_START_HANDLERS = {
    "TextPart": "text_start",
    "ThinkingPart": "thinking_start",
}

_PART_DELTA_HANDLERS = {
    "TextPartDelta": "text_delta",
    "ThinkingPartDelta": "thinking_delta",
}



# --- Verbose streaming helpers (extracted from _process_verbose for CC reduction) ---


class _VerboseState:
    """Mutable state shared between verbose mode streaming helpers."""
    __slots__ = (
        "text_buffer", "tool_count", "active_status", "tool_start_time",
        "elapsed_task", "event_text_parts", "got_first_text", "_retry_handled",
        "loop_break_msg",
    )
    def __init__(self):
        self.text_buffer = ""
        self.tool_count = 0
        self.active_status = None
        self.tool_start_time = 0.0
        self.elapsed_task = None
        self.event_text_parts = []
        self.got_first_text = False
        self._retry_handled = False
        self.loop_break_msg = None


def _verbose_cleanup_spinner(st):
    """Stop any active spinner and elapsed timer in verbose state."""
    if st.elapsed_task:
        st.elapsed_task.cancel()
    if st.active_status:
        st.active_status.stop()


async def _verbose_update_spinner(st, deps):
    """Background task that updates the verbose mode spinner with elapsed time."""
    import asyncio as _aio
    import time as _time
    try:
        from autodebug.agent.tools import get_tool_progress
    except ImportError:
        get_tool_progress = None
    try:
        while True:
            await _aio.sleep(1)
            if st.active_status and st.tool_start_time > 0:
                elapsed = int(_time.monotonic() - st.tool_start_time)
                progress_str = ""
                if get_tool_progress:
                    prog = get_tool_progress()
                    if prog.get("total"):
                        progress_str = f" {prog['completed']}/{prog['total']} tests"
                        current = prog.get("current", "")
                        if current:
                            short = current.split("::")[-1] if "::" in current else current
                            if len(short) > 40:
                                short = short[:37] + "..."
                            progress_str += f" ({short})"
                st.active_status.update(f"    [dim]running...{progress_str} ({elapsed}s)[/]")
    except _aio.CancelledError:
        pass


def _verbose_make_event_handler(st, deps, headless):
    """Create the event handler closure for verbose streaming."""
    import time as _time
    from autodebug.agent.tools import _looks_like_error
    from autodebug.debug_log import debug as _debug, log_event, write_session_artifact

    async def _handler(ctx, events):
        async for event in events:
            try:
                _etype = type(event).__name__
                if _etype == "FunctionToolCallEvent":
                    st.tool_count += 1
                    tool = event.part
                    deps._current_tool_seq = int(getattr(deps, "_current_tool_seq", 0) or 0) + 1
                    _tool_seq = deps._current_tool_seq
                    _turn_id = getattr(deps, "_current_turn_id", "")
                    # Loop detection + budget tracking (runs early, before display formatting)
                    _detector = getattr(deps, "_loop_detector", None)
                    if _detector:
                        _tool_args = tool.args if type(tool.args) is dict else {}
                        _loop_msg = _detector.record_call(tool.tool_name, _tool_args)
                        if _loop_msg:
                            _cprint(f"\n  [bold yellow]LOOP DETECTED:[/] [yellow]{_loop_msg}[/]")
                            st.loop_break_msg = _loop_msg
                    if deps._skill_budget > 0:
                        deps._skill_turns_used += 1
                        _remaining = max(0, deps._skill_budget - deps._skill_turns_used)
                        if _remaining == 0:
                            _cprint(f"\n  [bold yellow]BUDGET EXHAUSTED[/] [yellow]Tool call budget reached.[/]")
                            deps._turn_budget_exhausted = True
                            _turn_synthesis_attempt(deps, reason="budget_exhausted")
                    # Format args for display
                    args_str = ""
                    if tool.args:
                        if type(tool.args) is dict:
                            pairs = []
                            for k, v in tool.args.items():
                                vs = str(v)
                                if len(vs) > 200:
                                    vs = vs[:197] + "..."
                                pairs.append(f"{k}={vs}")
                            args_str = ", ".join(pairs)
                        else:
                            args_str = str(tool.args)[:200]
                    if st.active_status:
                        st.active_status.stop()
                        st.active_status = None
                    if st.elapsed_task:
                        st.elapsed_task.cancel()
                        st.elapsed_task = None
                    _debug(f"TOOL_CALL | {tool.tool_name}({args_str[:200]})")
                    log_event(
                        "tool.call",
                        turn_id=_turn_id,
                        tool_call_seq=_tool_seq,
                        tool_name=tool.tool_name,
                        args_preview=args_str[:500],
                        args_len=len(args_str),
                        tool_index=st.tool_count,
                    )
                    _cprint(f"\n  [yellow]>[/] [bold]{tool.tool_name}[/]({args_str})")
                    desc = _TOOL_DESCRIPTIONS.get(tool.tool_name)
                    if desc:
                        _cprint(f"    [dim]{desc}[/]")
                    st.tool_start_time = _time.monotonic()
                    from rich.status import Status as RichStatus
                    st.active_status = RichStatus("    [dim]running... (0s)[/]", spinner="dots", console=console)
                    st.active_status.start()
                    import asyncio as _aio
                    st.elapsed_task = _aio.create_task(_verbose_update_spinner(st, deps))

                elif _etype == "FunctionToolResultEvent":
                    elapsed = int(_time.monotonic() - st.tool_start_time) if st.tool_start_time > 0 else 0
                    _turn_id = getattr(deps, "_current_turn_id", "")
                    _tool_seq = int(getattr(deps, "_current_tool_seq", 0) or 0)
                    if st.elapsed_task:
                        st.elapsed_task.cancel()
                        st.elapsed_task = None
                    if st.active_status:
                        st.active_status.stop()
                        st.active_status = None
                    st.tool_start_time = 0.0
                    elapsed_str = f" in {elapsed}s" if elapsed >= 1 else ""
                    result = event.result
                    result_len = len(str(result.content)) if result.content else 0
                    _debug(f"TOOL_RESULT | {result_len:,} chars ({elapsed}s)")
                    if not result.content:
                        log_event(
                            "tool.result",
                            turn_id=_turn_id,
                            tool_call_seq=_tool_seq,
                            tool_name=getattr(getattr(event, "part", None), "tool_name", None),
                            duration_ms=max(0, elapsed * 1000),
                            result_status="ok",
                            result_len=result_len,
                            elapsed_s=elapsed,
                            result_preview="",
                        )
                        _cprint(f"    [dim](empty){elapsed_str}[/]")
                    else:
                        content_str = str(result.content)
                        artifact_path = write_session_artifact("tool_result", content_str, "txt")
                        _result_status = "error" if _looks_like_error(content_str) else "ok"
                        log_event(
                            "tool.result",
                            turn_id=_turn_id,
                            tool_call_seq=_tool_seq,
                            tool_name=getattr(getattr(event, "part", None), "tool_name", None),
                            duration_ms=max(0, elapsed * 1000),
                            result_status=_result_status,
                            result_len=result_len,
                            elapsed_s=elapsed,
                            result_preview=content_str[:2000],
                            result_path=artifact_path,
                        )
                        _report_markers = ["# Codebase Scan Report", "Flaky Pattern Analysis", "**Healing Mode:"]
                        if any(marker in content_str[:200] for marker in _report_markers):
                            from rich.panel import Panel
                            from rich.markdown import Markdown as RichMarkdown
                            panel_content = content_str[:4000]
                            if len(content_str) > 4000:
                                panel_content += f"\n\n*... truncated for display ({len(content_str):,} chars total)*"
                                if artifact_path:
                                    panel_content += f"\n*Full results: {artifact_path}*"
                            panel = Panel(RichMarkdown(panel_content), border_style="dim", expand=False,
                                          title=f"[dim]{elapsed_str.strip()}[/]" if elapsed_str else None)
                            _cprint(panel)
                        else:
                            clines = content_str.split('\n')
                            if len(clines) > 5 and len(content_str) > 300:
                                preview = '\n'.join(clines[:4])[:300]
                                _cprint(f"    [dim green]OK[/] [dim]({len(content_str)} chars, {len(clines)} lines){elapsed_str}[/]")
                                _cprint(f"    [dim]{preview}...[/]")
                            elif len(content_str) > 120:
                                first_line = clines[0][:120]
                                _cprint(f"    [dim green]OK[/] [dim]({len(content_str)} chars){elapsed_str} {first_line}...[/]")
                            else:
                                _cprint(f"    [dim green]OK[/] [dim]{content_str}{elapsed_str}[/]")
                    st.tool_start_time = _time.monotonic()
                    from rich.status import Status as RichStatus
                    st.active_status = RichStatus("  [dim]thinking...[/]", spinner="dots", console=console)
                    st.active_status.start()

                elif _etype == "PartStartEvent":
                    _ptype = type(event.part).__name__
                    if _ptype == "TextPart":
                        if event.part.content:
                            if st.active_status:
                                st.active_status.stop()
                                st.active_status = None
                            if st.elapsed_task:
                                st.elapsed_task.cancel()
                                st.elapsed_task = None
                            _cprint(event.part.content, end="", highlight=False)
                            st.event_text_parts.append(event.part.content)
                    elif _ptype == "ThinkingPart":
                        if event.part.content:
                            _cprint(f"  [dim italic]{event.part.content[:200]}[/]")

                elif _etype == "PartDeltaEvent":
                    _dtype = type(event.delta).__name__
                    if _dtype == "TextPartDelta":
                        if event.delta.content_delta:
                            if st.active_status:
                                st.active_status.stop()
                                st.active_status = None
                            if st.elapsed_task:
                                st.elapsed_task.cancel()
                                st.elapsed_task = None
                            _cprint(event.delta.content_delta, end="", highlight=False)
                            st.event_text_parts.append(event.delta.content_delta)
                    elif _dtype == "ThinkingPartDelta":
                        if event.delta.content_delta:
                            _cprint(f"[dim italic]{event.delta.content_delta}[/]", end="")
            except Exception as ev_err:
                _ev_str = str(ev_err)
                if not any(pat in _ev_str for pat in (
                    "rich._unicode_data", "rich.markup", "MarkupError",
                    "ConsoleOptions", "NoneType", "closed file",
                )):
                    _cprint(f"  [dim red]Event error: {ev_err}[/]")

    return _handler


async def _verbose_stream_text(st, response, headless):
    """Stream text chunks from the response, updating verbose state."""
    async for chunk in response.stream_text(delta=True):
        if st.active_status:
            st.active_status.stop()
            st.active_status = None
        if st.elapsed_task:
            st.elapsed_task.cancel()
            st.elapsed_task = None
        st.text_buffer += chunk
        if st.event_text_parts:
            st.event_text_parts.clear()
            continue
        if not st.got_first_text:
            st.got_first_text = True
            if not headless:
                _cprint()
        if headless:
            print(chunk, end="", flush=True)
        else:
            _cprint(chunk, end="", highlight=False)


async def _verbose_no_tools_retry(agent, deps, st, prompt, history, headless, response, event_handler):
    """Auto-retry if agent narrated without calling tools."""
    from contextlib import suppress
    from pydantic_ai import UsageLimits
    from autodebug.debug_log import debug as _debug, log_event
    actual_tool_calls = st.tool_count
    if actual_tool_calls == 0:
        with suppress(Exception):
            actual_tool_calls = sum(
                1 for msg in response.all_messages()
                if hasattr(msg, "parts")
                for part in msg.parts
                if hasattr(part, "tool_name")
            )
    if actual_tool_calls != 0:
        return
    _prompt_is_str = type(prompt) is str
    prompt_text = prompt if _prompt_is_str else str(prompt[0]) if prompt else ""
    if not _is_action_prompt(prompt):
        return
    if not headless:
        _cprint("[dim yellow]Agent planned but didn't call tools \u2014 retrying with nudge...[/]")
    _debug("NO_TOOLS | Agent narrated without tool calls, auto-retrying with nudge")
    log_event("llm.no_tools_retry", prompt_preview=prompt_text[:200])
    history.clear()
    history.extend(response.all_messages())
    nudge = (
        "You just described a plan but didn't call any tools. "
        "Execute the plan NOW \u2014 call the appropriate tools immediately. "
        "Do not narrate what you will do; do it."
    )
    st.tool_count = 0
    st.text_buffer = ""
    st.got_first_text = False
    st.event_text_parts.clear()
    _retry_settings = _force_tool_model_settings(deps)
    try:
        async with agent.run_stream(
            nudge, deps=deps, message_history=history,
            event_stream_handler=event_handler,
            usage_limits=UsageLimits(request_limit=50, tool_calls_limit=50),
            model_settings=_retry_settings,
        ) as retry_resp:
            await _verbose_stream_text(st, retry_resp, headless)
        if st.text_buffer.strip():
            if headless:
                print()
            else:
                _cprint()
        history.clear()
        history.extend(retry_resp.all_messages())
        st._retry_handled = True
        retry_tool_calls = st.tool_count
        if retry_tool_calls == 0:
            with suppress(Exception):
                retry_tool_calls = sum(
                    1 for msg in retry_resp.all_messages()
                    if hasattr(msg, "parts")
                    for part in msg.parts
                    if hasattr(part, "tool_name")
                )
        if retry_tool_calls == 0 and not headless:
            _cprint("[dim yellow]Warning: Agent still didn't call tools after retry. The model may not support tool calling for this request, or the API may be having issues. Try again or check /status.[/]")
    except Exception as retry_err:
        _debug(f"RETRY_ERROR | {type(retry_err).__name__}: {retry_err}")
        if not headless:
            _cprint(f"[dim yellow]Warning: Retry failed ({type(retry_err).__name__}). Try again or check /status.[/]")


def _verbose_log_tokens_and_finalize(st, response, deps, history, headless, turn_start):
    """Log token usage, turn metrics, and finalize verbose turn."""
    import time as _time
    from contextlib import suppress
    from autodebug.debug_log import debug as _debug, log_event, write_session_artifact
    # Merge inline detection (from event handler) with post-stream detection
    loop_break_msg = st.loop_break_msg or _check_loop_detection(deps, history)
    if loop_break_msg:
        if not headless:
            _cprint(f"\n[bold yellow]Loop detected:[/] [yellow]{loop_break_msg}[/]")
        # Inject the break message into history so the agent sees it on the next turn
        with suppress(Exception):
            from pydantic_ai.messages import ModelRequest, UserPromptPart
            history.append(ModelRequest(parts=[
                UserPromptPart(content=(
                    f"SYSTEM: {loop_break_msg}\n\n"
                    "Summarize what you have found so far and present your results. "
                    "Do NOT make more tool calls for this request."
                ))
            ]))
    if not headless:
        with suppress(Exception):
            usage = response.usage()
            parts = []
            if usage.request_tokens:
                parts.append(f"in:{usage.request_tokens:,}")
            if usage.response_tokens:
                parts.append(f"out:{usage.response_tokens:,}")
            if usage.total_tokens:
                parts.append(f"total:{usage.total_tokens:,}")
            if parts:
                _cprint(f"[dim]tokens: {' | '.join(parts)}[/]")
            _debug(f"TOKENS | {' | '.join(parts)}")
            _tc = getattr(deps, "_session_token_counter", None)
            if _tc is not None:
                _tc["request_tokens"] += usage.request_tokens or 0
                _tc["response_tokens"] += usage.response_tokens or 0
                _tc["total_tokens"] += usage.total_tokens or 0
            log_event("llm.tokens", mode="verbose", request_tokens=usage.request_tokens,
                      response_tokens=usage.response_tokens, total_tokens=usage.total_tokens)
    response_len = len(st.text_buffer)
    turn_elapsed = _time.monotonic() - turn_start
    _debug(f"RESPONSE | {response_len:,} chars streamed")
    msg_tool_count = 0
    with suppress(Exception):
        msg_tool_count = sum(
            1 for msg in response.all_messages()
            if hasattr(msg, "parts")
            for part in msg.parts
            if hasattr(part, "tool_name")
        )
    effective_tool_count = max(st.tool_count, msg_tool_count)
    _debug(f"TURN COMPLETE | {effective_tool_count} tool calls, {turn_elapsed:.1f}s total")
    response_path = write_session_artifact("llm_response", st.text_buffer, "txt") if st.text_buffer else None
    log_event("llm.response", mode="verbose", response_len=response_len, tool_calls=effective_tool_count,
              turn_elapsed_s=round(turn_elapsed, 3), response_path=response_path)
    with suppress(Exception):
        from autodebug.agent.context import mask_old_observations, check_context_pressure
        mask_old_observations(history, deps.project_path)
        deps._context_pressure = check_context_pressure(history)
    _auto_compact_history(history, headless=headless)


async def _verbose_handle_api_errors(e, agent, deps, prompt, history, headless, event_handler):
    """Handle API errors in verbose mode with rate-limit and token-expired retries."""
    from pydantic_ai import UsageLimits
    if _is_rate_limit_error(e):
        import asyncio as _aio
        import random as _rng
        from autodebug.debug_log import debug as _debug, log_event
        for base_wait in (5, 15, 30):
            wait = base_wait + _rng.uniform(0, base_wait * 0.5)
            _debug(f"RATE_LIMIT | retrying in {wait:.1f}s")
            log_event("llm.rate_limit.retry", wait_s=round(wait), mode="verbose")
            _cprint(f"[yellow]{_format_rate_limit_message(e, round(wait))}[/]")
            await _aio.sleep(wait)
            try:
                async with agent.run_stream(
                    prompt, deps=deps,
                    message_history=history if history else None,
                    usage_limits=UsageLimits(request_limit=50, tool_calls_limit=50),
                ) as response:
                    async for chunk in response.stream_text(delta=True):
                        if headless:
                            print(chunk, end="", flush=True)
                        else:
                            _cprint(chunk, end="", highlight=False)
                if headless:
                    print()
                else:
                    _cprint()
                history.clear()
                history.extend(response.all_messages())
                _auto_compact_history(history, headless=headless)
                return True
            except Exception as retry_err:
                if not _is_rate_limit_error(retry_err):
                    _cprint(f"\n[red]{_format_api_error(retry_err)}[/]")
                    return False
        _cprint("[red]Rate limit persists after retries. Try again in a minute.[/]")
        return False
    if _is_token_expired_error(e) and _refresh_deps_token(deps):
        from contextlib import suppress
        with suppress(Exception):
            agent._model._provider._client.api_key = deps.token
        if not headless:
            _cprint("[dim yellow]Token refreshed \u2014 retrying...[/]")
        try:
            async with agent.run_stream(
                prompt, deps=deps,
                message_history=history if history else None,
                event_stream_handler=event_handler,
                usage_limits=UsageLimits(request_limit=50, tool_calls_limit=50),
            ) as response:
                async for chunk in response.stream_text(delta=True):
                    if headless:
                        print(chunk, end="", flush=True)
                    else:
                        _cprint(chunk, end="", highlight=False)
            if headless:
                print()
            else:
                _cprint()
            history.clear()
            history.extend(response.all_messages())
            return True
        except Exception:
            pass
    _cprint(f"\n[red]{_format_api_error(e)}[/]")
    return False



async def _process_verbose(agent, deps, prompt, history, headless) -> bool:
    """Verbose mode: live-stream every event as it happens.

    Returns True on success, False on error.
    """
    import time as _time
    from autodebug.debug_log import debug as _debug, log_event, write_session_artifact
    from pydantic_ai import UsageLimits
    from autodebug.agent.tools import reset_tool_tracking

    st = _VerboseState()
    _prompt_is_str = type(prompt) is str
    prompt_preview = prompt[:200] if _prompt_is_str else str(prompt)[:200]
    prompt_len = len(prompt) if _prompt_is_str else None
    prompt_is_multimodal = not _prompt_is_str
    _debug(f'PROMPT | "{prompt_preview}" ({prompt_len if prompt_len is not None else "multimodal"} chars)')
    prompt_full = str(prompt) if prompt_is_multimodal else prompt
    prompt_path = write_session_artifact("llm_prompt", prompt_full, "txt") if prompt_full else None
    log_event("llm.prompt", mode="verbose", prompt_preview=prompt_preview, prompt_len=prompt_len,
              prompt_is_multimodal=prompt_is_multimodal, prompt=prompt_full, prompt_path=prompt_path)

    event_handler = _verbose_make_event_handler(st, deps, headless)
    reset_tool_tracking()
    turn_start = _time.monotonic()
    _turn_id = _turn_start(
        deps,
        backend="pydantic-ai",
        prompt=prompt,
        is_action_prompt=_is_action_prompt(prompt),
    )

    # Set up SIGINT handler so Ctrl+C cancels the stream task
    import signal
    _cancel_stream = False

    def _sigint_handler(signum, frame):
        nonlocal _cancel_stream
        _cancel_stream = True
        raise KeyboardInterrupt

    try:
        _prev_handler = signal.signal(signal.SIGINT, _sigint_handler)
    except (ValueError, OSError):
        _prev_handler = signal.SIG_DFL  # not on main thread

    try:
        _action_settings = _force_tool_model_settings(deps) if _is_action_prompt(prompt) else None
        async with agent.run_stream(
            prompt, deps=deps, message_history=history if history else None,
            event_stream_handler=event_handler,
            usage_limits=UsageLimits(request_limit=50, tool_calls_limit=50),
            model_settings=_action_settings,
        ) as response:
            await _verbose_stream_text(st, response, headless)

        if (st.got_first_text or st.event_text_parts) and st.text_buffer.strip():
            if headless:
                print()
            else:
                _cprint()
            await _verbose_no_tools_retry(agent, deps, st, prompt, history, headless, response, event_handler)
        elif not st.text_buffer.strip():
            _cprint("\n[dim yellow]Agent returned no text output.[/]")

        if not st._retry_handled:
            history.clear()
            history.extend(response.all_messages())
        if getattr(deps, "_turn_budget_exhausted", False):
            synth_buffer, synth_response = await _run_forced_synthesis_pass(
                agent,
                deps,
                history,
                headless=headless,
                mode="verbose",
            )
            if synth_response is not None:
                response = synth_response
                st.text_buffer = synth_buffer
                deps._last_response_text = st.text_buffer
                history.clear()
                history.extend(synth_response.all_messages())
        else:
            deps._last_response_text = st.text_buffer
        _verbose_log_tokens_and_finalize(st, response, deps, history, headless, turn_start)
        _turn_reason = "forced_summary_budget_exhausted" if getattr(deps, "_turn_budget_exhausted", False) else "answered"
        _turn_outcome(
            deps,
            outcome="DELIVER",
            reason_code=_turn_reason,
            delivered_artifact_kind="answer" if st.text_buffer.strip() else "none",
        )
        with suppress(Exception):
            usage = response.usage()
            _turn_end(
                deps,
                model_input_tokens=usage.request_tokens or 0,
                model_output_tokens=usage.response_tokens or 0,
                estimated_model_cost_usd=_estimate_model_cost_usd(
                    usage.request_tokens or 0,
                    usage.response_tokens or 0,
                ),
                estimated_tool_cost_usd=_estimate_tool_cost_usd(deps),
            )
        with suppress(Exception):
            # If usage is unavailable for any reason, still close lifecycle deterministically.
            if not getattr(deps, "_turn_outcome_emitted", False):
                _turn_outcome(deps, outcome="DELIVER", reason_code="answered", delivered_artifact_kind="answer")
            _turn_end(deps)
        with suppress(Exception):
            signal.signal(signal.SIGINT, _prev_handler)
        return True

    except KeyboardInterrupt:
        _verbose_cleanup_spinner(st)
        with suppress(Exception):
            signal.signal(signal.SIGINT, _prev_handler)
        _debug("INTERRUPTED | User pressed Ctrl+C")
        log_event("session.interrupted", mode="verbose")
        _turn_outcome(deps, outcome="BLOCKED", reason_code="interrupted", delivered_artifact_kind="none")
        _turn_end(deps)
        _cprint("\n[yellow]Interrupted.[/]")
        return False
    except Exception as e:
        _verbose_cleanup_spinner(st)
        with suppress(Exception):
            signal.signal(signal.SIGINT, _prev_handler)
        # Suppress cosmetic rich stream failures that can occur during teardown
        # (e.g. MarkupError / closed file) so user-facing flow stays stable.
        _err_s = str(e)
        if any(pat in _err_s for pat in ("MarkupError", "closed file")):
            _debug(f"VERBOSE_COSMETIC_ERROR | {type(e).__name__}: {_err_s}")
            return False
        # Add jitter to verbose retry timing to avoid synchronized retries.
        if _is_rate_limit_error(e):
            import random as _rng
            base_wait = 1.0
            wait = base_wait + _rng.uniform(0, base_wait * 0.5)
            await asyncio.sleep(wait)
        _debug(f"ERROR | {type(e).__name__}: {e}")
        import traceback as _tb
        log_event("llm.error", mode="verbose", error_type=type(e).__name__,
                  error=str(e), error_trace=_tb.format_exc())
        _turn_outcome(deps, outcome="BLOCKED", reason_code="blocked_tool_failure", delivered_artifact_kind="none")
        _turn_end(deps)
        return await _verbose_handle_api_errors(e, agent, deps, prompt, history, headless, event_handler)



async def _watch_errors(interval: int, auto_analyze: bool, environment: Optional[str] = None, model: Optional[str] = None):
    """Watch for new production errors."""
    from autodebug.watch import watch_errors

    await watch_errors(interval, auto_analyze, environment=environment, model=model)


# =============================================================================
# Slash command definitions and interactive menu
# =============================================================================

SLASH_COMMANDS = [
    ("/help", "Show all commands and examples"),
    ("/verbose", "Toggle verbose mode — see tool calls & streaming"),
    ("/v", "Toggle verbose mode (shortcut)"),
    ("/errors", "List recent production errors"),
    ("/status", "Show project status and health"),
    ("/mcp", "Show MCP tool migration flag status"),
    ("/image", "Paste image from clipboard"),
    ("/image path.png", "Attach image from a file path"),
    ("/image clear", "Clear all pending images"),
    ("/clear", "Clear conversation history"),
    ("/model", "Show/switch AI model"),
    ("/compact", "Compact conversation to save tokens"),
    ("/memory", "Show project memory (.hikaflow/memory.md)"),
    ("/init <project_id>", "Link project in-chat (no shell needed)"),
    ("/exit", "Exit the session"),
    ("/quit", "Exit the session"),
]


def _extract_project_id_from_repl_command(user_input: str) -> Optional[str]:
    """Extract project_id from common in-chat init command variants."""
    import re

    text = (user_input or "").strip()
    if not text:
        return None

    patterns = [
        r"^/init\s+--project-id\s+(\S+)$",
        r"^/init\s+(\S+)$",
        r"^hikaflow\s+init\s+--project-id\s+(\S+)$",
        r"^init\s+--project-id\s+(\S+)$",
        r"^init\s+project\s+id\s+(\S+)$",
    ]
    for pat in patterns:
        m = re.match(pat, text, flags=re.IGNORECASE)
        if m:
            return m.group(1)
    return None


def _handle_repl_project_init(user_input: str) -> bool:
    """Handle in-chat project initialization commands. Returns True if handled."""
    import uuid as _uuid

    text = (user_input or "").strip()
    project_id = _extract_project_id_from_repl_command(text)

    if project_id is None and text.lower().startswith("/init"):
        _cprint("[yellow]Usage:[/] /init <project_id>")
        return True
    if project_id is None:
        return False

    if project_id in {"<id>", "<project_id>"} or ("<" in project_id and ">" in project_id):
        _cprint("[yellow]Use a real project UUID, not a placeholder.[/]")
        _cprint("[dim]Example: /init 550e8400-e29b-41d4-a716-446655440000[/]")
        return True

    try:
        _uuid.UUID(project_id)
    except ValueError:
        _cprint(f"[red]Invalid project_id:[/] {project_id}")
        _cprint("[dim]Project IDs must be UUIDs.[/]")
        return True

    from autodebug.config import load_credentials, save_credentials

    creds = load_credentials()
    creds["project_id"] = project_id
    save_credentials(creds)

    _cprint(f"[green]Project linked:[/] {project_id}")
    _cprint("[dim]You can now run: /status, /errors, or ask me to scan the codebase.[/]")
    return True


def _handle_nested_hikaflow_command(user_input: str) -> bool:
    """Prevent nested `hikaflow ...` commands from being sent to the LLM loop.

    Users are already inside Hikaflow REPL, so shell-style nested invocations
    should be handled locally with clear guidance.
    """
    import shlex

    text = (user_input or "").strip()
    if not text.lower().startswith("hikaflow "):
        return False

    # Try known in-chat project init path first.
    if _handle_repl_project_init(text):
        return True

    try:
        parts = shlex.split(text)
    except ValueError:
        parts = text.split()

    cmd = parts[1].lower() if len(parts) > 1 else ""
    if cmd in {"status", "errors", "help", "exit"}:
        mapping = {
            "status": "/status",
            "errors": "/errors",
            "help": "/help",
            "exit": "/exit",
        }
        _cprint(
            f"[yellow]You're already inside Hikaflow.[/] "
            f"Use [bold]{mapping[cmd]}[/] instead of `{text}`."
        )
        return True

    _cprint(
        "[yellow]You're already inside Hikaflow chat.[/]\n"
        "[dim]Use slash commands here (e.g. /init, /status, /errors).[/]\n"
        "[dim]If you need shell commands, run /exit first.[/]"
    )
    return True


async def _interactive_model_picker(current_model: str) -> Optional[str]:
    """Show an interactive arrow-key model picker. Returns selected model id or None if cancelled."""
    from autodebug.agent.core import get_supported_models

    models = get_supported_models()
    if not models:
        _cprint("[red]No models available.[/]")
        return None

    # Find current index
    current_idx = 0
    for i, m in enumerate(models):
        if m["id"] == current_model:
            current_idx = i
            break

    _cprint(f"\n[bold]Select model:[/] [dim](arrows to move, enter to select, esc to cancel)[/]\n")

    try:
        from prompt_toolkit import Application
        from prompt_toolkit.key_binding import KeyBindings
        from prompt_toolkit.layout import Layout
        from prompt_toolkit.layout.containers import Window
        from prompt_toolkit.layout.controls import FormattedTextControl
        from prompt_toolkit.styles import Style as PtStyle

        state = {"idx": current_idx, "cancelled": False}

        kb = KeyBindings()

        @kb.add("up")
        @kb.add("k")
        def _up(event):
            state["idx"] = (state["idx"] - 1) % len(models)

        @kb.add("down")
        @kb.add("j")
        def _down(event):
            state["idx"] = (state["idx"] + 1) % len(models)

        @kb.add("enter")
        def _enter(event):
            event.app.exit(result="select")

        @kb.add("escape")
        @kb.add("q")
        @kb.add("c-c")
        def _cancel(event):
            state["cancelled"] = True
            event.app.exit(result="cancel")

        def _get_text():
            lines = []
            for i, m in enumerate(models):
                is_current = m["id"] == current_model
                is_selected = i == state["idx"]
                prefix = " > " if is_selected else "   "
                current_marker = " (current)" if is_current else ""
                if is_selected:
                    lines.append(("class:selected", f"{prefix}{m['name']}{current_marker}\n"))
                    lines.append(("class:dim", f"     {m['id']}  •  {m['provider']}\n"))
                else:
                    lines.append(("class:normal", f"{prefix}{m['name']}{current_marker}\n"))
            return lines

        app: Application = Application(
            layout=Layout(Window(content=FormattedTextControl(_get_text), always_hide_cursor=True)),
            key_bindings=kb,
            style=PtStyle.from_dict({
                "selected": "bold reverse",
                "dim": "italic #888888",
                "normal": "",
            }),
            full_screen=False,
            mouse_support=False,
        )

        await app.run_async()

        if state["cancelled"]:
            _cprint("[dim]Cancelled.[/]")
            return None

        return models[state["idx"]]["id"]

    except Exception:
        # Fallback: numbered list
        _cprint("[dim]Interactive picker unavailable, showing list:[/]")
        for i, m in enumerate(models):
            marker = " [green]<-- current[/]" if m["id"] == current_model else ""
            _cprint(f"  [{i + 1}] {m['id']} [dim]({m['name']})[/]{marker}")
        _cprint(f"\n[dim]Switch with: /model provider:model-name[/]")
        return None


def _create_prompt_session(prompt_str: str = "you > "):
    """Create a prompt_toolkit session with slash command autocomplete and clipboard paste.

    When the user types '/', a dropdown menu appears showing all available
    commands with descriptions. Arrow keys navigate, Tab/Enter select.
    Ctrl+V checks clipboard for images and shows an indicator in the toolbar.
    """
    try:
        from prompt_toolkit import PromptSession
        from prompt_toolkit.completion import Completer, Completion
        from prompt_toolkit.formatted_text import HTML
        from prompt_toolkit.key_binding import KeyBindings
        from prompt_toolkit.styles import Style

        class SlashCompleter(Completer):
            def get_completions(self, document, complete_event):
                text = document.text_before_cursor
                # Only complete when input starts with "/"
                if not text.startswith("/"):
                    return
                typed = text.lower()
                for cmd, desc in SLASH_COMMANDS:
                    if cmd.lower().startswith(typed):
                        yield Completion(
                            cmd,
                            start_position=-len(text),
                            display=cmd,
                            display_meta=desc,
                        )

        # Track clipboard image state for toolbar display
        _clipboard_state = {"image": None, "size": ""}

        def _toolbar():
            if _clipboard_state["image"]:
                return HTML(f'<b>Image attached</b> ({_clipboard_state["size"]}) | ⌘K generate command')
            return HTML('<style bg=""></style>⌘K to generate command')

        # Key bindings for clipboard paste
        kb = KeyBindings()

        @kb.add("c-v")
        def _paste_handler(event):
            """On Ctrl+V, check clipboard for image and show indicator."""
            try:
                from autodebug.clipboard import format_image_size, get_clipboard_image
                result = get_clipboard_image()
                if result:
                    _clipboard_state["image"] = result
                    _clipboard_state["size"] = format_image_size(result[0])
                else:
                    # No image — let prompt_toolkit handle normal text paste
                    event.current_buffer.paste_clipboard_data(
                        event.app.clipboard.get_data()
                    )
            except Exception:
                event.current_buffer.paste_clipboard_data(
                    event.app.clipboard.get_data()
                )

        style = Style.from_dict({
            "prompt": "bold ansigreen",
            "completion-menu.completion": "bg:ansiblack ansiwhite",
            "completion-menu.completion.current": "bg:ansigreen ansiblack bold",
            "completion-menu.meta.completion": "bg:ansiblack ansigray italic",
            "completion-menu.meta.completion.current": "bg:ansigreen ansiblack italic",
            "bottom-toolbar": "bg:ansiblack ansigray",
        })

        session = PromptSession(
            completer=SlashCompleter(),
            style=style,
            complete_while_typing=True,
            key_bindings=kb,
            bottom_toolbar=_toolbar,
        )
        # Expose clipboard state so the REPL can retrieve the pasted image
        session._clipboard_image_state = _clipboard_state
        return session
    except ImportError:
        return None


async def _prompt_input(session, prompt_str: str = "\nyou > ") -> str:
    """Get user input with slash command menu, falling back to Rich console.input()."""
    if session is not None:
        from prompt_toolkit.formatted_text import HTML
        try:
            result = await session.prompt_async(
                HTML(f"\n<prompt>{prompt_str}</prompt>"),
            )
            return result.strip()
        except (EOFError, KeyboardInterrupt):
            raise
    else:
        return console.input(f"\n[bold green]{prompt_str}[/] ").strip()


def _show_help():
    """Show help message."""
    _cprint(Panel(
        "[bold]Commands:[/]  [dim](type / to see the menu)[/]\n\n"
        "[green]/verbose[/] or [green]/v[/] - Toggle verbose mode (show all tool calls & streaming)\n"
        "[green]/errors[/]         - List recent production errors\n"
        "[green]/status[/]         - Show project status\n"
        "[green]/image[/]          - Paste image from clipboard\n"
        "[green]/image path.png[/] - Attach image from file\n"
        "[green]/image clear[/]    - Clear pending images\n"
        "[green]/clear[/]          - Clear conversation history\n"
        "[green]/model[/]          - Show/switch AI model (e.g. /model claude-haiku-4-5-20251001)\n"
        "[green]/compact[/]        - Compact conversation to save tokens\n"
        "[green]/memory[/]         - Show project memory\n"
        "[green]/init <project_id>[/] - Link project in-chat\n"
        "[green]/help[/]           - Show this help\n"
        "[green]/exit[/]           - Exit the session\n\n"
        "[dim]Tip: you're already inside Hikaflow. Use /init, /status, /errors instead of `hikaflow ...`[/]\n\n"
        "[bold]Images:[/]\n"
        "  Use /image to paste a screenshot, then type your question.\n"
        "  Or include a file path in your message: \"what's this error? ~/screenshot.png\"\n\n"
        "[bold]Just talk to me — here are some things I can do:[/]\n\n"
        "[bold]Local Testing:[/]\n"
        '  "run tests in tests/"\n'
        '  "collect all test names"\n'
        '  "scan for flaky tests"\n'
        '  "detect flaky patterns in tests/"\n'
        '  "fix the flaky tests"\n\n'
        "[bold]Production Debugging:[/]\n"
        '  "what errors happened today?"\n'
        '  "debug error err_xxx"\n'
        '  "show the I/O transcript for err_xxx"\n\n'
        "[bold]Environment & Management:[/]\n"
        '  "check my environment"\n'
        '  "show quarantined tests"\n'
        '  "commit these changes"',
        title="Help",
        border_style="green",
    ))


# =============================================================================
# Quarantine Commands (No-Code Quarantine)
# =============================================================================

quarantine_app = typer.Typer(
    name="quarantine",
    help=ADVANCED_HELP_PREFIX + "Manage test quarantine (no code changes required).",
    hidden=True,
    context_settings={"help_option_names": ["--help", "-h"]},
)
app.add_typer(quarantine_app)


@quarantine_app.command("list")
@handle_cli_errors
def quarantine_list(
    ctx: typer.Context,
    limit: int = typer.Option(50, "--limit", "-n", help="Max tests to show.", min=1, max=500),
    project_id: Optional[str] = typer.Option(
        None,
        "--project",
        "-p",
        envvar="HIKAFLOW_PROJECT_ID",
        help="Project ID.",
    ),
):
    """List quarantined tests for a project.

    [bold]Examples:[/]

        hikaflow quarantine list

        hikaflow quarantine list --limit 10
    """
    from autodebug.auth import get_valid_token
    from autodebug.flakiness import QuarantineManager

    output = ctx.obj.get("output", OutputFormatter())
    token = get_valid_token()
    if not token:
        output.error("Not authenticated.", "Run: hikaflow login")
        raise typer.Exit(1)

    if not project_id:
        from autodebug.config import load_credentials
        creds = load_credentials()
        project_id = creds.get("project_id")

    if not project_id:
        output.error("Project ID required.", "Use --project or set HIKAFLOW_PROJECT_ID.")
        raise typer.Exit(1)

    mgr = QuarantineManager(project_id=project_id, token=token)
    tests = mgr.get_quarantined_tests()

    if not tests:
        output.success("No tests are currently quarantined.")
        return

    display = sorted(tests)[:limit]
    title = f"Quarantined Tests ({len(display)}/{len(tests)})" if len(tests) > limit else f"Quarantined Tests ({len(tests)})"
    output.table(
        title=title,
        columns=[
            {"name": "Test ID", "style": "green"},
            {"name": "Score", "justify": "right"},
            {"name": "Reason"},
        ],
        rows=[[tid, "-", "-"] for tid in display],
    )


@quarantine_app.command("add")
@handle_cli_errors
def quarantine_add(
    ctx: typer.Context,
    test_id: str = typer.Argument(..., help="Test ID (e.g., 'test_module.py::test_func')"),
    reason: Optional[str] = typer.Option(None, "--reason", "-r", help="Reason for quarantine."),
    expires_days: int = typer.Option(14, "--expires", "-e", help="Days until quarantine expires."),
    project_id: Optional[str] = typer.Option(
        None, "--project", "-p", envvar="HIKAFLOW_PROJECT_ID", help="Project ID.",
    ),
):
    """Quarantine a test without modifying code."""
    from autodebug.auth import get_valid_token
    from autodebug.flakiness import QuarantineManager

    output = ctx.obj.get("output", OutputFormatter())
    token = get_valid_token()
    if not token:
        output.error("Not authenticated.", "Run: hikaflow login")
        raise typer.Exit(1)

    if not project_id:
        from autodebug.config import load_credentials
        creds = load_credentials()
        project_id = creds.get("project_id")

    if not project_id:
        output.error("Project ID required.", "Use --project or set HIKAFLOW_PROJECT_ID.")
        raise typer.Exit(1)

    mgr = QuarantineManager(project_id=project_id, token=token)
    success = mgr.quarantine_test(
        test_id=test_id,
        reason=reason or "Manually quarantined via CLI",
        expires_days=expires_days,
    )

    if success:
        output.success(f"Test quarantined: {test_id}")
        output.info(f"  Expires in {expires_days} days")
        output.dim("Enable quarantine in CI with: HIKAFLOW_QUARANTINE=1 pytest")
    else:
        detail = getattr(mgr, "_last_error", None) or "Unknown error"
        output.error(f"Failed to quarantine test: {detail}")
        raise typer.Exit(1)


@quarantine_app.command("remove")
@handle_cli_errors
def quarantine_remove(
    ctx: typer.Context,
    test_id: str = typer.Argument(..., help="Test ID to unquarantine"),
    project_id: Optional[str] = typer.Option(
        None, "--project", "-p", envvar="HIKAFLOW_PROJECT_ID", help="Project ID.",
    ),
):
    """Remove a test from quarantine."""
    from autodebug.auth import get_valid_token
    from autodebug.flakiness import QuarantineManager

    output = ctx.obj.get("output", OutputFormatter())
    token = get_valid_token()
    if not token:
        output.error("Not authenticated.", "Run: hikaflow login")
        raise typer.Exit(1)

    if not project_id:
        from autodebug.config import load_credentials
        creds = load_credentials()
        project_id = creds.get("project_id")

    if not project_id:
        output.error("Project ID required.", "Use --project or set HIKAFLOW_PROJECT_ID.")
        raise typer.Exit(1)

    mgr = QuarantineManager(project_id=project_id, token=token)
    success = mgr.unquarantine_test(test_id)

    if success:
        output.success(f"Test removed from quarantine: {test_id}")
    else:
        detail = getattr(mgr, "_last_error", None) or "Unknown error"
        output.error(f"Failed to unquarantine test: {detail}")
        raise typer.Exit(1)


@quarantine_app.command("auto")
@handle_cli_errors
def quarantine_auto(
    ctx: typer.Context,
    threshold: float = typer.Option(0.3, "--threshold", "-t", min=0.0, max=1.0, help="Flakiness score threshold (0.0-1.0)."),
    min_runs: int = typer.Option(5, "--min-runs", "-m", help="Minimum test runs before considering."),
    dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Show what would be quarantined."),
    project_id: Optional[str] = typer.Option(
        None, "--project", "-p", envvar="HIKAFLOW_PROJECT_ID", help="Project ID.",
    ),
):
    """Auto-quarantine flaky tests based on flakiness score."""
    import requests

    from autodebug.auth import get_api_base, get_valid_token

    output = ctx.obj.get("output", OutputFormatter())
    token = get_valid_token()
    if not token:
        output.error("Not authenticated.", "Run: hikaflow login")
        raise typer.Exit(1)

    if not project_id:
        from autodebug.config import load_credentials
        creds = load_credentials()
        project_id = creds.get("project_id")

    if not project_id:
        output.error("Project ID required.", "Use --project or set HIKAFLOW_PROJECT_ID.")
        raise typer.Exit(1)

    api_base = get_api_base()

    if dry_run:
        output.warning("DRY RUN - No changes will be made")
        output.info("")

    try:
        resp = requests.get(
            f"{api_base}/v1/flaky-tests",
            params={"project_id": project_id, "min_score": threshold, "include_quarantined": False},
            headers={"Authorization": f"Bearer {token}"},
            timeout=30,
        )
        _ensure_http_ok(
            resp,
            operation="Fetch flaky tests",
            output=output,
            detail="Check project configuration and API availability.",
        )
        data = resp.json()
    except Exception as e:
        output.error(f"Failed to fetch flaky tests: {e}")
        raise typer.Exit(1)

    flaky_tests = data.get("tests", [])

    if not flaky_tests:
        output.success(f"No flaky tests found above threshold {threshold}")
        return

    rows = []
    for t in flaky_tests:
        if t["total_runs"] < min_runs:
            continue
        rows.append([
            t["test_id"],
            f"{t['flakiness_score']:.2f}",
            f"{t['pass_rate']:.0%}",
            str(t["total_runs"]),
        ])

    output.table(
        title=f"Flaky Tests (score >= {threshold})",
        columns=[
            {"name": "Test ID", "style": "green"},
            {"name": "Score", "justify": "right", "style": "red"},
            {"name": "Pass Rate", "justify": "right"},
            {"name": "Runs", "justify": "right"},
        ],
        rows=rows,
    )

    if dry_run:
        output.warning(f"Would quarantine {len(flaky_tests)} test(s)")
        return

    try:
        resp = requests.post(
            f"{api_base}/v1/projects/{project_id}/auto-quarantine",
            params={"threshold": threshold, "min_runs": min_runs},
            headers={"Authorization": f"Bearer {token}"},
            timeout=30,
        )
        _ensure_http_ok(
            resp,
            operation="Auto-quarantine flaky tests",
            output=output,
            detail="Verify project permissions and threshold parameters.",
        )
        result = resp.json()
    except Exception as e:
        output.error(f"Failed to auto-quarantine: {e}")
        raise typer.Exit(1)

    count = result.get("quarantined_count", 0)
    output.success(f"Auto-quarantined {count} test(s)")


# =============================================================================
# Repro Command
# =============================================================================

@replay_app.command("repro", help="Reproduce a run locally.")
@handle_cli_errors
def repro(
    ctx: typer.Context,
    run_id: str = typer.Argument(..., help="Run ID to reproduce locally."),
    output_dir: Optional[Path] = typer.Option(None, "--output", "-o", help="Output directory."),
    debug_mode: bool = typer.Option(False, "--debug", "-d", help="Launch debugger at failure point."),
    force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing repro directory."),
):
    """Download and replay a run locally.

    [bold]Examples:[/]

        hikaflow replay repro run_abc123

        hikaflow replay repro run_abc123 --debug
    """
    import shutil

    import requests
    import zstandard as zstd

    from autodebug.config import get_api_base, get_valid_token

    output = ctx.obj.get("output", OutputFormatter())
    token = get_valid_token()
    if not token:
        output.error("Not authenticated.", "Run: hikaflow login")
        raise typer.Exit(1)

    api_base = get_api_base()

    if output_dir is None:
        output_dir = Path(".hikaflow/repro") / run_id

    if output_dir.exists():
        if not force:
            output.warning(f"Directory {output_dir} already exists.")
            output.info("Use --force to overwrite or specify a different directory.")
            raise typer.Exit(1)
        shutil.rmtree(output_dir)

    output_dir.mkdir(parents=True, exist_ok=True)
    transcripts_dir = output_dir / "transcripts"
    transcripts_dir.mkdir(exist_ok=True)

    output.info(f"Downloading run {run_id}...")

    try:
        resp = requests.get(
            f"{api_base}/v1/runs/{run_id}/repro-bundle",
            headers={"Authorization": f"Bearer {token}"},
            timeout=30,
        )
        _ensure_http_ok(
            resp,
            operation="Fetch repro bundle",
            output=output,
            detail="Ensure run ID exists and belongs to the active project.",
        )
        bundle = resp.json()
    except Exception as e:
        output.error(f"Failed to fetch run info: {e}")
        raise typer.Exit(1)

    test_file = bundle.get("test_file", "test_file.py")
    test_name = bundle.get("test_name", "test_case")
    artifacts = bundle.get("artifacts", {})

    with output.progress_spinner("Downloading artifacts...") as status:
        for name, url in artifacts.items():
            if not url:
                continue
            status.update(f"Downloading {name}...")
            try:
                artifact_resp = requests.get(url, timeout=60)
                if not _ensure_http_ok(
                    artifact_resp,
                    operation=f"Download artifact {name}",
                    output=output,
                    detail="Artifact may have expired or storage is unavailable.",
                    exit_on_error=False,
                ):
                    continue

                content = artifact_resp.content
                if name.endswith(".zst"):
                    dctx = zstd.ZstdDecompressor()
                    content = dctx.decompress(content)
                    name = name[:-4]

                if name.startswith("deps_") or name in ("time_rng.jsonl", "events.jsonl"):
                    (transcripts_dir / name).write_bytes(content)
                else:
                    (output_dir / name).write_bytes(content)

                output.success(f"  {name}")
            except Exception as e:
                output.warning(f"  {name}: {e}")

    conftest_content = bundle.get("conftest_snippet", "")
    if conftest_content:
        (output_dir / "conftest.py").write_text(conftest_content)
        output.success("  conftest.py")

    run_script = f'#!/bin/bash\ncd "$(dirname "$0")"\nexport HIKAFLOW_REPLAY_DIR="$(pwd)/transcripts"\npytest {test_file}::{test_name} -v --tb=long\n'
    run_script_path = output_dir / "run_repro.sh"
    run_script_path.write_text(run_script)
    run_script_path.chmod(0o755)
    output.success("  run_repro.sh")

    output.success("Repro ready!")
    output.panel(
        f"[bold]To replay the test:[/]\n\n"
        f"  [green]cd {output_dir}[/]\n"
        f"  [green]./run_repro.sh[/]",
        title="Next Steps",
        border_style="green",
    )

    if debug_mode:
        import subprocess
        output.info("Launching debugger...")
        env = os.environ.copy()
        env["HIKAFLOW_REPLAY_DIR"] = str(transcripts_dir)
        subprocess.run(
            ["pytest", f"{test_file}::{test_name}", "-v", "--pdb"],
            cwd=str(output_dir),
            env=env,
        )


@app.command(
    hidden=True,
    context_settings={"allow_extra_args": True, "allow_interspersed_args": False}
)
@handle_cli_errors
def run(
    ctx: typer.Context,
):
    """Run a command under Hikaflow instrumentation.

    Wraps any command with I/O capture, deterministic replay support,
    and automatic error detection. Captured sessions appear in your
    Hikaflow dashboard and can be replayed with `hikaflow debug`.

    [bold]Examples:[/]

        hikaflow run -- pytest tests/              # Instrument test suite

        hikaflow run -- python my_script.py        # Instrument a script

        hikaflow run -- npm test                   # Works with any command

    [dim]Tip: use -- to separate hikaflow flags from the command's flags.[/]
    """
    import click

    from autodebug.cli import run as legacy_run

    output = ctx.obj.get("output", OutputFormatter())
    if not ctx.args:
        output.error("Missing command.", "Usage: hikaflow run -- <command>")
        raise typer.Exit(1)

    click_ctx = click.Context(legacy_run)
    click_ctx.args = ctx.args
    try:
        legacy_run.invoke(click_ctx)
    except SystemExit as e:
        raise typer.Exit(e.code if e.code else 0)


if __name__ == "__main__":
    app()
