import argparse
import asyncio
import base64
import contextlib
import os
import re
import shlex
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any, Iterator

from rich.console import Console

from brokk_code.avante_config import configure_nvim_avante_acp_settings
from brokk_code.event_utils import is_failure_state, safe_data
from brokk_code.executor import (
    BUNDLED_EXECUTOR_VERSION,
    ExecutorError,
    ensure_jbang_ready,
    resolve_jbang_binary,
)
from brokk_code.git_utils import infer_github_repo_from_remote
from brokk_code.intellij_config import configure_intellij_acp_settings
from brokk_code.mcp_config import (
    configure_claude_code_mcp_settings,
    configure_codex_mcp_settings,
    install_codex_mcp_workspace_skill,
)
from brokk_code.mcp_launcher import run_mcp_server
from brokk_code.nvim_config import configure_nvim_codecompanion_acp_settings
from brokk_code.nvim_init_patch import wire_nvim_plugin_setup
from brokk_code.settings import Settings
from brokk_code.uv_utils import UvSetupError, ensure_uv_ready
from brokk_code.workspace import resolve_workspace_dir
from brokk_code.zed_config import ExistingBrokkCodeEntryError, configure_zed_acp_settings

REPO_COMPONENT_ALLOWLIST_REGEX = r"^[A-Za-z0-9_.-]+$"
_EXECUTOR_JAR_BASE_URL = "https://github.com/BrokkAi/brokk-releases/releases/download"
_HEADLESS_EXECUTOR_MAIN_CLASS = "ai.brokk.executor.HeadlessExecutorMain"
_MCP_SERVER_MAIN_CLASS = "ai.brokk.mcpserver.BrokkExternalMcpServer"
_JBANG_PREFETCH_TIMEOUT_SECONDS = 120.0


def _resolve_neovim_plugin(*, plugin: str | None) -> str:
    if plugin:
        return plugin
    if not sys.stdin.isatty():
        return "codecompanion"

    console = Console()
    console.print("Choose a Neovim plugin integration:")
    console.print("1) CodeCompanion (ACP adapter)")
    console.print("2) Avante (ACP provider)")
    choice = console.input("Selection [1/2] (default: 1): ").strip().lower()
    if choice in {"", "1", "codecompanion"}:
        return "codecompanion"
    if choice in {"2", "avante"}:
        return "avante"
    raise ValueError(f"Invalid plugin selection: '{choice}'")


def _validate_github_params(
    github_token: str | None,
    repo_owner: str | None,
    repo_name: str | None,
    command_name: str,
) -> None:
    if not github_token:
        print(f"Error: --github-token is required for {command_name}", file=sys.stderr)
        sys.exit(1)
    if not repo_owner:
        print(f"Error: --repo-owner is required for {command_name}", file=sys.stderr)
        sys.exit(1)
    if not repo_name:
        print(f"Error: --repo-name is required for {command_name}", file=sys.stderr)
        sys.exit(1)

    if not re.match(REPO_COMPONENT_ALLOWLIST_REGEX, repo_owner):
        print(
            f"Error: Invalid --repo-owner '{repo_owner}'. "
            + f"Repo owner must match {REPO_COMPONENT_ALLOWLIST_REGEX}",
            file=sys.stderr,
        )
        sys.exit(1)
    if not re.match(REPO_COMPONENT_ALLOWLIST_REGEX, repo_name):
        print(
            f"Error: Invalid --repo-name '{repo_name}'. "
            + f"Repo name must match {REPO_COMPONENT_ALLOWLIST_REGEX}",
            file=sys.stderr,
        )
        sys.exit(1)


def _build_executor_prefetch_command(
    *, jbang_binary: str, executor_version: str | None
) -> list[str]:
    version = executor_version or BUNDLED_EXECUTOR_VERSION
    jar_url = f"{_EXECUTOR_JAR_BASE_URL}/{version}/brokk-{version}.jar"
    return [
        jbang_binary,
        "--java",
        "21",
        "-R",
        "-Djava.awt.headless=true "
        + "-Dapple.awt.UIElement=true "
        + "--enable-native-access=ALL-UNNAMED",
        "--main",
        _HEADLESS_EXECUTOR_MAIN_CLASS,
        jar_url,
        "--help",
    ]


def _build_mcp_prefetch_command(*, jbang_binary: str) -> list[str]:
    version = BUNDLED_EXECUTOR_VERSION
    jar_url = f"{_EXECUTOR_JAR_BASE_URL}/{version}/brokk-{version}.jar"
    return [
        jbang_binary,
        "--java",
        "21",
        "-R",
        "-Djava.awt.headless=true -Dapple.awt.UIElement=true",
        "-R",
        "--enable-native-access=ALL-UNNAMED",
        "--main",
        _MCP_SERVER_MAIN_CLASS,
        jar_url,
        "--help",
    ]


def _build_install_prefetch_commands(
    *, target: str, jbang_binary: str, executor_version: str | None
) -> list[tuple[str, list[str]]]:
    if target == "mcp":
        return [("MCP runtime", _build_mcp_prefetch_command(jbang_binary=jbang_binary))]
    return [
        (
            "Executor runtime",
            _build_executor_prefetch_command(
                jbang_binary=jbang_binary, executor_version=executor_version
            ),
        )
    ]


def _run_jbang_prefetch_command(label: str, command: list[str]) -> None:
    proc = subprocess.run(
        command,
        capture_output=True,
        text=True,
        timeout=_JBANG_PREFETCH_TIMEOUT_SECONDS,
    )
    if proc.returncode != 0:
        detail = (proc.stderr or proc.stdout or "").strip()
        if detail:
            detail = f": {detail}"
        raise ExecutorError(f"{label} prefetch failed with code {proc.returncode}{detail}")


async def _spin_for_prefetch(commands: list[asyncio.Task[None]], *, label: str) -> None:
    if not commands or not sys.stdout.isatty():
        return

    spinner_frames = "|/-\\"
    frame_index = 0
    total = len(commands)
    while True:
        done = sum(1 for command in commands if command.done())
        if done >= total:
            break
        frame = spinner_frames[frame_index % len(spinner_frames)]
        frame_index += 1
        sys.stdout.write(f"\r{frame} {label} ({done}/{total})")
        sys.stdout.flush()
        await asyncio.sleep(0.12)

    clear_len = len(f"{label} ({total}/{total})") + 10
    sys.stdout.write(f"\r{' ' * clear_len}\r")
    print(f"{label} ({total}/{total})")


async def _run_install_prefetch_async(commands: list[tuple[str, list[str]]]) -> None:
    tasks: list[asyncio.Task[None]] = [
        asyncio.create_task(asyncio.to_thread(_run_jbang_prefetch_command, label, cmd))
        for label, cmd in commands
    ]
    spinner = asyncio.create_task(_spin_for_prefetch(tasks, label="Prefetching Brokk dependencies"))
    try:
        await asyncio.gather(*tasks)
    finally:
        await spinner


def _run_install_prefetch(commands: list[tuple[str, list[str]]]) -> None:
    if not commands:
        return
    asyncio.run(_run_install_prefetch_async(commands))


def _print_install_prefetch_commands(commands: list[tuple[str, list[str]]]) -> None:
    for _, command in commands:
        print(shlex.join(command))


def _add_common_runtime_args(parser: argparse.ArgumentParser) -> None:
    parser.add_argument(
        "--workspace",
        type=str,
        default=".",
        help="Path to the workspace directory (default: current directory)",
    )
    parser.add_argument(
        "--vendor",
        type=str,
        choices=["Default", "Anthropic", "Gemini", "OpenAI", "OpenAI - Codex"],
        default=None,
        help=(
            "Set 'Other Models' vendor preference (affects "
            + "internal roles like summarize/scan/commit). "
            "Use 'Default' to clear overrides."
        ),
    )
    parser.add_argument(
        "--jar",
        type=str,
        default=None,
        help="Path to brokk.jar (bypasses jbang; default: use jbang to launch)",
    )
    parser.add_argument(
        "--executor-version",
        type=str,
        default=None,
        help="Executor version to use (default: bundled version)",
    )
    parser.add_argument(
        "--executor-snapshot",
        action="store_true",
        default=True,
        help="[Ignored] Use jbang to manage versions",
    )
    parser.add_argument(
        "--executor-stable",
        action="store_false",
        dest="executor_snapshot",
        help="[Ignored] Use jbang to manage versions",
    )


@contextlib.contextmanager
def _temporary_issue_repo_checkout(
    *,
    repo_owner: str,
    repo_name: str,
    github_token: str,
    action_label: str,
) -> Iterator[Path]:
    temp_parent = Path(tempfile.mkdtemp(prefix="brokk-issue-repo-"))
    temp_workspace_dir = temp_parent / repo_name
    clone_url = f"https://github.com/{repo_owner}/{repo_name}.git"
    basic_auth = base64.b64encode(f"x-access-token:{github_token}".encode("utf-8")).decode("ascii")
    try:
        print(
            f"{action_label}: shallow cloning {repo_owner}/{repo_name} into {temp_workspace_dir}",
            flush=True,
        )
        subprocess.run(
            [
                "git",
                "-c",
                "credential.helper=",
                "-c",
                "credential.interactive=never",
                "-c",
                "core.askPass=",
                "-c",
                f"http.extraHeader=Authorization: Basic {basic_auth}",
                "clone",
                "--depth",
                "1",
                "--single-branch",
                clone_url,
                str(temp_workspace_dir),
            ],
            check=True,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            env={
                **os.environ,
                "GIT_TERMINAL_PROMPT": "0",
                "GIT_ASKPASS": "true",
                "SSH_ASKPASS": "true",
                "GCM_INTERACTIVE": "Never",
                "GIT_CONFIG_GLOBAL": os.devnull,
                "GIT_CONFIG_NOSYSTEM": "1",
            },
        )
        print(f"{action_label}: cloned repository at {temp_workspace_dir}", flush=True)
        yield temp_workspace_dir
    except subprocess.CalledProcessError as exc:
        detail = (exc.stderr or "").strip() or (exc.stdout or "").strip() or str(exc)
        print(f"Error: {action_label.lower()} clone failed: {detail}", file=sys.stderr)
        sys.exit(1)
    except OSError as exc:
        print(f"Error: {action_label.lower()} clone failed: {exc}", file=sys.stderr)
        sys.exit(1)
    finally:
        print(f"{action_label}: removing temporary checkout at {temp_workspace_dir}", flush=True)
        shutil.rmtree(temp_parent, ignore_errors=True)


def _build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Brokk Code - Interactive Terminal Interface")
    _add_common_runtime_args(parser)
    parser.add_argument(
        "--session",
        type=str,
        default=None,
        help="Attempt to resume a specific session by ID",
    )
    parser.add_argument(
        "--resume",
        action="store_true",
        dest="resume_session",
        default=False,
        help="Resume the last used session instead of creating a new one",
    )

    subparsers = parser.add_subparsers(dest="command")

    resume_parser = subparsers.add_parser("resume", help="Resume a specific session")
    _add_common_runtime_args(resume_parser)
    resume_parser.add_argument(
        "session_id",
        type=str,
        help="The ID of the session to resume",
    )

    sessions_parser = subparsers.add_parser("sessions", help="List and switch between sessions")
    _add_common_runtime_args(sessions_parser)

    acp_parser = subparsers.add_parser("acp", help="Run in ACP server mode")
    _add_common_runtime_args(acp_parser)
    acp_parser.add_argument(
        "--ide",
        choices=["intellij", "zed"],
        default=None,
        help=(
            "[Deprecated] Legacy IDE hint (no-op). "
            "ACP behavior is now derived from client capabilities and client_info."
        ),
    )

    mcp_parser = subparsers.add_parser("mcp", help="Run in MCP server mode")
    _add_common_runtime_args(mcp_parser)

    install_parser = subparsers.add_parser("install", help="Install integration settings")
    install_parser.add_argument(
        "target",
        choices=["zed", "intellij", "nvim", "neovim", "mcp"],
        help="Install target for integration settings",
    )
    install_parser.add_argument(
        "--plugin",
        choices=["codecompanion", "avante"],
        default=None,
        help=(
            "Neovim plugin integration to install (codecompanion or avante). "
            "Only used for install targets nvim/neovim; when omitted, an interactive "
            "selection menu is shown in TTY sessions."
        ),
    )
    install_parser.add_argument(
        "--force",
        action="store_true",
        default=False,
        help="Overwrite existing install configuration when supported",
    )
    install_parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        default=False,
        help="Print the JBang prefetch command(s) instead of executing them",
    )

    commit_parser = subparsers.add_parser("commit", help="Commit current changes")
    _add_common_runtime_args(commit_parser)
    commit_parser.add_argument(
        "message",
        type=str,
        nargs="?",
        default=None,
        help="Commit message (optional; if omitted, a message will be generated)",
    )

    issue_parser = subparsers.add_parser("issue", help="Manage GitHub issues")
    issue_subparsers = issue_parser.add_subparsers(dest="issue_command", required=True)

    # Note: 'issue create' maps to ISSUE_WRITER mode in the Java HeadlessExecCli.
    # It follows the same required parameters: repo owner/name, token, and prompt.
    issue_create_parser = issue_subparsers.add_parser("create", help="Create a new GitHub issue")
    _add_common_runtime_args(issue_create_parser)
    issue_create_parser.add_argument(
        "prompt",
        type=str,
        help="Description of the issue to create",
    )
    issue_create_parser.add_argument(
        "--github-token",
        type=str,
        default=Settings().get_github_token(),
        help="GitHub API token (from brokk.properties, GITHUB_TOKEN env var, or --github-token)",
    )
    issue_create_parser.add_argument(
        "--repo-owner",
        type=str,
        help="GitHub repository owner",
    )
    issue_create_parser.add_argument(
        "--repo-name",
        type=str,
        help="GitHub repository name",
    )
    # Default to a fast planner model for issue creation. Reasoning is disabled
    # explicitly in the headless submit path for this command.
    issue_create_parser.add_argument(
        "--planner-model",
        type=str,
        default="gemini-3-flash-preview",
        help="LLM model for planning (default: gemini-3-flash-preview)",
    )
    issue_create_parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        default=False,
        help="Show full headless executor output (events/tokens) for debugging",
    )

    issue_solve_parser = issue_subparsers.add_parser("solve", help="Fix an existing GitHub issue")
    _add_common_runtime_args(issue_solve_parser)
    issue_solve_parser.add_argument(
        "--issue-number",
        type=int,
        required=True,
        help="The GitHub issue number to solve",
    )
    issue_solve_parser.add_argument(
        "--github-token",
        type=str,
        default=Settings().get_github_token(),
        help="GitHub API token (from brokk.properties, GITHUB_TOKEN env var, or --github-token)",
    )
    issue_solve_parser.add_argument(
        "--repo-owner",
        type=str,
        help="GitHub repository owner",
    )
    issue_solve_parser.add_argument(
        "--repo-name",
        type=str,
        help="GitHub repository name",
    )
    issue_solve_parser.add_argument(
        "--planner-model",
        type=str,
        default="gpt-5.1",
        help="LLM model for planning (default: gpt-5.1)",
    )
    issue_solve_parser.add_argument(
        "--code-model",
        type=str,
        default="gemini-3-flash-preview",
        help="LLM model for code generation (default: gemini-3-flash-preview)",
    )
    issue_solve_parser.add_argument(
        "--planner-reasoning-level",
        type=str,
        default="medium",
        help="Reasoning level for planner model (default: medium)",
    )
    issue_solve_parser.add_argument(
        "--code-reasoning-level",
        type=str,
        default="disable",
        help="Reasoning level for code model (default: disable)",
    )
    issue_solve_parser.add_argument(
        "--skip-verification",
        action="store_true",
        default=False,
        help="Skip per-task and final verification steps",
    )
    issue_solve_parser.add_argument(
        "--max-issue-fix-attempts",
        type=int,
        default=None,
        help="Maximum iterations for final verification fix loop",
    )
    issue_solve_parser.add_argument(
        "--build-settings",
        type=str,
        default=None,
        help="JSON string of build settings overrides (matching executor expectations)",
    )
    issue_solve_parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        default=False,
        help="Show full headless executor output (events/tokens) for debugging",
    )

    # PR commands
    pr_parser = subparsers.add_parser("pr", help="Manage pull requests")
    pr_subparsers = pr_parser.add_subparsers(dest="pr_command", required=True)

    pr_create_parser = pr_subparsers.add_parser("create", help="Create a pull request")
    _add_common_runtime_args(pr_create_parser)
    pr_create_parser.add_argument(
        "--title",
        type=str,
        default=None,
        help="PR title (if omitted, will be suggested by LLM)",
    )
    pr_create_parser.add_argument(
        "--body",
        type=str,
        default=None,
        help="PR body/description (if omitted, will be suggested by LLM)",
    )
    pr_create_parser.add_argument(
        "--base",
        type=str,
        default=None,
        help="Target/base branch (defaults to repository default branch)",
    )
    pr_create_parser.add_argument(
        "--head",
        type=str,
        default=None,
        help="Source/head branch (defaults to current branch)",
    )
    pr_create_parser.add_argument(
        "--github-token",
        type=str,
        default=Settings().get_github_token(),
        help="GitHub API token (from brokk.properties, GITHUB_TOKEN env var, or --github-token)",
    )

    pr_review_parser = pr_subparsers.add_parser("review", help="Review a pull request")
    _add_common_runtime_args(pr_review_parser)
    pr_review_parser.add_argument(
        "--pr-number",
        type=int,
        required=True,
        help="The pull request number to review",
    )
    pr_review_parser.add_argument(
        "--github-token",
        type=str,
        default=Settings().get_github_token(),
        help="GitHub API token (from brokk.properties, GITHUB_TOKEN env var, or --github-token)",
    )
    pr_review_parser.add_argument(
        "--repo-owner",
        type=str,
        default=None,
        help="GitHub repository owner (inferred from git remote if omitted)",
    )
    pr_review_parser.add_argument(
        "--repo-name",
        type=str,
        default=None,
        help="GitHub repository name (inferred from git remote if omitted)",
    )
    pr_review_parser.add_argument(
        "--planner-model",
        type=str,
        default="gpt-5.1",
        help="LLM model for the review (default: gpt-5.1)",
    )
    pr_review_parser.add_argument(
        "--severity",
        type=str,
        default=None,
        choices=["CRITICAL", "HIGH", "MEDIUM", "LOW"],
        help="Minimum severity threshold for inline comments (default: HIGH)",
    )
    pr_review_parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        default=False,
        help="Show full headless executor output (events/tokens) for debugging",
    )

    version_parser = subparsers.add_parser("version", help="Print version information")
    _add_common_runtime_args(version_parser)

    return parser


async def run_commit(
    workspace_dir: Path,
    message: str | None = None,
    jar_path: Path | None = None,
    executor_version: str | None = None,
    executor_snapshot: bool = True,
    vendor: str | None = None,
) -> None:
    """Commits current changes via ExecutorManager."""
    from brokk_code.executor import ExecutorError, ExecutorManager

    manager = ExecutorManager(
        workspace_dir=workspace_dir,
        jar_path=jar_path,
        executor_version=executor_version,
        executor_snapshot=executor_snapshot,
        vendor=vendor,
        exit_on_stdin_eof=True,
    )

    try:
        await manager.start()
        await manager.create_session(name="Headless commit")
        if not await manager.wait_ready():
            print("Error: executor failed to become ready.", file=sys.stderr)
            sys.exit(1)

        result = await manager.commit_context(message)

        if result.get("status") == "no_changes":
            print("No uncommitted changes.")
        else:
            commit_id = result.get("commitId", "")
            first_line = result.get("firstLine", "")
            short_id = commit_id[:7] if commit_id else ""
            print(f"Committed {short_id}: {first_line}")

    except ExecutorError as e:
        print(f"Executor error: {e}", file=sys.stderr)
        sys.exit(1)
    except Exception as e:
        print(f"Unexpected error: {e}", file=sys.stderr)
        sys.exit(1)
    finally:
        await manager.stop()


async def run_pr_create(
    workspace_dir: Path,
    title: str | None = None,
    body: str | None = None,
    base_branch: str | None = None,
    head_branch: str | None = None,
    github_token: str | None = None,
    jar_path: Path | None = None,
    executor_version: str | None = None,
    executor_snapshot: bool = True,
    vendor: str | None = None,
) -> None:
    """Creates a pull request via ExecutorManager.

    If title or body is not provided, the executor will suggest them via LLM.
    """
    from brokk_code.executor import ExecutorError, ExecutorManager

    manager = ExecutorManager(
        workspace_dir=workspace_dir,
        jar_path=jar_path,
        executor_version=executor_version,
        executor_snapshot=executor_snapshot,
        vendor=vendor,
        exit_on_stdin_eof=True,
    )

    try:
        await manager.start()
        await manager.create_session(name="Headless PR create")
        if not await manager.wait_ready():
            print("Error: executor failed to become ready.", file=sys.stderr)
            sys.exit(1)

        # If title or body is missing, suggest them first
        effective_title = title
        effective_body = body
        if not effective_title or not effective_body:
            print("Suggesting PR title and description...", flush=True)
            suggestion = await manager.pr_suggest(
                source_branch=head_branch,
                target_branch=base_branch,
                github_token=github_token,
            )
            if not effective_title:
                effective_title = suggestion.get("title", "")
            if not effective_body:
                effective_body = suggestion.get("description", "")

            if not effective_title:
                print("Error: could not determine PR title.", file=sys.stderr)
                sys.exit(1)
            if not effective_body:
                effective_body = ""

        # Create the PR
        result = await manager.pr_create(
            title=effective_title,
            body=effective_body,
            source_branch=head_branch,
            target_branch=base_branch,
            github_token=github_token,
        )

        pr_url = result.get("url", "")
        if pr_url:
            print(f"Pull request created: {pr_url}")
        else:
            print("Pull request created.")

    except ExecutorError as e:
        print(f"Executor error: {e}", file=sys.stderr)
        sys.exit(1)
    except Exception as e:
        print(f"Unexpected error: {e}", file=sys.stderr)
        sys.exit(1)
    finally:
        await manager.stop()


async def run_pr_review_job(
    workspace_dir: Path,
    pr_number: int,
    github_token: str,
    repo_owner: str,
    repo_name: str,
    planner_model: str,
    severity_threshold: str | None = None,
    verbose: bool = False,
    jar_path: Path | None = None,
    executor_version: str | None = None,
    executor_snapshot: bool = True,
    vendor: str | None = None,
) -> None:
    """Runs a PR review job via ExecutorManager and streams events to stdout."""
    from brokk_code.executor import ExecutorError, ExecutorManager

    manager = ExecutorManager(
        workspace_dir=workspace_dir,
        jar_path=jar_path,
        executor_version=executor_version,
        executor_snapshot=executor_snapshot,
        vendor=vendor,
        exit_on_stdin_eof=True,
    )

    stage = "initializing"
    job_id: str | None = None
    last_state: str | None = None
    error_messages: list[str] = []
    spinner_index = 0
    spinner_active = False
    spinner_label = f"Reviewing PR #{pr_number}"
    spinner_frames = "|/-\\"
    spinner_enabled = sys.stdout.isatty() and not verbose

    def _extract_message(event: dict[str, Any]) -> str:
        raw = event.get("data")
        if isinstance(raw, str):
            return raw.strip()
        data = safe_data(event)
        for key in ("message", "text", "detail", "error"):
            value = data.get(key)
            if isinstance(value, str) and value.strip():
                return value.strip()
        for key in ("message", "text", "detail", "error"):
            value = event.get(key)
            if isinstance(value, str) and value.strip():
                return value.strip()
        return ""

    def _render_spinner() -> None:
        nonlocal spinner_index, spinner_active
        if not spinner_enabled:
            return
        frame = spinner_frames[spinner_index % len(spinner_frames)]
        spinner_index += 1
        sys.stdout.write(f"\r{spinner_label}... {frame}")
        sys.stdout.flush()
        spinner_active = True

    def _clear_spinner() -> None:
        nonlocal spinner_active
        if not spinner_enabled or not spinner_active:
            return
        width = len(spinner_label) + 20
        sys.stdout.write("\r" + (" " * width) + "\r")
        sys.stdout.flush()
        spinner_active = False

    def _update_shutdown_context() -> None:
        context_parts = ["mode=REVIEW", f"stage={stage}"]
        if job_id:
            context_parts.append(f"job_id={job_id}")
        if last_state:
            context_parts.append(f"last_state={last_state}")
        if error_messages:
            context_parts.append(f"last_error={error_messages[-1]}")
        manager.shutdown_context = ", ".join(context_parts)

    try:
        stage = "starting executor"
        _update_shutdown_context()
        await manager.start()

        stage = "creating executor session"
        _update_shutdown_context()
        await manager.create_session(name=f"PR Review #{pr_number}")

        stage = "waiting for executor readiness"
        _update_shutdown_context()
        if not await manager.wait_ready():
            print(
                f"Error during PR review job ({stage}): executor failed to become ready.",
                file=sys.stderr,
            )
            sys.exit(1)

        stage = "submitting job"
        _update_shutdown_context()
        _render_spinner()
        job_id = await manager.submit_pr_review_job(
            planner_model=planner_model,
            github_token=github_token,
            owner=repo_owner,
            repo=repo_name,
            pr_number=pr_number,
            severity_threshold=severity_threshold,
        )
        _update_shutdown_context()

        stage = "streaming job events"
        _update_shutdown_context()
        async for event in manager.stream_events(job_id):
            _render_spinner()
            event_type = event.get("type")
            data = safe_data(event)
            if event_type == "NOTIFICATION":
                message = _extract_message(event)
                if not message:
                    continue
                level = str(data.get("level", event.get("level", "INFO"))).strip().upper()
                if not verbose and level not in {"WARN", "WARNING", "ERROR"}:
                    continue
                _clear_spinner()
                print(f"[{level}] {message}")
            elif event_type == "STATE_CHANGE":
                last_state = str(data.get("state", event.get("state", "UNKNOWN")))
                _update_shutdown_context()
                if verbose:
                    _clear_spinner()
                    print(f"Job state: {last_state}")
            elif event_type in {"TOKEN", "LLM_TOKEN"}:
                text = str(data.get("token", event.get("text", "")))
                if verbose and text:
                    sys.stdout.write(text)
                    sys.stdout.flush()
                continue
            elif event_type == "ERROR":
                message = _extract_message(event) or "Unknown error event"
                error_messages.append(message)
                _update_shutdown_context()
                _clear_spinner()
                print(f"\nError event: {message}", file=sys.stderr)
            elif event_type == "COMMAND_RESULT":
                if verbose:
                    _clear_spinner()
                    print(f"[COMMAND_RESULT] {data}")
            elif event_type == "TOOL_OUTPUT":
                if verbose:
                    _clear_spinner()
                    print(f"[TOOL_OUTPUT] {data}")

        if is_failure_state(last_state or ""):
            _clear_spinner()
            detail = f" Last error: {error_messages[-1]}" if error_messages else ""
            print(
                f"\nPR review job ended with state {last_state}.{detail}",
                file=sys.stderr,
            )
            sys.exit(1)

        if error_messages and last_state != "COMPLETED":
            _clear_spinner()
            detail = f" Last error: {error_messages[-1]}"
            observed_state = last_state or "UNKNOWN"
            msg = f"\nPR review job ended with errors (last observed state: {observed_state})."
            print(f"{msg}{detail}", file=sys.stderr)
            sys.exit(1)

        _clear_spinner()
        print(f"PR #{pr_number} review complete.")

    except ExecutorError as e:
        _clear_spinner()
        _update_shutdown_context()
        print(f"Executor error during PR review job ({stage}): {e}", file=sys.stderr)
        sys.exit(1)
    except Exception as e:
        _clear_spinner()
        _update_shutdown_context()
        print(f"Unexpected error during PR review job ({stage}): {e}", file=sys.stderr)
        sys.exit(1)
    finally:
        _clear_spinner()
        _update_shutdown_context()
        await manager.stop()


async def run_headless_job(
    workspace_dir: Path,
    task_input: str,
    planner_model: str,
    mode: str,
    tags: dict[str, str],
    planner_reasoning_level: str | None = None,
    code_reasoning_level: str | None = None,
    code_model: str | None = None,
    skip_verification: bool | None = None,
    max_issue_fix_attempts: int | None = None,
    verbose: bool = False,
    jar_path: Path | None = None,
    executor_version: str | None = None,
    executor_snapshot: bool = True,
    vendor: str | None = None,
) -> None:
    """Runs a non-interactive job via ExecutorManager and streams events to stdout."""
    from brokk_code.executor import ExecutorError, ExecutorManager

    manager = ExecutorManager(
        workspace_dir=workspace_dir,
        jar_path=jar_path,
        executor_version=executor_version,
        executor_snapshot=executor_snapshot,
        vendor=vendor,
        exit_on_stdin_eof=True,
    )

    stage = "initializing"
    job_id: str | None = None
    last_state: str | None = None
    error_messages: list[str] = []
    created_issue_url: str | None = None
    token_url_scan_buffer = ""
    spinner_index = 0
    spinner_active = False
    spinner_label = "Creating issue"
    spinner_frames = "|/-\\"
    spinner_enabled = sys.stdout.isatty() and not verbose

    def _extract_message(event: dict[str, Any]) -> str:
        raw = event.get("data")
        if isinstance(raw, str):
            return raw.strip()
        data = safe_data(event)
        for key in ("message", "text", "detail", "error"):
            value = data.get(key)
            if isinstance(value, str) and value.strip():
                return value.strip()
        for key in ("message", "text", "detail", "error"):
            value = event.get(key)
            if isinstance(value, str) and value.strip():
                return value.strip()
        return ""

    def _record_issue_url(text: str) -> None:
        nonlocal created_issue_url, token_url_scan_buffer
        if created_issue_url or not text:
            return
        token_url_scan_buffer = (token_url_scan_buffer + text)[-8192:]
        match = re.search(r"https://github\.com/[^\s)\]>\"]+/issues/\d+", token_url_scan_buffer)
        if match:
            created_issue_url = match.group(0)

    def _record_issue_url_from_issue_writer_notification(message: str) -> None:
        # Java executor success path emits:
        # "ISSUE_WRITER: issue created <id> <htmlUrl>"
        if created_issue_url or not message:
            return
        normalized = message.strip()
        if not normalized.startswith("ISSUE_WRITER: issue created"):
            return
        _record_issue_url(normalized)

    def _record_issue_url_from_structured_issue_created(event: dict[str, Any]) -> None:
        if created_issue_url:
            return
        data = safe_data(event)
        issue_url = data.get("issueUrl")
        if isinstance(issue_url, str) and issue_url.strip():
            _record_issue_url(issue_url.strip())

    def _render_spinner() -> None:
        nonlocal spinner_index, spinner_active
        if mode not in {"ISSUE_WRITER", "ISSUE"} or not spinner_enabled:
            return
        frame = spinner_frames[spinner_index % len(spinner_frames)]
        spinner_index += 1
        label = "Solving issue" if mode == "ISSUE" else spinner_label
        sys.stdout.write(f"\r{label}... {frame}")
        sys.stdout.flush()
        spinner_active = True

    def _clear_spinner() -> None:
        nonlocal spinner_active
        if not spinner_enabled or not spinner_active:
            return
        # Calculate max possible width to clear (Issue # + long labels)
        width = len(spinner_label) + 20
        sys.stdout.write("\r" + (" " * width) + "\r")
        sys.stdout.flush()
        spinner_active = False

    def _update_shutdown_context() -> None:
        context_parts = [f"mode={mode}", f"stage={stage}"]
        if job_id:
            context_parts.append(f"job_id={job_id}")
        if last_state:
            context_parts.append(f"last_state={last_state}")
        if error_messages:
            context_parts.append(f"last_error={error_messages[-1]}")
        manager.shutdown_context = ", ".join(context_parts)

    try:
        stage = "starting executor"
        _update_shutdown_context()
        await manager.start()

        # Create session before wait_ready to satisfy Java-side readiness requirements
        stage = "creating executor session"
        _update_shutdown_context()
        await manager.create_session(name=f"Headless {mode}")

        stage = "waiting for executor readiness"
        _update_shutdown_context()
        if not await manager.wait_ready():
            print(
                f"Error during {mode} job ({stage}): executor failed to become ready.",
                file=sys.stderr,
            )
            sys.exit(1)

        stage = "submitting job"
        _update_shutdown_context()
        _render_spinner()
        job_id = await manager.submit_job(
            task_input=task_input,
            planner_model=planner_model,
            code_model=code_model,
            reasoning_level=planner_reasoning_level,
            reasoning_level_code=code_reasoning_level,
            mode=mode,
            tags=tags,
            skip_verification=skip_verification,
            max_issue_fix_attempts=max_issue_fix_attempts,
        )
        _update_shutdown_context()

        stage = "streaming job events"
        _update_shutdown_context()
        async for event in manager.stream_events(job_id):
            _render_spinner()
            event_type = event.get("type")
            data = safe_data(event)
            if event_type == "NOTIFICATION":
                message = _extract_message(event)
                if not message:
                    continue
                _record_issue_url_from_issue_writer_notification(message)
                _record_issue_url(message)
                level = str(data.get("level", event.get("level", "INFO"))).strip().upper()
                # Keep headless issue mode quiet by default: warnings/errors matter,
                # routine INFO/COST/CONFIRM notifications do not.
                if not verbose and level not in {"WARN", "WARNING", "ERROR"}:
                    continue
                _clear_spinner()
                print(f"[{level}] {message}")
            elif event_type == "STATE_CHANGE":
                last_state = str(data.get("state", event.get("state", "UNKNOWN")))
                _update_shutdown_context()
                if verbose:
                    _clear_spinner()
                    print(f"Job state: {last_state}")
            elif event_type in {"TOKEN", "LLM_TOKEN"}:
                text = str(data.get("token", event.get("text", "")))
                _record_issue_url(text)
                if verbose and text:
                    sys.stdout.write(text)
                    sys.stdout.flush()
                continue
            elif event_type == "ERROR":
                message = _extract_message(event) or "Unknown error event"
                _record_issue_url(message)
                error_messages.append(message)
                _update_shutdown_context()
                _clear_spinner()
                print(f"\nError event: {message}", file=sys.stderr)
            elif event_type == "ISSUE_CREATED":
                _record_issue_url_from_structured_issue_created(event)
                if verbose:
                    _clear_spinner()
                    print(f"[ISSUE_CREATED] {data}")
            elif event_type == "COMMAND_RESULT":
                if verbose:
                    _clear_spinner()
                    print(f"[COMMAND_RESULT] {data}")
                _record_issue_url(str(data.get("output", "")))
                _record_issue_url(str(data.get("resultText", "")))
                _record_issue_url(str(data.get("command", "")))
                _record_issue_url(str(data.get("exception", "")))
            elif event_type == "TOOL_OUTPUT":
                if verbose:
                    _clear_spinner()
                    print(f"[TOOL_OUTPUT] {data}")
                _record_issue_url(str(data.get("output", "")))
                _record_issue_url(str(data.get("text", "")))
                _record_issue_url(str(data.get("resultText", "")))

        if is_failure_state(last_state or ""):
            _clear_spinner()
            detail = f" Last error: {error_messages[-1]}" if error_messages else ""
            print(
                f"\n{mode} job ended with state {last_state}.{detail}",
                file=sys.stderr,
            )
            sys.exit(1)

        if error_messages and last_state != "COMPLETED":
            _clear_spinner()
            detail = f" Last error: {error_messages[-1]}"
            observed_state = last_state or "UNKNOWN"
            print(
                f"\n{mode} job ended with errors (last observed state: {observed_state}).{detail}",
                file=sys.stderr,
            )
            sys.exit(1)

        _clear_spinner()
        if mode == "ISSUE_WRITER":
            if created_issue_url:
                print(f"Issue created: {created_issue_url}")
            else:
                print("Issue created.")
        else:
            print("Job finished.")

    except ExecutorError as e:
        _clear_spinner()
        _update_shutdown_context()
        print(f"Executor error during {mode} job ({stage}): {e}", file=sys.stderr)
        sys.exit(1)
    except Exception as e:
        _clear_spinner()
        _update_shutdown_context()
        print(f"Unexpected error during {mode} job ({stage}): {e}", file=sys.stderr)
        sys.exit(1)
    finally:
        _clear_spinner()
        _update_shutdown_context()
        await manager.stop()


def main():
    parser = _build_parser()
    args = parser.parse_args()

    if args.command == "install":
        messages: list[str] = []
        prefetch_commands: list[tuple[str, list[str]]] = []
        try:
            if args.plugin and args.target not in {"nvim", "neovim"}:
                raise ValueError("--plugin is only valid for install targets nvim/neovim")
            uv_binary = ensure_uv_ready()
            uvx_command = str(Path(uv_binary).parent / "uvx")
            jbang_binary = resolve_jbang_binary() if args.verbose else ensure_jbang_ready()
            if args.verbose and not jbang_binary:
                jbang_binary = "jbang"
            if args.target == "zed":
                settings_path = configure_zed_acp_settings(
                    force=args.force, uvx_command=uvx_command
                )
                prefetch_commands = _build_install_prefetch_commands(
                    target=args.target,
                    jbang_binary=jbang_binary,
                    executor_version=args.executor_version,
                )
                messages = [f"Configured Zed ACP integration in {settings_path}"]
            elif args.target == "intellij":
                settings_path = configure_intellij_acp_settings(
                    force=args.force, uvx_command=uvx_command
                )
                prefetch_commands = _build_install_prefetch_commands(
                    target=args.target,
                    jbang_binary=jbang_binary,
                    executor_version=args.executor_version,
                )
                messages = [f"Configured IntelliJ ACP integration in {settings_path}"]
            elif args.target in {"nvim", "neovim"}:
                selected_plugin = _resolve_neovim_plugin(plugin=args.plugin)
                if selected_plugin == "codecompanion":
                    settings_path = configure_nvim_codecompanion_acp_settings(force=args.force)
                    patch_result = wire_nvim_plugin_setup(
                        plugin_repo="olimorris/codecompanion.nvim",
                        module_name="brokk.brokk_codecompanion",
                    )
                    messages = [
                        f"Configured Neovim CodeCompanion ACP adapter in {settings_path}",
                        "",
                        "What this is:",
                        "- CodeCompanion is a Neovim AI/chat plugin.",
                        "- Brokk runs as an ACP agent server (`brokk acp`).",
                        "- The generated file wires CodeCompanion -> Brokk over ACP.",
                        "",
                        "Next steps:",
                        "1. Install codecompanion.nvim in Neovim (plugin manager):",
                        "   https://github.com/olimorris/codecompanion.nvim",
                        "2. Read setup docs if needed:",
                        "   https://codecompanion.olimorris.dev/",
                    ]
                    if patch_result.status == "patched":
                        messages.extend(
                            [
                                f"3. Updated {patch_result.path} to load Brokk automatically.",
                            ]
                        )
                    elif patch_result.status == "already_configured":
                        messages.extend(
                            [
                                f"3. {patch_result.path} already loads Brokk.",
                            ]
                        )
                    else:
                        messages.extend(
                            [
                                "3. Auto-wiring skipped to avoid risky edits.",
                                "   Add this in your lazy.nvim spec for codecompanion.nvim:",
                                "   opts = function()",
                                "     return require('brokk.brokk_codecompanion')",
                                "   end",
                            ]
                        )
                    messages.extend(
                        [
                            "4. Use the Brokk adapter in CodeCompanion chat:",
                            "   :CodeCompanionChat adapter=brokk",
                            "   (the generated module sets brokk as the default chat adapter)",
                            "",
                            "Troubleshooting:",
                            "- If you see 'Copilot Adapter: No token found', ",
                            "CodeCompanion is still",
                            "  using its default adapter and your Brokk module is not loaded",
                            "  in setup yet.",
                            "",
                            "Note: this command writes adapter config and may patch init.lua only",
                            "when it",
                            "can do a conservative, safe edit. It does not install Neovim plugins.",
                        ]
                    )
                else:
                    settings_path = configure_nvim_avante_acp_settings(force=args.force)
                    patch_result = wire_nvim_plugin_setup(
                        plugin_repo="yetone/avante.nvim",
                        module_name="brokk.brokk_avante",
                    )
                    messages = [
                        f"Configured Neovim Avante ACP provider in {settings_path}",
                        "",
                        "What this is:",
                        "- Avante is a Neovim AI coding assistant plugin.",
                        "- Brokk runs as an ACP agent server (`brokk acp`).",
                        "- The generated file wires Avante -> Brokk over ACP.",
                        "",
                        "Next steps:",
                        "1. Install avante.nvim in Neovim (plugin manager):",
                        "   https://github.com/yetone/avante.nvim",
                    ]
                    if patch_result.status == "patched":
                        messages.extend(
                            [
                                f"2. Updated {patch_result.path} to load Brokk automatically.",
                            ]
                        )
                    elif patch_result.status == "already_configured":
                        messages.extend(
                            [
                                f"2. {patch_result.path} already loads Brokk.",
                            ]
                        )
                    else:
                        messages.extend(
                            [
                                "2. Auto-wiring skipped to avoid risky edits.",
                                "   Load the generated Brokk provider in your config:",
                                "   local brokk = require('brokk.brokk_avante')",
                                "   require('avante').setup(",
                                "       vim.tbl_deep_extend('force', brokk, {}))",
                            ]
                        )
                    messages.extend(
                        [
                            "3. Use Brokk by setting provider='brokk' in Avante setup",
                            "   (the generated module already does this)",
                            "",
                            "Note: this command writes provider config and may patch init.lua only",
                            "when it",
                            "can do a conservative, safe edit. It does not install Neovim plugins.",
                        ]
                    )
                prefetch_commands = _build_install_prefetch_commands(
                    target=args.target,
                    jbang_binary=jbang_binary,
                    executor_version=args.executor_version,
                )
            elif args.target == "mcp":
                claude_settings_path = configure_claude_code_mcp_settings(
                    force=args.force, uvx_command=uvx_command
                )
                codex_settings_path = configure_codex_mcp_settings(
                    force=args.force, uvx_command=uvx_command
                )
                codex_skill_path = install_codex_mcp_workspace_skill()
                prefetch_commands = _build_install_prefetch_commands(
                    target=args.target,
                    jbang_binary=jbang_binary,
                    executor_version=args.executor_version,
                )
                messages = [
                    f"Configured Claude Code MCP integration in {claude_settings_path}",
                    f"Configured Codex MCP integration in {codex_settings_path}",
                    f"Installed Codex MCP workspace skill in {codex_skill_path}",
                ]
            else:
                # Should not happen due to argparse choices
                raise ValueError(f"Unknown target: {args.target}")
            for message in messages:
                print(message)

            if args.verbose:
                _print_install_prefetch_commands(prefetch_commands)
                return

            _run_install_prefetch(prefetch_commands)
        except (ExistingBrokkCodeEntryError, ValueError) as exc:
            print(f"Error: {exc}", file=sys.stderr)
            sys.exit(1)
        except (ExecutorError, UvSetupError) as exc:
            print(f"Error: {exc}", file=sys.stderr)
            sys.exit(1)

        return

    if args.command == "version":
        from brokk_code import __version__

        print(f"brokk {__version__}")
        return

    workspace_path = Path(args.workspace).resolve()
    jar_path = Path(args.jar).resolve() if args.jar else None

    if args.command == "acp":
        try:
            from brokk_code.acp_server import run_acp_server
        except ImportError:
            print("Error: Could not import ACP server module.", file=sys.stderr)
            sys.exit(1)

        asyncio.run(
            run_acp_server(
                workspace_dir=workspace_path,
                jar_path=jar_path,
                executor_version=args.executor_version,
                executor_snapshot=args.executor_snapshot,
                vendor=args.vendor,
            )
        )
        return

    if args.command == "mcp":
        run_mcp_server(
            workspace_dir=workspace_path,
            jar_path=jar_path,
            executor_version=args.executor_version,
        )
        return

    try:
        from brokk_code.app import BrokkApp
    except ImportError:
        print("Error: Could not import BrokkApp. Is app.py missing?")
        sys.exit(1)

    session_id = getattr(args, "session", None)
    resume_session = getattr(args, "resume_session", False)
    pick_session = False

    if args.command == "resume":
        session_id = args.session_id
        resume_session = False  # Explicitly using the provided ID, not "last session" logic
    elif args.command == "sessions":
        # For explicit session picking, ignore any resume hints; the picker should run regardless.
        pick_session = True
        session_id = None
        resume_session = False

    if args.command == "commit":
        asyncio.run(
            run_commit(
                workspace_dir=workspace_path,
                message=args.message,
                jar_path=jar_path,
                executor_version=args.executor_version,
                executor_snapshot=args.executor_snapshot,
                vendor=args.vendor,
            )
        )
        return

    if args.command == "pr":
        if args.pr_command == "create":
            asyncio.run(
                run_pr_create(
                    workspace_dir=workspace_path,
                    title=args.title,
                    body=args.body,
                    base_branch=args.base,
                    head_branch=args.head,
                    github_token=args.github_token,
                    jar_path=jar_path,
                    executor_version=args.executor_version,
                    executor_snapshot=args.executor_snapshot,
                    vendor=args.vendor,
                )
            )
        if args.pr_command == "review":
            repo_owner = args.repo_owner
            repo_name = args.repo_name
            if not repo_owner or not repo_name:
                inferred_owner, inferred_repo = infer_github_repo_from_remote(workspace_path)
                if not repo_owner:
                    repo_owner = inferred_owner
                if not repo_name:
                    repo_name = inferred_repo

            _validate_github_params(args.github_token, repo_owner, repo_name, "pr review")

            asyncio.run(
                run_pr_review_job(
                    workspace_dir=workspace_path,
                    pr_number=args.pr_number,
                    github_token=args.github_token,
                    repo_owner=repo_owner,
                    repo_name=repo_name,
                    planner_model=args.planner_model,
                    severity_threshold=args.severity,
                    verbose=args.verbose,
                    jar_path=jar_path,
                    executor_version=args.executor_version,
                    executor_snapshot=args.executor_snapshot,
                    vendor=args.vendor,
                )
            )
        return

    if args.command == "issue":
        if args.issue_command == "create":
            _validate_github_params(
                args.github_token, args.repo_owner, args.repo_name, "issue create"
            )
            # Handle issue create mode by launching a non-interactive job
            tags = {
                "github_token": args.github_token,
                "repo_owner": args.repo_owner,
                "repo_name": args.repo_name,
            }

            with _temporary_issue_repo_checkout(
                repo_owner=args.repo_owner,
                repo_name=args.repo_name,
                github_token=args.github_token,
                action_label="Issue create",
            ) as issue_workspace_path:
                asyncio.run(
                    run_headless_job(
                        workspace_dir=issue_workspace_path,
                        task_input=args.prompt,
                        planner_model=args.planner_model,
                        planner_reasoning_level="disable",
                        verbose=args.verbose,
                        mode="ISSUE_WRITER",
                        tags=tags,
                        jar_path=jar_path,
                        executor_version=args.executor_version,
                        executor_snapshot=args.executor_snapshot,
                        vendor=args.vendor,
                    )
                )
            return

        if args.issue_command == "solve":
            _validate_github_params(
                args.github_token, args.repo_owner, args.repo_name, "issue solve"
            )
            tags = {
                "github_token": args.github_token,
                "repo_owner": args.repo_owner,
                "repo_name": args.repo_name,
                "issue_number": str(args.issue_number),
            }
            if args.build_settings:
                tags["build_settings"] = args.build_settings

            task_input = f"Resolve GitHub Issue #{args.issue_number}"

            with _temporary_issue_repo_checkout(
                repo_owner=args.repo_owner,
                repo_name=args.repo_name,
                github_token=args.github_token,
                action_label="Issue solve",
            ) as issue_workspace_path:
                asyncio.run(
                    run_headless_job(
                        workspace_dir=issue_workspace_path,
                        task_input=task_input,
                        planner_model=args.planner_model,
                        planner_reasoning_level=args.planner_reasoning_level,
                        code_model=args.code_model,
                        code_reasoning_level=args.code_reasoning_level,
                        skip_verification=args.skip_verification,
                        max_issue_fix_attempts=args.max_issue_fix_attempts,
                        verbose=args.verbose,
                        mode="ISSUE",
                        tags=tags,
                        jar_path=jar_path,
                        executor_version=args.executor_version,
                        executor_snapshot=args.executor_snapshot,
                        vendor=args.vendor,
                    )
                )
            return

    if not workspace_path.exists():
        print(f"Error: Workspace path does not exist: {workspace_path}")
        sys.exit(1)
    workspace_path = resolve_workspace_dir(workspace_path)

    app = BrokkApp(
        workspace_dir=workspace_path,
        jar_path=jar_path,
        executor_version=args.executor_version,
        executor_snapshot=args.executor_snapshot,
        session_id=session_id,
        resume_session=resume_session,
        pick_session=pick_session,
        vendor=args.vendor,
    )
    try:
        app.run()
    finally:
        # Best-effort cleanup for abnormal TUI exits (e.g., KeyboardInterrupt / runtime errors)
        # so the Java executor and Windows asyncio pipe transports do not linger.
        try:
            if app.executor.check_alive():
                asyncio.run(app.executor.stop())
        except Exception:
            pass

    get_renderables = getattr(app, "get_exit_transcript_renderables", None)
    transcript_renderables = get_renderables() if callable(get_renderables) else []
    get_transcript = getattr(app, "get_exit_transcript", None)
    transcript = get_transcript().strip() if callable(get_transcript) else ""
    if transcript_renderables:
        console = Console()
        for renderable in transcript_renderables:
            if renderable == "":
                console.print()
            else:
                console.print(renderable)
        console.print()
    elif transcript:
        print(transcript)
        print()

    # Print resume hint on exit if the session has tasks
    from brokk_code.session_persistence import (
        get_session_zip_resume_path,
        has_tasks,
        load_last_session_id,
    )

    last_id = load_last_session_id(workspace_path)
    if last_id:
        zip_path = get_session_zip_resume_path(workspace_path, last_id)
        if has_tasks(zip_path):
            print(f"brokk resume {last_id}")


if __name__ == "__main__":
    main()
