"""REPL loop and one-shot mode for the Anteroom CLI."""

from __future__ import annotations

import asyncio
import json
import logging
import os
import platform
import re
import shutil
import signal
import sqlite3 as _sqlite3
import subprocess
import sys
import time
from collections.abc import Callable
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any

from rich.markup import escape

from .. import __version__
from ..config import AppConfig, build_runtime_context
from ..db import init_db
from ..services import packs as packs_service
from ..services import storage
from ..services.agent_loop import _build_compaction_history, run_agent_loop
from ..services.ai_service import AIService, create_ai_service
from ..services.context_trust import (
    sanitize_trust_tags,
    trusted_section_marker,
    untrusted_section_marker,
    wrap_untrusted,
)
from ..services.embeddings import get_effective_dimensions
from ..services.rewind import collect_file_paths
from ..services.rewind import rewind_conversation as rewind_service
from ..services.slug import is_valid_slug, suggest_unique_slug
from ..tools import ToolRegistry, register_default_tools
from . import renderer
from .instructions import (
    CONVENTIONS_TOKEN_WARNING_THRESHOLD,
    discover_conventions,
    estimate_tokens,
    find_global_instructions,
    find_project_instructions_path,
)
from .skills import SkillRegistry
from .themes import CliTheme

# Module-level color aliases — updated by run_cli() after theme is set.
# These are short names for use in f-strings throughout this module.
GOLD = "#C5A059"
MUTED = "#8b8b8b"
CHROME = "#6b7280"
_SUCCESS = "#22c55e"
_ERROR = "#CD6B6B"

logger = logging.getLogger(__name__)

_IS_WINDOWS = platform.system() == "Windows"


def _add_signal_handler(loop: asyncio.AbstractEventLoop, sig: int, callback: Any) -> bool:
    """Add a signal handler, returning False on Windows where it's unsupported."""
    if _IS_WINDOWS:
        return False
    try:
        loop.add_signal_handler(sig, callback)
        return True
    except NotImplementedError:
        return False


def _remove_signal_handler(loop: asyncio.AbstractEventLoop, sig: int) -> None:
    """Remove a signal handler, no-op on Windows."""
    if _IS_WINDOWS:
        return
    try:
        loop.remove_signal_handler(sig)
    except NotImplementedError:
        pass


async def _watch_for_escape(cancel_event: asyncio.Event) -> None:
    """Watch for Escape key press during AI generation to cancel."""
    loop = asyncio.get_event_loop()

    if _IS_WINDOWS:
        import msvcrt

        def _poll() -> None:
            while not cancel_event.is_set():
                if msvcrt.kbhit():  # type: ignore[attr-defined]
                    ch = msvcrt.getch()  # type: ignore[attr-defined]
                    if ch == b"\x1b":
                        # Distinguish bare Escape from escape sequences (arrow keys, etc.)
                        time.sleep(0.05)
                        if not msvcrt.kbhit():  # type: ignore[attr-defined]
                            cancel_event.set()
                            return
                        # Consume the rest of the escape sequence
                        while msvcrt.kbhit():  # type: ignore[attr-defined]
                            msvcrt.getch()  # type: ignore[attr-defined]
                time.sleep(0.05)
    else:
        import select
        import termios
        import tty

        def _poll() -> None:
            fd = sys.stdin.fileno()
            if not os.isatty(fd):
                return
            old_settings = termios.tcgetattr(fd)
            try:
                tty.setcbreak(fd)
                while not cancel_event.is_set():
                    ready, _, _ = select.select([sys.stdin], [], [], 0.1)
                    if ready:
                        ch = sys.stdin.read(1)
                        if ch == "\x1b":
                            # Distinguish bare Escape from escape sequences
                            more, _, _ = select.select([sys.stdin], [], [], 0.05)
                            if not more:
                                cancel_event.set()
                                return
                            # Consume the rest of the escape sequence
                            while True:
                                more, _, _ = select.select([sys.stdin], [], [], 0.01)
                                if more:
                                    sys.stdin.read(1)
                                else:
                                    break
            finally:
                termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)

    try:
        await loop.run_in_executor(None, _poll)
    except asyncio.CancelledError:
        pass


_MAX_PASTE_DISPLAY_LINES = 6
_PASTE_THRESHOLD = 0.05  # 50ms; paste arrives faster than human typing


def _is_paste(last_text_change: float, threshold: float = _PASTE_THRESHOLD) -> bool:
    """Return True if Enter arrived fast enough after last buffer change to be paste."""
    return (time.monotonic() - last_text_change) < threshold


def _collapse_long_input(user_input: str) -> None:
    """Collapse long pasted input for terminal readability.

    Replaces the displayed multi-line input with the first few lines
    plus a "... (N more lines)" indicator. The actual content is
    preserved; only the visual display is truncated.
    """
    if not sys.stdout.isatty():
        return

    lines = user_input.split("\n")
    if len(lines) <= _MAX_PASTE_DISPLAY_LINES:
        return

    term_cols = shutil.get_terminal_size((80, 24)).columns
    usable = max(term_cols - 2, 10)  # 2 = "❯ " prompt width

    # Estimate terminal rows the prompt_toolkit input occupied
    total_rows = sum(max(1, (len(ln) + usable - 1) // usable) if ln else 1 for ln in lines)

    show = 3
    hidden = len(lines) - show

    # Move cursor up to input start and clear to end of screen.
    # Use renderer._stdout (real fd) to bypass patch_stdout() proxy
    # which corrupts raw ESC bytes.
    renderer._stdout.write(f"\033[{total_rows}A\033[J")
    # Reprint truncated with styled prompt via Rich (handles patch_stdout correctly)
    renderer.console.print(f"[bold cyan]❯[/] {escape(lines[0])}")
    for ln in lines[1:show]:
        renderer.console.print(f"  {escape(ln)}")
    renderer.console.print(f"  [dim]... ({hidden} more lines)[/]")
    renderer._stdout.flush()


_FILE_REF_RE = re.compile(r"@((?:[^\s\"']+|\"[^\"]+\"|'[^']+'))")


def _detect_git_branch() -> str | None:
    """Detect the current git branch, or None if not in a git repo."""
    try:
        result = subprocess.run(
            ["git", "rev-parse", "--abbrev-ref", "HEAD"],
            capture_output=True,
            text=True,
            timeout=5,
        )
        if result.returncode == 0:
            return result.stdout.strip() or None
    except (FileNotFoundError, subprocess.TimeoutExpired):
        pass
    return None


def _load_conversation_messages(db: Any, conversation_id: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
    """Load existing conversation messages into AI message format.

    Returns (ai_messages, stored_messages) where stored_messages is the raw
    list from storage.list_messages (includes metadata).
    """
    stored = storage.list_messages(db, conversation_id)
    messages: list[dict[str, Any]] = []
    for msg in stored:
        role = msg["role"]
        if role in ("user", "assistant", "system"):
            entry: dict[str, Any] = {"role": role, "content": msg["content"]}
            # Reconstruct tool_calls for assistant messages
            tool_calls = msg.get("tool_calls", [])
            if tool_calls and role == "assistant":
                entry["tool_calls"] = [
                    {
                        "id": tc["id"],
                        "type": "function",
                        "function": {
                            "name": tc["tool_name"],
                            "arguments": json.dumps(tc["input"]),
                        },
                    }
                    for tc in tool_calls
                ]
                # Add tool result messages
                messages.append(entry)
                for tc in tool_calls:
                    messages.append(
                        {
                            "role": "tool",
                            "tool_call_id": tc["id"],
                            "content": json.dumps(tc.get("output", {})),
                        }
                    )
                continue
            messages.append(entry)
    return messages, stored


# Context window management — overridden at runtime by config.cli thresholds
_CONTEXT_WARN_TOKENS = 80_000
_CONTEXT_AUTO_COMPACT_TOKENS = 100_000


_tiktoken_encoding: Any = None


def _get_tiktoken_encoding() -> Any:
    global _tiktoken_encoding
    if _tiktoken_encoding is None:
        try:
            import tiktoken

            _tiktoken_encoding = tiktoken.get_encoding("cl100k_base")
        except Exception:
            _tiktoken_encoding = False  # Signal fallback
    return _tiktoken_encoding


def _estimate_tokens(messages: list[dict[str, Any]]) -> int:
    """Count tokens using tiktoken, falling back to char estimate.

    Delegates to the shared token_estimator module.
    """
    from ..services.token_estimator import count_message_tokens

    return count_message_tokens(messages)


async def _check_for_update(current: str) -> str | None:
    """Check PyPI for a newer version. Returns latest if newer, else None."""
    proc = None
    try:
        proc = await asyncio.create_subprocess_exec(
            sys.executable,
            "-m",
            "pip",
            "index",
            "versions",
            "anteroom",
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5.0)
        if proc.returncode != 0:
            return None
        output = stdout.decode().strip()
        # Output format: "anteroom (X.Y.Z)"
        if "(" in output and ")" in output:
            latest = output.split("(")[1].split(")")[0].strip()
            from packaging.version import Version

            if Version(latest) > Version(current):
                return latest
    except Exception:
        if proc and proc.returncode is None:
            try:
                proc.kill()
                await proc.wait()
            except Exception:
                pass
    return None


def _picker_relative_time(ts: str) -> str:
    """Format a timestamp as a relative time string for the picker UI."""
    try:
        dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
        now = datetime.now(timezone.utc)
        delta = now - dt
        if delta.days > 0:
            return f"{delta.days}d ago"
        hours = delta.seconds // 3600
        if hours > 0:
            return f"{hours}h ago"
        mins = delta.seconds // 60
        return f"{mins}m ago" if mins > 0 else "just now"
    except (ValueError, TypeError):
        return ""


def _picker_type_badge(conv_type: str) -> str:
    """Return a type badge string for non-chat conversation types."""
    return {"note": "[note]", "document": "[doc]"}.get(conv_type, "")


def _picker_format_preview(messages: list[dict[str, Any]]) -> list[tuple[str, str]]:
    """Format messages into styled fragments for the picker preview pane."""
    fragments: list[tuple[str, str]] = []
    recent = messages[-8:] if len(messages) > 8 else messages
    for msg in recent:
        role = msg.get("role", "")
        content = msg.get("content", "")
        if not content or not isinstance(content, str):
            continue
        if role == "user":
            fragments.append(("class:preview.role-user", " You: "))
            text = content[:200].replace("\n", " ")
            if len(content) > 200:
                text += "..."
            fragments.append(("class:preview.content", f"{text}\n\n"))
        elif role == "assistant":
            fragments.append(("class:preview.role-ai", " AI: "))
            text = content[:300].replace("\n", " ")
            if len(content) > 300:
                text += "..."
            fragments.append(("class:preview.content", f"{text}\n\n"))
    if not fragments:
        fragments.append(("class:preview.empty", " (no messages)"))
    return fragments


def _resolve_conversation(db: Any, target: str) -> dict[str, Any] | None:
    """Resolve a target (list number, UUID, or slug) to a conversation dict."""
    if target.isdigit():
        idx = int(target) - 1
        convs = storage.list_conversations(db, limit=20)
        if 0 <= idx < len(convs):
            return storage.get_conversation(db, convs[idx]["id"])
        return None
    return storage.get_conversation(db, target)


def _restore_working_dir(
    conv: dict[str, Any],
    tool_registry: Any,
    current_working_dir: str,
) -> str:
    """Restore working directory from a resumed conversation.

    Returns the effective working directory (stored or current fallback).
    """
    stored_dir: str | None = conv.get("working_dir")
    if not stored_dir:
        return current_working_dir
    # Resolve symlinks for consistent validation
    stored_dir = os.path.realpath(stored_dir)
    if not os.path.isdir(stored_dir):
        logger.warning("Stored working_dir %s no longer exists, using current", stored_dir)
        renderer.console.print(
            f"[{MUTED}]Note: original directory {stored_dir} no longer exists, using current[/{MUTED}]"
        )
        return current_working_dir
    # Block sensitive system directories
    _blocked_prefixes = ("/proc/", "/sys/", "/dev/")
    if any(stored_dir.startswith(p) or stored_dir == p.rstrip("/") for p in _blocked_prefixes):
        logger.warning("Blocked unsafe stored working_dir: %s", stored_dir)
        return current_working_dir
    # Re-scope tools to the stored directory
    from ..tools import bash, edit, glob_tool, grep, read, write

    for module in [read, write, edit, bash, glob_tool, grep]:
        if hasattr(module, "set_working_dir"):
            module.set_working_dir(stored_dir)
    tool_registry._working_dir = stored_dir
    return stored_dir


def _show_resume_info(db: Any, conv: dict[str, Any], ai_messages: list[dict[str, Any]]) -> None:
    """Display resume header with last exchange context and persisted RAG sources."""
    stored = storage.list_messages(db, conv["id"])
    title = conv.get("title", "Untitled")
    renderer.console.print(f"[{CHROME}]Resumed: {title} ({len(ai_messages)} messages)[/{CHROME}]")
    renderer.render_conversation_recap(stored)
    # Render persisted RAG source provenance from stored metadata
    for msg in stored:
        if msg.get("role") == "assistant" and msg.get("metadata"):
            meta = msg["metadata"]
            sources = meta.get("rag_sources") if isinstance(meta, dict) else None
            if sources:
                renderer.render_rag_sources(sources)


def _show_usage_stats(db: Any, config: Any) -> None:
    """Display token usage statistics for today, this week, and this month."""
    usage_cfg = config.cli.usage
    now = datetime.now(timezone.utc)
    periods = [
        ("Today", (now - timedelta(days=1)).isoformat()),
        ("This week", (now - timedelta(days=usage_cfg.week_days)).isoformat()),
        ("This month", (now - timedelta(days=usage_cfg.month_days)).isoformat()),
        ("All time", None),
    ]

    renderer.console.print("\n[bold]Token Usage[/bold]")

    for label, since in periods:
        stats = storage.get_usage_stats(db, since=since)
        if not stats:
            renderer.console.print(f"\n  [{MUTED}]{label}: no usage data[/{MUTED}]")
            continue

        total_prompt = sum(s.get("prompt_tokens", 0) or 0 for s in stats)
        total_completion = sum(s.get("completion_tokens", 0) or 0 for s in stats)
        total_tokens = sum(s.get("total_tokens", 0) or 0 for s in stats)
        total_messages = sum(s.get("message_count", 0) or 0 for s in stats)

        # Calculate cost
        total_cost = 0.0
        for s in stats:
            model = s.get("model", "") or ""
            prompt_t = s.get("prompt_tokens", 0) or 0
            completion_t = s.get("completion_tokens", 0) or 0
            costs = usage_cfg.model_costs.get(model, {})
            input_rate = costs.get("input", 0.0)
            output_rate = costs.get("output", 0.0)
            total_cost += (prompt_t / 1_000_000) * input_rate + (completion_t / 1_000_000) * output_rate

        renderer.console.print(f"\n  [bold]{label}[/bold] ({total_messages} messages)")
        renderer.console.print(f"    Prompt:     {total_prompt:>12,} tokens")
        renderer.console.print(f"    Completion: {total_completion:>12,} tokens")
        renderer.console.print(f"    Total:      {total_tokens:>12,} tokens")
        if total_cost > 0:
            renderer.console.print(f"    Est. cost:  ${total_cost:>11,.4f}")

        # Per-model breakdown if multiple models
        if len(stats) > 1:
            renderer.console.print(f"    [{MUTED}]By model:[/{MUTED}]")
            for s in stats:
                model = s.get("model", "unknown") or "unknown"
                t = s.get("total_tokens", 0) or 0
                renderer.console.print(f"      {model}: {t:,} tokens")

    renderer.console.print()


_EXIT_COMMANDS = frozenset({"/quit", "/exit"})


def _route_cancel_signal(
    agent_busy: asyncio.Event,
    current_cancel_event: list[asyncio.Event | None],
) -> bool:
    """Route a cancel signal (Ctrl-C or Escape) to the active cancel event.

    Returns True if cancel was routed (agent was busy), False otherwise.
    Called from prompt_toolkit keybinding handlers.
    """
    if agent_busy.is_set() and current_cancel_event[0] is not None:
        current_cancel_event[0].set()
        return True
    return False


def _cleanup_after_turn(
    cancel_event: asyncio.Event,
    agent_busy: asyncio.Event,
    msg_queue: asyncio.Queue[dict[str, Any]],
    ai_messages: list[dict[str, Any]],
    has_pending_work: Callable[[], bool],
) -> None:
    """Clean up state after an agent turn completes or is cancelled.

    On cancel: backfills msg_queue items into ai_messages (they are already
    persisted in the DB by _drain_input_to_msg_queue but not yet in the live
    session context) and unconditionally clears agent_busy.

    On normal completion: clears agent_busy only if no pending work remains.
    """
    if cancel_event.is_set():
        while not msg_queue.empty():
            try:
                leftover = msg_queue.get_nowait()
                ai_messages.append(leftover)
            except asyncio.QueueEmpty:
                break
        agent_busy.clear()
    elif not has_pending_work():
        agent_busy.clear()


async def _drain_input_to_msg_queue(
    input_queue: asyncio.Queue[str],
    msg_queue: asyncio.Queue[dict[str, Any]],
    working_dir: str,
    db: Any,
    conversation_id: str,
    cancel_event: asyncio.Event,
    exit_flag: asyncio.Event,
    warn_callback: Any | None = None,
    identity_kwargs: dict[str, Any] | None = None,
    file_max_chars: int = 100_000,
    skill_registry: Any | None = None,
) -> None:
    """Drain input_queue into msg_queue, filtering out / commands.

    - /quit and /exit trigger cancel_event and exit_flag
    - Skill invocations are expanded and queued as user messages
    - Other / commands are ignored with a warning
    - Normal text is expanded and queued as user messages
    """
    while not input_queue.empty():
        try:
            queued_text = input_queue.get_nowait()
            if queued_text.startswith("/"):
                cmd = queued_text.lower().split()[0]
                if cmd in _EXIT_COMMANDS:
                    cancel_event.set()
                    exit_flag.set()
                    break
                # Try expanding as a skill invocation
                if skill_registry:
                    is_skill, skill_prompt = skill_registry.resolve_input(queued_text)
                    if is_skill:
                        q_expanded = _expand_file_references(skill_prompt, working_dir, file_max_chars=file_max_chars)
                        storage.create_message(db, conversation_id, "user", q_expanded, **(identity_kwargs or {}))
                        await msg_queue.put({"role": "user", "content": q_expanded})
                        continue
                if warn_callback:
                    warn_callback(cmd)
                continue
            q_expanded = _expand_file_references(queued_text, working_dir, file_max_chars=file_max_chars)
            storage.create_message(db, conversation_id, "user", q_expanded, **(identity_kwargs or {}))
            await msg_queue.put({"role": "user", "content": q_expanded})
        except asyncio.QueueEmpty:
            break


def _expand_file_references(text: str, working_dir: str, file_max_chars: int = 100_000) -> str:
    """Expand @path references in user input.

    @file.py      -> includes file contents inline
    @src/          -> includes directory listing
    @"path with spaces/file.py" -> handles quoted paths

    Binary files (PPTX, XLSX, PDF, etc.) are routed through
    document_extractor for text extraction instead of raw UTF-8 reads.
    """
    import mimetypes

    from ..tools.security import validate_path as _validate_ref_path

    text_mime_prefixes = ("text/",)
    text_mime_extras = frozenset(
        {
            "application/json",
            "application/javascript",
            "application/x-yaml",
            "application/yaml",
            "application/toml",
            "application/xml",
        }
    )

    def _is_text_mime(mime: str | None) -> bool:
        if mime is None:
            return True  # Unknown MIME — try as text
        if any(mime.startswith(p) for p in text_mime_prefixes):
            return True
        return mime in text_mime_extras

    def _replace(match: re.Match[str]) -> str:
        raw_path = match.group(1).strip("\"'")
        validated, error = _validate_ref_path(raw_path, working_dir)
        if error:
            return match.group(0)  # Skip blocked paths silently
        resolved = Path(validated)

        if resolved.is_file():
            mime, _ = mimetypes.guess_type(str(resolved))
            if _is_text_mime(mime):
                try:
                    content = resolved.read_text(encoding="utf-8", errors="replace")
                    if len(content) > file_max_chars:
                        content = content[:file_max_chars] + "\n... (truncated)"
                    return f'\n<file path="{raw_path}">\n{content}\n</file>\n'
                except OSError:
                    return match.group(0)
            else:
                try:
                    from ..services.document_extractor import EXTRACTABLE_MIME_TYPES, extract_text

                    if mime in EXTRACTABLE_MIME_TYPES:
                        file_data = resolved.read_bytes()
                        extracted = extract_text(file_data, mime).text
                        if extracted:
                            if len(extracted) > file_max_chars:
                                extracted = extracted[:file_max_chars] + "\n... (truncated)"
                            return f'\n<file path="{raw_path}">\n{extracted}\n</file>\n'
                    return (
                        f'\n<file path="{raw_path}">'
                        f"\n[Binary file: {resolved.name} ({mime})"
                        f" — use tools to read this file]"
                        f"\n</file>\n"
                    )
                except OSError:
                    return match.group(0)
        elif resolved.is_dir():
            try:
                entries = sorted(resolved.iterdir())
                listing = []
                for entry in entries[:200]:
                    suffix = "/" if entry.is_dir() else ""
                    listing.append(f"  {entry.name}{suffix}")
                content = "\n".join(listing)
                return f'\n<directory path="{raw_path}">\n{content}\n</directory>\n'
            except OSError:
                return match.group(0)
        else:
            return match.group(0)

    return _FILE_REF_RE.sub(_replace, text)


def _detect_project_context(working_dir: str) -> str:
    """Detect project context from the working directory for the system prompt."""
    from pathlib import Path

    wd = Path(working_dir)
    lines: list[str] = []

    # Git awareness
    git_dir = wd / ".git"
    if git_dir.exists():
        lines.append("This is a git repository.")

    # Project type detection
    markers = {
        "pyproject.toml": "Python project (pyproject.toml)",
        "setup.py": "Python project (setup.py)",
        "package.json": "Node.js project (package.json)",
        "Cargo.toml": "Rust project (Cargo.toml)",
        "go.mod": "Go project (go.mod)",
        "Makefile": "Has Makefile",
    }
    for marker, desc in markers.items():
        if (wd / marker).exists():
            lines.append(desc)
            break

    # Common directories
    dirs = [d.name for d in wd.iterdir() if d.is_dir() and not d.name.startswith(".")]
    notable = [d for d in dirs if d in ("src", "tests", "test", "lib", "docs", "scripts", "cmd", "pkg", "internal")]
    if notable:
        lines.append(f"Key directories: {', '.join(sorted(notable))}")

    return "\n".join(lines)


def _build_introspect_instructions_info(working_dir: str) -> dict[str, Any]:
    """Build a summary of loaded instructions for the introspect tool."""
    from .instructions import estimate_tokens, find_global_instructions_path, find_project_instructions_path

    sources: list[dict[str, Any]] = []
    total_tokens = 0

    global_result = find_global_instructions_path()
    if global_result:
        path, content = global_result
        tokens = estimate_tokens(content)
        sources.append({"path": str(path), "source": "global", "estimated_tokens": tokens})
        total_tokens += tokens

    project_result = find_project_instructions_path(working_dir)
    if project_result:
        path, content = project_result
        tokens = estimate_tokens(content)
        sources.append({"path": str(path), "source": "project", "estimated_tokens": tokens})
        total_tokens += tokens

    return {"sources": sources, "total_tokens": total_tokens}


def _build_system_prompt(
    config: AppConfig,
    working_dir: str,
    instructions: str | None,
    builtin_tools: list[str] | None = None,
    mcp_servers: dict[str, Any] | None = None,
    space_instructions: str | None = None,
    artifact_registry: Any = None,
) -> str:
    runtime_ctx = build_runtime_context(
        model=config.ai.model,
        builtin_tools=builtin_tools,
        mcp_servers=mcp_servers,
        interface="cli",
        working_dir=working_dir,
    )
    parts = [trusted_section_marker() + runtime_ctx]

    # Project context (ANTEROOM.md filesystem context)
    project_ctx = _detect_project_context(working_dir)
    if project_ctx:
        parts.append(f"\n<project_context>\nWorking directory: {working_dir}\n{project_ctx}\n</project_context>")
    else:
        parts.append(f"\n<project_context>\nWorking directory: {working_dir}\n</project_context>")

    if space_instructions:
        parts.append(f"\n{space_instructions}")
    if instructions:
        parts.append(f"\n{instructions}")

    # Inject artifacts (instructions, rules, context) from the registry
    if artifact_registry is not None:
        from ..services.artifacts import ArtifactType

        for art_type in (ArtifactType.INSTRUCTION, ArtifactType.RULE, ArtifactType.CONTEXT):
            artifacts = artifact_registry.list_all(artifact_type=art_type)
            for art in artifacts:
                if art.content.strip():
                    if art.source == "built_in":
                        tag = f'<artifact type="{art_type.value}" fqn="{art.fqn}">'
                        parts.append(f"\n{tag}\n{art.content}\n</artifact>")
                    else:
                        wrapped = wrap_untrusted(art.content, origin=f"artifact:{art.fqn}", content_type=art_type.value)
                        parts.append(f"\n{wrapped}")

    return "\n".join(parts)


def _identity_kwargs(config: AppConfig) -> dict[str, Any]:
    """Extract user_id/user_display_name from config identity, or empty dict."""
    if config.identity:
        return {"user_id": config.identity.user_id, "user_display_name": config.identity.display_name}
    return {"user_id": None, "user_display_name": None}


async def _check_project_trust(
    file_path: Path,
    content: str,
    trust_project: bool = False,
    data_dir: Path | None = None,
) -> str | None:
    """Gate project-level ANTEROOM.md behind user trust consent.

    Returns the file content if trusted, or None if denied/skipped.
    Caller must check no_project_context before calling this function.
    """
    from ..services.trust import check_trust, compute_content_hash, save_trust_decision

    folder_path = str(file_path.parent)
    content_hash = compute_content_hash(content)

    if trust_project:
        save_trust_decision(folder_path, content_hash, data_dir=data_dir)
        return content

    status = check_trust(folder_path, content_hash, data_dir=data_dir)

    if status == "trusted":
        return content

    # Both "changed" and "untrusted" require user consent
    file_size = len(content.encode("utf-8"))

    if status == "changed":
        renderer.console.print("\n[yellow bold]Warning:[/yellow bold] ANTEROOM.md has changed since last trusted.")
    else:
        renderer.console.print(
            "\n[yellow bold]Warning:[/yellow bold] This project contains an ANTEROOM.md "
            "file that will be loaded into the AI context."
        )

    renderer.console.print(f"  Path: [{MUTED}]{file_path}[/{MUTED}]")
    renderer.console.print(f"  Size: [{MUTED}]{file_size:,} bytes[/{MUTED}]")

    try:
        from prompt_toolkit import PromptSession as _TrustSession

        _trust_session: Any = _TrustSession()

        while True:
            answer = await _trust_session.prompt_async(
                "  [y] Trust this folder  [r] Trust parent  [v] View  [n] Skip: "
            )
            choice = answer.strip().lower()

            if choice in ("y", "yes"):
                save_trust_decision(folder_path, content_hash, data_dir=data_dir)
                renderer.console.print(f"  [{MUTED}]Trusted: {folder_path}[/{MUTED}]\n")
                return content

            if choice in ("r", "recursive"):
                parent_path = str(file_path.parent.parent)
                save_trust_decision(parent_path, content_hash, recursive=True, data_dir=data_dir)
                renderer.console.print(f"  [{MUTED}]Trusted (recursive): {parent_path}[/{MUTED}]\n")
                return content

            if choice in ("v", "view"):
                renderer.console.print(f"\n[dim]{'─' * 60}[/dim]")
                lines = content.splitlines()
                if len(lines) > 50:
                    for line in lines[:50]:
                        renderer.console.print(f"  [dim]{line}[/dim]")
                    renderer.console.print(f"  [dim]... ({len(lines) - 50} more lines)[/dim]")
                else:
                    for line in lines:
                        renderer.console.print(f"  [dim]{line}[/dim]")
                renderer.console.print(f"[dim]{'─' * 60}[/dim]\n")
                continue

            if choice in ("n", "no", ""):
                renderer.console.print(f"  [{MUTED}]Skipped: project context not loaded[/{MUTED}]\n")
                return None

    except (EOFError, KeyboardInterrupt):
        renderer.console.print(f"\n  [{MUTED}]Skipped: project context not loaded[/{MUTED}]\n")
        return None


async def _load_instructions_with_trust(
    working_dir: str,
    trust_project: bool = False,
    no_project_context: bool = False,
    data_dir: Path | None = None,
) -> str | None:
    """Load global + project instructions with trust gating on project files."""
    parts: list[str] = []

    # Global instructions (~/.anteroom/ANTEROOM.md) are loaded unconditionally.
    # They share the same trust boundary as the config file itself — if an attacker
    # can write to ~/.anteroom/, they already control the app configuration.
    global_inst = find_global_instructions()
    if global_inst:
        parts.append(f"# Global Instructions\n{global_inst}")

    if not no_project_context:
        result = find_project_instructions_path(working_dir)
        if result is not None:
            file_path, content = result
            trusted_content = await _check_project_trust(
                file_path,
                content,
                trust_project=trust_project,
                data_dir=data_dir,
            )
            if trusted_content is not None:
                tokens = estimate_tokens(trusted_content)
                if tokens > CONVENTIONS_TOKEN_WARNING_THRESHOLD:
                    renderer.console.print(
                        f"  [yellow]Warning: ANTEROOM.md is ~{tokens:,} tokens "
                        f"(threshold: {CONVENTIONS_TOKEN_WARNING_THRESHOLD:,}). "
                        f"Large files reduce prompt effectiveness.[/yellow]\n"
                    )
                parts.append(f"# Project Instructions\n{trusted_content}")

    if not parts:
        return None
    return "\n\n".join(parts)


async def _embed_after_upload(
    get_worker: Any,
    source_id: str,
) -> int | None:
    """Embed source chunks inline after CLI upload.

    Extracted for testability — this is the parity fix for #834.
    Returns chunk count on success, None if skipped or failed.
    """
    worker = await get_worker()
    if not worker:
        return None
    try:
        return await worker.embed_source(source_id)
    except Exception:
        logger.debug("Inline embed failed for upload; background worker will retry", exc_info=True)
        return None


async def _run_mcp_startup_live(
    mcp_manager: Any,
    mcp_servers: list[Any],
    statuses: dict[str, dict[str, Any]],
) -> None:
    """Connect MCP servers in parallel, printing each result as it resolves."""
    server_names = [s.name for s in mcp_servers]
    pending = set(server_names)

    def _on_status(name: str, status: dict[str, Any]) -> None:
        statuses[name] = status
        st = status.get("status", "unknown")
        if st == "connected":
            pending.discard(name)
            count = status.get("tool_count", 0)
            renderer.console.print(f"  [{_SUCCESS}]✓[/{_SUCCESS}] [{MUTED}]{name} ({count} tools)[/{MUTED}]")
        elif st == "error":
            pending.discard(name)
            err = status.get("error_message", "failed")
            if len(err) > 40:
                err = err[:37] + "..."
            renderer.console.print(f"  [{_ERROR}]✗[/{_ERROR}] [{MUTED}]{name} ({err})[/{MUTED}]")

    try:
        with renderer.startup_step(f"Connecting {len(server_names)} MCP server(s)..."):
            await mcp_manager.startup(status_callback=_on_status)
    except Exception as e:
        logger.warning("MCP startup failed: %s", e)

    # Print summary
    connected = sum(1 for s in statuses.values() if s.get("status") == "connected")
    failed = sum(1 for s in statuses.values() if s.get("status") == "error")
    total_tools = sum(s.get("tool_count", 0) for s in statuses.values())
    if connected or failed:
        parts = [f"{connected} server(s)", f"{total_tools} tools"]
        if failed:
            parts.append(f"{failed} failed")
        renderer.console.print(f"  [{MUTED}]MCP: {', '.join(parts)}[/{MUTED}]\n")


_SUB_PROMPT_TIMEOUT: float = 300.0  # 5 min timeout for interactive sub-prompts


async def _timed_input(prompt_text: str, timeout: float = _SUB_PROMPT_TIMEOUT) -> str:
    """Read a line of input with a timeout. Raises TimeoutError on expiry."""
    try:
        return await asyncio.wait_for(asyncio.to_thread(input, prompt_text), timeout=timeout)
    except asyncio.TimeoutError:
        raise TimeoutError("Input timed out") from None


async def _resolve_pack_interactive(
    db: Any,
    ns: str,
    name: str,
    *,
    escape_markup: bool = False,
) -> dict[str, Any] | None:
    """Resolve a pack by namespace/name, prompting the user to disambiguate if needed.

    Returns the pack dict on success, ``None`` if not found or user cancels.
    When *escape_markup* is True, Rich markup characters in ``ns``/``name`` are
    escaped in console output (used by attach/detach which accept arbitrary input).
    """
    _pm, _pc = packs_service.resolve_pack(db, ns, name)
    if not _pm and _pc:
        if escape_markup:
            from rich.markup import escape as rich_escape

            display_ns, display_name = rich_escape(ns), rich_escape(name)
        else:
            display_ns, display_name = ns, name
        renderer.console.print(f"\nMultiple packs match @{display_ns}/{display_name}:")
        for _pi, _c in enumerate(_pc, 1):
            renderer.console.print(
                f"  {_pi}. {_c.get('namespace', '')}/{_c.get('name', '')} v{_c.get('version', '')} [{_c['id'][:8]}...]"
            )
        try:
            _ch = (await _timed_input(f"Select (1-{len(_pc)}): ")).strip()
            _idx = int(_ch) - 1
            if 0 <= _idx < len(_pc):
                _pm = _pc[_idx]
        except (ValueError, EOFError, KeyboardInterrupt, TimeoutError):
            pass
    return _pm


# ---------------------------------------------------------------------------
# /config command handler
# ---------------------------------------------------------------------------

_LAYER_COLORS: dict[str, str] = {
    "default": "dim",
    "team": "bright_red",
    "pack": "magenta",
    "personal": "bright_blue",
    "space": "bright_green",
    "project": "bright_yellow",
    "env var": "bright_cyan",
    "team (enforced)": "bold red",
}


def _handle_config_command(
    user_input: str,
    *,
    config: Any,
    db: Any,
    active_space: dict[str, Any] | None,
    working_dir: str,
    ai_service: Any,
    toolbar_refresh: Any,
) -> None:
    """Dispatch /config subcommands: list, get, set, reset."""
    from rich.table import Table

    from ..services.config_editor import (
        _SENSITIVE_FIELDS,
        apply_field_to_config,
        build_full_source_map,
        check_write_allowed,
        collect_env_overrides,
        get_field,
        list_settable_fields,
        reset_personal_field,
        reset_project_field,
        reset_space_field,
        validate_field_value,
        write_personal_field,
        write_project_field,
        write_space_field,
    )

    parts = user_input.split()
    sub = parts[1] if len(parts) > 1 else ""

    # Build context for reads
    def _build_context() -> tuple[dict[str, str], list[str]]:
        """Build source map and enforced fields from current config state."""
        from ..config import _get_config_path
        from ..services.config_editor import _read_yaml
        from ..services.project_config import discover_project_config
        from ..services.team_config import discover_team_config

        team_raw: dict[str, Any] = {}
        enforced: list[str] = []
        team_path = discover_team_config(cwd=working_dir)
        if team_path:
            from ..services.team_config import load_team_config

            team_raw, enforced = load_team_config(team_path, interactive=False)

        personal_raw = _read_yaml(_get_config_path())

        # Pack overlays from DB
        pack_raw: dict[str, Any] = {}
        try:
            from ..services.config_overlays import collect_pack_overlays, merge_pack_overlays
            from ..services.pack_attachments import (
                get_active_pack_ids,
                get_active_pack_ids_for_space,
                get_attachment_priorities,
            )

            space_id = active_space["id"] if active_space else None
            if space_id:
                active_ids = get_active_pack_ids_for_space(db, space_id, project_path=working_dir)
            else:
                active_ids = get_active_pack_ids(db, project_path=working_dir)
            if active_ids:
                overlays = collect_pack_overlays(db, active_ids)
                if overlays:
                    priorities = get_attachment_priorities(db, active_ids)
                    pack_raw = merge_pack_overlays(overlays, priorities) or {}
        except Exception:
            pass  # graceful degradation if DB schema is minimal

        space_raw: dict[str, Any] = {}
        if active_space and active_space.get("source_file"):
            sp_path = Path(active_space["source_file"])
            if sp_path.exists():
                from ..services.spaces import parse_space_file

                sc = parse_space_file(sp_path)
                space_raw = sc.config or {}

        project_raw: dict[str, Any] = {}
        proj_path = discover_project_config(working_dir)
        if proj_path:
            project_raw = _read_yaml(proj_path)
            project_raw.pop("required", None)

        env_overrides = collect_env_overrides()

        source_map = build_full_source_map(
            team_raw=team_raw,
            pack_raw=pack_raw,
            personal_raw=personal_raw,
            space_raw=space_raw,
            project_raw=project_raw,
            env_overrides=env_overrides,
        )
        return source_map, enforced

    def _styled_layer(layer: str) -> str:
        color = _LAYER_COLORS.get(layer, "white")
        return f"[{color}]{layer}[/{color}]"

    # ── /config list ──
    if sub == "list":
        fields = list_settable_fields()
        source_map, enforced = _build_context()
        filter_text = parts[2].lower() if len(parts) > 2 else ""

        table = Table(show_header=True, header_style="bold", padding=(0, 1))
        table.add_column("Field", style="bold")
        table.add_column("Type", style="dim")
        table.add_column("Value")
        table.add_column("Source")

        from dataclasses import asdict

        from ..services.config_overlays import flatten_to_dot_paths

        flat = flatten_to_dot_paths(asdict(config))

        count = 0
        for fi in fields:
            if filter_text and filter_text not in fi.dot_path:
                continue
            val = flat.get(fi.dot_path, "")
            src = source_map.get(fi.dot_path, "default")
            if fi.dot_path in enforced:
                src = "team (enforced)"
            # Truncate long values
            val_str = str(val) if val is not None else ""
            if len(val_str) > 50:
                val_str = val_str[:47] + "..."
            table.add_row(fi.dot_path, fi.field_type, val_str, _styled_layer(src))
            count += 1

        renderer.console.print()
        renderer.console.print(table)
        filter_msg = ' matching "%s"' % filter_text if filter_text else ""
        renderer.console.print(f"\n  [{MUTED}]{count} fields{filter_msg}[/{MUTED}]")
        renderer.console.print(f"  [{CHROME}]Usage: /config get <field> for details[/{CHROME}]\n")
        return

    # ── /config get <field> ──
    if sub == "get":
        if len(parts) < 3:
            renderer.console.print(f"  [{CHROME}]Usage: /config get <dot.path>[/{CHROME}]\n")
            return

        dot_path = parts[2]
        if dot_path in _SENSITIVE_FIELDS:
            renderer.render_error(f"'{dot_path}' is a sensitive field and cannot be read via /config.")
            return
        source_map, enforced = _build_context()

        try:
            result = get_field(config, dot_path, source_map, enforced)
        except ValueError as e:
            renderer.render_error(str(e))
            return

        renderer.console.print()
        renderer.console.print(f"  [bold]{dot_path}[/bold]")
        renderer.console.print(f"  Value:   {result.effective_value!r}")
        renderer.console.print(f"  Source:  {_styled_layer(result.source_layer)}")
        if result.is_enforced:
            renderer.console.print("  [bold red]Enforced by team config — cannot be changed[/bold red]")
        if result.field_info:
            fi = result.field_info
            renderer.console.print(f"  Type:    {fi.field_type}")
            if fi.default is not None:
                renderer.console.print(f"  Default: {fi.default!r}")
            if fi.allowed_values:
                renderer.console.print(f"  Allowed: {', '.join(fi.allowed_values)}")
            if fi.min_val is not None or fi.max_val is not None:
                renderer.console.print(f"  Range:   {fi.min_val} .. {fi.max_val}")
        renderer.console.print()
        return

    # ── /config set <field> <value> [--scope personal|space|project] ──
    if sub == "set":
        if len(parts) < 4:
            renderer.console.print(
                f"  [{CHROME}]Usage: /config set <dot.path> <value> [--scope personal|space|project][/{CHROME}]\n"
            )
            return

        dot_path = parts[2]

        # Parse --scope flag
        scope = "personal"  # default
        value_parts: list[str] = []
        i = 3
        while i < len(parts):
            if parts[i] == "--scope" and i + 1 < len(parts):
                scope = parts[i + 1].lower()
                i += 2
            else:
                value_parts.append(parts[i])
                i += 1
        raw_value = " ".join(value_parts)

        if scope not in ("personal", "space", "project"):
            renderer.render_error(f"Invalid scope: {scope!r}. Must be personal, space, or project.")
            return

        # Validate scope availability
        if scope == "space" and not active_space:
            renderer.render_error("No active space. Load a space first with /space load <name>.")
            return
        if scope == "space" and not active_space.get("source_file"):
            renderer.render_error("Active space has no YAML file. Cannot write space config.")
            return

        # Check write guards (sensitive fields blocked)
        _, enforced = _build_context()
        allowed, reason = check_write_allowed(dot_path, enforced)
        if not allowed:
            renderer.render_error(reason or "Write not allowed")
            return

        # Validate and parse value
        parsed, errors = validate_field_value(dot_path, raw_value)
        if errors:
            for err in errors:
                renderer.render_error(err)
            return

        # Write to the appropriate scope
        try:
            if scope == "personal":
                path = write_personal_field(dot_path, parsed)
                renderer.console.print(f"\n  [bold]{dot_path}[/bold] = {parsed!r}")
                renderer.console.print(f"  Saved to {_styled_layer('personal')} ({path})")
            elif scope == "space":
                sp_path = Path(active_space["source_file"])  # type: ignore[index]
                sp_id = active_space["id"] if active_space else None  # type: ignore[index]
                path = write_space_field(dot_path, parsed, sp_path, db=db, space_id=sp_id)
                renderer.console.print(f"\n  [bold]{dot_path}[/bold] = {parsed!r}")
                renderer.console.print(f"  Saved to {_styled_layer('space')} ({path})")
            elif scope == "project":
                path = write_project_field(dot_path, parsed, working_dir=working_dir)
                renderer.console.print(f"\n  [bold]{dot_path}[/bold] = {parsed!r}")
                renderer.console.print(f"  Saved to {_styled_layer('project')} ({path})")

            # Apply to live config
            try:
                apply_field_to_config(config, dot_path, parsed)
                # Special case: model change needs ai_service update
                if dot_path in ("ai.model", "model"):
                    if ai_service and hasattr(ai_service, "config"):
                        ai_service.config.model = parsed
                    toolbar_refresh()
                renderer.console.print("  [dim]Applied to current session[/dim]\n")
            except AttributeError:
                renderer.console.print("  [dim]Saved (restart to apply)[/dim]\n")
        except Exception as e:
            renderer.render_error(f"Failed to write: {e}")
        return

    # ── /config reset <field> [--scope personal|space|project] ──
    if sub == "reset":
        if len(parts) < 3:
            renderer.console.print(
                f"  [{CHROME}]Usage: /config reset <dot.path> [--scope personal|space|project][/{CHROME}]\n"
            )
            return

        dot_path = parts[2]
        if dot_path in _SENSITIVE_FIELDS:
            renderer.render_error(f"'{dot_path}' is a sensitive field and cannot be reset via /config.")
            return

        scope = "personal"
        if len(parts) > 3 and parts[3] == "--scope" and len(parts) > 4:
            scope = parts[4].lower()

        if scope not in ("personal", "space", "project"):
            renderer.render_error(f"Invalid scope: {scope!r}. Must be personal, space, or project.")
            return

        if scope == "space" and not active_space:
            renderer.render_error("No active space.")
            return
        if scope == "space" and not active_space.get("source_file"):  # type: ignore[union-attr]
            renderer.render_error("Active space has no YAML file. Cannot reset space config.")
            return

        try:
            deleted = False
            if scope == "personal":
                deleted = reset_personal_field(dot_path)
            elif scope == "space":
                sp_path = Path(active_space["source_file"])  # type: ignore[index]
                sp_id = active_space["id"] if active_space else None  # type: ignore[index]
                deleted = reset_space_field(dot_path, sp_path, db=db, space_id=sp_id)
            elif scope == "project":
                deleted = reset_project_field(dot_path, working_dir=working_dir)

            if deleted:
                renderer.console.print(f"\n  Removed [bold]{dot_path}[/bold] from {_styled_layer(scope)}")
                renderer.console.print("  [dim]Restart to apply defaults[/dim]\n")
            else:
                renderer.console.print(f"\n  [{MUTED}]{dot_path} not set in {scope} config[/{MUTED}]\n")
        except Exception as e:
            renderer.render_error(f"Failed to reset: {e}")
        return

    # ── /config (no subcommand or unknown) ──
    renderer.console.print(f"\n  [{CHROME}]Config commands:[/{CHROME}]")
    renderer.console.print("    [bold]/config list[/bold] [filter]     — list all fields (optionally filtered)")
    renderer.console.print("    [bold]/config get[/bold]  <field>      — show field details and source")
    renderer.console.print("    [bold]/config set[/bold]  <field> <val> [--scope personal|space|project]")
    renderer.console.print("    [bold]/config reset[/bold] <field>     [--scope personal|space|project]")
    renderer.console.print()
    scopes = ["personal"]
    if active_space:
        scopes.append(f"space ({active_space['name']})")
    scopes.append("project")
    renderer.console.print(f"  [{MUTED}]Available scopes: {', '.join(scopes)}[/{MUTED}]\n")


def _phase_badge(status: str) -> str:
    """Return a Rich-markup badge for a spec phase status."""
    if status == "approved":
        return f"[{_SUCCESS}]approved[/{_SUCCESS}]"
    elif status == "stale":
        return "[#fbbf24]stale[/#fbbf24]"
    return f"[{CHROME}]draft[/{CHROME}]"


async def _handle_spec_command(
    user_input: str,
    *,
    cmd: str,
    db: Any,
    config: Any = None,
    space: dict[str, Any] | None = None,
    working_dir: str | None = None,
) -> None:
    """Handle /spec and /specs REPL commands. Extracted for testability."""
    from ..services.artifacts import validate_fqn as _validate_fqn

    parts = user_input.split(maxsplit=3)
    sub = parts[1].lower() if len(parts) >= 2 else ""
    if cmd == "/specs":
        sub = "list"

    if sub == "list" or not sub:
        from ..services import artifact_storage as _art_store
        from ..services.artifacts import ArtifactType as _ArtType
        from ..services.spec_schema import SpecMode as _SpecMode
        from ..services.spec_schema import get_phase_status as _gps
        from ..services.spec_schema import parse_spec_content as _psc_list

        # Parse --attached flag
        _rest = parts[2] if len(parts) >= 3 else ""
        _attached = "--attached" in _rest

        import os as _os_mod

        _space_id = space["id"] if space else None
        _project_path = working_dir or _os_mod.getcwd()
        specs = _art_store.list_artifacts(
            db,
            artifact_type=_ArtType.SPEC,
            attached_only=_attached,
            space_id=_space_id,
            project_path=_project_path,
        )
        if not specs:
            renderer.console.print(f"[{CHROME}]No specs found.[/{CHROME}]\n")
            return
        renderer.console.print("\n[bold]Specs:[/bold]")
        for s in specs:
            meta = s.get("metadata", {})
            statuses = []
            for p in ("requirements", "design", "tasks"):
                st = _gps(meta, p).value
                color = _SUCCESS if st == "approved" else "#fbbf24" if st == "stale" else CHROME
                statuses.append(f"[{color}]{p}:{st}[/{color}]")
            mode_badge = ""
            content_str = s.get("content", "")
            if content_str:
                try:
                    _sc = _psc_list(content_str)
                    if _sc.mode == _SpecMode.BUGFIX:
                        mode_badge = " [#fbbf24]\\[bugfix][/#fbbf24]"
                except Exception:
                    pass
            _pack_badge = f" [{CHROME}][pack][/{CHROME}]" if _art_store.is_pack_owned(db, s["id"]) else ""
            renderer.console.print(f"  {s['fqn']}{mode_badge}{_pack_badge}  {' '.join(statuses)}")
        renderer.console.print()

    elif sub == "show":
        _fqn = parts[2].strip() if len(parts) >= 3 else ""
        if not _fqn:
            renderer.console.print(f"[{CHROME}]Usage: /spec show <fqn>[/{CHROME}]\n")
            return
        if not _validate_fqn(_fqn):
            renderer.console.print(f"[{CHROME}]Invalid FQN format.[/{CHROME}]\n")
            return
        from ..services import artifact_storage as _art_store_show
        from ..services.spec_service import get_spec as _get_spec

        _result = _get_spec(db, _fqn)
        if _result is None:
            renderer.console.print(f"[{CHROME}]Spec not found.[/{CHROME}]\n")
            return
        _art, _spec = _result
        from rich.markup import escape as _spec_esc

        from ..services.spec_schema import SpecMode as _SpecMode2
        from ..services.spec_schema import get_phase_status as _gps2

        _mode_badge2 = " [#fbbf24]\\[bugfix][/#fbbf24]" if _spec.mode == _SpecMode2.BUGFIX else ""
        _ro_badge = f" [{CHROME}][read-only][/{CHROME}]" if _art_store_show.is_pack_owned(db, _art["id"]) else ""
        renderer.console.print(f"\n[bold]Spec:[/bold] {_spec_esc(_art['fqn'])}{_mode_badge2}{_ro_badge}")
        renderer.console.print()
        for _phase in ("requirements", "design", "tasks"):
            _st = _gps2(_art["metadata"], _phase).value
            _color = _SUCCESS if _st == "approved" else "#fbbf24" if _st == "stale" else CHROME
            renderer.console.print(f"  [bold]{_phase}:[/bold] [{_color}]{_st}[/{_color}]")
        renderer.console.print()
        renderer.console.print(f"[bold]Requirements:[/bold]\n{_spec_esc(_spec.requirements.strip())}")
        renderer.console.print()
        renderer.console.print(f"[bold]Design:[/bold]\n{_spec_esc(_spec.design.strip())}")
        renderer.console.print()
        renderer.console.print("[bold]Tasks:[/bold]")
        for _t in _spec.tasks:
            _deps = f" (depends: {', '.join(_t.depends_on)})" if _t.depends_on else ""
            renderer.console.print(f"  [{CHROME}]{_t.id}[/{CHROME}] {_spec_esc(_t.summary)}{_deps}")
        renderer.console.print()

    elif sub == "create":
        _all_parts = user_input.split()
        _is_bugfix = "--bugfix" in _all_parts
        _is_design_first = "--design-first" in _all_parts
        _non_flag_parts = [p for p in _all_parts if not p.startswith("--")]
        _ns = _non_flag_parts[2].strip() if len(_non_flag_parts) >= 3 else ""
        _name = _non_flag_parts[3].strip() if len(_non_flag_parts) >= 4 else ""
        if not _ns or not _name:
            _msg = f"[{CHROME}]Usage: /spec create [--bugfix|--design-first] <namespace> <name>[/{CHROME}]\n"
            renderer.console.print(_msg)
            return
        import os
        import subprocess
        import tempfile

        from ..services.spec_schema import BUGFIX_TEMPLATE, FEATURE_TEMPLATE

        if _is_design_first:
            from ..services.spec_schema import DESIGN_FIRST_TEMPLATE

            _template = DESIGN_FIRST_TEMPLATE
        elif _is_bugfix:
            _template = BUGFIX_TEMPLATE
        else:
            _template = FEATURE_TEMPLATE
        _editor = os.environ.get("EDITOR", os.environ.get("VISUAL", "vi"))
        try:
            with tempfile.NamedTemporaryFile(suffix=".yaml", mode="w", delete=False) as _tf:
                _tf.write(_template)
                _tf_path = _tf.name
            subprocess.run([_editor, _tf_path], check=True)
            with open(_tf_path) as _f:
                _content = _f.read()
            os.unlink(_tf_path)
        except Exception as _e:
            renderer.console.print(f"[{_ERROR}]Editor failed: {_e}[/{_ERROR}]\n")
            return
        if not _content.strip() or _content.strip() == _template.strip():
            renderer.console.print(f"[{CHROME}]Spec creation cancelled (no changes).[/{CHROME}]\n")
            return
        from ..services.spec_service import create_spec as _cs

        _creation_flow = "design_first" if _is_design_first else None
        try:
            _new = _cs(db, _ns, _name, _content, creation_flow=_creation_flow)
            renderer.console.print(f"[{_SUCCESS}]Created[/{_SUCCESS}] {_new['fqn']}\n")
        except Exception as _e:
            renderer.console.print(f"[{_ERROR}]{_e}[/{_ERROR}]\n")

    elif sub == "approve":
        _fqn = parts[2].strip() if len(parts) >= 3 else ""
        _phase = parts[3].strip() if len(parts) >= 4 else ""
        if not _fqn or not _phase:
            renderer.console.print(f"[{CHROME}]Usage: /spec approve <fqn> <phase>[/{CHROME}]\n")
            return
        from ..services.spec_service import approve_phase as _ap

        try:
            _r = _ap(db, _fqn, _phase)
            if _r is None:
                renderer.console.print(f"[{CHROME}]Spec not found.[/{CHROME}]\n")
            else:
                renderer.console.print(f"[{_SUCCESS}]Approved[/{_SUCCESS}] {_phase} on {_fqn}\n")
        except (ValueError, Exception) as _e:
            renderer.console.print(f"[{_ERROR}]{_e}[/{_ERROR}]\n")

    elif sub == "unapprove":
        _fqn = parts[2].strip() if len(parts) >= 3 else ""
        _phase = parts[3].strip() if len(parts) >= 4 else ""
        if not _fqn or not _phase:
            renderer.console.print(f"[{CHROME}]Usage: /spec unapprove <fqn> <phase>[/{CHROME}]\n")
            return
        from ..services.spec_service import unapprove_phase as _up

        try:
            _r = _up(db, _fqn, _phase)
            if _r is None:
                renderer.console.print(f"[{CHROME}]Spec not found.[/{CHROME}]\n")
            else:
                renderer.console.print(f"[{_SUCCESS}]Reset to draft[/{_SUCCESS}] {_phase} on {_fqn}\n")
        except (ValueError, Exception) as _e:
            renderer.console.print(f"[{_ERROR}]{_e}[/{_ERROR}]\n")

    elif sub == "diff":
        _fqn = parts[2].strip() if len(parts) >= 3 else ""
        if not _fqn:
            renderer.console.print(f"[{CHROME}]Usage: /spec diff <fqn>[/{CHROME}]\n")
            return
        if not _validate_fqn(_fqn):
            renderer.console.print(f"[{CHROME}]Invalid FQN format.[/{CHROME}]\n")
            return
        from ..services import artifact_storage as _art_store2
        from ..services.spec_diff import diff_spec_versions as _dsv
        from ..services.spec_schema import parse_spec_content as _psc

        _art2 = _art_store2.get_artifact_by_fqn(db, _fqn)
        if not _art2 or _art2["type"] != "spec":
            renderer.console.print(f"[{CHROME}]Spec not found.[/{CHROME}]\n")
            return
        _vers = _art_store2.list_artifact_versions(db, _art2["id"])
        if len(_vers) < 2:
            renderer.console.print(f"[{CHROME}]Only one version — nothing to diff.[/{CHROME}]\n")
            return
        _old = _psc(_vers[1]["content"])
        _new = _psc(_vers[0]["content"])
        _diff = _dsv(_old, _new, version_from=_vers[1]["version"], version_to=_vers[0]["version"])
        renderer.console.print(f"\n[bold]Diff:[/bold] v{_diff.version_from} → v{_diff.version_to}")
        for _pc in _diff.phase_changes:
            if _pc.content_changed:
                renderer.console.print(f"\n  [bold]{_pc.phase}:[/bold] [#fbbf24]changed[/#fbbf24]")
                if _pc.content_diff:
                    for _line in _pc.content_diff.split("\n"):
                        if _line.startswith("+"):
                            renderer.console.print(f"    [{_SUCCESS}]{_line}[/{_SUCCESS}]")
                        elif _line.startswith("-"):
                            renderer.console.print(f"    [{_ERROR}]{_line}[/{_ERROR}]")
                        else:
                            renderer.console.print(f"    [{CHROME}]{_line}[/{CHROME}]")
        for _tc in _diff.task_changes:
            renderer.console.print(f"  Task {_tc.task_id}: [{CHROME}]{_tc.change_type}[/{CHROME}]")
        if not any(pc.content_changed for pc in _diff.phase_changes) and not _diff.task_changes:
            renderer.console.print(f"  [{CHROME}]No content changes.[/{CHROME}]")
        renderer.console.print()

    elif sub == "launch":
        # /spec launch <fqn> <task_id> <workflow_id>
        _launch_parts = user_input.split(maxsplit=5)
        _launch_fqn = _launch_parts[2].strip() if len(_launch_parts) >= 3 else ""
        _launch_tid = _launch_parts[3].strip() if len(_launch_parts) >= 4 else ""
        _launch_wid = _launch_parts[4].strip() if len(_launch_parts) >= 5 else ""
        if not _launch_fqn or not _launch_tid or not _launch_wid:
            renderer.console.print(f"[{CHROME}]Usage: /spec launch <fqn> <task_id> <workflow_id>[/{CHROME}]\n")
            return
        if not _validate_fqn(_launch_fqn):
            renderer.console.print(f"[{CHROME}]Invalid FQN format.[/{CHROME}]\n")
            return
        if config is None:
            renderer.console.print(f"[{_ERROR}]Config not available for workflow launch.[/{_ERROR}]\n")
            return

        from ..services.spec_service import launch_from_task as _lft

        _launch_extra: dict[str, Any] = {}
        if space and space.get("id"):
            _launch_extra["space_id"] = space["id"]
        if working_dir:
            _launch_extra["project_path"] = working_dir

        try:
            _inputs = _lft(db, _launch_fqn, _launch_tid, _launch_wid, extra_inputs=_launch_extra or None)
        except ValueError as _e:
            renderer.console.print(f"[{_ERROR}]{_e}[/{_ERROR}]\n")
            return

        from ..services.workflow_resolution import resolve_workflow_path as _rwp

        _wf_path = _rwp(_launch_wid)
        if _wf_path is None:
            renderer.console.print(f"[{_ERROR}]Workflow not found: {_launch_wid}[/{_ERROR}]\n")
            return

        from ..services.workflow_engine import load_definition as _ld

        try:
            _definition = _ld(_wf_path)
        except Exception as _e:
            renderer.console.print(f"[{_ERROR}]Invalid workflow: {_e}[/{_ERROR}]\n")
            return

        from .workflow_cli import _cleanup_event_bus, _create_engine

        _engine, _event_bus = _create_engine(config, db)
        try:
            _run = await _engine.start_run(
                _definition,
                target_kind="spec",
                target_ref=_launch_fqn,
                inputs=_inputs,
            )
            renderer.console.print(
                f"[{_SUCCESS}]Launched[/{_SUCCESS}] workflow {_launch_wid}"
                f" for task {_launch_tid}\n"
                f"  Run ID: {_run['id']}\n"
                f"  Status: {_run.get('status', 'unknown')}\n"
            )
        except (ValueError, RuntimeError) as _e:
            renderer.console.print(f"[{_ERROR}]{_e}[/{_ERROR}]\n")
        finally:
            _cleanup_event_bus(_event_bus)

    elif sub == "queue":
        import os as _os_q

        from ..services.spec_queue import get_review_queue as _grq

        _space_id_q = space["id"] if space else None
        _project_path_q = working_dir or _os_q.getcwd()
        _queue = _grq(db, space_id=_space_id_q, project_path=_project_path_q)
        if _queue.total == 0 and not _queue.blocked:
            renderer.console.print(f"[{CHROME}]All specs are up to date.[/{CHROME}]\n")
            return

        from rich.table import Table as _QTable

        if _queue.needs_review:
            _qt = _QTable(title="Needs Review", show_lines=False)
            _qt.add_column("FQN", style="bold")
            _qt.add_column("Requirements")
            _qt.add_column("Design")
            _qt.add_column("Tasks")
            for _item in _queue.needs_review:
                _qt.add_row(
                    _item.fqn,
                    _phase_badge(_item.phases.get("requirements", "draft")),
                    _phase_badge(_item.phases.get("design", "draft")),
                    _phase_badge(_item.phases.get("tasks", "draft")),
                )
            renderer.console.print(_qt)

        if _queue.stale:
            renderer.console.print(f"\n[#fbbf24]Stale specs: {len(_queue.stale)}[/#fbbf24]")
            for _item in _queue.stale:
                for _phase, _reason in _item.stale_reasons.items():
                    renderer.console.print(f"  {_item.fqn} — {_reason}")

        if _queue.blocked:
            renderer.console.print(f"\n[{_ERROR}]Blocked by spec gates: {len(_queue.blocked)}[/{_ERROR}]")
            for _item in _queue.blocked:
                for _brun in _item.blocked_runs:
                    _rid = _brun.get("id", "?")[:8]
                    _reason = _brun.get("stop_reason", "blocked")
                    renderer.console.print(f"  {_item.fqn} — run {_rid}: {_reason}")

        renderer.console.print()

    elif sub == "edit":
        _edit_rest = user_input.split()[2:]
        if not _edit_rest:
            renderer.console.print(f"[{CHROME}]Usage: /spec edit <fqn> [phase][/{CHROME}]\n")
            return
        _edit_fqn = _edit_rest[0]
        _edit_phase = _edit_rest[1] if len(_edit_rest) > 1 else None

        from ..services.spec_schema import serialize_spec_content as _ssc
        from ..services.spec_service import get_spec as _get_spec_edit
        from ..services.spec_service import update_spec_content as _usc
        from ..services.spec_service import update_spec_phase as _usp

        _edit_result = _get_spec_edit(db, _edit_fqn)
        if _edit_result is None:
            renderer.console.print(f"[{_ERROR}]Spec not found: {_edit_fqn}[/{_ERROR}]\n")
            return
        _edit_art, _edit_spec = _edit_result

        import os as _os_edit
        import tempfile as _tf_edit

        if _edit_phase:
            if _edit_phase not in ("requirements", "design"):
                _emsg = "Phase must be 'requirements' or 'design'. Use full edit for tasks."
                renderer.console.print(f"[{_ERROR}]{_emsg}[/{_ERROR}]\n")
                return
            _current_content = getattr(_edit_spec, _edit_phase)
            with _tf_edit.NamedTemporaryFile(suffix=".yaml", mode="w", delete=False) as _etf:
                _etf.write(_current_content)
                _etf_path = _etf.name
            _editor = _os_edit.environ.get("EDITOR", _os_edit.environ.get("VISUAL", "vi"))
            _os_edit.system(f'{_editor} "{_etf_path}"')
            with open(_etf_path) as _ef:
                _new_content = _ef.read()
            _os_edit.unlink(_etf_path)
            if _new_content.strip() == _current_content.strip():
                renderer.console.print(f"[{CHROME}]No changes.[/{CHROME}]\n")
                return
            try:
                _usp(db, _edit_fqn, _edit_phase, _new_content)
            except (ValueError, Exception) as _e:
                renderer.console.print(f"[{_ERROR}]{_e}[/{_ERROR}]\n")
                return
            renderer.console.print(f"[{_SUCCESS}]Updated {_edit_phase} for {_edit_fqn}[/{_SUCCESS}]\n")
        else:
            _yaml_content = _ssc(_edit_spec)
            with _tf_edit.NamedTemporaryFile(suffix=".yaml", mode="w", delete=False) as _etf:
                _etf.write(_yaml_content)
                _etf_path = _etf.name
            _editor = _os_edit.environ.get("EDITOR", _os_edit.environ.get("VISUAL", "vi"))
            _os_edit.system(f'{_editor} "{_etf_path}"')
            with open(_etf_path) as _ef:
                _new_yaml = _ef.read()
            _os_edit.unlink(_etf_path)
            if _new_yaml.strip() == _yaml_content.strip():
                renderer.console.print(f"[{CHROME}]No changes.[/{CHROME}]\n")
                return
            try:
                _usc(db, _edit_fqn, _new_yaml)
            except (ValueError, Exception) as _e:
                renderer.console.print(f"[{_ERROR}]{_e}[/{_ERROR}]\n")
                return
            renderer.console.print(f"[{_SUCCESS}]Updated {_edit_fqn}[/{_SUCCESS}]\n")

    elif sub == "link":
        _link_rest = user_input.split()[2:]
        if not _link_rest:
            _lusage = "Usage: /spec link <fqn> [branch <name> | pr <number> [url]]"
            renderer.console.print(f"[{CHROME}]{_lusage}[/{CHROME}]\n")
            return
        _link_fqn = _link_rest[0]
        from ..services.spec_linkage import detect_current_branch as _dcb
        from ..services.spec_linkage import link_branch_to_spec as _lbs
        from ..services.spec_linkage import link_pr_to_spec as _lps

        if len(_link_rest) == 1:
            _branch = _dcb()
            if not _branch:
                _emsg = "Not in a git repo or detached HEAD. Specify: /spec link <fqn> branch <name>"
                renderer.console.print(f"[{_ERROR}]{_emsg}[/{_ERROR}]\n")
                return
            _lr = _lbs(db, _link_fqn, _branch, auto=True)
            if _lr is None:
                renderer.console.print(f"[{_ERROR}]Spec not found: {_link_fqn}[/{_ERROR}]\n")
                return
            renderer.console.print(f"[{_SUCCESS}]Linked branch '{_branch}' to {_link_fqn}[/{_SUCCESS}]\n")
        elif _link_rest[1] == "branch" and len(_link_rest) >= 3:
            _lr = _lbs(db, _link_fqn, _link_rest[2])
            if _lr is None:
                renderer.console.print(f"[{_ERROR}]Spec not found: {_link_fqn}[/{_ERROR}]\n")
                return
            renderer.console.print(f"[{_SUCCESS}]Linked branch '{_link_rest[2]}' to {_link_fqn}[/{_SUCCESS}]\n")
        elif _link_rest[1] == "pr" and len(_link_rest) >= 3:
            _pr_url = _link_rest[3] if len(_link_rest) >= 4 else None
            _lr = _lps(db, _link_fqn, _link_rest[2], pr_url=_pr_url)
            if _lr is None:
                renderer.console.print(f"[{_ERROR}]Spec not found: {_link_fqn}[/{_ERROR}]\n")
                return
            renderer.console.print(f"[{_SUCCESS}]Linked PR #{_link_rest[2]} to {_link_fqn}[/{_SUCCESS}]\n")
        else:
            _lusage = "Usage: /spec link <fqn> [branch <name> | pr <number> [url]]"
            renderer.console.print(f"[{CHROME}]{_lusage}[/{CHROME}]\n")

    elif sub == "unlink":
        _unlink_rest = user_input.split()[2:]
        if len(_unlink_rest) < 2:
            renderer.console.print(f"[{CHROME}]Usage: /spec unlink <fqn> <link_id>[/{CHROME}]\n")
            return
        from ..services.spec_linkage import unlink_from_spec as _ufs

        _ur = _ufs(db, _unlink_rest[0], _unlink_rest[1])
        if _ur is None:
            renderer.console.print(f"[{_ERROR}]Spec not found: {_unlink_rest[0]}[/{_ERROR}]\n")
            return
        renderer.console.print(f"[{_SUCCESS}]Removed link {_unlink_rest[1][:8]} from {_unlink_rest[0]}[/{_SUCCESS}]\n")

    elif sub == "links":
        _links_rest = user_input.split()[2:]
        if not _links_rest:
            renderer.console.print(f"[{CHROME}]Usage: /spec links <fqn>[/{CHROME}]\n")
            return
        from ..services.spec_linkage import get_spec_links as _gsl

        _links = _gsl(db, _links_rest[0])
        if not _links:
            renderer.console.print(f"[{CHROME}]No links.[/{CHROME}]\n")
            return
        for _lnk in _links:
            _auto_tag = " (auto)" if _lnk.auto else ""
            _url_tag = f" — {_lnk.url}" if _lnk.url else ""
            renderer.console.print(f"  {_lnk.type}: {_lnk.ref}{_url_tag}{_auto_tag} [{_lnk.id[:8]}]")
        renderer.console.print()

    elif sub == "conformance":
        _conf_rest = user_input.split()[2:]
        if not _conf_rest:
            renderer.console.print(f"[{CHROME}]Usage: /spec conformance <fqn>[/{CHROME}]\n")
            return
        _conf_fqn = _conf_rest[0]
        from ..services.spec_conformance import ConformanceSeverity as _CSev
        from ..services.spec_conformance import run_conformance_check as _rcc

        _conf_result = _rcc(db, _conf_fqn)
        if _conf_result is None:
            renderer.console.print(f"[{_ERROR}]Spec not found: {_conf_fqn}[/{_ERROR}]\n")
            return

        renderer.console.print(f"\n[bold]Spec Conformance: {_conf_fqn}[/bold] (v{_conf_result.spec_version})\n")

        _by_cat: dict[str, list] = {}
        for _f in _conf_result.findings:
            _by_cat.setdefault(_f.category, []).append(_f)

        for _cat, _findings in _by_cat.items():
            renderer.console.print(f"[bold]{_cat.replace('_', ' ').title()}[/bold]")
            for _f in _findings:
                if _f.severity == _CSev.ERROR:
                    _icon = f"[{_ERROR}][FAIL][/{_ERROR}]"
                elif _f.severity == _CSev.WARNING:
                    _icon = "[#fbbf24][WARN][/#fbbf24]"
                else:
                    _icon = f"[{CHROME}][INFO][/{CHROME}]"
                renderer.console.print(f"  {_icon} {_f.message}")
            renderer.console.print()

        if _conf_result.conformant:
            renderer.console.print(f"[{_SUCCESS}]CONFORMANT[/{_SUCCESS}]\n")
        else:
            _cerrors = sum(1 for _f in _conf_result.findings if _f.severity == _CSev.ERROR)
            _cwarnings = sum(1 for _f in _conf_result.findings if _f.severity == _CSev.WARNING)
            _cmsg = f"NOT CONFORMANT — {_cerrors} error(s), {_cwarnings} warning(s)"
            renderer.console.print(f"[{_ERROR}]{_cmsg}[/{_ERROR}]\n")

    else:
        _subs = "list,show,create,edit,approve,unapprove,diff,launch,queue,link,unlink,links,conformance"
        renderer.console.print(f"[{CHROME}]Usage: /spec {{{_subs}}}[/{CHROME}]\n")


async def run_cli(
    config: AppConfig,
    prompt: str | None = None,
    no_tools: bool = False,
    continue_last: bool = False,
    conversation_id: str | None = None,
    trust_project: bool = False,
    no_project_context: bool = False,
    plan_mode: bool = False,
    space_id: str | None = None,
) -> None:
    """Main entry point for CLI mode."""
    working_dir = os.getcwd()

    # Initialize CLI theme from config
    theme = CliTheme.load(config.cli.theme)
    renderer.set_theme(theme)
    # Update module-level color aliases from active theme
    global GOLD, MUTED, CHROME, _SUCCESS, _ERROR
    GOLD = renderer.GOLD
    MUTED = renderer.MUTED
    CHROME = renderer.CHROME
    _SUCCESS = renderer._theme.success
    _ERROR = renderer._theme.error

    # Init DB (same as web UI)
    db_path = config.app.data_dir / "chat.db"
    config.app.data_dir.mkdir(parents=True, exist_ok=True)
    vec_dims = get_effective_dimensions(config)

    # Derive encryption key if encryption at rest is enabled
    encryption_key: bytes | None = None
    if config.storage.encrypt_at_rest:
        from ..services.encryption import derive_db_key, is_sqlcipher_available

        if not is_sqlcipher_available():
            renderer.render_error(
                "Encryption at rest enabled but sqlcipher3 not installed. Install with: pip install sqlcipher3"
            )
            return
        pk = config.identity.private_key if config.identity else ""
        if not pk:
            renderer.render_error("Encryption at rest requires an identity key. Run: aroom init")
            return
        encryption_key = derive_db_key(pk)

    db = init_db(db_path, vec_dimensions=vec_dims, encryption_key=encryption_key)

    # Initialize vector index manager (usearch-based)
    from ..services.vector_index import VectorIndexManager

    _vec_manager = VectorIndexManager(config.app.data_dir, dimensions=vec_dims)
    if _vec_manager.enabled:
        _vec_manager.rebuild_from_db(db)

    # Load space if specified via --space or auto-detect by cwd
    _space: dict[str, Any] | None = None
    _space_instructions: str | None = None
    if space_id:
        from ..services.space_storage import get_space

        _space = get_space(db, space_id)
        if _space:
            renderer.console.print(f"[dim]Space:[/dim] {_space['name']}")
        else:
            renderer.render_error(f"Space ID {space_id} not found in database")
            space_id = None
    if not _space:
        from ..services.space_storage import resolve_space_by_cwd

        _space = resolve_space_by_cwd(db, working_dir)
        if _space:
            space_id = _space["id"]
            renderer.console.print(f"[dim]Space (auto):[/dim] {_space['name']}")
    if not _space:
        from ..services.space_storage import discover_space_file

        _discovered_path = discover_space_file(working_dir)
        if _discovered_path:
            try:
                from ..services.spaces import sync_space_from_file as _sync_disc

                _space = _sync_disc(db, _discovered_path)
                space_id = _space["id"]
                renderer.console.print(f"[dim]Space (discovered):[/dim] {_space['name']}")
            except Exception:
                logger.debug("Failed to load discovered space file %s", _discovered_path, exc_info=True)
    if _space:
        # Load space instructions from the DB record
        if _space.get("instructions"):
            _space_instructions = _space["instructions"]

    # Clean up empty conversations
    try:
        storage.delete_empty_conversations(db, config.app.data_dir)
    except Exception:
        logger.debug("Failed to clean up empty conversations")

    # Register built-in tools
    tool_registry = ToolRegistry()
    if config.cli.builtin_tools and not no_tools:
        register_default_tools(tool_registry, working_dir=working_dir)

    # Prepare MCP manager (startup deferred to background after REPL prompt appears)
    mcp_manager = None
    _mcp_statuses: dict[str, dict[str, Any]] = {}
    if config.mcp_servers:
        try:
            from ..services.mcp_manager import McpManager

            mcp_manager = McpManager(config.mcp_servers, tool_warning_threshold=config.mcp_tool_warning_threshold)
        except Exception as e:
            logger.warning("Failed to initialize MCP manager: %s", e)
            renderer.render_error(f"MCP init failed: {e}")

    # Initialize audit writer
    from ..services.audit import AuditEntry, create_audit_writer

    _private_key = config.identity.private_key if config.identity else ""
    audit_writer = create_audit_writer(config, private_key_pem=_private_key)

    # Start retention worker if configured
    retention_worker = None
    if config.storage.retention_days > 0:
        from ..services.retention import RetentionWorker

        retention_worker = RetentionWorker(
            db=db,
            data_dir=config.app.data_dir,
            retention_days=config.storage.retention_days,
            check_interval=config.storage.retention_check_interval,
            purge_attachments=config.storage.purge_attachments,
        )
        retention_worker.start()

    # Set up safety configuration and confirmation callback
    from ..tools.safety import SafetyVerdict

    _approval_lock = asyncio.Lock()
    thinking = False

    async def _sub_prompt_async(prompt_text: str) -> str | None:
        """Read one line of input using a nested PromptSession.

        The caller must print any context (options, warnings) via
        ``renderer.console.print()`` *before* calling this, so the text
        persists through patch_stdout.  This function only shows a
        minimal prompt (e.g. ``"> "``) via the nested session.

        Returns the stripped input, or ``None`` on EOF/interrupt.
        """
        from prompt_toolkit import PromptSession as _SubSession

        try:
            _sub: Any = _SubSession()
            answer = await _sub.prompt_async(prompt_text)
            return answer.strip() if answer is not None else None
        except (EOFError, KeyboardInterrupt):
            return None

    async def _confirm_destructive(verdict: SafetyVerdict) -> bool:
        nonlocal thinking
        from rich.markup import escape

        def _apply_choice(choice: str) -> bool:
            if choice in ("a", "always"):
                tool_registry.grant_session_permission(verdict.tool_name)
                _persist_allowed_tool(verdict.tool_name)
                renderer.console.print(f"  [{MUTED}]✓ Allowed: {escape(verdict.tool_name)} (always)[/{MUTED}]\n")
                return True
            if choice in ("s", "session"):
                tool_registry.grant_session_permission(verdict.tool_name)
                renderer.console.print(f"  [{MUTED}]✓ Allowed: {escape(verdict.tool_name)} (session)[/{MUTED}]\n")
                return True
            if choice in ("y", "yes"):
                renderer.console.print(f"  [{MUTED}]✓ Allowed: {escape(verdict.tool_name)} (once)[/{MUTED}]\n")
                return True
            renderer.console.print(f"  [{MUTED}]✗ Denied: {escape(verdict.tool_name)}[/{MUTED}]\n")
            return False

        await renderer.stop_thinking(quiet=True)
        thinking = False
        renderer.stop_tool_ticker_sync()

        async with _approval_lock:
            renderer.console.print(f"\n  [{GOLD}]── Approval Needed ──[/{GOLD}]")
            renderer.console.print(f"  {escape(verdict.reason)}")
            if verdict.details.get("command"):
                renderer.console.print(f"  Command: [{MUTED}]{escape(verdict.details['command'])}[/{MUTED}]")
            elif verdict.details.get("path"):
                renderer.console.print(f"  Path: [{MUTED}]{escape(verdict.details['path'])}[/{MUTED}]")
            renderer.console.print(f"  [{MUTED}]y once   s session   a always   n deny[/{MUTED}]")
            renderer.console.print(f"  [{MUTED}]ctrl-d to deny[/{MUTED}]")
            answer = await _sub_prompt_async("  Choice [y/s/a/n] > ")
            if answer is None:
                renderer.console.print(f"  [{MUTED}]✗ Denied: {escape(verdict.tool_name)}[/{MUTED}]\n")
                return False
            result = _apply_choice(answer.lower())
            return result

    def _persist_allowed_tool(tool_name: str) -> None:
        """Append a tool to safety.allowed_tools in the config file."""
        try:
            from ..config import write_allowed_tool

            write_allowed_tool(tool_name)
        except Exception as e:
            renderer.console.print(f"[{MUTED}]Could not persist preference: {e}[/{MUTED}]")

    tool_registry.set_safety_config(config.safety, working_dir=working_dir)
    tool_registry.set_confirm_callback(_confirm_destructive)

    # Set up ask_user callback for mid-turn questions
    async def _ask_user_callback(question: str, options: list[str] | None = None) -> str:
        nonlocal thinking

        def _resolve_choice(answer: str, opts: list[str] | None) -> str:
            if not opts:
                return answer
            if answer.isdigit():
                idx = int(answer)
                if 1 <= idx <= len(opts):
                    return opts[idx - 1]
                renderer.console.print(f"  [{MUTED}]Invalid choice #{idx}, using as freeform answer[/{MUTED}]")
                return answer
            lowered = answer.strip().lower()
            matches = [opt for opt in opts if opt.lower().startswith(lowered)]
            if len(matches) == 1:
                return matches[0]
            return answer

        await renderer.stop_thinking(quiet=True)
        thinking = False
        renderer.stop_tool_ticker_sync()

        renderer.console.print(f"\n  [{MUTED}]── Input Needed ──[/{MUTED}]")
        renderer.console.print(f"  [{GOLD}]{escape(question)}[/{GOLD}]")
        if options:
            for i, opt in enumerate(options, 1):
                renderer.console.print(f"    [{MUTED}]{i}.[/{MUTED}] {escape(opt)}")
            prompt_text = f"  Choice [1-{len(options)} or text] > "
            renderer.console.print(f"  [{MUTED}]ctrl-d to cancel[/{MUTED}]")
            answer = await _sub_prompt_async(prompt_text)
        else:
            renderer.console.print(f"  [{MUTED}]ctrl-d to cancel[/{MUTED}]")
            answer = await _sub_prompt_async("  Answer > ")

        if answer is None:
            renderer.console.print(f"  [{MUTED}](cancelled)[/{MUTED}]\n")
            return ""

        if options:
            answer = _resolve_choice(answer, options)

        renderer.console.print(f"  [{MUTED}]→ {answer}[/{MUTED}]")
        return str(answer)

    # Build unified tool executor
    _subagent_counter = 0
    _active_cancel_event: list[asyncio.Event | None] = [None]

    from typing import cast as _cast

    from ..services.tool_rate_limit import ToolRateLimitConfig as _SvcRateLimitConfig
    from ..services.tool_rate_limit import ToolRateLimiter
    from ..tools.subagent import SubagentLimiter

    _sa_config = config.safety.subagent
    _subagent_limiter = SubagentLimiter(
        max_concurrent=_sa_config.max_concurrent,
        max_total=_sa_config.max_total,
    )

    _rate_limiter = ToolRateLimiter(_cast(_SvcRateLimitConfig, config.safety.tool_rate_limit))
    tool_registry.set_rate_limiter(_rate_limiter)

    # Construct DLP scanner if configured
    _dlp_scanner = None
    if config.safety.dlp.enabled:
        from ..services.dlp import DlpScanner

        _dlp_scanner = DlpScanner(config.safety.dlp)

    # Construct injection detector if configured
    _injection_detector = None
    if config.safety.prompt_injection.enabled:
        from ..services.injection_detector import InjectionDetector

        _injection_detector = InjectionDetector(config.safety.prompt_injection)

    async def _cli_event_sink(agent_id: str, event: Any) -> None:
        """Render sub-agent progress events in the CLI."""
        kind = event.kind
        data = event.data
        if kind == "subagent_start":
            renderer.render_subagent_start(
                agent_id, data.get("prompt", ""), data.get("model", ""), data.get("depth", 1)
            )
        elif kind == "tool_call_start":
            renderer.render_subagent_tool(agent_id, data.get("tool_name", ""), data.get("arguments"))
        elif kind == "subagent_end":
            renderer.render_subagent_end(
                agent_id, data.get("elapsed_seconds", 0), data.get("tool_calls", []), data.get("error")
            )

    def _audit_tool_call(
        writer: Any, tool_name: str, arguments: dict[str, Any], result: dict[str, Any], conv_id: str | None
    ) -> None:
        if writer is None or not writer.enabled:
            return
        approval = result.get("_approval_decision", "auto") if isinstance(result, dict) else "auto"
        status = "error" if isinstance(result, dict) and result.get("error") else "success"
        writer.emit(
            AuditEntry.create(
                "tool_calls.executed",
                "info",
                conversation_id=conv_id or "",
                tool_name=tool_name,
                user_id=config.identity.user_id if config.identity else "",
                details={
                    "status": status,
                    "approval_decision": approval,
                    "tool_input": str(arguments)[:500],
                    "tool_output": str(result)[:500] if result else "",
                },
            )
        )

    # Mutable ref so tool_executor can queue skill prompts into the per-turn msg_queue
    _active_msg_queue: list[asyncio.Queue[dict[str, Any]] | None] = [None]

    async def tool_executor(tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]:
        nonlocal _subagent_counter
        if tool_name == "invoke_skill":
            # Apply safety tier check (invoke_skill is READ tier but respects denied_tools)
            verdict = tool_registry.check_safety(tool_name, arguments)
            if verdict and verdict.needs_approval:
                if verdict.hard_denied:
                    return {"error": f"Tool '{tool_name}' is blocked by configuration", "safety_blocked": True}
                confirmed = await _confirm_destructive(verdict)
                if not confirmed:
                    return {"error": "Operation denied by user", "exit_code": -1}
            skill_name = arguments.get("skill_name", "")
            skill = skill_registry.get(skill_name) if skill_registry else None
            if not skill:
                return {"error": f"Unknown skill: {skill_name}"}
            queue = _active_msg_queue[0]
            if queue is None:
                return {"error": "Skill invocation unavailable in this context"}
            args = arguments.get("args", "")
            prompt = skill.prompt
            if args:
                # Trust boundary: args originate from the LLM, not the user.
                # Truncate, sanitize trust tags to prevent envelope breakout,
                # and delimit to limit injection surface.
                from .skills import _expand_args

                args = sanitize_trust_tags(args[:2000])
                prompt = _expand_args(prompt, f"<skill_args>{args}</skill_args>")
            await queue.put({"role": "user", "content": prompt})
            return {"status": "skill_invoked", "skill": skill_name}
        if tool_name == "run_agent":
            _subagent_counter += 1
            arguments = {
                **arguments,
                "_ai_service": ai_service,
                "_tool_registry": tool_registry,
                "_mcp_manager": mcp_manager,
                "_cancel_event": _active_cancel_event[0],
                "_depth": 0,
                "_agent_id": f"agent-{_subagent_counter}",
                "_event_sink": _cli_event_sink,
                "_limiter": _subagent_limiter,
                "_confirm_callback": _confirm_destructive,
                "_config": _sa_config,
            }
        elif tool_name == "ask_user":
            arguments = {**arguments, "_ask_callback": _ask_user_callback}
        elif tool_name == "introspect":
            _rt_info: dict[str, Any] = {"interface": "cli"}
            _rt_space: dict[str, Any] | None = None
            if conversation_id:
                try:
                    _rt_conv = storage.get_conversation(db, conversation_id)
                except Exception:
                    _rt_conv = None
                if _rt_conv:
                    _rt_info["conversation_id"] = conversation_id
                    _rt_info["conversation_title"] = _rt_conv.get("title")
                    _rt_info["slug"] = _rt_conv.get("slug")
                    try:
                        _rt_msgs = storage.list_messages(db, conversation_id)
                        _rt_info["message_count"] = len(_rt_msgs)
                    except Exception:
                        _rt_info["message_count"] = 0
                    try:
                        _rt_info["token_totals"] = storage.get_conversation_token_total(db, conversation_id)
                    except Exception:
                        _rt_info["token_totals"] = 0
                    _rt_sid = _rt_conv.get("space_id")
                    if _rt_sid:
                        from ..services.space_storage import get_space as _get_sp

                        _rt_space = _get_sp(db, _rt_sid)
            if _rt_space:
                _rt_info["active_space"] = {
                    "name": _rt_space.get("name"),
                    "id": _rt_space.get("id"),
                }
            arguments = {
                **arguments,
                "_config": config,
                "_mcp_manager": mcp_manager,
                "_tool_registry": tool_registry,
                "_skill_registry": skill_registry,
                "_instructions_info": _introspect_instructions_info,
                "_tools_openai": tools_openai,
                "_working_dir": working_dir,
                "_active_space": _rt_space,
                "_db": db,
                "_runtime_info": _rt_info,
            }
        if tool_registry.has_tool(tool_name):
            result = await tool_registry.call_tool(tool_name, arguments)
            _audit_tool_call(audit_writer, tool_name, arguments, result, conversation_id)
            return result
        if mcp_manager:
            # MCP tools bypass ToolRegistry — apply safety gate here
            verdict = tool_registry.check_safety(tool_name, arguments)
            if verdict and verdict.needs_approval:
                if verdict.hard_denied:
                    return {"error": f"Tool '{tool_name}' is blocked by configuration", "safety_blocked": True}
                confirmed = await _confirm_destructive(verdict)
                if not confirmed:
                    return {"error": "Operation denied by user", "exit_code": -1}
            # Rate limiting for MCP tools (built-in tools are checked in call_tool)
            if _rate_limiter:
                rl_v = _rate_limiter.check(tool_name)
                if rl_v and rl_v.exceeded and _rate_limiter.config.action == "block":
                    return {"error": rl_v.reason, "safety_blocked": True, "rate_limited": True}
            mcp_result: dict[str, Any] = await mcp_manager.call_tool(tool_name, arguments)
            if _rate_limiter:
                _rate_limiter.record_call(success="error" not in mcp_result)
            _audit_tool_call(audit_writer, tool_name, arguments, mcp_result, conversation_id)
            return mcp_result
        raise ValueError(f"Unknown tool: {tool_name}")

    # Build unified tool list (exclude canvas tools — they require web UI context)
    _canvas_tool_names = {"create_canvas", "update_canvas", "patch_canvas"}
    tools_openai: list[dict[str, Any]] = []
    tools_openai.extend(
        t for t in tool_registry.get_openai_tools() if t.get("function", {}).get("name") not in _canvas_tool_names
    )
    if mcp_manager:
        mcp_tools = mcp_manager.get_openai_tools()
        if mcp_tools:
            tools_openai.extend(mcp_tools)

    # Cap tools to provider limit, prioritising built-in tools
    from ..tools import cap_tools

    tools_openai = cap_tools(tools_openai, set(tool_registry.list_tools()), limit=config.ai.max_tools)
    if config.safety.read_only:
        from ..tools.tiers import filter_read_only_tools

        tools_openai = filter_read_only_tools(tools_openai, config.safety.tool_tiers)
    tools_openai_or_none = tools_openai if tools_openai else None

    # Load skills (before system prompt so skill descriptions can be injected)
    skill_registry = SkillRegistry()
    skill_registry.load(working_dir)
    for warn in skill_registry.load_warnings:
        renderer.console.print(f"[yellow]Skill warning:[/yellow] {warn}")

    # Load ANTEROOM.md instructions (with trust gating for project-level files)
    instructions = await _load_instructions_with_trust(
        working_dir,
        trust_project=trust_project,
        no_project_context=no_project_context,
        data_dir=config.app.data_dir,
    )
    # Build introspect instructions info for the introspect tool
    _introspect_instructions_info = _build_introspect_instructions_info(working_dir)

    mcp_statuses = mcp_manager.get_server_statuses() if mcp_manager else None
    # Initialize artifact registry with 6-layer precedence
    _artifact_registry = None
    try:
        from ..services.artifact_registry import ArtifactRegistry

        _artifact_registry = ArtifactRegistry()
        _artifact_registry.load_from_db(db, space_id=space_id, project_path=working_dir)
        if _artifact_registry.count:
            skill_registry.load_from_artifacts(_artifact_registry)
        # Load hard-enforced rules from the registry
        from ..services.artifacts import ArtifactType as _ArtType
        from ..services.rule_enforcer import RuleEnforcer

        _rule_enforcer = RuleEnforcer()
        _rule_enforcer.load_rules(_artifact_registry.list_all(artifact_type=_ArtType.RULE))
        tool_registry.set_rule_enforcer(_rule_enforcer)
    except Exception:
        pass

    extra_system_prompt = _build_system_prompt(
        config,
        working_dir,
        instructions,
        builtin_tools=tool_registry.list_tools(),
        mcp_servers=mcp_statuses,
        space_instructions=_space_instructions,
        artifact_registry=_artifact_registry,
    )

    # Inject canary token into trusted section (before untrusted marker)
    if _injection_detector is not None and _injection_detector.enabled:
        _canary_seg = _injection_detector.canary_prompt_segment()
        if _canary_seg:
            extra_system_prompt += _canary_seg

    # Structural separation: everything below is external/auto-generated context
    extra_system_prompt += untrusted_section_marker()

    # Inject codebase index (tree-sitter symbol map) if enabled
    try:
        from ..services.codebase_index import create_index_service

        _index_service = create_index_service(config)
        if _index_service:
            _index_map = _index_service.get_map(working_dir, token_budget=config.codebase_index.map_tokens)
            if _index_map:
                extra_system_prompt += "\n" + _index_map
    except Exception:
        logger.debug("Codebase index unavailable, continuing without it", exc_info=True)

    # Inject skill catalog and invoke_skill tool only when auto-invoke is enabled
    if config.cli.skills.auto_invoke:
        skill_descs = skill_registry.get_skill_descriptions()
        if skill_descs:
            skill_lines = [
                "\n<available_skills>",
                "The following skills are available. When the user's request clearly matches a skill, "
                "use the invoke_skill tool to run it.",
            ]
            for name, desc in skill_descs:
                skill_lines.append(f"- {name}: {desc}")
            skill_lines.append("</available_skills>")
            extra_system_prompt += "\n".join(skill_lines)
    if config.cli.skills.auto_invoke:
        invoke_def = skill_registry.get_invoke_skill_definition()
        if invoke_def:
            tools_openai.append(invoke_def)
            tools_openai_or_none = tools_openai if tools_openai else None

    ai_service = create_ai_service(config.ai)

    # Validate connection before proceeding
    with renderer.startup_step("Validating AI connection..."):
        valid, message, _ = await ai_service.validate_connection()
    if not valid:
        renderer.render_error(f"Cannot connect to AI service: {message}")
        renderer.console.print(f"  [{MUTED}]base_url: {config.ai.base_url}[/{MUTED}]")
        renderer.console.print(f"  [{MUTED}]model: {config.ai.model}[/{MUTED}]")
        renderer.console.print(f"  [{MUTED}]Check ~/.anteroom/config.yaml[/{MUTED}]\n")
        if mcp_manager:
            await mcp_manager.shutdown()
        db.close()
        return

    all_tool_names = tool_registry.list_tools()
    if mcp_manager:
        all_tool_names.extend(t["name"] for t in mcp_manager.get_all_tools())

    # Resolve conversation to continue
    resume_conversation_id: str | None = None
    if conversation_id:
        resume_conversation_id = conversation_id
    elif continue_last:
        convs = storage.list_conversations(db, limit=1)
        if convs:
            resume_conversation_id = convs[0]["id"]

    # Construct output content filter if configured (needs system prompt for leak detection)
    _output_filter = None
    if config.safety.output_filter.enabled:
        from ..services.output_filter import OutputContentFilter

        _output_filter = OutputContentFilter(config.safety.output_filter, system_prompt=extra_system_prompt)

    try:
        if prompt:
            await _run_one_shot(
                config=config,
                db=db,
                ai_service=ai_service,
                tool_executor=tool_executor,
                tools_openai=tools_openai_or_none,
                extra_system_prompt=extra_system_prompt,
                prompt=prompt,
                working_dir=working_dir,
                tool_registry=tool_registry,
                resume_conversation_id=resume_conversation_id,
                cancel_event_ref=_active_cancel_event,
                dlp_scanner=_dlp_scanner,
                injection_detector=_injection_detector,
                output_filter=_output_filter,
            )
        else:
            git_branch = _detect_git_branch()
            build_date = renderer._get_build_date()
            with renderer.startup_step("Checking for updates..."):
                latest_version = await _check_for_update(__version__)
            installed_packs = packs_service.list_packs(db)
            pack_count = len(installed_packs)
            pack_names = [p["name"] for p in installed_packs] if installed_packs else None
            is_first_run = not storage.list_conversations(db, limit=1)
            renderer.render_welcome(
                model=config.ai.model,
                tool_count=len(all_tool_names),
                instructions_loaded=instructions is not None,
                working_dir=working_dir,
                git_branch=git_branch,
                version=__version__,
                build_date=build_date,
                skill_count=len(skill_registry.list_skills()),
                pack_count=pack_count,
                pack_names=pack_names,
                is_first_run=is_first_run,
            )
            if latest_version:
                renderer.render_update_available(__version__, latest_version)
            if config.mcp_servers and mcp_manager:
                await _run_mcp_startup_live(mcp_manager, config.mcp_servers, _mcp_statuses)
                # Rebuild tool list now that MCP tools are available
                mcp_tools_post = mcp_manager.get_openai_tools()
                if mcp_tools_post:
                    tools_openai.extend(mcp_tools_post)
                    tools_openai[:] = cap_tools(
                        tools_openai, set(tool_registry.list_tools()), limit=config.ai.max_tools
                    )
                    if config.safety.read_only:
                        from ..tools.tiers import filter_read_only_tools as _fro

                        tools_openai[:] = _fro(tools_openai, config.safety.tool_tiers)
                    tools_openai_or_none = tools_openai if tools_openai else None
                    all_tool_names.extend(t["name"] for t in mcp_manager.get_all_tools())
            if config.safety.read_only:
                renderer.console.print(
                    f"[yellow]Read-only mode active.[/yellow] Only READ-tier tools are available.\n"
                    f"  [{MUTED}]Write, execute, and destructive tools are disabled.[/{MUTED}]\n"
                )
            if plan_mode:
                renderer.console.print(
                    f"[yellow]Planning mode active.[/yellow] The AI will explore and write a plan.\n"
                    f"  [{MUTED}]Use /plan approve to execute, /plan off to exit.[/{MUTED}]\n"
                )
            await _run_repl(
                config=config,
                db=db,
                ai_service=ai_service,
                tool_executor=tool_executor,
                tools_openai=tools_openai_or_none,
                extra_system_prompt=extra_system_prompt,
                all_tool_names=all_tool_names,
                working_dir=working_dir,
                resume_conversation_id=resume_conversation_id,
                skill_registry=skill_registry,
                mcp_manager=mcp_manager,
                tool_registry=tool_registry,
                cancel_event_ref=_active_cancel_event,
                subagent_limiter=_subagent_limiter,
                rate_limiter=_rate_limiter,
                plan_mode=plan_mode,
                skill_msg_queue_ref=_active_msg_queue,
                dlp_scanner=_dlp_scanner,
                injection_detector=_injection_detector,
                output_filter=_output_filter,
                instructions=instructions,
                artifact_registry=_artifact_registry,
                space=_space,
                space_instructions=_space_instructions,
                vec_manager=_vec_manager,
            )
    finally:
        if _vec_manager and _vec_manager.enabled:
            _vec_manager.save_all()
        if retention_worker:
            retention_worker.stop()
        if mcp_manager:
            try:
                await mcp_manager.shutdown()
            except BaseException:
                pass
        db.close()


async def _run_one_shot(
    config: AppConfig,
    db: Any,
    ai_service: AIService,
    tool_executor: Any,
    tools_openai: list[dict[str, Any]] | None,
    extra_system_prompt: str,
    prompt: str,
    working_dir: str,
    tool_registry: Any = None,
    resume_conversation_id: str | None = None,
    cancel_event_ref: list[asyncio.Event | None] | None = None,
    dlp_scanner: Any | None = None,
    injection_detector: Any | None = None,
    output_filter: Any | None = None,
) -> None:
    """Run a single prompt and exit."""
    id_kw = _identity_kwargs(config)
    expanded = _expand_file_references(prompt, working_dir, file_max_chars=config.cli.file_reference_max_chars)

    if resume_conversation_id:
        conv = storage.get_conversation(db, resume_conversation_id)
        if not conv:
            renderer.render_error(f"Conversation {resume_conversation_id} not found")
            return
        working_dir = _restore_working_dir(conv, tool_registry, working_dir)
        messages, _ = _load_conversation_messages(db, resume_conversation_id)
    else:
        conv = storage.create_conversation(db, working_dir=working_dir, **id_kw)
        messages = []

    storage.create_message(db, conv["id"], "user", expanded, **id_kw)
    messages.append({"role": "user", "content": expanded})

    cancel_event = asyncio.Event()
    if cancel_event_ref is not None:
        cancel_event_ref[0] = cancel_event

    loop = asyncio.get_event_loop()
    _add_signal_handler(loop, signal.SIGINT, cancel_event.set)
    escape_task = asyncio.create_task(_watch_for_escape(cancel_event))

    renderer.clear_subagent_state()
    thinking = False
    user_attempt = 0

    _budget_cfg = config.cli.usage.budgets

    async def _get_token_totals() -> tuple[int, int]:
        return (
            storage.get_conversation_token_total(db, conv["id"]),
            storage.get_daily_token_total(db),
        )

    try:
        while True:
            user_attempt += 1
            should_retry = False

            async for event in run_agent_loop(
                ai_service=ai_service,
                messages=messages,
                tool_executor=tool_executor,
                tools_openai=tools_openai,
                cancel_event=cancel_event,
                extra_system_prompt=extra_system_prompt,
                max_iterations=config.cli.max_tool_iterations,
                narration_cadence=ai_service.config.narration_cadence,
                tool_output_max_chars=config.cli.tool_output_max_chars,
                budget_config=_budget_cfg,
                get_token_totals=_get_token_totals,
                dlp_scanner=dlp_scanner,
                injection_detector=injection_detector,
                output_filter=output_filter,
                max_consecutive_text_only=config.cli.max_consecutive_text_only,
                max_line_repeats=config.cli.max_line_repeats,
            ):
                if event.kind == "thinking":
                    if not thinking:
                        renderer.start_thinking()
                        thinking = True
                elif event.kind == "phase":
                    renderer.set_thinking_phase(event.data.get("phase", ""))
                elif event.kind == "retrying":
                    renderer.set_retrying(event.data)
                elif event.kind == "token":
                    if not thinking:
                        renderer.start_thinking()
                        thinking = True
                    renderer.render_token(event.data["content"])
                    renderer.increment_thinking_tokens()
                    renderer.increment_streaming_chars(len(event.data.get("content", "")))
                    renderer.update_thinking()
                    # Yield to event loop so the thinking ticker task can update (#775)
                    await asyncio.sleep(0)
                elif event.kind == "tool_call_start":
                    if thinking:
                        await renderer.stop_thinking()
                        thinking = False
                    renderer.render_tool_call_start(event.data["tool_name"], event.data["arguments"])
                elif event.kind == "tool_call_end":
                    renderer.render_tool_call_end(event.data["tool_name"], event.data["status"], event.data["output"])
                elif event.kind == "assistant_message":
                    if event.data["content"]:
                        storage.create_message(db, conv["id"], "assistant", event.data["content"], **id_kw)
                elif event.kind == "dlp_blocked":
                    if thinking:
                        await renderer.stop_thinking(error_msg="Response blocked by DLP policy")
                        thinking = False
                    else:
                        renderer.render_error("Response blocked by DLP policy")
                elif event.kind == "dlp_warning":
                    rules = ", ".join(event.data.get("matches", []))
                    renderer.render_error(f"DLP warning: sensitive data detected [{rules}]")
                elif event.kind == "injection_detected":
                    action = event.data.get("action", "warn")
                    detail = event.data.get("detail", "prompt injection detected")
                    if action == "block":
                        renderer.render_error(f"Tool output blocked: {detail}")
                    else:
                        renderer.render_error(f"Injection warning: {detail}")
                elif event.kind == "output_filter_blocked":
                    if thinking:
                        await renderer.stop_thinking(error_msg="Response blocked by output content filter")
                        thinking = False
                    else:
                        renderer.render_error("Response blocked by output content filter")
                elif event.kind == "output_filter_warning":
                    rules = ", ".join(event.data.get("matches", []))
                    renderer.render_error(f"Output filter warning: forbidden content detected [{rules}]")
                elif event.kind == "error":
                    error_msg = event.data.get("message", "Unknown error")
                    retryable = event.data.get("retryable", False)
                    if thinking and retryable and user_attempt < config.cli.max_retries:
                        # Show countdown on thinking line, auto-retry
                        should_retry = await renderer.thinking_countdown(
                            config.cli.retry_delay, cancel_event, error_msg
                        )
                        if should_retry and not cancel_event.is_set():
                            # Reset cancel_event for the retry
                            cancel_event.clear()
                            renderer.start_thinking()
                            # thinking stays True
                        else:
                            await renderer.stop_thinking(cancel_msg="cancelled")
                            thinking = False
                    elif thinking and retryable and user_attempt >= config.cli.max_retries:
                        # Exhausted user retries
                        await renderer.stop_thinking(error_msg=f"{error_msg} · {user_attempt} attempts failed")
                        thinking = False
                    elif thinking:
                        # Non-retryable error
                        await renderer.stop_thinking(error_msg=error_msg)
                        thinking = False
                    else:
                        renderer.render_error(error_msg)
                elif event.kind == "budget_warning":
                    if thinking:
                        await renderer.stop_thinking()
                        thinking = False
                    renderer.render_warning(event.data.get("message", "Token budget warning"))
                elif event.kind == "done":
                    if thinking and cancel_event.is_set():
                        await renderer.stop_thinking(cancel_msg="cancelled")
                        thinking = False
                    elif thinking:
                        await renderer.stop_thinking()
                        thinking = False
                    if not cancel_event.is_set():
                        renderer.render_response_end()

            if not should_retry:
                break

        if not cancel_event.is_set():
            try:
                title = await ai_service.generate_title(prompt)
                storage.update_conversation_title(db, conv["id"], title)
            except Exception:
                pass

    except KeyboardInterrupt:
        if thinking:
            renderer.stop_thinking_sync()
            thinking = False
        renderer.render_response_end()
    finally:
        cancel_event.set()
        escape_task.cancel()
        _remove_signal_handler(loop, signal.SIGINT)


def _patch_completion_menu_position() -> None:
    """Patch FloatContainer so the completion menu renders above the cursor.

    prompt_toolkit positions the completion menu below the cursor by default and
    only flips above when there is more vertical space above than below.  This
    patches ``FloatContainer._draw_float`` at the **class** level so completion
    menus are always placed above the cursor line — eliminating clipping when the
    prompt is near the terminal bottom.
    """
    from prompt_toolkit.data_structures import Point
    from prompt_toolkit.layout.containers import Float, FloatContainer
    from prompt_toolkit.layout.menus import CompletionsMenu, MultiColumnCompletionsMenu
    from prompt_toolkit.layout.screen import WritePosition

    _orig = FloatContainer._draw_float

    def _draw_float_patched(
        self: Any,
        fl: Float,
        screen: Any,
        mouse_handlers: Any,
        write_position: WritePosition,
        style: str,
        erase_bg: bool,
        z_index: int | None,
    ) -> None:
        if not isinstance(fl.content, (CompletionsMenu, MultiColumnCompletionsMenu)):
            return _orig(self, fl, screen, mouse_handlers, write_position, style, erase_bg, z_index)

        try:
            from prompt_toolkit.application.current import get_app

            cpos = screen.get_menu_position(fl.attach_to_window or get_app().layout.current_window)
            cursor = Point(x=cpos.x - write_position.xpos, y=cpos.y - write_position.ypos)

            fl_w = fl.get_width()
            width = (
                fl_w
                if fl_w is not None
                else min(write_position.width, fl.content.preferred_width(write_position.width).preferred)
            )
            xpos = cursor.x
            if xpos + width > write_position.width:
                xpos = max(0, write_position.width - width)

            fl_h = fl.get_height()
            height = fl_h if fl_h is not None else fl.content.preferred_height(width, write_position.height).preferred

            if cursor.y >= height:
                height = min(height, cursor.y)
                ypos = cursor.y - height
            else:
                ypos = cursor.y + 1
                height = min(height, write_position.height - ypos)

            if height > 0 and width > 0:
                fl.content.write_to_screen(
                    screen,
                    mouse_handlers,
                    WritePosition(
                        xpos=xpos + write_position.xpos,
                        ypos=ypos + write_position.ypos,
                        width=width,
                        height=height,
                    ),
                    style,
                    erase_bg=not fl.transparent(),
                    z_index=z_index,
                )
        except Exception:
            return _orig(self, fl, screen, mouse_handlers, write_position, style, erase_bg, z_index)

    FloatContainer._draw_float = _draw_float_patched  # type: ignore[method-assign]


async def _run_repl(
    config: AppConfig,
    db: Any,
    ai_service: AIService,
    tool_executor: Any,
    tools_openai: list[dict[str, Any]] | None,
    extra_system_prompt: str,
    all_tool_names: list[str],
    working_dir: str,
    resume_conversation_id: str | None = None,
    skill_registry: SkillRegistry | None = None,
    mcp_manager: Any = None,
    tool_registry: Any = None,
    cancel_event_ref: list[asyncio.Event | None] | None = None,
    subagent_limiter: Any = None,
    rate_limiter: Any = None,
    plan_mode: bool = False,
    skill_msg_queue_ref: list[asyncio.Queue[dict[str, Any]] | None] | None = None,
    dlp_scanner: Any | None = None,
    injection_detector: Any | None = None,
    output_filter: Any | None = None,
    instructions: str | None = None,
    artifact_registry: Any = None,
    space: dict[str, Any] | None = None,
    space_instructions: str | None = None,
    vec_manager: Any | None = None,
) -> None:
    """Run the interactive REPL."""
    id_kw = _identity_kwargs(config)

    from prompt_toolkit import PromptSession
    from prompt_toolkit.completion import Completer, Completion
    from prompt_toolkit.document import Document
    from prompt_toolkit.formatted_text import HTML
    from prompt_toolkit.history import FileHistory
    from prompt_toolkit.key_binding import KeyBindings
    from prompt_toolkit.styles import Style as PtStyle

    _command_descriptions: dict[str, str] = {
        "new": "new conversation",
        "append": "add to last message",
        "last": "continue last conversation",
        "list": "list conversations",
        "search": "search conversations",
        "resume": "resume a conversation",
        "delete": "delete a conversation",
        "rename": "rename a conversation",
        "slug": "show conversation slug",
        "rewind": "undo messages",
        "clear": "clear screen and reset history",
        "compact": "compress context",
        "conventions": "show directory conventions",
        "instructions": "show directory conventions (alias)",
        "tools": "list available tools",
        "skills": "list loaded skills",
        "reload-skills": "reload skill files",
        "pack": "manage packs",
        "packs": "list installed packs",
        "space": "manage spaces",
        "spaces": "list spaces",
        "mcp": "MCP server status",
        "model": "switch model",
        "plan": "planning mode",
        "upload": "upload a file",
        "usage": "token usage stats",
        "verbose": "cycle verbosity",
        "detail": "tool call details",
        "help": "show help",
        "artifact": "manage artifacts",
        "artifacts": "list artifacts",
        "spec": "manage specs",
        "specs": "list specs",
        "artifact-check": "artifact health check",
        "config": "view/edit scoped config",
        "quit": "exit",
        "exit": "exit",
    }

    _subcommand_completions: dict[str, list[str]] = {
        "artifact": ["list", "show", "delete", "import", "create"],
        "spec": ["list", "show", "create", "approve", "unapprove", "diff"],
        "pack": ["list", "show", "install", "remove", "sources", "attach", "detach", "update", "add-source", "refresh"],
        "space": ["list", "show", "switch", "create", "load", "refresh", "clear", "init", "clone", "map"],
        "config": ["list", "get", "set", "reset"],
    }

    class AnteroomCompleter(Completer):
        """Tab completer for / commands, @ file paths, and conversation slugs."""

        _slug_commands = frozenset({"resume", "delete", "rename"})

        def __init__(
            self,
            commands: list[str],
            skill_names: list[str],
            skill_descriptions: dict[str, str],
            wd: str,
            db: Any,
        ) -> None:
            self._commands = commands
            self._skill_names = skill_names
            self._skill_descriptions = skill_descriptions
            self._wd = wd
            self._db = db

        def update_skill_names(self, skill_names: list[str], skill_descriptions: dict[str, str]) -> None:
            self._skill_names = skill_names
            self._skill_descriptions = skill_descriptions

        def _get_slug_completions(self, partial: str) -> Any:
            """Yield slug completions matching the partial input."""
            try:
                slugs = storage.list_conversation_slugs(self._db, limit=50)
            except Exception:
                return
            for slug, title in slugs:
                if slug.startswith(partial):
                    display = title[:50] if title else ""
                    yield Completion(slug, start_position=-len(partial), display_meta=display)

        def get_completions(self, document: Document, complete_event: Any) -> Any:
            text = document.text_before_cursor
            word = document.get_word_before_cursor(WORD=True)

            if text.lstrip().startswith("/") and " " not in text.lstrip():
                prefix = word.lstrip("/")
                for cmd in self._commands:
                    if cmd.startswith(prefix):
                        meta = _command_descriptions.get(cmd, "")
                        yield Completion(f"/{cmd} ", start_position=-len(word), display_meta=meta)
                for sname in self._skill_names:
                    if sname.startswith(prefix):
                        desc = self._skill_descriptions.get(sname, "skill")
                        yield Completion(f"/{sname} ", start_position=-len(word), display_meta=desc)
            elif text.lstrip().startswith("/"):
                # Check if we're completing an argument after a slug-accepting command
                parts = text.lstrip().split(None, 2)
                cmd_name = parts[0].lstrip("/") if parts else ""
                if cmd_name in self._slug_commands and len(parts) <= 2:
                    partial = parts[1] if len(parts) == 2 else ""
                    yield from self._get_slug_completions(partial)
                elif cmd_name in _subcommand_completions and len(parts) <= 2:
                    partial = parts[1] if len(parts) == 2 else ""
                    for sc in _subcommand_completions[cmd_name]:
                        if sc.startswith(partial):
                            yield Completion(sc + " ", start_position=-len(partial))
            elif "@" in word:
                # Complete file paths after @
                at_idx = word.rfind("@")
                partial = word[at_idx + 1 :]
                base = Path(self._wd)
                if "/" in partial:
                    parent_str, stem = partial.rsplit("/", 1)
                    parent = base / parent_str
                else:
                    parent = base
                    stem = partial
                    parent_str = ""
                try:
                    if parent.is_dir():
                        for entry in sorted(parent.iterdir()):
                            name = entry.name
                            if name.startswith("."):
                                continue
                            if name.lower().startswith(stem.lower()):
                                suffix = "/" if entry.is_dir() else ""
                                if parent_str:
                                    full = f"@{parent_str}/{name}{suffix}"
                                else:
                                    full = f"@{name}{suffix}"
                                yield Completion(full, start_position=-len(word))
                except OSError:
                    pass

    commands = [
        "new",
        "append",
        "last",
        "list",
        "search",
        "resume",
        "delete",
        "rename",
        "slug",
        "rewind",
        "clear",
        "compact",
        "conventions",
        "instructions",
        "tools",
        "skills",
        "reload-skills",
        "artifact",
        "artifacts",
        "artifact-check",
        "spec",
        "specs",
        "pack",
        "packs",
        "space",
        "spaces",
        "mcp",
        "config",
        "model",
        "plan",
        "upload",
        "reprocess",
        "usage",
        "verbose",
        "detail",
        "help",
        "quit",
        "exit",
    ]
    skill_descs_list = skill_registry.get_skill_descriptions() if skill_registry else []
    skill_names = [name for name, _ in skill_descs_list]
    skill_descs = {name: desc for name, desc in skill_descs_list}
    completer = AnteroomCompleter(commands, skill_names, skill_descs, working_dir, db)

    def _rebuild_tools() -> None:
        """Rebuild the tool list after MCP changes."""
        nonlocal tools_openai, all_tool_names
        new_tools: list[dict[str, Any]] = []
        if tool_registry:
            new_tools.extend(tool_registry.get_openai_tools())
        if mcp_manager:
            mcp_tools = mcp_manager.get_openai_tools()
            if mcp_tools:
                new_tools.extend(mcp_tools)
        from ..tools import cap_tools as _cap_tools

        builtin = set(tool_registry.list_tools()) if tool_registry else set()
        new_tools = _cap_tools(new_tools, builtin, limit=config.ai.max_tools)
        if config.safety.read_only:
            from ..tools.tiers import filter_read_only_tools as _fro2

            new_tools = _fro2(new_tools, config.safety.tool_tiers)
        tools_openai = new_tools if new_tools else None
        new_names: list[str] = list(tool_registry.list_tools()) if tool_registry else []
        if mcp_manager:
            new_names.extend(t["name"] for t in mcp_manager.get_all_tools())
        all_tool_names = new_names

    history_path = config.app.data_dir / "cli_history"

    # Map Shift+Enter (CSI u: \x1b[13;2u) to Ctrl+J for terminals that
    # natively send kitty keyboard protocol sequences (iTerm2, kitty, WezTerm).
    # Terminal.app doesn't send this sequence — Shift+Enter = Enter there.
    # See #673 for Warp terminal limitations.
    try:
        from prompt_toolkit.input import vt100_parser

        vt100_parser.ANSI_SEQUENCES["\x1b[13;2u"] = "c-j"  # type: ignore[assignment]
    except Exception:
        pass

    # Key bindings
    kb = KeyBindings()

    # Paste detection: track buffer changes to distinguish paste from typing.
    # Pasted characters arrive in < 5ms bursts; human typing is > 50ms apart.
    _last_text_change: list[float] = [0.0]

    # Enter submits; Alt+Enter / Shift+Enter / Ctrl+J inserts newline
    def _accept_completion(buf: Any) -> bool:
        """Accept the current completion if the menu is open. Returns True if handled."""
        if buf.complete_state and buf.complete_state.current_completion:
            saved_completer = buf.completer
            buf.completer = None
            try:
                buf.apply_completion(buf.complete_state.current_completion)
            finally:
                buf.completer = saved_completer
            return True
        return False

    @kb.add("enter")
    def _submit(event: Any) -> None:
        buf = event.current_buffer
        if _accept_completion(buf):
            return
        if _is_paste(_last_text_change[0]):
            # Rapid input (paste) — insert newline, don't submit
            buf.insert_text("\n")
        else:
            buf.validate_and_handle()

    @kb.add("escape", "enter")
    @kb.add("c-j")
    def _newline(event: Any) -> None:
        event.current_buffer.insert_text("\n")

    # Ctrl+C: clear buffer if text present (with double-press exit), exit if empty
    _exit_flag: list[bool] = [False]
    _last_ctrl_c: list[float] = [0.0]

    @kb.add("c-c")
    def _handle_ctrl_c(event: Any) -> None:
        import time

        # When agent is busy, route Ctrl-C to cancel the active run (#937).
        # On Windows, loop.add_signal_handler(SIGINT) is a no-op, so this
        # prompt_toolkit keybinding is the only cancel path.
        if _route_cancel_signal(agent_busy, _current_cancel_event):
            return

        buf = event.current_buffer
        now = time.monotonic()
        if buf.text:
            buf.reset()
            _last_ctrl_c[0] = now
        elif now - _last_ctrl_c[0] < 2.0:
            # Second Ctrl+C within 2 seconds — exit
            _exit_flag[0] = True
            buf.validate_and_handle()
        else:
            # First Ctrl+C with empty buffer — show hint, don't exit
            _last_ctrl_c[0] = now
            renderer.console.print(f"[{CHROME}]Press Ctrl+C again to exit[/{CHROME}]")

    @kb.add("escape")
    def _handle_escape(event: Any) -> None:
        # When agent is busy, Escape cancels the active run (#937).
        # When idle, this is a no-op because _route_cancel_signal returns
        # False (agent_busy is not set). Escape+Enter chord for newline
        # is unaffected — prompt_toolkit distinguishes via flush timeout.
        _route_cancel_signal(agent_busy, _current_cancel_event)

    # Styled prompt — dim while agent is working to signal "you can type to queue"
    _prompt_text = HTML(f"<style fg='{renderer._theme.accent}'>❯</style> ") if renderer._theme.accent else HTML("❯ ")
    _prompt_dim = HTML(f"<style fg='{renderer._theme.chrome}'>❯</style> ") if renderer._theme.chrome else HTML("❯ ")
    _continuation = "  "  # align with "❯ "

    def _prompt() -> HTML:
        return _prompt_dim if agent_busy.is_set() else _prompt_text

    _repl_style = PtStyle.from_dict(
        {
            "completion-menu": f"bg:{renderer._theme.bg_dark} {renderer._theme.chrome}",
            "completion-menu.completion": f"bg:{renderer._theme.bg_dark} {renderer._theme.chrome}",
            "completion-menu.completion.current": f"bg:{renderer._theme.accent} {renderer._theme.bg_dark}",
            "completion-menu.meta.completion": f"bg:{renderer._theme.bg_dark} {renderer._theme.muted}",
            "completion-menu.meta.completion.current": f"bg:{renderer._theme.accent} {renderer._theme.bg_dark}",
            "bottom-toolbar": f"bg:{renderer._theme.bg_darker} {renderer._theme.muted} noreverse",
            "bottom-toolbar.text": "noreverse",
            "bottom-toolbar.model": renderer._theme.accent,
            "bottom-toolbar.tokens": renderer._theme.text_secondary,
            "bottom-toolbar.tokens-warn": renderer._theme.warning,
            "bottom-toolbar.tokens-danger": renderer._theme.danger,
            "bottom-toolbar.dim": renderer._theme.chrome,
            "bottom-toolbar.dir": renderer._theme.dir_display,
            "bottom-toolbar.sep": renderer._theme.toolbar_sep,
            "bottom-toolbar.mcp": renderer._theme.mcp_indicator,
        }
    )

    _toolbar_cache: list[tuple[str, str]] = []
    _toolbar_dirty: list[bool] = [True]

    _cached_git_branch: list[str] = [_detect_git_branch() or ""]
    _git_branch_last_check: list[float] = [0.0]
    _git_branch_throttle: float = 10.0  # seconds between re-checks

    def _refresh_git_branch() -> None:
        """Re-detect git branch if enough time has elapsed."""
        import time as _time

        now = _time.monotonic()
        if now - _git_branch_last_check[0] >= _git_branch_throttle:
            _git_branch_last_check[0] = now
            _cached_git_branch[0] = _detect_git_branch() or ""

    def _toolbar_refresh() -> None:
        """Recompute the cached toolbar content."""
        _toolbar_dirty[0] = False
        _refresh_git_branch()
        # _active_space and _plan_active are defined later in _run_repl but
        # _toolbar_refresh is only ever called during prompt rendering, which
        # happens after those variables exist.  Guard with try/except for safety.
        try:
            sn = _active_space[0]["name"] if _active_space[0] else ""
        except NameError:
            sn = ""
        try:
            pm = _plan_active[0]
        except NameError:
            pm = False
        cn = conv.get("slug") or ""
        _toolbar_cache[:] = renderer.format_status_toolbar(
            model=current_model,
            current_tokens=_estimate_tokens(ai_messages),
            max_context=config.cli.model_context_window,
            message_count=len(ai_messages),
            approval_mode=config.safety.approval_mode,
            tool_count=len(all_tool_names),
            mcp_statuses=mcp_manager.get_server_statuses() if mcp_manager else None,
            working_dir=working_dir,
            git_branch=_cached_git_branch[0],
            space_name=sn,
            plan_mode=pm,
            conversation_name=cn,
        )

    def invalidate_toolbar() -> None:
        """Mark the toolbar as needing a refresh on next render."""
        _toolbar_dirty[0] = True

    def _bottom_toolbar() -> list[tuple[str, str] | tuple[str, str, Any]]:
        if _toolbar_dirty[0]:
            _toolbar_refresh()
        return _toolbar_cache

    session: PromptSession[str] = PromptSession(
        history=FileHistory(str(history_path)),
        key_bindings=kb,
        multiline=True,
        prompt_continuation=_continuation,
        completer=completer,
        reserve_space_for_menu=4,
        style=_repl_style,
        bottom_toolbar=_bottom_toolbar,
    )

    _patch_completion_menu_position()

    # Hook buffer changes for paste detection timing
    def _on_buffer_change(_buf: Any) -> None:
        _last_text_change[0] = time.monotonic()

    session.default_buffer.on_text_changed += _on_buffer_change

    # Set approval mode for prompt coloring
    from anteroom.cli.layout import set_approval_mode

    set_approval_mode(config.safety.approval_mode)

    current_model = config.ai.model
    _pending_resume_info = False
    ai_messages: list[dict[str, Any]] = []

    if resume_conversation_id:
        conv_data = storage.get_conversation(db, resume_conversation_id)
        if conv_data:
            conv = conv_data
            ai_messages, _ = _load_conversation_messages(db, resume_conversation_id)
            is_first_message = False
            working_dir = _restore_working_dir(conv, tool_registry, working_dir)
            _pending_resume_info = True
            # Load space from resumed conversation
            if not space and conv.get("space_id"):
                from ..services.space_storage import get_space as _get_resumed_space

                _resumed_space = _get_resumed_space(db, conv["space_id"])
                if _resumed_space:
                    space = _resumed_space
                    renderer.console.print(f"[dim]Space:[/dim] {_resumed_space['name']}")
        else:
            renderer.render_error(f"Conversation {resume_conversation_id} not found, starting new")
            conv = storage.create_conversation(db, working_dir=working_dir, **id_kw)
            ai_messages = []
            is_first_message = True
    else:
        conv = storage.create_conversation(db, working_dir=working_dir, **id_kw)
        ai_messages = []
        is_first_message = True

    async def _show_help_dialog() -> None:
        """Show help in a floating dialog that doesn't disturb scrollback."""
        from prompt_toolkit.formatted_text import FormattedText
        from prompt_toolkit.shortcuts import message_dialog
        from prompt_toolkit.styles import Style

        cmd = f"{renderer._theme.accent} bold" if renderer._theme.accent else "bold"
        desc = renderer._theme.secondary or ""
        help_text = FormattedText(
            [
                ("bold", " Conversations\n"),
                (cmd, "  /new"),
                (desc, "              Start a new chat conversation\n"),
                (cmd, "  /new note <t>"),
                (desc, "     Start a new note\n"),
                (cmd, "  /new doc <t>"),
                (desc, "      Start a new document\n"),
                (cmd, "  /append <text>"),
                (desc, "    Add entry to current note\n"),
                (cmd, "  /last"),
                (desc, "             Resume the most recent conversation\n"),
                (cmd, "  /list [N]"),
                (desc, "         Show recent conversations (default 20)\n"),
                (cmd, "  /search <query>"),
                (desc, "   Search conversations by content\n"),
                (cmd, "  /resume [N|id|slug]"),
                (desc, " Resume (picker if no arg)\n"),
                (cmd, "  /delete <N|id|slug>"),
                (desc, " Delete a conversation\n"),
                (cmd, "  /rename [N|id|slug] <title>"),
                (desc, "\n                          Rename a conversation\n"),
                (cmd, "  /slug [name]"),
                (desc, "        Show or set conversation slug\n"),
                (cmd, "  /rewind"),
                (desc, "           Roll back to an earlier message\n"),
                ("", "\n"),
                ("bold", " Session\n"),
                (cmd, "  /clear"),
                (desc, "           Clear screen and reset message history\n"),
                (cmd, "  /compact"),
                (desc, "          Summarize history to free context\n"),
                (cmd, "  /model <name>"),
                (desc, "     Switch AI model mid-session\n"),
                (cmd, "  /tools"),
                (desc, "            List available tools\n"),
                (cmd, "  /skills"),
                (desc, "           List loaded skills (auto-reloads)\n"),
                (cmd, "  /reload-skills"),
                (desc, "    Reload skill files from disk\n"),
                (cmd, "  /mcp"),
                (desc, "              Show MCP server status\n"),
                (cmd, "  /conventions"),
                (desc, "      Show loaded ANTEROOM.md conventions\n"),
                (cmd, "  /plan"),
                (desc, "             Plan mode: on/approve/status/off\n"),
                (cmd, "  /verbose"),
                (desc, "          Cycle: compact > detailed > verbose\n"),
                (cmd, "  /detail"),
                (desc, "           Replay last turn's tool calls\n"),
                (cmd, "  /usage"),
                (desc, "            Show token usage statistics\n"),
                ("", "\n"),
                ("bold", " Input\n"),
                (cmd, "  /upload <path>"),
                (desc, "    Upload a file to the conversation\n"),
                (cmd, "  @<path>"),
                (desc, "           Include file contents inline\n"),
                (cmd, "  Alt+Enter"),
                (desc, "         Insert newline\n"),
                (cmd, "  Escape"),
                (desc, "            Cancel AI generation\n"),
                (cmd, "  /quit"),
                (desc, " · "),
                (cmd, "Ctrl+D"),
                (desc, "      Exit\n"),
            ]
        )
        dialog_style = Style.from_dict(
            {
                "dialog": f"bg:{renderer._theme.bg_dark}",
                "dialog frame.label": f"bg:{renderer._theme.bg_dark} {renderer._theme.accent} bold",
                "dialog.body": f"bg:{renderer._theme.bg_dark} {renderer._theme.text_light}",
                "dialog shadow": f"bg:{renderer._theme.bg_shadow}",
                "button": f"bg:{renderer._theme.accent} {renderer._theme.bg_dark}",
                "button.focused": f"bg:{renderer._theme.accent_hover} {renderer._theme.bg_dark} bold",
            }
        )
        await message_dialog(
            title="Help",
            text=help_text,
            ok_text="Close",
            style=dialog_style,
        ).run_async()

    async def _show_resume_picker() -> dict[str, Any] | None:
        """Show an interactive conversation picker with preview panel."""
        from prompt_toolkit.application import Application
        from prompt_toolkit.formatted_text import FormattedText
        from prompt_toolkit.key_binding import KeyBindings as PickerKB
        from prompt_toolkit.layout.containers import HSplit, VSplit, Window
        from prompt_toolkit.layout.controls import FormattedTextControl
        from prompt_toolkit.layout.layout import Layout
        from prompt_toolkit.styles import Style

        convs = storage.list_conversations(db, limit=20)
        if not convs:
            renderer.console.print(f"[{CHROME}]No conversations found[/{CHROME}]\n")
            return None

        selected_idx = [0]
        preview_cache: dict[str, list[dict[str, Any]]] = {}

        def _get_list_text() -> FormattedText:
            fragments: list[tuple[str, str]] = []
            for i, c in enumerate(convs):
                is_sel = i == selected_idx[0]
                title = (c.get("title") or "Untitled")[:35]
                slug = (c.get("slug") or "")[:20]
                badge = _picker_type_badge(c.get("type") or "chat")
                ts = _picker_relative_time(c.get("updated_at") or "")
                count = c.get("message_count") or 0

                if is_sel:
                    fragments.append(("class:list.selected", f" > {title}"))
                    if badge:
                        fragments.append(("class:list.badge", f" {badge}"))
                    fragments.append(("class:list.selected-meta", f"  {slug}  {count}msg  {ts}"))
                    fragments.append(("class:list.selected", "\n"))
                else:
                    fragments.append(("class:list.item", f"   {title}"))
                    if badge:
                        fragments.append(("class:list.badge", f" {badge}"))
                    fragments.append(("class:list.meta", f"  {slug}  {count}msg  {ts}"))
                    fragments.append(("class:list.item", "\n"))
            return FormattedText(fragments)

        def _get_preview_text() -> FormattedText:
            if not convs:
                return FormattedText([])
            conv_id = convs[selected_idx[0]]["id"]
            if conv_id not in preview_cache:
                preview_cache[conv_id] = storage.list_messages(db, conv_id)
            msgs = preview_cache[conv_id]
            return FormattedText(_picker_format_preview(msgs))

        list_control = FormattedTextControl(_get_list_text)
        preview_control = FormattedTextControl(_get_preview_text)

        def _refresh() -> None:
            list_control.text = _get_list_text()
            preview_control.text = _get_preview_text()

        picker_kb = PickerKB()
        result: list[dict[str, Any] | None] = [None]

        @picker_kb.add("up")
        @picker_kb.add("k")
        def _up(event: Any) -> None:
            if selected_idx[0] > 0:
                selected_idx[0] -= 1
                _refresh()

        @picker_kb.add("down")
        @picker_kb.add("j")
        def _down(event: Any) -> None:
            if selected_idx[0] < len(convs) - 1:
                selected_idx[0] += 1
                _refresh()

        @picker_kb.add("enter")
        def _select(event: Any) -> None:
            result[0] = convs[selected_idx[0]]
            event.app.exit()

        @picker_kb.add("escape")
        @picker_kb.add("c-c")
        def _cancel(event: Any) -> None:
            event.app.exit()

        separator = Window(width=1, char="│", style="class:separator")
        body = VSplit(
            [
                Window(content=list_control, width=50, wrap_lines=False),
                separator,
                Window(content=preview_control, wrap_lines=True),
            ]
        )
        title_bar = Window(
            content=FormattedTextControl(
                FormattedText(
                    [
                        ("class:title", " Resume Conversation  "),
                        ("class:hint", " ↑↓ navigate  Enter select  Esc cancel "),
                    ]
                )
            ),
            height=1,
        )
        layout = Layout(HSplit([title_bar, body]))

        style = Style.from_dict(
            {
                "title": f"bg:{renderer._theme.accent} {renderer._theme.bg_dark} bold",
                "hint": f"bg:{renderer._theme.bg_subtle} {renderer._theme.secondary}",
                "separator": renderer._theme.bg_subtle,
                "list.selected": f"bg:{renderer._theme.bg_highlight} {renderer._theme.accent} bold",
                "list.selected-meta": f"bg:{renderer._theme.bg_highlight} {renderer._theme.secondary}",
                "list.item": renderer._theme.text_light,
                "list.meta": renderer._theme.chrome,
                "list.badge": f"{renderer._theme.accent} italic",
                "preview.role-user": f"{renderer._theme.accent} bold",
                "preview.role-ai": f"{renderer._theme.secondary} bold",
                "preview.content": renderer._theme.text_light,
                "preview.empty": f"{renderer._theme.chrome} italic",
            }
        )

        app: Application[None] = Application(
            layout=layout,
            key_bindings=picker_kb,
            style=style,
        )
        await app.run_async()
        return result[0]

    # -- Concurrent input/output architecture --
    # Instead of blocking on prompt_async then running agent loop sequentially,
    # we use two coroutines: one collects input, one processes agent responses.
    # prompt_toolkit's patch_stdout keeps the input prompt anchored at the bottom.

    input_queue: asyncio.Queue[str] = asyncio.Queue(maxsize=10)
    agent_busy = asyncio.Event()  # set while agent loop is running
    exit_flag = asyncio.Event()
    _current_cancel_event: list[asyncio.Event | None] = [None]

    @kb.add("tab")
    def _tab_complete(event: Any) -> None:
        buf = event.current_buffer
        if buf.complete_state:
            if not _accept_completion(buf):
                buf.complete_next()
        else:
            buf.start_completion()

    @kb.add("s-tab")
    def _tab_complete_prev(event: Any) -> None:
        buf = event.current_buffer
        if buf.complete_state:
            buf.complete_previous()

    def _has_pending_work() -> bool:
        """Check if there's more work queued."""
        return not input_queue.empty() and not exit_flag.is_set()

    async def _agent_runner() -> None:
        """Process messages from input_queue, run commands and agent loop."""
        nonlocal conv, ai_messages, is_first_message, tools_openai, all_tool_names
        nonlocal current_model, ai_service, extra_system_prompt, working_dir
        nonlocal artifact_registry

        # -- Plan mode state --
        from .plan import (
            PLAN_MODE_ALLOWED_TOOLS,
            build_planning_system_prompt,
            delete_plan,
            get_editor,
            get_plan_file_path,
            parse_plan_command,
            parse_plan_steps,
            read_plan,
        )

        _plan_active: list[bool] = [plan_mode]
        _plan_file: list[Path | None] = [None]
        _full_tools_backup: list[list[dict[str, Any]] | None] = [None]
        _plan_checklist_steps: list[str] = []  # parsed step descriptions for live checklist
        _plan_current_step: list[int] = [0]  # index of the step currently in progress

        # -- RAG state --
        _rag_embedding_service: list[Any] = [None]
        _rag_service_checked: list[bool] = [False]

        async def _get_rag_embedding_service() -> Any:
            """Lazily create embedding service for RAG retrieval, with auto-detect probe."""
            if _rag_service_checked[0]:
                return _rag_embedding_service[0]
            _rag_service_checked[0] = True
            try:
                from ..services.embeddings import create_embedding_service

                svc = create_embedding_service(config)
                if svc and config.embeddings.enabled is None:
                    probe_ok = await svc.probe()
                    if not probe_ok:
                        logger.info("Embedding endpoint unavailable; semantic search disabled")
                        svc = None
                _rag_embedding_service[0] = svc
                return svc
            except Exception:
                logger.debug("RAG: failed to create embedding service", exc_info=True)
                return None

        _embedding_worker: list[Any] = [None]

        async def _get_embedding_worker() -> Any:
            """Lazily create an EmbeddingWorker for inline embedding after uploads."""
            if _embedding_worker[0] is not None:
                return _embedding_worker[0]
            svc = await _get_rag_embedding_service()
            if svc is None:
                return None
            try:
                from ..services.embedding_worker import EmbeddingWorker

                _embedding_worker[0] = EmbeddingWorker(db, svc, vec_manager=vec_manager)
                return _embedding_worker[0]
            except Exception:
                logger.debug("Failed to create embedding worker for CLI", exc_info=True)
                return None

        _rag_reranker_service: list[Any] = [None]
        _reranker_checked: list[bool] = [False]

        async def _get_rag_reranker_service() -> Any:
            """Lazily create reranker service for RAG reranking, with auto-detect probe."""
            if _reranker_checked[0]:
                return _rag_reranker_service[0]
            _reranker_checked[0] = True
            try:
                from ..services.reranker import create_reranker_service

                svc = create_reranker_service(config)
                if svc and config.reranker.enabled is None:
                    probe_ok = await svc.probe()
                    if not probe_ok:
                        logger.info("Reranker model unavailable; reranking disabled")
                        svc = None
                _rag_reranker_service[0] = svc
                return svc
            except Exception:
                logger.debug("RAG: failed to create reranker service", exc_info=True)
                return None

        def _apply_plan_mode(conv_id: str) -> None:
            nonlocal tools_openai, extra_system_prompt
            plan_path = get_plan_file_path(config.app.data_dir, conv_id)
            _plan_file[0] = plan_path
            _plan_active[0] = True
            # Back up full tools before filtering
            _full_tools_backup[0] = tools_openai

            # Filter tools to plan-mode allowlist
            if tools_openai:
                tools_openai = [t for t in tools_openai if t.get("function", {}).get("name") in PLAN_MODE_ALLOWED_TOOLS]

            # Inject planning prompt (remove existing if re-entering)
            extra_system_prompt = _strip_planning_prompt(extra_system_prompt)
            extra_system_prompt += "\n\n" + build_planning_system_prompt(plan_path)

        def _exit_plan_mode(plan_content: str | None = None) -> None:
            nonlocal tools_openai, extra_system_prompt
            _plan_active[0] = False

            # Restore full tools
            if _full_tools_backup[0] is not None:
                tools_openai = _full_tools_backup[0]
                _full_tools_backup[0] = None
            else:
                _rebuild_tools()

            # Remove planning prompt, optionally inject approved plan
            extra_system_prompt = _strip_planning_prompt(extra_system_prompt)
            if plan_content:
                extra_system_prompt += (
                    "\n\n<approved_plan>\n"
                    "The user has approved the following implementation plan. "
                    "Execute it step by step.\n\n" + plan_content + "\n</approved_plan>"
                )

        def _strip_planning_prompt(prompt: str) -> str:
            prompt = re.sub(r"\n*<planning_mode>.*?</planning_mode>", "", prompt, flags=re.DOTALL)
            return prompt

        # -- Space state --
        _active_space: list[dict[str, Any] | None] = [space]

        async def _resolve_space(name_or_id: str) -> dict[str, Any] | None:
            """Look up a space by name, UUID, or UUID prefix. Shows picker on ambiguity."""
            from ..services.space_storage import resolve_space

            match, candidates = resolve_space(db, name_or_id)
            if match:
                return match
            if candidates:
                renderer.console.print(f"\nMultiple spaces match '{name_or_id}':")
                for i, c in enumerate(candidates, 1):
                    renderer.console.print(f"  {i}. {c['name']} [{c['id'][:8]}...]")
                try:
                    choice = (await _timed_input(f"Select (1-{len(candidates)}): ")).strip()
                    idx = int(choice) - 1
                    if 0 <= idx < len(candidates):
                        return candidates[idx]
                except (ValueError, EOFError, KeyboardInterrupt, TimeoutError):
                    pass
            return None

        def _inject_space_instructions(sp: dict[str, Any], instr: str | None = None) -> None:
            nonlocal extra_system_prompt
            extra_system_prompt = _strip_space_instructions(extra_system_prompt)
            if not instr:
                instr = sp.get("instructions", "")
            if instr:
                safe_name = sanitize_trust_tags(sp["name"]).replace('"', "&quot;")
                safe_instr = sanitize_trust_tags(instr)
                extra_system_prompt += (
                    '\n\n<space_instructions space="' + safe_name + '">\n' + safe_instr + "\n</space_instructions>"
                )

        def _strip_space_instructions(prompt: str) -> str:
            return re.sub(r"\n*<space_instructions[^>]*>.*?</space_instructions>", "", prompt, flags=re.DOTALL)

        def _refresh_artifact_prompt() -> None:
            """Rebuild the artifact (rules/instructions/context) section in extra_system_prompt."""
            nonlocal extra_system_prompt, artifact_registry
            if artifact_registry is None:
                return
            # Strip existing artifact tags (trusted and untrusted)
            extra_system_prompt = re.sub(r"\n*<artifact [^>]*>.*?</artifact>", "", extra_system_prompt, flags=re.DOTALL)
            extra_system_prompt = re.sub(
                r"\n*<untrusted_content [^>]*origin=\"artifact:[^\"]*\"[^>]*>.*?</untrusted_content>",
                "",
                extra_system_prompt,
                flags=re.DOTALL,
            )
            # Re-inject from current registry
            from ..services.artifacts import ArtifactType

            for art_type in (ArtifactType.INSTRUCTION, ArtifactType.RULE, ArtifactType.CONTEXT):
                artifacts = artifact_registry.list_all(artifact_type=art_type)
                for art in artifacts:
                    if art.content.strip():
                        if art.source == "built_in":
                            tag = f'<artifact type="{art_type.value}" fqn="{art.fqn}">'
                            extra_system_prompt += f"\n{tag}\n{art.content}\n</artifact>"
                        else:
                            wrapped = wrap_untrusted(
                                art.content, origin=f"artifact:{art.fqn}", content_type=art_type.value
                            )
                            extra_system_prompt += f"\n{wrapped}"
            # Reload hard-enforced rules into the tool registry's enforcer
            enforcer = getattr(tool_registry, "_rule_enforcer", None) if tool_registry else None
            if enforcer is not None:
                enforcer.load_rules(artifact_registry.list_all(artifact_type=ArtifactType.RULE))

        def _refresh_skill_tools() -> None:
            """Rebuild invoke_skill tool schema and tab-completion after skill changes."""
            if not skill_registry:
                return
            if config.cli.skills.auto_invoke and tools_openai is not None:
                tools_openai[:] = [t for t in tools_openai if t.get("function", {}).get("name") != "invoke_skill"]
                invoke_def = skill_registry.get_invoke_skill_definition()
                if invoke_def:
                    tools_openai.append(invoke_def)

        def _rebuild_pack_config(
            *,
            rollback_pack_id: str | None = None,
            rollback_project_path: str | None = None,
            rollback_action: str | None = None,
        ) -> bool:
            """Rebuild effective config after pack attach/detach/install/remove.

            Fails closed: on error, rolls back the DB mutation and keeps
            the previous config.

            Parameters
            ----------
            rollback_pack_id:
                Pack ID to rollback on failure.
            rollback_project_path:
                Project path for rollback scope.
            rollback_action:
                ``"detach"`` to undo an attach, ``"attach"`` to undo a detach.

            Returns ``True`` on success.
            """
            nonlocal config
            from ..services.config_overlays import ComplianceError, rebuild_effective_config

            previous = config
            try:
                _space_id = space["id"] if space else None
                result = rebuild_effective_config(
                    db,
                    project_path=str(Path(working_dir)),
                    space_id=_space_id,
                    previous_config=previous,
                )
                config = result.config
                for warning in result.warnings:
                    renderer.console.print(f"[yellow]{warning}[/yellow]")
                return True
            except ComplianceError as exc:
                config = previous
                renderer.console.print(f"[{_ERROR}]Config rebuild blocked (compliance failure): {exc}[/{_ERROR}]")
                logger.warning("Config rebuild failed after pack change", exc_info=True)
                if rollback_pack_id and rollback_action:
                    _rollback_pack_mutation(db, rollback_pack_id, rollback_project_path, rollback_action)
                return False
            except Exception:
                config = previous
                renderer.console.print(f"[{_ERROR}]Config rebuild failed — keeping previous config.[/{_ERROR}]")
                logger.warning("Config rebuild failed after pack change", exc_info=True)
                return False

        def _rollback_pack_mutation(
            db: Any,
            pack_id: str,
            project_path: str | None,
            action: str,
        ) -> None:
            """Undo a pack attachment/detachment after config rebuild failure."""
            from ..services.pack_attachments import attach_pack as _do_attach
            from ..services.pack_attachments import detach_pack as _do_detach

            try:
                if action == "detach":
                    _do_detach(db, pack_id, project_path=project_path)
                    renderer.console.print("[yellow]Rolled back: pack detached to restore valid config.[/yellow]")
                elif action == "attach":
                    _do_attach(
                        db,
                        pack_id,
                        project_path=project_path,
                        check_overlay_conflicts=False,
                    )
                    renderer.console.print("[yellow]Rolled back: pack re-attached to restore valid config.[/yellow]")
            except Exception:
                logger.error("Rollback failed", exc_info=True)
                renderer.console.print(f"[{_ERROR}]Rollback also failed. Manual intervention required.[/{_ERROR}]")

        # Inject initial space instructions if space is active
        if _active_space[0] and space_instructions:
            _inject_space_instructions(_active_space[0], space_instructions)

        # Apply plan mode at startup if --plan was passed
        if _plan_active[0]:
            _apply_plan_mode(conv["id"])

        while not exit_flag.is_set():
            if agent_busy.is_set() and not _has_pending_work():
                agent_busy.clear()

            try:
                user_input = await asyncio.wait_for(input_queue.get(), timeout=0.5)
            except asyncio.TimeoutError:
                continue

            # Handle commands
            if user_input.startswith("/"):
                cmd = user_input.lower().split()[0]
                if cmd in ("/quit", "/exit"):
                    exit_flag.set()
                    return
                elif cmd == "/new":
                    parts = user_input.split(maxsplit=2)
                    conv_type = "chat"
                    conv_title = "New Conversation"
                    if len(parts) >= 2 and parts[1] in ("note", "doc", "document"):
                        conv_type = "document" if parts[1] in ("doc", "document") else "note"
                        conv_title = parts[2].strip() if len(parts) >= 3 else f"New {conv_type.title()}"
                    active_sid = _active_space[0]["id"] if _active_space[0] else None
                    conv = storage.create_conversation(
                        db,
                        title=conv_title,
                        conversation_type=conv_type,
                        working_dir=working_dir,
                        **id_kw,
                    )
                    if active_sid:
                        from ..services.space_storage import update_conversation_space

                        update_conversation_space(db, conv["id"], active_sid)
                    ai_messages = []
                    is_first_message = conv_type == "chat"
                    if _plan_active[0]:
                        _apply_plan_mode(conv["id"])
                    type_label = f" ({conv_type})" if conv_type != "chat" else ""
                    renderer.console.print(f"[{CHROME}]New conversation started{type_label}[/{CHROME}]\n")
                    continue
                elif cmd == "/append":
                    parts = user_input.split(maxsplit=1)
                    if len(parts) < 2 or not parts[1].strip():
                        renderer.console.print(f"[{CHROME}]Usage: /append <text>[/{CHROME}]\n")
                        continue
                    current_type = conv.get("type", "chat")
                    if current_type != "note":
                        renderer.render_error("Current conversation is not a note. Use /new note <title> first.")
                        continue
                    entry_text = parts[1].strip()
                    storage.create_message(db, conv["id"], "user", entry_text, **id_kw)
                    renderer.console.print(f"[{CHROME}]Entry added to '{conv.get('title', 'Untitled')}'[/{CHROME}]\n")
                    continue
                elif cmd == "/tools":
                    renderer.render_tools(all_tool_names)
                    continue
                elif cmd in ("/conventions", "/instructions"):
                    info = discover_conventions(working_dir)
                    if info.source == "none":
                        renderer.console.print(
                            f"[{CHROME}]No conventions file found.[/{CHROME}]\n"
                            f"  [{MUTED}]Create ANTEROOM.md in your project root to define conventions.[/{MUTED}]\n"
                        )
                    else:
                        label = "Project" if info.source == "project" else "Global"
                        renderer.console.print(f"\n[bold]{label} conventions:[/bold] {info.path}")
                        renderer.console.print(f"  [{MUTED}]~{info.estimated_tokens:,} tokens[/{MUTED}]")
                        if info.warning:
                            renderer.console.print(f"  [yellow]{info.warning}[/yellow]")
                        renderer.console.print()
                        lines = (info.content or "").splitlines()
                        preview = lines[:50]
                        for line in preview:
                            renderer.console.print(f"  {line}")
                        if len(lines) > 50:
                            renderer.console.print(
                                f"\n  [{MUTED}]... {len(lines) - 50} more lines. View full file: {info.path}[/{MUTED}]"
                            )
                        renderer.console.print()
                    continue
                elif cmd == "/upload":
                    parts = user_input.split(maxsplit=1)
                    if len(parts) < 2 or not parts[1].strip():
                        renderer.console.print(f"[{CHROME}]Usage: /upload <path>[/{CHROME}]\n")
                        continue
                    upload_path = Path(parts[1].strip()).expanduser().resolve()
                    if not upload_path.is_file():
                        renderer.console.print(f"[{CHROME}]File not found: {upload_path}[/{CHROME}]\n")
                        continue
                    try:
                        import mimetypes

                        import filetype as _ft

                        max_size_mb = 10
                        max_size = max_size_mb * 1024 * 1024
                        file_size = upload_path.stat().st_size
                        if file_size > max_size:
                            size_mb = file_size // (1024 * 1024)
                            renderer.console.print(
                                f"[{CHROME}]File too large ({size_mb} MB). Maximum is {max_size_mb} MB.[/{CHROME}]\n"
                            )
                            continue
                        file_data = upload_path.read_bytes()
                        guess = _ft.guess(file_data)
                        mime = guess.mime if guess else (mimetypes.guess_type(str(upload_path))[0] or "text/plain")
                        source, upload_warnings = storage.save_source_file(
                            db,
                            title=upload_path.name,
                            filename=upload_path.name,
                            mime_type=mime,
                            data=file_data,
                            data_dir=config.app.data_dir,
                            user_id=config.identity.user_id if config.identity else None,
                            user_display_name=config.identity.display_name
                            if config.identity and hasattr(config.identity, "display_name")
                            else None,
                        )
                        renderer.console.print(
                            f"[{CHROME}]Uploaded {upload_path.name} -> source {source['id'][:8]}...[/{CHROME}]"
                        )
                        if source.get("content"):
                            renderer.console.print(
                                f"  [{MUTED}]{mime}, {len(source['content']):,} chars extracted[/{MUTED}]"
                            )
                            n = await _embed_after_upload(_get_embedding_worker, source["id"])
                            if n:
                                renderer.console.print(f"  [{MUTED}]{n} chunk(s) embedded for search[/{MUTED}]")
                        else:
                            renderer.console.print(f"  [{MUTED}]{mime}, stored (no text extracted)[/{MUTED}]")
                        for w in upload_warnings:
                            renderer.console.print(f"  [yellow]{w}[/yellow]")
                        renderer.console.print()
                    except Exception:
                        logger.error("CLI upload failed", exc_info=True)
                        renderer.console.print(f"[{CHROME}]Upload failed[/{CHROME}]\n")
                    continue
                elif cmd == "/reprocess":
                    parts = user_input.split(maxsplit=1)
                    if len(parts) < 2 or not parts[1].strip():
                        renderer.console.print(
                            f"[{CHROME}]Usage: /reprocess <source_id> or /reprocess all[/{CHROME}]\n"
                        )
                        continue
                    arg = parts[1].strip()
                    data_dir = config.app.data_dir
                    if arg.lower() == "all":
                        rows = db.execute_fetchall(
                            "SELECT id, title FROM sources WHERE content IS NULL AND storage_path IS NOT NULL"
                        )
                        if not rows:
                            renderer.console.print(f"[{CHROME}]No sources need reprocessing.[/{CHROME}]\n")
                            continue
                        renderer.console.print(f"[{CHROME}]Reprocessing {len(rows)} source(s)...[/{CHROME}]")
                        for r in rows:
                            rd = dict(r)
                            src, warns = storage.reprocess_source(db, rd["id"], data_dir)
                            title = rd.get("title", rd["id"][:8])
                            if src.get("content"):
                                cc = src.get("chunk_count", 0)
                                renderer.console.print(f"  [{MUTED}]{title} — {cc} chunk(s)[/{MUTED}]")
                            else:
                                renderer.console.print(f"  [{MUTED}]{title} — no text[/{MUTED}]")
                            for w in warns:
                                renderer.console.print(f"  [yellow]{w}[/yellow]")
                        renderer.console.print()
                    else:
                        src, warns = storage.reprocess_source(db, arg, data_dir)
                        if not src:
                            renderer.console.print(f"[{CHROME}]Source not found.[/{CHROME}]\n")
                        else:
                            if src.get("content"):
                                cc = src.get("chunk_count", 0)
                                renderer.console.print(f"[{CHROME}]Reprocessed — {cc} chunk(s)[/{CHROME}]")
                            else:
                                renderer.console.print(f"[{CHROME}]Reprocessed — no text extracted[/{CHROME}]")
                            for w in warns:
                                renderer.console.print(f"  [yellow]{w}[/yellow]")
                            renderer.console.print()
                    continue
                elif cmd == "/usage":
                    _show_usage_stats(db, config)
                    continue
                elif cmd == "/help":
                    await _show_help_dialog()
                    continue
                elif cmd == "/compact":
                    await _compact_messages(ai_service, ai_messages, db, conv["id"])
                    continue
                elif cmd == "/last":
                    convs = storage.list_conversations(db, limit=1)
                    if convs:
                        conv = storage.get_conversation(db, convs[0]["id"]) or conv
                        ai_messages, _ = _load_conversation_messages(db, conv["id"])
                        is_first_message = False
                        _show_resume_info(db, conv, ai_messages)
                    else:
                        renderer.console.print(f"[{CHROME}]No previous conversations[/{CHROME}]\n")
                    continue
                elif cmd == "/list":
                    parts = user_input.split()
                    list_limit = 20
                    if len(parts) >= 2 and parts[1].isdigit():
                        list_limit = max(1, int(parts[1]))
                    convs = storage.list_conversations(db, limit=list_limit + 1)
                    has_more = len(convs) > list_limit
                    display_convs = convs[:list_limit]
                    if display_convs:
                        renderer.console.print("\n[bold]Recent conversations:[/bold]")
                        for i, c in enumerate(display_convs):
                            msg_count = c.get("message_count", 0)
                            ctype = c.get("type", "chat")
                            type_badge = f" [cyan]\\[{ctype}][/cyan]" if ctype != "chat" else ""
                            slug_label = f" [{MUTED}]{c['slug']}[/{MUTED}]" if c.get("slug") else ""
                            renderer.console.print(
                                f"  {i + 1}. {c['title']}{type_badge} ({msg_count} msgs){slug_label}"
                            )
                        if has_more:
                            more_n = list_limit + 20
                            msg = f"... more available. Use /list {more_n} to show more."
                            renderer.console.print(f"  [{MUTED}]{msg}[/{MUTED}]")
                        renderer.console.print("  Use [bold]/resume <number>[/bold] or [bold]/resume <slug>[/bold]\n")
                    else:
                        renderer.console.print(f"[{CHROME}]No conversations[/{CHROME}]\n")
                    continue
                elif cmd == "/delete":
                    parts = user_input.split(maxsplit=1)
                    if len(parts) < 2:
                        renderer.console.print(f"[{CHROME}]Usage: /delete <number|slug|id>[/{CHROME}]\n")
                        continue
                    target = parts[1].strip()
                    to_delete = _resolve_conversation(db, target)
                    if not to_delete:
                        renderer.render_error(f"Conversation not found: {target}. Use /list to see conversations.")
                        continue
                    title = to_delete.get("title", "Untitled")
                    try:
                        answer = (await _timed_input(f'  Delete "{title}"? [y/N] ')).strip().lower()
                    except (EOFError, KeyboardInterrupt, TimeoutError):
                        renderer.console.print(f"[{CHROME}]Cancelled[/{CHROME}]\n")
                        continue
                    if answer not in ("y", "yes"):
                        renderer.console.print(f"[{CHROME}]Cancelled[/{CHROME}]\n")
                        continue
                    storage.delete_conversation(db, to_delete["id"], config.app.data_dir)
                    renderer.console.print(f"[{CHROME}]Deleted: {title}[/{CHROME}]\n")
                    if conv.get("id") == to_delete["id"]:
                        conv = storage.create_conversation(db, working_dir=working_dir, **id_kw)
                        ai_messages = []
                        is_first_message = True
                    continue
                elif cmd == "/rename":
                    parts = user_input.split(maxsplit=2)
                    if len(parts) < 2:
                        renderer.console.print(
                            f"[{CHROME}]Usage: /rename <title> or /rename <N|id|slug> <title>[/{CHROME}]\n"
                        )
                        continue
                    # Two forms: /rename <title> (current conv) or /rename <target> <title>
                    first_arg = parts[1].strip()
                    looks_like_target = first_arg.isdigit() or ("-" in first_arg and len(first_arg) >= 36)
                    # Also treat as target if it resolves as a slug
                    if not looks_like_target and len(parts) == 3:
                        maybe_conv = storage.get_conversation(db, first_arg)
                        if maybe_conv:
                            looks_like_target = True
                    if len(parts) == 3 and looks_like_target:
                        target = parts[1].strip()
                        new_title = parts[2].strip()
                        resolved = _resolve_conversation(db, target)
                        if not resolved:
                            renderer.render_error(f"Conversation not found: {target}. Use /list to see conversations.")
                            continue
                        resolved_id = resolved["id"]
                    else:
                        # /rename <title> — rename current conversation
                        new_title = user_input.split(maxsplit=1)[1].strip()
                        resolved_id = conv.get("id")
                    if not resolved_id:
                        renderer.render_error("No active conversation to rename.")
                        continue
                    if not new_title:
                        renderer.console.print(
                            f"[{CHROME}]Usage: /rename <title> or /rename <N|id|slug> <title>[/{CHROME}]\n"
                        )
                        continue
                    storage.update_conversation_title(db, resolved_id, new_title)
                    renderer.console.print(f'[{CHROME}]Renamed conversation to "{new_title}"[/{CHROME}]\n')
                    if conv.get("id") == resolved_id:
                        conv["title"] = new_title
                    continue
                elif cmd == "/slug":
                    parts = user_input.split(maxsplit=1)
                    if len(parts) < 2:
                        # Show current slug
                        current_slug = conv.get("slug", "none")
                        renderer.console.print(f"[{CHROME}]Slug: {current_slug}[/{CHROME}]\n")
                        continue
                    desired = parts[1].strip().lower()
                    if not conv.get("id"):
                        renderer.render_error("No active conversation.")
                        continue
                    if not is_valid_slug(desired):
                        renderer.render_error(
                            "Invalid slug. Use lowercase letters, numbers, and hyphens (e.g. my-project)."
                        )
                        continue
                    suggestion = suggest_unique_slug(db, desired)
                    if suggestion is None:
                        # Desired slug is available
                        try:
                            storage.update_conversation_slug(db, conv["id"], desired)
                            conv["slug"] = desired
                            invalidate_toolbar()
                            renderer.console.print(f"[{CHROME}]Slug set to: {desired}[/{CHROME}]\n")
                        except _sqlite3.IntegrityError:
                            # Race: slug was taken between check and write
                            fallback = suggest_unique_slug(db, desired)
                            renderer.render_error(f'"{desired}" is taken. Try: {fallback}')
                    else:
                        renderer.console.print(f'[{CHROME}]"{desired}" is taken. Suggestion: {suggestion}[/{CHROME}]\n')
                    continue
                elif cmd == "/search":
                    parts = user_input.split(maxsplit=1)
                    if len(parts) < 2 or not parts[1].strip():
                        renderer.console.print(
                            f"[{CHROME}]Usage: /search <query> | /search --keyword <query>[/{CHROME}]\n"
                        )
                        continue
                    search_arg = parts[1].strip()

                    # Check for --keyword flag
                    force_keyword = False
                    type_filter = None
                    if search_arg.startswith("--keyword "):
                        force_keyword = True
                        search_arg = search_arg[len("--keyword ") :].strip()
                        if not search_arg:
                            renderer.console.print(f"[{CHROME}]Usage: /search --keyword <query>[/{CHROME}]\n")
                            continue
                    elif search_arg.startswith("--type "):
                        rest = search_arg[len("--type ") :].strip()
                        type_parts = rest.split(maxsplit=1)
                        if type_parts and type_parts[0] in ("chat", "note", "document"):
                            type_filter = type_parts[0]
                            search_arg = type_parts[1] if len(type_parts) > 1 else ""
                        else:
                            renderer.render_error("Invalid type. Use: chat, note, or document")
                            continue
                        if not search_arg:
                            renderer.console.print(f"[{CHROME}]Usage: /search --type <type> <query>[/{CHROME}]\n")
                            continue

                    query = search_arg

                    # Try semantic search if usearch is available
                    use_semantic = False
                    if not force_keyword:
                        try:
                            from ..services.embeddings import create_embedding_service as _create_emb
                            from ..services.vector_index import has_vector_support as _has_vec

                            if _has_vec():
                                _emb_svc = _create_emb(config)
                                if _emb_svc:
                                    use_semantic = True
                        except Exception:
                            pass

                    if use_semantic and _emb_svc is not None:
                        try:
                            query_emb = await _emb_svc.embed(query)
                            if query_emb:
                                sem_results = storage.search_similar_messages(
                                    db,
                                    query_emb,
                                    limit=20,
                                    conversation_type=type_filter,
                                    vec_index=vec_manager.messages if vec_manager and vec_manager.enabled else None,
                                )
                                if sem_results:
                                    renderer.console.print(f"\n[bold]Semantic search results for '{query}':[/bold]")
                                    _type_badges = {"note": "[note]", "document": "[doc]"}
                                    for i, r in enumerate(sem_results):
                                        snippet = r["content"][:80].replace("\n", " ")
                                        dist = r.get("distance", 0)
                                        relevance = max(0, 100 - int(dist * 100))
                                        ctype = r.get("conversation_type", "chat")
                                        badge = _type_badges.get(ctype, "")
                                        badge_str = f" {badge}" if badge else ""
                                        renderer.console.print(
                                            f"  {i + 1}. [{r['role']}]{badge_str} {snippet}... "
                                            f"[{CHROME}]({relevance}% match, {r['conversation_id'][:8]}...)[/{CHROME}]"
                                        )
                                    renderer.console.print()
                                    continue
                        except Exception:
                            pass  # Fall through to keyword search

                    results = storage.list_conversations(db, search=query, limit=20, conversation_type=type_filter)
                    if results:
                        renderer.console.print(f"\n[bold]Search results for '{query}':[/bold]")
                        for i, c in enumerate(results):
                            msg_count = c.get("message_count", 0)
                            renderer.console.print(
                                f"  {i + 1}. {c['title']} ({msg_count} msgs) [{CHROME}]{c['id'][:8]}...[/{CHROME}]"
                            )
                        renderer.console.print("  Use [bold]/resume <number|slug>[/bold] to open\n")
                    else:
                        renderer.console.print(f"[{CHROME}]No conversations matching '{query}'[/{CHROME}]\n")
                    continue
                elif cmd in ("/skills", "/reload-skills"):
                    if skill_registry:
                        skill_registry.reload(working_dir)
                        if artifact_registry is not None:
                            skill_registry.load_from_artifacts(artifact_registry)
                        descs = skill_registry.get_skill_descriptions()
                        if descs:
                            renderer.console.print("\n[bold]Available skills:[/bold]")
                            for display_name, desc in descs:
                                sk = skill_registry.get(display_name)
                                src = sk.source if sk else "unknown"
                                renderer.console.print(f"  /{display_name} - {desc} [{CHROME}]({src})[/{CHROME}]")
                        else:
                            renderer.console.print(
                                f"\n[{CHROME}]No skills loaded. Add .yaml files to"
                                f" ~/.anteroom/skills/ or .anteroom/skills/[/{CHROME}]"
                            )
                        if skill_registry.load_warnings:
                            n = len(skill_registry.load_warnings)
                            renderer.console.print(f"\n[yellow]Warnings ({n}):[/yellow]")
                            for warn in skill_registry.load_warnings:
                                renderer.console.print(f"  [yellow]- {warn}[/yellow]")
                        if skill_registry.searched_dirs:
                            renderer.console.print(f"\n[{CHROME}]Searched directories:[/{CHROME}]")
                            for sd in skill_registry.searched_dirs:
                                if sd.source == "default":
                                    continue
                                status = f"{sd.skill_count} skill(s)" if sd.exists else "not found"
                                renderer.console.print(f"  [{CHROME}]{sd.path} ({sd.source}) — {status}[/{CHROME}]")
                        renderer.console.print()
                        _refresh_skill_tools()
                        # Refresh tab-completion skill names and descriptions
                        descs = skill_registry.get_skill_descriptions()
                        completer.update_skill_names(
                            [n for n, _ in descs],
                            {n: d for n, d in descs},
                        )
                    continue
                elif cmd in ("/spaces", "/space"):
                    from ..services.space_storage import (
                        count_space_conversations,
                    )
                    from ..services.space_storage import (
                        list_spaces as _list_spaces,
                    )
                    from ..services.space_storage import (
                        update_conversation_space as _update_conv_space,
                    )

                    parts = user_input.split(maxsplit=2)
                    sub = parts[1].lower() if len(parts) >= 2 else ""
                    if cmd == "/spaces":
                        sub = "list"

                    if sub == "list" or (cmd == "/space" and not sub):
                        from ..services.spaces import is_local_space as _is_local

                        spaces = _list_spaces(db)
                        if not spaces:
                            renderer.console.print(
                                f"[{CHROME}]No spaces. Create one with: /space create <name>[/{CHROME}]"
                            )
                            renderer.console.print(
                                f"[{CHROME}]  or: /space init  (derives name from directory)[/{CHROME}]\n"
                            )
                            continue
                        renderer.console.print("\n[bold]Spaces:[/bold]")
                        for sp in spaces:
                            cnt = count_space_conversations(db, sp["id"])
                            active = (
                                f" [{_SUCCESS}](active)[/{_SUCCESS}]"
                                if (_active_space[0] and _active_space[0]["id"] == sp["id"])
                                else ""
                            )
                            _sf = sp.get("source_file", "")
                            origin = "local" if (_sf and _is_local(_sf)) else "global"
                            renderer.console.print(
                                f"  {sp['name']}{active}"
                                f" [{MUTED}]{origin} · {cnt} conversations · {sp['id'][:8]}...[/{MUTED}]"
                            )
                        renderer.console.print()

                    elif sub in ("switch", "select", "use"):
                        target = parts[2].strip() if len(parts) >= 3 else ""
                        if not target:
                            renderer.console.print(f"[{CHROME}]Usage: /space switch <name>[/{CHROME}]\n")
                            continue
                        sp: dict[str, Any] | None = await _resolve_space(target)
                        if not sp:
                            renderer.render_error(f"Space '{target}' not found. Run /spaces to list available spaces.")
                            continue
                        _active_space[0] = sp
                        conv["space_id"] = sp["id"]
                        _update_conv_space(db, conv["id"], sp["id"])
                        _inject_space_instructions(sp)
                        renderer.console.print(f"[{_SUCCESS}]Active space: {sp['name']}[/{_SUCCESS}]\n")

                    elif sub == "show":
                        target = parts[2].strip() if len(parts) >= 3 else ""
                        if not target and _active_space[0]:
                            target = _active_space[0]["name"]
                        if not target:
                            renderer.console.print(f"[{CHROME}]Usage: /space show <name>[/{CHROME}]\n")
                            continue
                        sp = await _resolve_space(target)  # type: ignore[assignment]
                        if not sp:
                            renderer.render_error(f"Space '{target}' not found.")
                            continue
                        from ..services.space_storage import get_space_paths as _get_sp_paths

                        paths = _get_sp_paths(db, sp["id"])
                        cnt = count_space_conversations(db, sp["id"])
                        renderer.console.print(f"\n[bold]{sp['name']}[/bold]")
                        _sf = sp.get("source_file", "")
                        if _sf:
                            renderer.console.print(f"  Source: {_sf}")
                        else:
                            renderer.console.print("  Source: (DB-only)")
                        if sp.get("instructions"):
                            _instr_preview = sp["instructions"][:80].replace("\n", " ")
                            renderer.console.print(f"  Instructions: {_instr_preview}...")
                        if sp.get("model"):
                            renderer.console.print(f"  Model: {sp['model']}")
                        renderer.console.print(f"  Convs: {cnt}")
                        if paths:
                            renderer.console.print("  Paths:")
                            for p in paths:
                                label = p.get("repo_url") or "(mapped)"
                                renderer.console.print(f"    {label} -> {p['local_path']}")
                        renderer.console.print()

                    elif sub == "refresh":
                        sp = _active_space[0]  # type: ignore[assignment]
                        if not sp:
                            renderer.console.print(f"[{CHROME}]No active space[/{CHROME}]\n")
                            continue
                        _sf = sp.get("source_file", "")
                        if not _sf:
                            renderer.render_error("This space has no source file to refresh from.")
                            continue
                        from ..services.spaces import compute_file_hash as _cfh
                        from ..services.spaces import parse_space_file as _psf2

                        fpath = Path(_sf)
                        if not fpath.is_file():
                            renderer.render_error(f"Space file not found: {fpath}")
                            continue
                        from ..services.space_storage import update_space as _update_sp

                        new_hash = _cfh(fpath)
                        try:
                            cfg = _psf2(fpath)
                            _updates: dict[str, Any] = {
                                "source_hash": new_hash,
                                "instructions": cfg.instructions or "",
                                "model": cfg.config.get("model") or None,
                            }
                            _update_sp(db, sp["id"], **_updates)
                            from ..services.space_storage import get_space as _get_sp_ref

                            sp_refreshed = _get_sp_ref(db, sp["id"])
                            if sp_refreshed:
                                _active_space[0] = sp_refreshed
                            if cfg.instructions:
                                _inject_space_instructions(sp_refreshed or sp, cfg.instructions)
                            renderer.console.print(f"[{_SUCCESS}]Refreshed: {sp['name']}[/{_SUCCESS}]\n")
                        except Exception:
                            renderer.render_error(f"Failed to refresh space from {fpath}")
                        continue

                    elif sub == "clear":
                        if not _active_space[0]:
                            renderer.console.print(f"[{CHROME}]No active space[/{CHROME}]\n")
                            continue
                        old_name = _active_space[0]["name"]
                        _active_space[0] = None
                        conv["space_id"] = None
                        _update_conv_space(db, conv["id"], None)
                        extra_system_prompt = _strip_space_instructions(extra_system_prompt)
                        renderer.console.print(f"[{CHROME}]Cleared space: {old_name}[/{CHROME}]\n")

                    elif sub in ("create", "init"):
                        import re as _re_mod

                        from ..services.space_storage import (
                            create_space as _cs,
                        )
                        from ..services.space_storage import (
                            sync_space_paths as _ssp,
                        )
                        from ..services.spaces import compute_file_hash as _cfh2
                        from ..services.spaces import slugify_dir_name as _slug
                        from ..services.spaces import write_space_template as _wst

                        _cwd = Path(working_dir)

                        if sub == "init":
                            name = _slug(_cwd.name)
                            if not name:
                                renderer.render_error(
                                    "Cannot derive a space name from this directory. Use /space create <name> instead."
                                )
                                continue
                        else:
                            name = parts[2].strip() if len(parts) >= 3 else ""
                            if not name:
                                renderer.console.print(f"[{CHROME}]Usage: /space create <name>[/{CHROME}]\n")
                                continue

                        if not _re_mod.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$", name):
                            renderer.render_error(
                                f"Invalid space name: {name!r} (must be alphanumeric, hyphens, underscores)"
                            )
                            continue

                        from ..services.space_storage import get_space_by_name as _gspbn

                        _existing = _gspbn(db, name)
                        if _existing:
                            renderer.console.print(
                                f"[yellow]Space '{name}' already exists.[/yellow]"
                                f" Use [bold]/space show {name}[/bold] to view it.\n"
                            )
                            continue

                        spath = _cwd / ".anteroom" / "space.yaml"
                        if spath.exists():
                            renderer.console.print(f"[yellow]Space file already exists:[/yellow] {spath}")
                            renderer.console.print(
                                "  Use [bold]/space load[/bold] to register it, or edit it directly.\n"
                            )
                            continue

                        _wst(spath, name)
                        sp = _cs(db, name, source_file=str(spath), source_hash=_cfh2(spath))
                        _ssp(db, sp["id"], [{"local_path": str(_cwd)}])

                        # Auto-activate the new space
                        _active_space[0] = sp
                        conv["space_id"] = sp["id"]
                        _update_conv_space(db, conv["id"], sp["id"])
                        _inject_space_instructions(sp)

                        renderer.console.print(f"[{_SUCCESS}]Created local space: {sp['name']}[/{_SUCCESS}]\n")
                        renderer.console.print(f"  File: {spath}")
                        renderer.console.print("  Edit the YAML to add instructions, packs, and config.\n")

                    elif sub == "load":
                        target = parts[2].strip() if len(parts) >= 3 else ""
                        if not target:
                            renderer.console.print(f"[{CHROME}]Usage: /space load <path-to-yaml>[/{CHROME}]\n")
                            continue
                        from ..services.space_storage import create_space as _cs
                        from ..services.spaces import compute_file_hash as _cfh2
                        from ..services.spaces import parse_space_file as _psf3
                        from ..services.spaces import validate_space as _vs

                        spath = Path(target).expanduser().resolve()
                        if not spath.is_file():
                            renderer.render_error(f"File not found: {spath}")
                            continue
                        try:
                            scfg = _psf3(spath)
                        except (ValueError, FileNotFoundError) as e:
                            renderer.render_error(str(e))
                            continue
                        errors = _vs(scfg)
                        if errors:
                            for err in errors:
                                renderer.render_error(err)
                            continue

                        from ..services.space_storage import get_space_by_name as _gspbn2

                        _existing_sp = _gspbn2(db, scfg.name)
                        if _existing_sp:
                            renderer.console.print(
                                f"[yellow]Space '{scfg.name}' already exists.[/yellow]"
                                f" Use [bold]/space show {scfg.name}[/bold] to view it.\n"
                            )
                            continue
                        sp = _cs(
                            db,
                            name=scfg.name,
                            source_file=str(spath),
                            source_hash=_cfh2(spath),
                            instructions=scfg.instructions or "",
                            model=scfg.config.get("model"),
                        )
                        renderer.console.print(
                            f"[{_SUCCESS}]Loaded space: {sp['name']}[/{_SUCCESS}]"
                            f" [{MUTED}]{sp['id'][:8]}...[/{MUTED}]\n"
                        )

                    elif sub == "clone":
                        _clone_name = parts[2].strip() if len(parts) >= 3 else ""
                        if not _clone_name:
                            renderer.console.print(f"[{CHROME}]Usage: /space clone <name>[/{CHROME}]\n")
                            continue

                        _sp_match = await _resolve_space(_clone_name)
                        if not _sp_match:
                            renderer.console.print(f"[{CHROME}]Space not found: {_clone_name}[/{CHROME}]\n")
                            continue
                        try:
                            from ..services.space_bootstrap import bootstrap_space as _boot_space
                            from ..services.spaces import parse_space_file as _psf_clone

                            sp_file = _sp_match.get("source_file")
                            if not sp_file or not Path(sp_file).is_file():
                                renderer.render_error("Space has no valid YAML file to clone from.")
                                continue
                            scfg = _psf_clone(Path(sp_file))
                            result = _boot_space(db, scfg, None, config.app.data_dir)
                            if result.errors:
                                for err in result.errors:
                                    renderer.render_error(err)
                            else:
                                renderer.console.print(f"[{_SUCCESS}]Cloned space: {_sp_match['name']}[/{_SUCCESS}]\n")
                        except Exception as e:
                            renderer.render_error(str(e))

                    elif sub == "map":
                        _map_dir = parts[2].strip() if len(parts) >= 3 else ""
                        if not _map_dir:
                            renderer.console.print(f"[{CHROME}]Usage: /space map <directory>[/{CHROME}]\n")
                            continue
                        if not _active_space[0]:
                            renderer.console.print(
                                f"[{CHROME}]No active space. Switch first: /space switch <name>[/{CHROME}]\n"
                            )
                            continue
                        from ..services.space_storage import (
                            get_space_paths as _get_sp_paths2,
                        )
                        from ..services.space_storage import (
                            sync_space_paths as _sync_sp_paths,
                        )

                        _map_path = Path(_map_dir).expanduser().resolve()
                        if not _map_path.is_dir():
                            renderer.render_error(f"Not a directory: {_map_path}")
                            continue
                        try:
                            existing_paths: list[dict[str, Any]] = _get_sp_paths2(db, _active_space[0]["id"])
                            existing_paths.append({"local_path": str(_map_path), "repo_url": ""})
                            _sync_sp_paths(db, _active_space[0]["id"], existing_paths)
                            renderer.console.print(
                                f"[{_SUCCESS}]Mapped[/{_SUCCESS}] {_map_path} to space {_active_space[0]['name']}\n"
                            )
                        except Exception as e:
                            renderer.render_error(str(e))

                    elif sub == "edit":
                        sp = _active_space[0]  # type: ignore[assignment]
                        if not sp:
                            renderer.console.print(f"[{CHROME}]No active space[/{CHROME}]\n")
                            continue
                        from ..services.space_storage import get_space as _get_sp_edit
                        from ..services.space_storage import update_space as _update_sp_edit

                        _edit_rest = parts[2].strip() if len(parts) >= 3 else ""
                        if not _edit_rest:
                            renderer.console.print(
                                f"[{CHROME}]Usage: /space edit instructions <text>[/{CHROME}]\n"
                                f"[{CHROME}]       /space edit model <model-name>[/{CHROME}]\n"
                                f"[{CHROME}]       /space edit name <new-name>[/{CHROME}]\n"
                            )
                            continue
                        _edit_parts = _edit_rest.split(maxsplit=1)
                        _edit_field = _edit_parts[0].lower()
                        _edit_value = _edit_parts[1] if len(_edit_parts) >= 2 else ""
                        if _edit_field == "instructions":
                            _update_sp_edit(db, sp["id"], instructions=_edit_value)
                            sp_updated = _get_sp_edit(db, sp["id"])
                            if sp_updated:
                                _active_space[0] = sp_updated
                            if _edit_value:
                                _inject_space_instructions(sp_updated or sp, _edit_value)
                            renderer.console.print(f"[{_SUCCESS}]Updated instructions for {sp['name']}[/{_SUCCESS}]\n")
                        elif _edit_field == "model":
                            _update_sp_edit(db, sp["id"], model=_edit_value or None)
                            sp_updated = _get_sp_edit(db, sp["id"])
                            if sp_updated:
                                _active_space[0] = sp_updated
                            renderer.console.print(
                                f"[{_SUCCESS}]Updated model for {sp['name']}:"
                                f" {_edit_value or '(cleared)'}[/{_SUCCESS}]\n"
                            )
                        elif _edit_field == "name":
                            if not _edit_value:
                                renderer.console.print(f"[{CHROME}]Usage: /space edit name <new-name>[/{CHROME}]\n")
                                continue
                            _update_sp_edit(db, sp["id"], name=_edit_value)
                            sp_updated = _get_sp_edit(db, sp["id"])
                            if sp_updated:
                                _active_space[0] = sp_updated
                            renderer.console.print(f"[{_SUCCESS}]Renamed space to: {_edit_value}[/{_SUCCESS}]\n")
                        else:
                            renderer.console.print(
                                f"[{CHROME}]Unknown field: {_edit_field}. Use: instructions, model, name[/{CHROME}]\n"
                            )

                    elif sub == "export":
                        sp = _active_space[0]  # type: ignore[assignment]
                        target_name = parts[2].strip() if len(parts) >= 3 else ""
                        if target_name:
                            sp = await _resolve_space(target_name)  # type: ignore[assignment]
                        if not sp:
                            renderer.console.print(
                                f"[{CHROME}]No active space (or specify name: /space export <name>)[/{CHROME}]\n"
                            )
                            continue
                        from ..services.spaces import export_space_to_yaml as _export
                        from ..services.spaces import write_space_file as _wsf

                        try:
                            cfg = _export(db, sp["id"])
                        except ValueError as e:
                            renderer.render_error(str(e))
                            continue
                        _export_path = Path(working_dir) / ".anteroom" / "space.yaml"
                        if _export_path.exists():
                            renderer.console.print(f"[yellow]File already exists:[/yellow] {_export_path}")
                            renderer.console.print("  Overwrite? Use /space export and manually save.\n")
                            # Print YAML to console instead
                            import yaml as _yaml_mod

                            _data: dict[str, Any] = {"name": cfg.name, "version": cfg.version}
                            if cfg.instructions:
                                _data["instructions"] = cfg.instructions
                            if cfg.config:
                                _data["config"] = cfg.config
                            renderer.console.print("\n[bold]Space YAML:[/bold]")
                            renderer.console.print(_yaml_mod.dump(_data, default_flow_style=False, sort_keys=False))
                        else:
                            _wsf(_export_path, cfg)
                            renderer.console.print(f"[{_SUCCESS}]Exported space to:[/{_SUCCESS}] {_export_path}\n")

                    elif sub == "sources":
                        sp = _active_space[0]  # type: ignore[assignment]
                        if not sp:
                            renderer.console.print(f"[{CHROME}]No active space[/{CHROME}]\n")
                            continue
                        from ..services.storage import get_direct_space_source_links

                        linked = get_direct_space_source_links(db, sp["id"])
                        if not linked:
                            renderer.console.print(f"[{CHROME}]No sources linked to space '{sp['name']}'.[/{CHROME}]")
                            renderer.console.print(
                                f"[{CHROME}]  Link one with: /space link-source <title-or-id>[/{CHROME}]\n"
                            )
                            continue
                        renderer.console.print(f"\n[bold]Sources in {sp['name']}:[/bold]")
                        for _src_row in linked:
                            _title = _src_row.get("title", "Untitled")
                            _type = _src_row.get("type", "")
                            _sid = str(_src_row["id"])[:8]
                            renderer.console.print(f"  {_title} [{MUTED}]{_type} · {_sid}...[/{MUTED}]")
                        renderer.console.print()

                    elif sub == "link-source":
                        sp = _active_space[0]  # type: ignore[assignment]
                        if not sp:
                            renderer.console.print(f"[{CHROME}]No active space[/{CHROME}]\n")
                            continue
                        query = parts[2].strip() if len(parts) >= 3 else ""
                        if not query:
                            renderer.console.print(f"[{CHROME}]Usage: /space link-source <title-or-id>[/{CHROME}]\n")
                            continue
                        from ..services.storage import (
                            get_direct_space_source_links,
                        )
                        from ..services.storage import (
                            link_source_to_space as _link_src,
                        )
                        from ..services.storage import (
                            list_sources as _list_srcs,
                        )

                        all_srcs = _list_srcs(db, limit=0)
                        match = None
                        # Exact match by ID (unique, no ambiguity)
                        for s in all_srcs:
                            if s["id"] == query:
                                match = s
                                break
                        # Exact title match with disambiguation
                        if not match:
                            exact = [s for s in all_srcs if s.get("title", "").lower() == query.lower()]
                            if len(exact) == 1:
                                match = exact[0]
                            elif len(exact) > 1:
                                renderer.console.print(f"[{CHROME}]Multiple sources named '{query}':[/{CHROME}]")
                                for c in exact[:10]:
                                    _ct = c.get("title", "Untitled")
                                    _ci = str(c["id"])[:8]
                                    renderer.console.print(f"  {_ct} [{MUTED}]{_ci}...[/{MUTED}]")
                                renderer.console.print(f"[{CHROME}]Use the source ID to disambiguate.[/{CHROME}]\n")
                                continue
                        # Partial title match with disambiguation
                        if not match:
                            candidates = [s for s in all_srcs if query.lower() in s.get("title", "").lower()]
                            if len(candidates) == 1:
                                match = candidates[0]
                            elif len(candidates) > 1:
                                renderer.console.print(f"[{CHROME}]Multiple sources match '{query}':[/{CHROME}]")
                                for c in candidates[:10]:
                                    _ct = c.get("title", "Untitled")
                                    _ci = str(c["id"])[:8]
                                    renderer.console.print(f"  {_ct} [{MUTED}]{_ci}...[/{MUTED}]")
                                renderer.console.print(f"[{CHROME}]Be more specific or use the source ID.[/{CHROME}]\n")
                                continue
                        if not match:
                            renderer.render_error(f"Source '{query}' not found.")
                            continue
                        already = get_direct_space_source_links(db, sp["id"])
                        if any(r["id"] == match["id"] for r in already):
                            _t = match.get("title", "Untitled")
                            renderer.console.print(f"[{CHROME}]'{_t}' is already linked to '{sp['name']}'[/{CHROME}]\n")
                            continue
                        _link_src(db, sp["id"], source_id=match["id"])
                        renderer.console.print(
                            f"[{_SUCCESS}]Linked '{match.get('title', 'Untitled')}'"
                            f" to space '{sp['name']}'[/{_SUCCESS}]\n"
                        )

                    elif sub == "unlink-source":
                        sp = _active_space[0]  # type: ignore[assignment]
                        if not sp:
                            renderer.console.print(f"[{CHROME}]No active space[/{CHROME}]\n")
                            continue
                        query = parts[2].strip() if len(parts) >= 3 else ""
                        if not query:
                            renderer.console.print(f"[{CHROME}]Usage: /space unlink-source <title-or-id>[/{CHROME}]\n")
                            continue
                        from ..services.storage import get_direct_space_source_links as _get_direct
                        from ..services.storage import (
                            unlink_source_from_space as _unlink_src,
                        )

                        linked = _get_direct(db, sp["id"])
                        match = None
                        # Exact match by ID (unique, no ambiguity)
                        for s in linked:
                            if s["id"] == query:
                                match = s
                                break
                        # Exact title match with disambiguation
                        if not match:
                            exact = [s for s in linked if s.get("title", "").lower() == query.lower()]
                            if len(exact) == 1:
                                match = exact[0]
                            elif len(exact) > 1:
                                renderer.console.print(f"[{CHROME}]Multiple sources named '{query}':[/{CHROME}]")
                                for c in exact[:10]:
                                    _ct = c.get("title", "Untitled")
                                    _ci = str(c["id"])[:8]
                                    renderer.console.print(f"  {_ct} [{MUTED}]{_ci}...[/{MUTED}]")
                                renderer.console.print(f"[{CHROME}]Use the source ID to disambiguate.[/{CHROME}]\n")
                                continue
                        # Partial title match with disambiguation
                        if not match:
                            candidates = [s for s in linked if query.lower() in s.get("title", "").lower()]
                            if len(candidates) == 1:
                                match = candidates[0]
                            elif len(candidates) > 1:
                                renderer.console.print(f"[{CHROME}]Multiple sources match '{query}':[/{CHROME}]")
                                for c in candidates[:10]:
                                    _ct = c.get("title", "Untitled")
                                    _ci = str(c["id"])[:8]
                                    renderer.console.print(f"  {_ct} [{MUTED}]{_ci}...[/{MUTED}]")
                                renderer.console.print(f"[{CHROME}]Be more specific or use the source ID.[/{CHROME}]\n")
                                continue
                        if not match:
                            renderer.render_error(f"Source '{query}' is not directly linked to space '{sp['name']}'.")
                            continue
                        _unlink_src(db, sp["id"], source_id=match["id"])
                        renderer.console.print(
                            f"[{_SUCCESS}]Unlinked '{match.get('title', 'Untitled')}'"
                            f" from space '{sp['name']}'[/{_SUCCESS}]\n"
                        )

                    else:
                        if _active_space[0]:
                            renderer.console.print(f"[{CHROME}]Active space: {_active_space[0]['name']}[/{CHROME}]")
                        renderer.console.print(
                            f"[{CHROME}]Usage: /space [list|show|switch|create|init|load|refresh|clear|"
                            f"clone|map|edit|export|sources|link-source|unlink-source][/{CHROME}]\n"
                        )
                    continue
                elif cmd in ("/packs", "/pack"):
                    parts = user_input.split(maxsplit=2)
                    sub = parts[1].lower() if len(parts) >= 2 else ""
                    if cmd == "/packs":
                        sub = "list"

                    if sub == "list" or (cmd == "/pack" and not sub):
                        installed = packs_service.list_packs(db)
                        if not installed:
                            renderer.console.print(
                                f"\n[{CHROME}]No packs installed."
                                f" Install from a directory: /pack install <path>[/{CHROME}]\n"
                            )
                            continue
                        renderer.console.print("\n[bold]Installed Packs:[/bold]")
                        for p in installed:
                            ns = p.get("namespace", "default")
                            desc = f" — {p['description']}" if p.get("description") else ""
                            renderer.console.print(
                                f"  @{ns}/{p['name']} v{p.get('version', '?')}"
                                f" ({p.get('artifact_count', 0)} artifacts){desc}"
                            )
                        renderer.console.print()

                    elif sub == "show":
                        ref = parts[2].strip() if len(parts) >= 3 else ""
                        if not ref:
                            renderer.console.print(f"[{CHROME}]Usage: /pack show <namespace/name>[/{CHROME}]\n")
                            continue
                        ns, _, name = ref.rpartition("/")
                        if not ns:
                            ns = "default"
                        pack_match = await _resolve_pack_interactive(db, ns, name)
                        if not pack_match:
                            renderer.console.print(f"[{CHROME}]Pack @{ns}/{name} not found.[/{CHROME}]\n")
                            continue
                        pack_info = packs_service.get_pack(db, pack_match["namespace"], pack_match["name"])
                        if not pack_info:
                            renderer.console.print(f"[{CHROME}]Pack @{ns}/{name} not found.[/{CHROME}]\n")
                            continue
                        renderer.console.print(f"\n[bold]@{ns}/{name}[/bold] v{pack_info.get('version', '?')}")
                        if pack_info.get("description"):
                            renderer.console.print(f"  {pack_info['description']}")
                        artifacts = pack_info.get("artifacts", [])
                        if artifacts:
                            renderer.console.print(f"\n  [bold]Artifacts ({len(artifacts)}):[/bold]")
                            for a in artifacts:
                                renderer.console.print(f"    {a.get('type', '?')}: {a.get('name', '?')}")
                        renderer.console.print()

                    elif sub == "install":
                        target = parts[2].strip() if len(parts) >= 3 else ""
                        if not target:
                            renderer.console.print(f"[{CHROME}]Usage: /pack install <path>[/{CHROME}]\n")
                            continue
                        pack_path = Path(target).expanduser().resolve()
                        manifest_path = pack_path / "pack.yaml"
                        if not manifest_path.exists():
                            renderer.console.print(f"[{CHROME}]No pack.yaml found in {pack_path}[/{CHROME}]\n")
                            continue
                        try:
                            manifest = packs_service.parse_manifest(manifest_path)
                            errors = packs_service.validate_manifest(manifest, pack_path)
                            if errors:
                                for err in errors:
                                    renderer.console.print(f"[{_ERROR}]  {err}[/{_ERROR}]")
                                continue
                            install_result: dict[str, Any] = packs_service.install_pack(db, manifest, pack_path)
                            action_word = "Updated" if install_result.get("action") == "updated" else "Installed"
                            renderer.console.print(
                                f"[{_SUCCESS}]{action_word}[/{_SUCCESS}] @{manifest.namespace}/{manifest.name}"
                                f" v{manifest.version} ({install_result.get('artifact_count', 0)} artifacts)"
                            )
                            _rebuild_pack_config()
                            if artifact_registry is not None:
                                artifact_registry.load_from_db(
                                    db,
                                    space_id=space["id"] if space else None,
                                    project_path=working_dir,
                                )
                                if skill_registry is not None:
                                    skill_registry.load_from_artifacts(artifact_registry)
                                _refresh_artifact_prompt()
                                _refresh_skill_tools()
                        except ValueError as exc:
                            renderer.console.print(f"[{_ERROR}]{exc}[/{_ERROR}]")
                        renderer.console.print()

                    elif sub == "remove":
                        ref = parts[2].strip() if len(parts) >= 3 else ""
                        if not ref:
                            renderer.console.print(f"[{CHROME}]Usage: /pack remove <namespace/name>[/{CHROME}]\n")
                            continue
                        ns, _, name = ref.rpartition("/")
                        if not ns:
                            ns = "default"
                        _pm = await _resolve_pack_interactive(db, ns, name)
                        if not _pm:
                            renderer.console.print(f"[{CHROME}]Pack @{ns}/{name} not found.[/{CHROME}]\n")
                            continue
                        removed = packs_service.remove_pack_by_id(db, _pm["id"])
                        if removed:
                            renderer.console.print(f"[{_SUCCESS}]Removed[/{_SUCCESS}] @{ns}/{name}\n")
                            _rebuild_pack_config()
                            if artifact_registry is not None:
                                artifact_registry.load_from_db(
                                    db,
                                    space_id=space["id"] if space else None,
                                    project_path=working_dir,
                                )
                                if skill_registry is not None:
                                    skill_registry.load_from_artifacts(artifact_registry)
                                _refresh_artifact_prompt()
                                _refresh_skill_tools()
                        else:
                            renderer.console.print(f"[{CHROME}]Pack @{ns}/{name} not found.[/{CHROME}]\n")

                    elif sub == "sources":
                        from ..services.pack_sources import list_cached_sources

                        sources_cfg = getattr(config, "pack_sources", []) or []
                        if not sources_cfg:
                            renderer.console.print(
                                f"\n[{CHROME}]No pack sources configured. Add one: /pack add-source <url>[/{CHROME}]\n"
                            )
                            continue
                        data_dir = config.app.data_dir
                        cached = list_cached_sources(data_dir)
                        cached_map = {c.url: c for c in cached}
                        renderer.console.print("\n[bold]Pack Sources:[/bold]")
                        for psc in sources_cfg:
                            url = getattr(psc, "url", None) or "?"
                            branch = getattr(psc, "branch", "main") or "main"
                            auto_attach = getattr(psc, "auto_attach", True)
                            cached_entry = cached_map.get(url)
                            status = (
                                f"[{_SUCCESS}]cached[/{_SUCCESS}] ({cached_entry.ref[:8]})"
                                if cached_entry
                                else "[yellow]not cloned[/yellow]"
                            )
                            attach_label = "auto-attach" if auto_attach else "manual attach"
                            renderer.console.print(f"  {url} ({branch}) — {status}, {attach_label}")
                        renderer.console.print()

                    elif sub == "refresh":
                        from ..services import pack_sources as ps_mod

                        sources_cfg = getattr(config, "pack_sources", []) or []
                        if not sources_cfg:
                            renderer.console.print(
                                f"[{CHROME}]No pack sources configured. Add one: /pack add-source <url>[/{CHROME}]\n"
                            )
                            continue
                        data_dir = config.app.data_dir
                        total_installed = 0
                        total_updated = 0
                        total_attached = 0
                        changed_pack_ids: list[str] = []
                        for psc in sources_cfg:
                            url = getattr(psc, "url", None) or "?"
                            branch = getattr(psc, "branch", "main") or "main"
                            renderer.console.print(f"  Refreshing {url}...")
                            src_result = ps_mod.ensure_source(url, branch, data_dir)
                            if not src_result.success:
                                renderer.console.print(f"  [{_ERROR}]Failed: {src_result.error}[/{_ERROR}]")
                                continue
                            if src_result.path:
                                from ..services.pack_refresh import install_from_source

                                ifs_result = install_from_source(
                                    db,
                                    src_result.path,
                                    auto_attach=getattr(psc, "auto_attach", True),
                                    priority=getattr(psc, "priority", 50),
                                )
                                total_installed += ifs_result.installed
                                total_updated += ifs_result.updated
                                total_attached += ifs_result.attached
                                changed_pack_ids.extend(ifs_result.changed_pack_ids)
                        parts_msg = f"{total_installed} installed, {total_updated} updated"
                        if total_attached:
                            parts_msg += f", {total_attached} attached"
                        renderer.console.print(f"[{_SUCCESS}]Done:[/{_SUCCESS}] {parts_msg}\n")
                        if total_installed > 0 or total_updated > 0 or total_attached > 0:
                            if _rebuild_pack_config():
                                if artifact_registry is not None:
                                    artifact_registry.load_from_db(db, space_id=space["id"] if space else None)
                                    if skill_registry is not None:
                                        skill_registry.load_from_artifacts(artifact_registry)
                                    _refresh_artifact_prompt()
                            else:
                                # Quarantine: detach changed packs, then rebuild
                                # from the clean attachment set
                                from ..services.pack_attachments import detach_pack as _q_detach

                                for pid in changed_pack_ids:
                                    try:
                                        _q_detach(db, pid)
                                    except Exception:
                                        pass
                                if changed_pack_ids:
                                    renderer.console.print(
                                        f"[yellow]Quarantined {len(changed_pack_ids)} pack(s) "
                                        "— detached until config issue is resolved.[/yellow]"
                                    )
                                # Second rebuild from the now-clean attachment set
                                _rebuild_pack_config()
                                if artifact_registry is not None:
                                    artifact_registry.load_from_db(db, space_id=space["id"] if space else None)
                                    if skill_registry is not None:
                                        skill_registry.load_from_artifacts(artifact_registry)
                                    _refresh_artifact_prompt()
                        if total_installed > 0 and total_attached == 0:
                            renderer.console.print(
                                f"[{MUTED}]Packs installed but not attached. Use /pack attach to activate.[/{MUTED}]\n"
                            )

                    elif sub == "add-source":
                        url = parts[2].strip() if len(parts) >= 3 else ""
                        if not url:
                            renderer.console.print(f"[{CHROME}]Usage: /pack add-source <git-url>[/{CHROME}]\n")
                            continue

                        from rich.markup import escape as rich_escape

                        from ..services.pack_sources import add_pack_source

                        add_result = add_pack_source(url)
                        if not add_result.ok:
                            renderer.console.print(f"[{_ERROR}]{rich_escape(add_result.message)}[/{_ERROR}]\n")
                            continue
                        if add_result.message:
                            renderer.console.print(f"[{CHROME}]{rich_escape(add_result.message)}[/{CHROME}]\n")
                            continue
                        renderer.console.print(f"[{_SUCCESS}]Added pack source:[/{_SUCCESS}] {rich_escape(url)}")
                        renderer.console.print(f"[{MUTED}]Run /pack refresh to clone and install packs.[/{MUTED}]\n")

                    elif sub == "attach":
                        _attach_rest = parts[2].strip() if len(parts) >= 3 else ""
                        _attach_project = "--project" in _attach_rest
                        ref = _attach_rest.replace("--project", "").strip()
                        if not ref:
                            renderer.console.print(
                                f"[{CHROME}]Usage: /pack attach <namespace/name> [--project][/{CHROME}]\n"
                            )
                            continue
                        ns, _, name = ref.rpartition("/")
                        if not ns:
                            ns = "default"

                        from rich.markup import escape as rich_escape

                        from ..services.pack_attachments import attach_pack

                        _pm = await _resolve_pack_interactive(db, ns, name, escape_markup=True)
                        if not _pm:
                            renderer.console.print(
                                f"[{CHROME}]Pack @{rich_escape(ns)}/{rich_escape(name)} not found.[/{CHROME}]\n"
                            )
                            continue
                        project_path = str(Path(working_dir)) if _attach_project else None
                        try:
                            attach_pack(db, _pm["id"], project_path=project_path)
                        except ValueError as exc:
                            renderer.console.print(f"[{_ERROR}]{rich_escape(str(exc))}[/{_ERROR}]\n")
                            continue
                        scope = "directory" if project_path else "global"
                        renderer.console.print(
                            f"[{_SUCCESS}]Attached[/{_SUCCESS}] @{rich_escape(ns)}/{rich_escape(name)} ({scope})\n"
                        )
                        if _rebuild_pack_config(
                            rollback_pack_id=_pm["id"],
                            rollback_project_path=project_path,
                            rollback_action="detach",
                        ):
                            if artifact_registry is not None:
                                artifact_registry.load_from_db(
                                    db,
                                    space_id=space["id"] if space else None,
                                    project_path=working_dir,
                                )
                                if skill_registry is not None:
                                    skill_registry.load_from_artifacts(artifact_registry)
                                _refresh_artifact_prompt()
                                _refresh_skill_tools()

                    elif sub == "detach":
                        _detach_rest = parts[2].strip() if len(parts) >= 3 else ""
                        _detach_project = "--project" in _detach_rest
                        ref = _detach_rest.replace("--project", "").strip()
                        if not ref:
                            renderer.console.print(
                                f"[{CHROME}]Usage: /pack detach <namespace/name> [--project][/{CHROME}]\n"
                            )
                            continue
                        ns, _, name = ref.rpartition("/")
                        if not ns:
                            ns = "default"

                        from rich.markup import escape as rich_escape

                        from ..services.pack_attachments import detach_pack

                        _pm = await _resolve_pack_interactive(db, ns, name, escape_markup=True)
                        if not _pm:
                            renderer.console.print(
                                f"[{CHROME}]Pack @{rich_escape(ns)}/{rich_escape(name)} not found.[/{CHROME}]\n"
                            )
                            continue
                        project_path = str(Path(working_dir)) if _detach_project else None
                        removed = detach_pack(db, _pm["id"], project_path=project_path)
                        if removed:
                            scope = "directory" if project_path else "global"
                            renderer.console.print(
                                f"[{_SUCCESS}]Detached[/{_SUCCESS}] @{rich_escape(ns)}/{rich_escape(name)} ({scope})\n"
                            )
                            if _rebuild_pack_config(
                                rollback_pack_id=_pm["id"],
                                rollback_project_path=project_path,
                                rollback_action="attach",
                            ):
                                if artifact_registry is not None:
                                    artifact_registry.load_from_db(
                                        db,
                                        space_id=space["id"] if space else None,
                                        project_path=working_dir,
                                    )
                                    if skill_registry is not None:
                                        skill_registry.load_from_artifacts(artifact_registry)
                                    _refresh_artifact_prompt()
                                    _refresh_skill_tools()
                        else:
                            renderer.console.print(
                                f"[yellow]Not attached:[/yellow] @{rich_escape(ns)}/{rich_escape(name)}\n"
                            )

                    elif sub == "update":
                        target = parts[2].strip() if len(parts) >= 3 else ""
                        if not target:
                            renderer.console.print(f"[{CHROME}]Usage: /pack update <path>[/{CHROME}]\n")
                            continue
                        pack_path = Path(target).expanduser().resolve()
                        manifest_path = pack_path / "pack.yaml"
                        if not manifest_path.exists():
                            renderer.console.print(f"[{CHROME}]No pack.yaml found in {pack_path}[/{CHROME}]\n")
                            continue
                        try:
                            manifest = packs_service.parse_manifest(manifest_path)
                            errors = packs_service.validate_manifest(manifest, pack_path)
                            if errors:
                                for err in errors:
                                    renderer.console.print(f"[{_ERROR}]  {err}[/{_ERROR}]")
                                continue
                            update_result: dict[str, Any] = packs_service.update_pack(db, manifest, pack_path)
                            renderer.console.print(
                                f"[{_SUCCESS}]Updated[/{_SUCCESS}] @{manifest.namespace}/{manifest.name}"
                                f" v{manifest.version} ({update_result.get('artifact_count', 0)} artifacts)"
                            )
                        except ValueError as exc:
                            renderer.console.print(f"[{_ERROR}]{exc}[/{_ERROR}]")
                        renderer.console.print()

                    else:
                        renderer.console.print(
                            f"[{CHROME}]Usage: /pack"
                            f" [list|show|install|update|remove|attach|detach|sources|refresh|add-source][/{CHROME}]\n"
                        )
                    continue
                elif cmd in ("/artifact", "/artifacts"):
                    from ..services import artifact_storage as _art_store
                    from ..services.artifacts import validate_fqn as _validate_fqn

                    parts = user_input.split(maxsplit=2)
                    sub = parts[1].lower() if len(parts) >= 2 else ""
                    if cmd == "/artifacts":
                        sub = "list"

                    if sub == "list" or not sub:
                        _atype = None
                        _asource = None
                        _attached_flag = False
                        # For /artifacts --type=X, flags are in parts[1]; for /artifact list --type=X, in parts[2]
                        if cmd == "/artifacts":
                            _rest = " ".join(parts[1:]) if len(parts) >= 2 else ""
                        else:
                            _rest = parts[2] if len(parts) >= 3 else ""
                        for _tok in _rest.split():
                            if _tok.startswith("--type="):
                                _atype = _tok.split("=", 1)[1]
                            elif _tok.startswith("--source="):
                                _asource = _tok.split("=", 1)[1]
                            elif _tok == "--attached":
                                _attached_flag = True
                        try:
                            arts = _art_store.list_artifacts(
                                db,
                                artifact_type=_atype,
                                source=_asource,
                                attached_only=_attached_flag,
                                space_id=space["id"] if space else None,
                                project_path=working_dir,
                            )
                        except ValueError as _ve:
                            renderer.console.print(f"[{_ERROR}]Invalid filter: {_ve}[/{_ERROR}]\n")
                            continue
                        if not arts:
                            renderer.console.print(f"[{CHROME}]No artifacts found.[/{CHROME}]\n")
                            continue
                        renderer.console.print("\n[bold]Artifacts:[/bold]")
                        for a in arts:
                            _po_badge = f" [{CHROME}][pack][/{CHROME}]" if _art_store.is_pack_owned(db, a["id"]) else ""
                            renderer.console.print(
                                f"  {a['fqn']}{_po_badge}  [{a.get('type', '?')}]  ({a.get('source', '?')})"
                            )
                        renderer.console.print()

                    elif sub == "show":
                        _fqn = parts[2].strip() if len(parts) >= 3 else ""
                        if not _fqn:
                            renderer.console.print(f"[{CHROME}]Usage: /artifact show <fqn>[/{CHROME}]\n")
                            continue
                        if not _validate_fqn(_fqn):
                            renderer.console.print(f"[{CHROME}]Invalid FQN format.[/{CHROME}]\n")
                            continue
                        art = _art_store.get_artifact_by_fqn(db, _fqn)
                        if not art:
                            renderer.console.print(f"[{CHROME}]Artifact not found.[/{CHROME}]\n")
                            continue
                        from rich.markup import escape as _art_esc

                        renderer.console.print(f"\n[bold]FQN:[/bold]       {_art_esc(art['fqn'])}")
                        renderer.console.print(f"[bold]Type:[/bold]      {_art_esc(art['type'])}")
                        renderer.console.print(f"[bold]Source:[/bold]    {_art_esc(art['source'])}")
                        renderer.console.print(f"[bold]Hash:[/bold]      {_art_esc(art['content_hash'])}")
                        renderer.console.print(f"[bold]Updated:[/bold]   {_art_esc(art.get('updated_at', ''))}")
                        renderer.console.print()
                        renderer.console.print("[bold]Content:[/bold]")
                        renderer.console.print(_art_esc(art["content"]))
                        renderer.console.print()

                    elif sub == "delete":
                        _fqn = parts[2].strip() if len(parts) >= 3 else ""
                        if not _fqn:
                            renderer.console.print(f"[{CHROME}]Usage: /artifact delete <fqn>[/{CHROME}]\n")
                            continue
                        if not _validate_fqn(_fqn):
                            renderer.console.print(f"[{CHROME}]Invalid FQN format.[/{CHROME}]\n")
                            continue
                        art = _art_store.get_artifact_by_fqn(db, _fqn)
                        if not art:
                            renderer.console.print(f"[{CHROME}]Artifact not found.[/{CHROME}]\n")
                            continue
                        if _art_store.is_pack_owned(db, art["id"]):
                            renderer.console.print(
                                f"[{_ERROR}]Cannot delete pack-owned artifact. Remove the pack instead.[/{_ERROR}]\n"
                            )
                            continue
                        _art_store.delete_artifact(db, art["id"])
                        renderer.console.print(f"[{_SUCCESS}]Deleted[/{_SUCCESS}] {_fqn}\n")

                    elif sub == "import":
                        renderer.console.print(
                            f"[{CHROME}]Use the CLI: aroom artifact import --skills|--instructions|--all[/{CHROME}]\n"
                        )

                    elif sub == "create":
                        renderer.console.print(
                            f"[{CHROME}]Use the CLI: aroom artifact create <type> <name>[/{CHROME}]\n"
                        )

                    else:
                        renderer.console.print(
                            f"[{CHROME}]Usage: /artifact {{list,show,delete,import,create}}[/{CHROME}]\n"
                        )
                    continue

                elif cmd in ("/spec", "/specs"):
                    await _handle_spec_command(
                        user_input,
                        cmd=cmd,
                        db=db,
                        config=config,
                        space=space,
                        working_dir=working_dir,
                    )
                    continue

                elif cmd == "/artifact-check":
                    from ..services import artifact_health

                    _proj_dir = Path(working_dir) if working_dir else None
                    _ahc_report = artifact_health.run_health_check(db, project_dir=_proj_dir)
                    renderer.console.print()
                    renderer.console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
                    renderer.console.print("[bold]  🏥 Artifact Health Check[/bold]")
                    renderer.console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
                    renderer.console.print()
                    renderer.console.print(
                        f"  📊 Loaded: {_ahc_report.artifact_count} artifacts from {_ahc_report.pack_count} packs"
                    )
                    renderer.console.print(
                        f"  📏 Total size: {_ahc_report.total_size_bytes:,} bytes"
                        f" (~{_ahc_report.estimated_tokens:,} tokens)"
                    )
                    renderer.console.print()
                    if not _ahc_report.issues:
                        renderer.console.print(f"[{_SUCCESS}]  ✅ No issues found[/{_SUCCESS}]\n")
                    else:
                        for _ahc_issue in _ahc_report.issues:
                            _icon = {"error": "❌", "warn": "⚠️", "info": "💡"}.get(_ahc_issue.severity.value, "•")
                            renderer.console.print(f"  {_icon} {_ahc_issue.message}")
                        renderer.console.print()
                        _parts_summary = []
                        if _ahc_report.error_count:
                            _parts_summary.append(f"❌ {_ahc_report.error_count} errors")
                        if _ahc_report.warn_count:
                            _parts_summary.append(f"⚠️ {_ahc_report.warn_count} warnings")
                        if _ahc_report.info_count:
                            _parts_summary.append(f"💡 {_ahc_report.info_count} suggestions")
                        renderer.console.print(f"  {' '.join(_parts_summary)}\n")
                    continue
                elif cmd == "/mcp":
                    parts = user_input.split()
                    if len(parts) == 1:
                        if mcp_manager:
                            renderer.render_mcp_status(mcp_manager.get_server_statuses())
                        else:
                            renderer.console.print(f"[{CHROME}]No MCP servers configured.[/{CHROME}]\n")
                    elif len(parts) >= 2 and parts[1].lower() == "status":
                        if not mcp_manager:
                            renderer.render_error("No MCP servers configured")
                            continue
                        if len(parts) >= 3:
                            renderer.render_mcp_server_detail(parts[2], mcp_manager.get_server_statuses(), mcp_manager)
                        else:
                            renderer.render_mcp_status(mcp_manager.get_server_statuses())
                    elif len(parts) >= 3:
                        action = parts[1].lower()
                        server_name = parts[2]
                        if not mcp_manager:
                            renderer.render_error("No MCP servers configured")
                            continue
                        try:
                            if action == "connect":
                                await mcp_manager.connect_server(server_name)
                                srv_status = mcp_manager.get_server_statuses().get(server_name, {})
                                if srv_status.get("status") == "connected":
                                    renderer.console.print(f"[{_SUCCESS}]Connected: {server_name}[/{_SUCCESS}]\n")
                                else:
                                    err = srv_status.get("error_message", "unknown error")
                                    renderer.render_error(f"Failed to connect '{server_name}': {err}")
                            elif action == "disconnect":
                                await mcp_manager.disconnect_server(server_name)
                                renderer.console.print(f"[{CHROME}]Disconnected: {server_name}[/{CHROME}]\n")
                            elif action == "reconnect":
                                await mcp_manager.reconnect_server(server_name)
                                srv_status = mcp_manager.get_server_statuses().get(server_name, {})
                                if srv_status.get("status") == "connected":
                                    renderer.console.print(f"[{_SUCCESS}]Reconnected: {server_name}[/{_SUCCESS}]\n")
                                else:
                                    err = srv_status.get("error_message", "unknown error")
                                    renderer.render_error(f"Failed to reconnect '{server_name}': {err}")
                            else:
                                renderer.render_error(
                                    f"Unknown action: {action}. Use connect, disconnect, reconnect, or status."
                                )
                                continue
                            _rebuild_tools()
                        except ValueError as e:
                            renderer.render_error(str(e))
                    else:
                        renderer.console.print(
                            f"[{CHROME}]Usage: /mcp [status [name]|connect|disconnect|reconnect <name>][/{CHROME}]\n"
                        )
                    continue
                elif cmd == "/config":
                    _cfg_parts = user_input.split()
                    _cfg_sub = _cfg_parts[1] if len(_cfg_parts) > 1 else ""
                    if _cfg_sub in ("list", "get", "set", "reset"):
                        # Power-user subcommands (no TUI)
                        _handle_config_command(
                            user_input,
                            config=config,
                            db=db,
                            active_space=_active_space[0],
                            working_dir=working_dir,
                            ai_service=ai_service,
                            toolbar_refresh=_toolbar_refresh,
                        )
                    else:
                        # Launch interactive TUI
                        from .config_tui import run_config_tui

                        await run_config_tui(
                            config=config,
                            db=db,
                            active_space=_active_space[0],
                            working_dir=working_dir,
                            ai_service=ai_service,
                            toolbar_refresh=_toolbar_refresh,
                        )
                    continue
                elif cmd == "/model":
                    parts = user_input.split(maxsplit=1)
                    if len(parts) < 2:
                        renderer.console.print(f"[{CHROME}]Current model: {current_model}[/{CHROME}]")
                        renderer.console.print(f"[{CHROME}]Usage: /model <model_name>[/{CHROME}]\n")
                        continue
                    new_model = parts[1].strip()
                    current_model = new_model
                    ai_service = create_ai_service(config.ai)
                    ai_service.config.model = new_model
                    _toolbar_refresh()
                    renderer.console.print(f"[{CHROME}]Switched to model: {new_model}[/{CHROME}]\n")
                    continue
                elif cmd == "/plan":
                    sub, inline_prompt = parse_plan_command(user_input)  # type: ignore[assignment]
                    if sub in ("on", "start"):
                        if _plan_active[0]:
                            renderer.console.print(f"[{CHROME}]Already in planning mode[/{CHROME}]\n")
                        else:
                            _apply_plan_mode(conv["id"])
                            renderer.console.print(
                                f"[yellow]Planning mode active.[/yellow] The AI will explore and write a plan.\n"
                                f"  [{MUTED}]Use /plan approve to execute, /plan off to exit.[/{MUTED}]\n"
                            )
                        continue
                    elif sub == "approve":
                        if not _plan_active[0]:
                            renderer.console.print(f"[{CHROME}]Not in planning mode[/{CHROME}]\n")
                        elif _plan_file[0] is None:
                            renderer.console.print(f"[{CHROME}]No plan file path set[/{CHROME}]\n")
                        else:
                            content = read_plan(_plan_file[0])
                            if not content:
                                renderer.console.print(
                                    f"[{CHROME}]No plan file found at {_plan_file[0]}[/{CHROME}]\n"
                                    f"  [{MUTED}]The AI needs to write the plan first.[/{MUTED}]\n"
                                )
                            else:
                                _exit_plan_mode(plan_content=content)
                                delete_plan(_plan_file[0])
                                # Parse implementation steps for live checklist
                                steps = parse_plan_steps(content)
                                _plan_checklist_steps.clear()
                                _plan_checklist_steps.extend(steps)
                                _plan_current_step[0] = 0
                                if steps:
                                    renderer.start_plan(steps)
                                renderer.console.print(
                                    f"[{_SUCCESS}]Plan approved.[/{_SUCCESS}] Full tools restored.\n"
                                    f"  [{MUTED}]Plan injected into context. "
                                    f"Send a message to start.[/{MUTED}]\n"
                                )
                        continue
                    elif sub == "status":
                        if _plan_active[0]:
                            renderer.console.print("[yellow]Planning mode: active[/yellow]")
                            if _plan_file[0]:
                                content = read_plan(_plan_file[0])
                                if content:
                                    renderer.console.print(f"  Plan file: {_plan_file[0]} ({len(content)} chars)")
                                    lines = content.splitlines()
                                    preview = lines[:20]
                                    renderer.console.print()
                                    for line in preview:
                                        renderer.console.print(f"  {line}")
                                    if len(lines) > 20:
                                        renderer.console.print(
                                            f"\n  [{MUTED}]... {len(lines) - 20} more lines[/{MUTED}]"
                                        )
                                else:
                                    renderer.console.print(
                                        f"  [{MUTED}]Plan file: {_plan_file[0]} (not yet written)[/{MUTED}]"
                                    )
                        else:
                            renderer.console.print(f"[{CHROME}]Planning mode: off[/{CHROME}]")
                        renderer.console.print()
                        continue
                    elif sub == "edit":
                        if not _plan_active[0]:
                            renderer.console.print(f"[{CHROME}]Not in planning mode[/{CHROME}]\n")
                            continue
                        if _plan_file[0] is None:
                            renderer.console.print(f"[{CHROME}]No plan file path set[/{CHROME}]\n")
                            continue
                        edit_args = user_input.split(maxsplit=2)
                        edit_instruction = edit_args[2] if len(edit_args) > 2 else ""
                        if edit_instruction:
                            user_input = f"Revise the plan based on this feedback: {edit_instruction}"
                        else:
                            content = read_plan(_plan_file[0])
                            if not content:
                                renderer.console.print(
                                    f"[{CHROME}]No plan file yet — the AI needs to write it first.[/{CHROME}]\n"
                                )
                                continue
                            import subprocess

                            editor = get_editor()
                            subprocess.call([editor, str(_plan_file[0])])
                            renderer.console.print(
                                "Plan updated. Use [bold]/plan status[/bold] to review, "
                                "[bold]/plan approve[/bold] to execute.\n"
                            )
                            continue
                    elif sub == "reject":
                        if not _plan_active[0]:
                            renderer.console.print(f"[{CHROME}]Not in planning mode[/{CHROME}]\n")
                            continue
                        reject_parts = user_input.split(maxsplit=2)
                        if len(reject_parts) < 3 or not reject_parts[2].strip():
                            renderer.console.print(f"[{CHROME}]Usage: /plan reject <reason for rejection>[/{CHROME}]\n")
                            continue
                        reason = reject_parts[2].strip()
                        user_input = (
                            f"The plan has been rejected. Reason: {reason}\n\n"
                            "Please revise the plan based on this feedback and write the updated "
                            "plan to the same plan file. Keep exploring if you need more information."
                        )
                    elif sub == "off":
                        if not _plan_active[0]:
                            renderer.console.print(f"[{CHROME}]Not in planning mode[/{CHROME}]\n")
                        else:
                            _exit_plan_mode()
                            renderer.console.print(f"[{CHROME}]Planning mode off. Full tools restored.[/{CHROME}]\n")
                        continue
                    else:
                        # Inline prompt mode: /plan <prompt text>
                        if not inline_prompt:
                            renderer.console.print(
                                f"[{CHROME}]Usage: /plan [on|approve|status|edit|reject|off]"
                                f" or /plan <prompt>[/{CHROME}]\n"
                            )
                            continue
                        if not _plan_active[0]:
                            _apply_plan_mode(conv["id"])
                            renderer.console.print("[yellow]Planning mode active.[/yellow]\n")
                        user_input = inline_prompt
                        # Fall through to agent loop — do NOT continue
                elif cmd == "/verbose":
                    new_v = renderer.cycle_verbosity()
                    renderer.render_verbosity_change(new_v)
                    continue
                elif cmd == "/detail":
                    renderer.render_tool_detail()
                    continue
                elif cmd == "/resume":
                    parts = user_input.split(maxsplit=1)
                    if len(parts) < 2:
                        picked = await _show_resume_picker()
                        if picked is None:
                            continue
                        loaded = storage.get_conversation(db, picked["id"])
                    else:
                        target = parts[1].strip()
                        loaded = _resolve_conversation(db, target)
                    if not loaded:
                        renderer.render_error("Conversation not found. Use /list to see conversations.")
                        continue
                    conv = loaded
                    working_dir = _restore_working_dir(conv, tool_registry, working_dir)
                    ai_messages, _ = _load_conversation_messages(db, conv["id"])
                    is_first_message = False
                    _show_resume_info(db, conv, ai_messages)
                    continue
                elif cmd == "/rewind":
                    stored = storage.list_messages(db, conv["id"])
                    if len(stored) < 2:
                        renderer.console.print(f"[{CHROME}]Not enough messages to rewind[/{CHROME}]\n")
                        continue

                    renderer.console.print("\n[bold]Messages:[/bold]")
                    for smsg in stored:
                        role_label = "You" if smsg["role"] == "user" else "AI"
                        msg_preview = smsg["content"][:80].replace("\n", " ")
                        if len(smsg["content"]) > 80:
                            msg_preview += "..."
                        renderer.console.print(f"  {smsg['position']}. [{role_label}] {msg_preview}")

                    renderer.console.print(
                        f"\n[{CHROME}]Enter position to rewind to (keep that message, delete after):[/{CHROME}]"
                    )
                    try:
                        pos_input = (await _timed_input("  Position: ")).strip()
                    except (EOFError, KeyboardInterrupt, TimeoutError):
                        renderer.console.print(f"[{CHROME}]Cancelled[/{CHROME}]\n")
                        continue

                    if not pos_input.isdigit():
                        renderer.render_error("Invalid position")
                        continue

                    target_pos = int(pos_input)
                    positions = [m["position"] for m in stored]
                    if target_pos not in positions:
                        renderer.render_error(f"Position {target_pos} not found")
                        continue

                    msgs_after = [m for m in stored if m["position"] > target_pos]
                    msg_ids_after = [m["id"] for m in msgs_after]
                    file_paths = collect_file_paths(db, msg_ids_after)

                    undo_files = False
                    if file_paths:
                        renderer.console.print(
                            f"\n[yellow]{len(file_paths)} file(s) were modified after this point:[/yellow]"
                        )
                        for fp in sorted(file_paths):
                            renderer.console.print(f"  - {fp}")
                        try:
                            answer = (await _timed_input("  Undo file changes? [y/N] ")).strip().lower()
                            undo_files = answer in ("y", "yes")
                        except (EOFError, KeyboardInterrupt, TimeoutError):
                            renderer.console.print(f"[{CHROME}]Cancelled[/{CHROME}]\n")
                            continue

                    rewind_result = await rewind_service(
                        db=db,
                        conversation_id=conv["id"],
                        to_position=target_pos,
                        undo_files=undo_files,
                        working_dir=working_dir,
                        vec_index=vec_manager.messages if vec_manager and vec_manager.enabled else None,
                    )

                    ai_messages, _ = _load_conversation_messages(db, conv["id"])

                    summary = f"Rewound {rewind_result.deleted_messages} message(s)"
                    if rewind_result.reverted_files:
                        summary += f", reverted {len(rewind_result.reverted_files)} file(s)"
                    if rewind_result.skipped_files:
                        summary += f", {len(rewind_result.skipped_files)} skipped"
                    renderer.console.print(f"[{CHROME}]{summary}[/{CHROME}]\n")

                    if rewind_result.skipped_files:
                        for sf in rewind_result.skipped_files:
                            renderer.console.print(f"  [yellow]Skipped: {sf}[/yellow]")
                        renderer.console.print()
                    continue

                elif cmd == "/clear":
                    active_sid = _active_space[0]["id"] if _active_space[0] else None
                    conv = storage.create_conversation(
                        db,
                        title="New Conversation",
                        conversation_type="chat",
                        working_dir=working_dir,
                        **id_kw,
                    )
                    if active_sid:
                        from ..services.space_storage import update_conversation_space

                        update_conversation_space(db, conv["id"], active_sid)
                    ai_messages = []
                    is_first_message = True
                    # Strip conversation-scoped additions from extra_system_prompt
                    # (preserves session-level content: canary tokens, codebase index, skill catalog)
                    extra_system_prompt = _strip_planning_prompt(extra_system_prompt)
                    extra_system_prompt = _strip_space_instructions(extra_system_prompt)
                    extra_system_prompt = re.sub(
                        r"\n*<approved_plan>.*?</approved_plan>", "", extra_system_prompt, flags=re.DOTALL
                    )
                    from ..services.rag import strip_rag_context

                    extra_system_prompt = strip_rag_context(extra_system_prompt)
                    _refresh_artifact_prompt()
                    if _plan_active[0]:
                        _apply_plan_mode(conv["id"])
                    renderer.console.clear()
                    renderer.console.print(f"[{CHROME}]Screen and message history cleared[/{CHROME}]\n")
                    continue

            # Check for skill invocation — preserve original for title generation
            original_user_input = user_input
            if skill_registry and user_input.startswith("/"):
                is_skill, skill_prompt = skill_registry.resolve_input(user_input)
                if is_skill:
                    user_input = skill_prompt

            # For note/document types, save message without AI response
            current_conv_type = conv.get("type", "chat")
            if current_conv_type in ("note", "document"):
                expanded = _expand_file_references(
                    user_input, working_dir, file_max_chars=config.cli.file_reference_max_chars
                )
                storage.create_message(db, conv["id"], "user", expanded, **id_kw)
                renderer.console.print(f"[{CHROME}]Entry added to '{conv.get('title', 'Untitled')}'[/{CHROME}]\n")
                if is_first_message:
                    is_first_message = False
                continue

            # Visual separation is handled by start_thinking() which writes
            # \n + Thinking... as a single atomic write to avoid a race with
            # prompt_toolkit's cursor teardown on the first message (#249).

            # Expand file references
            expanded = _expand_file_references(
                user_input, working_dir, file_max_chars=config.cli.file_reference_max_chars
            )

            # Auto-compact if approaching context limit (thresholds from config)
            token_estimate = _estimate_tokens(ai_messages)
            auto_compact_threshold = config.cli.context_auto_compact_tokens
            warn_threshold = config.cli.context_warn_tokens
            if token_estimate > auto_compact_threshold:
                renderer.console.print(
                    f"[yellow]Context approaching limit (~{token_estimate:,} tokens). Auto-compacting...[/yellow]"
                )
                await _compact_messages(ai_service, ai_messages, db, conv["id"])
            elif token_estimate > warn_threshold:
                renderer.console.print(
                    f"[yellow]Context: ~{token_estimate:,} tokens. Use /compact to free space.[/yellow]"
                )

            # Store user message
            storage.create_message(db, conv["id"], "user", expanded, **id_kw)
            ai_messages.append({"role": "user", "content": expanded})

            # Breathing room after user prompt echo
            renderer.render_newline()

            # RAG: retrieve relevant context from knowledge base
            _rag_chunks: list[Any] = []
            if config.rag.enabled and not _plan_active[0]:
                try:
                    from ..services.rag import format_rag_context, retrieve_context, strip_rag_context

                    # Always strip stale RAG context before attempting fresh retrieval,
                    # so we never send outdated context if this turn's retrieval fails.
                    extra_system_prompt = strip_rag_context(extra_system_prompt)

                    _rag_emb = await _get_rag_embedding_service()
                    _rag_mode = getattr(config.rag, "retrieval_mode", "dense")
                    _rag_uses_keyword = _rag_mode in ("keyword", "hybrid")
                    if _rag_emb or _rag_uses_keyword:
                        _rag_reranker = await _get_rag_reranker_service()
                        _rag_chunks, _rag_reason = await retrieve_context(
                            query=expanded,
                            db=db,
                            embedding_service=_rag_emb,
                            config=config.rag,
                            current_conversation_id=conv["id"],
                            space_id=conv.get("space_id"),
                            vec_manager=vec_manager,
                            reranker_service=_rag_reranker,
                            reranker_config=config.reranker,
                        )
                        if _rag_chunks:
                            extra_system_prompt += format_rag_context(_rag_chunks)
                            renderer.render_rag_status("ok", chunk_count=len(_rag_chunks))
                        else:
                            renderer.render_rag_status("no_results", reason=_rag_reason)
                    else:
                        renderer.render_rag_status("no_vec_support")
                except Exception:
                    logger.debug("RAG retrieval failed in CLI", exc_info=True)
                    renderer.render_rag_status("failed")

            # Build message queue for queued follow-ups during agent loop
            msg_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
            if skill_msg_queue_ref is not None:
                skill_msg_queue_ref[0] = msg_queue

            # Stream response
            renderer.clear_turn_history()
            renderer.clear_subagent_state()
            if subagent_limiter is not None:
                subagent_limiter.reset()
            if rate_limiter is not None:
                rate_limiter.reset()
            cancel_event = asyncio.Event()
            _current_cancel_event[0] = cancel_event
            if cancel_event_ref is not None:
                cancel_event_ref[0] = cancel_event
            loop = asyncio.get_event_loop()
            original_handler = signal.getsignal(signal.SIGINT)
            _add_signal_handler(loop, signal.SIGINT, cancel_event.set)

            agent_busy.set()

            thinking = False
            user_attempt = 0

            _budget_cfg = config.cli.usage.budgets

            async def _get_token_totals() -> tuple[int, int]:
                return (
                    storage.get_conversation_token_total(db, conv["id"]),
                    storage.get_daily_token_total(db),
                )

            try:
                response_token_count = 0
                total_elapsed = 0.0
                _pending_usage: dict | None = None

                # Drain any messages that arrived while we were setting up
                def _warn(cmd: str) -> None:
                    renderer.console.print(
                        f"[yellow]Command {cmd} ignored during streaming. Queue messages only.[/yellow]"
                    )

                await _drain_input_to_msg_queue(
                    input_queue,
                    msg_queue,
                    working_dir,
                    db,
                    conv["id"],
                    cancel_event,
                    exit_flag,
                    warn_callback=_warn,
                    identity_kwargs=id_kw,
                    file_max_chars=config.cli.file_reference_max_chars,
                    skill_registry=skill_registry,
                )

                while True:
                    user_attempt += 1
                    should_retry = False

                    async for event in run_agent_loop(
                        ai_service=ai_service,
                        messages=ai_messages,
                        tool_executor=tool_executor,
                        tools_openai=tools_openai,
                        cancel_event=cancel_event,
                        extra_system_prompt=extra_system_prompt,
                        max_iterations=config.cli.max_tool_iterations,
                        message_queue=msg_queue,
                        narration_cadence=ai_service.config.narration_cadence,
                        tool_output_max_chars=config.cli.tool_output_max_chars,
                        auto_plan_threshold=(
                            config.cli.planning.auto_threshold_tools
                            if not _plan_active[0] and config.cli.planning.auto_mode != "off"
                            else 0
                        ),
                        budget_config=_budget_cfg,
                        get_token_totals=_get_token_totals,
                        dlp_scanner=dlp_scanner,
                        injection_detector=injection_detector,
                        output_filter=output_filter,
                        max_consecutive_text_only=config.cli.max_consecutive_text_only,
                        max_line_repeats=config.cli.max_line_repeats,
                    ):
                        # Drain input_queue into msg_queue during streaming
                        await _drain_input_to_msg_queue(
                            input_queue,
                            msg_queue,
                            working_dir,
                            db,
                            conv["id"],
                            cancel_event,
                            exit_flag,
                            warn_callback=_warn,
                            identity_kwargs=id_kw,
                            file_max_chars=config.cli.file_reference_max_chars,
                            skill_registry=skill_registry,
                        )

                        if event.kind == "thinking":
                            if not thinking:
                                # Advance plan: if a step was in_progress, mark it complete
                                # and move to the next step
                                if _plan_checklist_steps and _plan_current_step[0] < len(_plan_checklist_steps):
                                    idx = _plan_current_step[0]
                                    if renderer.get_plan_steps() and idx < len(renderer.get_plan_steps()):
                                        step_state = renderer.get_plan_steps()[idx]
                                        if step_state.get("status") == "in_progress":
                                            renderer.update_plan_step(idx, "complete")
                                            _plan_current_step[0] = idx + 1
                                renderer.start_thinking(newline=True)
                                thinking = True
                        elif event.kind == "phase":
                            renderer.set_thinking_phase(event.data.get("phase", ""))
                        elif event.kind == "retrying":
                            renderer.set_retrying(event.data)
                        elif event.kind == "token":
                            if not thinking:
                                renderer.start_thinking(newline=True)
                                thinking = True
                            renderer.render_token(event.data["content"])
                            renderer.increment_thinking_tokens()
                            renderer.increment_streaming_chars(len(event.data.get("content", "")))
                            renderer.update_thinking()
                            enc = _get_tiktoken_encoding()
                            if enc:
                                response_token_count += len(enc.encode(event.data["content"], allowed_special="all"))
                            else:
                                response_token_count += max(1, len(event.data["content"]) // 4)
                            # Yield to event loop so the thinking ticker task can update (#775)
                            await asyncio.sleep(0)
                        elif event.kind == "tool_call_start":
                            if thinking:
                                total_elapsed += await renderer.stop_thinking()
                                thinking = False
                            # Advance plan checklist: mark current step as in_progress
                            if _plan_checklist_steps and _plan_current_step[0] < len(_plan_checklist_steps):
                                idx = _plan_current_step[0]
                                renderer.update_plan_step(idx, "in_progress")
                            renderer.render_tool_call_start(event.data["tool_name"], event.data["arguments"])
                        elif event.kind == "tool_call_end":
                            renderer.render_tool_call_end(
                                event.data["tool_name"], event.data["status"], event.data["output"]
                            )
                        elif event.kind == "auto_plan_suggest":
                            _auto_mode = config.cli.planning.auto_mode
                            if _auto_mode == "auto" and not _plan_active[0]:
                                if thinking:
                                    total_elapsed += await renderer.stop_thinking()
                                    thinking = False
                                renderer.console.print(
                                    "\n[yellow]Complex task detected "
                                    f"({event.data['tool_calls']} tool calls). "
                                    "Switching to planning mode...[/yellow]\n"
                                )
                                cancel_event.set()
                                _apply_plan_mode(conv["id"])
                            elif _auto_mode == "suggest" and not _plan_active[0]:
                                if thinking:
                                    total_elapsed += await renderer.stop_thinking()
                                    thinking = False
                                renderer.console.print(
                                    f"\n[yellow]This task looks complex "
                                    f"({event.data['tool_calls']} tool calls). "
                                    f"Consider using /plan for better results.[/yellow]\n"
                                )
                                renderer.start_thinking(newline=True)
                                thinking = True
                        elif event.kind == "usage":
                            _pending_usage = event.data
                        elif event.kind == "assistant_message":
                            if event.data["content"]:
                                new_msg = storage.create_message(
                                    db, conv["id"], "assistant", event.data["content"], **id_kw
                                )
                                if _pending_usage:
                                    storage.update_message_usage(
                                        db,
                                        new_msg["id"],
                                        _pending_usage.get("prompt_tokens", 0),
                                        _pending_usage.get("completion_tokens", 0),
                                        _pending_usage.get("total_tokens", 0),
                                        _pending_usage.get("model", ""),
                                    )
                                    if _pending_usage.get("estimated"):
                                        storage.merge_message_metadata(db, new_msg["id"], {"usage_estimated": True})
                                    _pending_usage = None
                        elif event.kind == "dlp_blocked":
                            if thinking:
                                total_elapsed += await renderer.stop_thinking(
                                    error_msg="Response blocked by DLP policy"
                                )
                                thinking = False
                            else:
                                renderer.render_error("Response blocked by DLP policy")
                        elif event.kind == "dlp_warning":
                            rules = ", ".join(event.data.get("matches", []))
                            renderer.render_error(f"DLP warning: sensitive data detected [{rules}]")
                        elif event.kind == "injection_detected":
                            action = event.data.get("action", "warn")
                            detail = event.data.get("detail", "prompt injection detected")
                            if action == "block":
                                renderer.render_error(f"Tool output blocked: {detail}")
                            else:
                                renderer.render_error(f"Injection warning: {detail}")
                        elif event.kind == "output_filter_blocked":
                            if thinking:
                                total_elapsed += await renderer.stop_thinking(
                                    error_msg="Response blocked by output content filter"
                                )
                                thinking = False
                            else:
                                renderer.render_error("Response blocked by output content filter")
                        elif event.kind == "output_filter_warning":
                            rules = ", ".join(event.data.get("matches", []))
                            renderer.render_error(f"Output filter warning: forbidden content detected [{rules}]")
                        elif event.kind == "queued_message":
                            if thinking:
                                total_elapsed += await renderer.stop_thinking()
                                thinking = False
                            renderer.save_turn_history()
                            renderer.render_newline()
                            renderer.render_response_end()
                            renderer.render_newline()
                            # Show styled separator for the queued user message
                            queued_content = event.data.get("content", "")
                            renderer.console.print(f"\n[{GOLD}]> {escape(queued_content[:200])}[/{GOLD}]")
                            renderer.render_newline()
                            renderer.clear_turn_history()
                            response_token_count = 0
                        elif event.kind == "error":
                            error_msg = event.data.get("message", "Unknown error")
                            retryable = event.data.get("retryable", False)
                            if thinking and retryable and user_attempt < config.cli.max_retries:
                                should_retry = await renderer.thinking_countdown(
                                    config.cli.retry_delay, cancel_event, error_msg
                                )
                                if should_retry and not cancel_event.is_set():
                                    cancel_event.clear()
                                    renderer.start_thinking()
                                else:
                                    total_elapsed += await renderer.stop_thinking(cancel_msg="cancelled")
                                    thinking = False
                            elif thinking and retryable and user_attempt >= config.cli.max_retries:
                                total_elapsed += await renderer.stop_thinking(
                                    error_msg=f"{error_msg} · {user_attempt} attempts failed"
                                )
                                thinking = False
                            elif thinking:
                                total_elapsed += await renderer.stop_thinking(error_msg=error_msg)
                                thinking = False
                            else:
                                renderer.render_error(error_msg)
                        elif event.kind == "budget_warning":
                            if thinking:
                                total_elapsed += await renderer.stop_thinking()
                                thinking = False
                            renderer.render_warning(event.data.get("message", "Token budget warning"))
                        elif event.kind == "done":
                            # Mark any in-progress plan step as complete
                            if _plan_checklist_steps and _plan_current_step[0] < len(_plan_checklist_steps):
                                idx = _plan_current_step[0]
                                if renderer.get_plan_steps() and idx < len(renderer.get_plan_steps()):
                                    step_state = renderer.get_plan_steps()[idx]
                                    if step_state.get("status") == "in_progress":
                                        renderer.update_plan_step(idx, "complete")
                            collapse = bool(_plan_checklist_steps)
                            if thinking and cancel_event.is_set():
                                total_elapsed += await renderer.stop_thinking(
                                    cancel_msg="cancelled", collapse_plan=collapse
                                )
                                thinking = False
                            elif thinking:
                                total_elapsed += await renderer.stop_thinking(collapse_plan=collapse)
                                thinking = False
                            # Clear plan checklist state after collapsing
                            if _plan_checklist_steps:
                                _plan_checklist_steps.clear()
                                _plan_current_step[0] = 0
                            if not cancel_event.is_set():
                                renderer.save_turn_history()
                                renderer.render_response_end()
                                if _rag_chunks:
                                    renderer.render_rag_sources(_rag_chunks)
                                renderer.render_newline()
                                context_tokens = _estimate_tokens(ai_messages)
                                renderer.render_context_footer(
                                    current_tokens=context_tokens,
                                    max_context=config.cli.model_context_window,
                                    auto_compact_threshold=config.cli.context_auto_compact_tokens,
                                    response_tokens=response_token_count,
                                    elapsed=total_elapsed,
                                )
                                invalidate_toolbar()
                                renderer.render_newline()
                                renderer.render_newline()

                    if not should_retry:
                        break

                # Generate title on first exchange (skip if user cancelled)
                if is_first_message:
                    is_first_message = False
                    if not cancel_event.is_set():
                        try:
                            title = await ai_service.generate_title(original_user_input)
                            storage.update_conversation_title(db, conv["id"], title)
                        except Exception:
                            pass

            except KeyboardInterrupt:
                if thinking:
                    renderer.stop_thinking_sync()
                renderer.render_response_end()
            finally:
                if thinking:
                    renderer.stop_thinking_sync()
                    thinking = False
                _cleanup_after_turn(cancel_event, agent_busy, msg_queue, ai_messages, _has_pending_work)
                _current_cancel_event[0] = None
                if cancel_event_ref is not None:
                    cancel_event_ref[0] = None
                cancel_event.set()
                _remove_signal_handler(loop, signal.SIGINT)
                if not _IS_WINDOWS:
                    signal.signal(signal.SIGINT, original_handler)

    renderer.set_tool_dedup(config.cli.tool_dedup)
    renderer.configure_thresholds(
        esc_hint_delay=config.cli.esc_hint_delay,
        stall_display=config.cli.stall_display_threshold,
        stall_warning=config.cli.stall_warning_threshold,
        throughput_threshold=config.cli.stall_throughput_threshold,
    )

    # -- Simple input loop --
    # Print deferred resume info before entering the main loop.
    if _pending_resume_info:
        _show_resume_info(db, conv, ai_messages)

    async def _collect_input_simple() -> None:
        """Collect user input via prompt_async and feed into input_queue."""
        from .layout import InputLexer

        while not exit_flag.is_set():
            invalidate_toolbar()
            try:
                user_input_raw = await session.prompt_async(
                    _prompt,
                    prompt_continuation=_continuation,
                    lexer=InputLexer(),
                )
            except (EOFError, KeyboardInterrupt):
                exit_flag.set()
                return

            if _exit_flag[0]:
                exit_flag.set()
                return

            _collapse_long_input(user_input_raw)
            text = user_input_raw.strip()
            if not text:
                continue

            if agent_busy.is_set():
                if input_queue.full():
                    renderer.console.print("[yellow]Queue full (max 10 messages)[/yellow]")
                    continue
                renderer.console.print(f"[{CHROME}]Message queued[/{CHROME}]")

            await input_queue.put(text)
            agent_busy.set()

    from prompt_toolkit.patch_stdout import patch_stdout as _patch_stdout

    with _patch_stdout(raw=True):
        renderer.use_stdout_console()
        input_task = asyncio.create_task(_collect_input_simple())
        runner_task = asyncio.create_task(_agent_runner())

        done_tasks, pending_tasks = await asyncio.wait({input_task, runner_task}, return_when=asyncio.FIRST_COMPLETED)
        # Surface exceptions from completed tasks so they aren't silently lost (#937)
        for t in done_tasks:
            try:
                t.result()
            except Exception:
                logger.exception("REPL task failed")
        exit_flag.set()
        for t in pending_tasks:
            t.cancel()
            try:
                await t
            except BaseException:
                pass

    # Show resume hint after exit
    if conv.get("id") and not is_first_message:
        resume_label = conv.get("slug") or conv["id"][:8]
        from rich.console import Console as _HintConsole

        _hint_console = _HintConsole(stderr=True)
        _hint_console.print(
            f"\n[{CHROME}]To resume this conversation:[/{CHROME}]"
            f"\n[{CHROME}]  aroom chat -c              [/{CHROME}][{MUTED}](continue last)[/{MUTED}]"
            f"\n[{CHROME}]  aroom chat -r {resume_label}[/{CHROME}][{MUTED}]"
            f" (this conversation)[/{MUTED}]\n"
        )


async def _compact_messages(
    ai_service: AIService,
    ai_messages: list[dict[str, Any]],
    db: Any,
    conversation_id: str,
) -> None:
    """Summarize conversation history to reduce context size."""
    if len(ai_messages) < 4:
        renderer.console.print(f"[{CHROME}]Not enough messages to compact[/{CHROME}]\n")
        return

    original_count = len(ai_messages)
    original_tokens = _estimate_tokens(ai_messages)

    history_text = _build_compaction_history(ai_messages)

    summary_prompt = (
        "Summarize the following conversation concisely, preserving:\n"
        "- Key decisions and conclusions\n"
        "- File paths that were read, written, or edited\n"
        "- Important code changes and their purpose\n"
        "- Which steps of any multi-step plan have been COMPLETED (tool_result SUCCESS) vs remaining\n"
        "- Current state of the task — what has been done and what is next\n"
        "- Any errors encountered and how they were resolved\n\n" + history_text
    )

    try:
        renderer.console.print(f"[{CHROME}]Generating summary...[/{CHROME}]")
        response = await ai_service.client.chat.completions.create(
            model=ai_service.config.model,
            messages=[{"role": "user", "content": summary_prompt}],
            max_completion_tokens=1000,
        )
        summary = response.choices[0].message.content or "Conversation summary unavailable."
    except Exception:
        renderer.render_error("Failed to generate summary")
        return

    ai_messages.clear()
    compact_note = (
        f"Previous conversation summary "
        f"(auto-compacted from {original_count} messages, "
        f"~{original_tokens:,} tokens):\n\n{summary}"
    )
    ai_messages.append({"role": "system", "content": compact_note})

    new_tokens = _estimate_tokens(ai_messages)
    renderer.render_compact_done(original_count, 1)
    renderer.console.print(f"  [{CHROME}]~{original_tokens:,} -> ~{new_tokens:,} tokens[/{CHROME}]\n")
