"""Runtime and concurrency helpers for the talk command.

Spec source: talk-prompt-toolkit-gate-review-fixes.extract-talk-session-lifecycle
Contains the long-running interactive loop and stdin/stream coordination.
"""

# @invar:allow file_size: talk runtime keeps interactive-loop state machine, stream synchronization, and child approval polling in one module
# @invar:allow file_size_warning: talk runtime approaching split threshold

from __future__ import annotations

import asyncio
import json
from contextlib import contextmanager
from collections.abc import Callable, Coroutine
from pathlib import Path
from typing import Any, cast

import httpx
from rich.console import Console
from rich.markup import escape as rich_escape
from returns.result import Success

from anima.core.lifecycle import is_terminal
from anima.core.schemas import AgentState
from anima.shell.cli_commands.talk_deps import TalkDeps
from anima.shell.cli_commands.talk_input import PROMPT_RICH_PREFIX, PROMPT_TEXT
from anima.shell.cli_commands.talk_runtime_support import (
    cleanup_stdin,
    read_stdin_loop,
    read_stdin_loop_console,
    render_thinking_indicator,
    supports_braille_spinner,
)
from anima.shell.cli_commands.talk_streaming import StreamSessionState


try:
    from prompt_toolkit import PromptSession
    from prompt_toolkit.patch_stdout import patch_stdout
except (
    ImportError
):  # pragma: no cover - exercised in environments without prompt_toolkit

    class _PromptSessionFallback:
        """Fallback session placeholder used when prompt_toolkit is unavailable."""

    PromptSession = _PromptSessionFallback

    @contextmanager
    def patch_stdout(*, raw: bool = False):
        yield None


# @invar:allow shell_result: Async concurrent wait loop for streaming, no Result needed
# @invar:allow function_size: Concurrent input/interact/stream dispatch with multiple branches
# @shell_complexity: Concurrent task dispatch with input classification and error handling
# @shell_orchestration: Concurrent wait loop coordinates shell tasks and interactive MCP command handling
async def wait_for_interact_or_input_or_stream(
    *,
    interact_task: "asyncio.Task[object]",
    stream_task: "asyncio.Task[tuple[bool, str | None]] | None",
    input_queue: "asyncio.Queue[str | None]",
    session: object,
    agent_id: str,
    json_output: bool,
    console: Console,
    was_awaiting_approval: bool,
    print_approval_prompt: Callable[[str | None], None],
    stdin_task: "asyncio.Task[None]",
    stream_chunk_rendered: asyncio.Event | None = None,
    deps: TalkDeps,
    render_thinking_indicator: Callable[[int], None] | None = None,
    persona_name: str = "agent",
) -> tuple[bool, bool, bool]:
    """Wait for interact completion, stream completion, or concurrent user input."""
    from anima.shell.cli_commands.talk_session import (
        clear_thinking_indicator,
        execute_stop_turn,
        handle_interact_result,
    )

    stream_was_active = stream_task is not None
    pending_messages: list[str] = []
    stop_requested = False

    def _emit_help() -> None:
        deps.print_help(json_output=json_output, console=console)

    async def _restore_pending_messages() -> None:
        queued_tail: list[str] = []
        saw_eof = False
        while True:
            try:
                queued_item = input_queue.get_nowait()
            except asyncio.QueueEmpty:
                break
            if queued_item is None:
                saw_eof = True
            else:
                queued_tail.append(queued_item)

        for msg in pending_messages:
            await input_queue.put(msg)
        for msg in queued_tail:
            await input_queue.put(msg)
        if saw_eof:
            await input_queue.put(None)

    while True:
        next_input_task: asyncio.Task[str | None] = asyncio.create_task(
            input_queue.get()
        )

        tasks: list[asyncio.Task[object]] = [interact_task, next_input_task]
        if stream_task is not None:
            tasks.append(stream_task)

        done, _pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)

        if interact_task in done:
            if next_input_task.done():
                queued_input = next_input_task.result()
                if queued_input is not None:
                    pending_messages.append(queued_input)
            else:
                next_input_task.cancel()
                try:
                    await next_input_task
                except asyncio.CancelledError:
                    pass

            if stream_task is not None and not stream_task.done():
                # Wait for stream to render at least one chunk OR complete.
                # This ensures suppress_display is evaluated accurately —
                # without this wait, interact can complete before stream
                # renders anything, causing duplicate output.
                if stream_chunk_rendered is not None and not stream_chunk_rendered.is_set():
                    chunk_wait = asyncio.create_task(stream_chunk_rendered.wait())
                    _done, _ = await asyncio.wait(
                        [chunk_wait, stream_task],
                        timeout=1.0,
                        return_when=asyncio.FIRST_COMPLETED,
                    )
                    if not chunk_wait.done():
                        chunk_wait.cancel()
                        try:
                            await chunk_wait
                        except asyncio.CancelledError:
                            pass

                if stream_task is not None and not stream_task.done():
                    try:
                        await asyncio.wait_for(asyncio.shield(stream_task), timeout=0.5)
                    except asyncio.TimeoutError:
                        stream_task.cancel()
                        try:
                            await stream_task
                        except asyncio.CancelledError:
                            pass

            try:
                mcp_result = interact_task.result()
            except asyncio.CancelledError:
                clear_thinking_indicator(
                    json_output=json_output,
                    console=console,
                    deps=deps,
                )
                await _restore_pending_messages()
                return (False, False, was_awaiting_approval)
            except Exception:
                clear_thinking_indicator(
                    json_output=json_output,
                    console=console,
                    deps=deps,
                )
                deps.print_inline_command_error(
                    json_output=json_output,
                    console=console,
                    code="INTERNAL",
                    numeric_code=10,
                    message="Agent interaction failed unexpectedly",
                    details={"agent_id": agent_id},
                )
                await _restore_pending_messages()
                return (False, True, was_awaiting_approval)
            suppress_display = (
                stream_chunk_rendered.is_set()
                if stream_chunk_rendered is not None
                else stream_was_active
            ) and not json_output
            had_error, was_awaiting_approval = handle_interact_result(
                mcp_result,
                agent_id,
                json_output,
                console,
                was_awaiting_approval,
                print_approval_prompt,
                deps=deps,
                suppress_display=suppress_display,
                persona_name=persona_name,
            )
            await _restore_pending_messages()
            return (False, had_error, was_awaiting_approval)

        if stream_task is not None and stream_task in done:
            next_input_task.cancel()
            try:
                await next_input_task
            except asyncio.CancelledError:
                pass
            stream_task = None
            continue

        input_msg = next_input_task.result()

        if input_msg and input_msg.strip().lower() == "/stop":
            if not stop_requested:
                await execute_stop_turn(session, json_output, console, deps=deps)
                stop_requested = True
                if not interact_task.done():
                    interact_task.cancel()
                    try:
                        await interact_task
                    except asyncio.CancelledError:
                        pass
                if stream_task is not None and not stream_task.done():
                    try:
                        await asyncio.wait_for(asyncio.shield(stream_task), timeout=0.5)
                    except asyncio.TimeoutError:
                        stream_task.cancel()
                        try:
                            await stream_task
                        except asyncio.CancelledError:
                            pass
                clear_thinking_indicator(
                    json_output=json_output,
                    console=console,
                    deps=deps,
                )
                await _restore_pending_messages()
                return (False, False, was_awaiting_approval)
            continue

        if input_msg and input_msg.strip().lower() == "/help":
            deps.emit_with_prompt_coordination(emit=_emit_help)
            continue

        if input_msg and input_msg.strip().lower() == "/quit":
            interact_task.cancel()
            if stream_task is not None:
                stream_task.cancel()
            try:
                await interact_task
            except asyncio.CancelledError:
                pass
            if stream_task is not None:
                try:
                    await stream_task
                except asyncio.CancelledError:
                    pass
            await cleanup_stdin(stdin_task)
            deps.print_detach(
                agent_id=agent_id,
                json_output=json_output,
                console=console,
            )
            return (True, False, was_awaiting_approval)

        if input_msg is not None and input_msg.strip():
            pending_messages.append(input_msg)
            if render_thinking_indicator is not None and not stop_requested:
                render_thinking_indicator(len(pending_messages))


# @invar:allow shell_result: Reads filesystem state for child polling, no Result needed at this level
# @shell_orchestration: Cross-process state polling for child agent approval propagation
# @shell_complexity: Child polling branches across state reads and log parsing for approval reasons
def _find_children_awaiting_approval(
    agent_id: str,
    deps: TalkDeps,
) -> list[tuple[str, str, str]]:
    """Find children in awaiting_approval state.

    Reads the main agent's state to get children[], then checks each child.
    For children in awaiting_approval, reads their log to find the approval reason.

    Args:
        agent_id: The parent agent instance ID.
        deps: Talk command dependencies for state/log access.

    Returns:
        List of (child_id, persona_name, reason) for children awaiting approval.
    """
    parent_state_result = deps.read_state(agent_id)
    if not isinstance(parent_state_result, Success):
        return []

    parent_state = parent_state_result.unwrap()
    result: list[tuple[str, str, str]] = []

    for child_id in parent_state.children:
        child_state_result = deps.read_state(child_id)
        if not isinstance(child_state_result, Success):
            continue

        child_state = child_state_result.unwrap()
        if child_state.state != AgentState.awaiting_approval:
            continue

        # Read the child's approval reason from its log
        reason = "no reason provided"
        log_result = deps.read_log(child_id)
        if isinstance(log_result, Success):
            for entry in reversed(log_result.unwrap()):
                if not isinstance(entry, dict) or entry.get("event") not in {
                    "approval_requested",
                    "request_approval",
                }:
                    continue
                details = entry.get("details")
                if isinstance(details, dict):
                    log_reason = details.get("reason")
                    if isinstance(log_reason, str) and log_reason.strip():
                        reason = log_reason.strip()
                        break

        result.append((child_id, child_state.persona_name, reason))

    return result


# @invar:allow shell_result: Async polling helper returns optional string, not Result
# @shell_complexity: Post-approval polling branches across MCP responses and state transitions
# @shell_orchestration: Polls agent logs for response text after approval resumes processing
async def _poll_post_approval_response(
    *,
    session: object,
    agent_id: str,
    deps: TalkDeps,
    timeout_seconds: float = 30.0,
    poll_interval: float = 0.5,
) -> str | None:
    """Poll agent logs for a response event after approval resumes processing.

    Returns the response_text if found within timeout, else None.
    """
    import time

    deadline = time.monotonic() + timeout_seconds
    # Remember current log count so we only look at new entries
    session_any: Any = session
    try:
        initial_logs = await session_any.call_tool("anima.logs", {})
        first_content = getattr(initial_logs, "content", None)
        if first_content:
            first_text = getattr(first_content[0], "text", None)
            if isinstance(first_text, str):
                initial_data = json.loads(first_text)
                initial_count = initial_data.get("count", 0)
            else:
                initial_count = 0
        else:
            initial_count = 0
    except Exception:
        initial_count = 0

    while time.monotonic() < deadline:
        # Check for terminal or awaiting_approval state
        state_result = deps.read_state(agent_id)
        if isinstance(state_result, Success):
            st = state_result.unwrap().state
            if is_terminal(st) or st == AgentState.awaiting_approval:
                break

        try:
            logs_result = await session_any.call_tool(
                "anima.logs", {"tail": 5}
            )
            log_content = getattr(logs_result, "content", None)
            if log_content:
                log_text = getattr(log_content[0], "text", None)
                if isinstance(log_text, str):
                    log_data = json.loads(log_text)
                    entries = log_data.get("entries", [])
                    # Look for a response event newer than initial
                    for entry in reversed(entries):
                        if entry.get("event") == "response":
                            details = entry.get("details", {})
                            response_text = details.get("response_text")
                            if response_text:
                                return response_text
        except Exception:
            pass

        # Also check if agent is no longer busy (entered daemon poll loop)
        try:
            status_result = await session_any.call_tool(
                "anima.status", {"agent_id": agent_id}
            )
            status_content = getattr(status_result, "content", None)
            if status_content:
                status_text = getattr(status_content[0], "text", None)
                if isinstance(status_text, str):
                    status_data = json.loads(status_text)
                    current_count = status_data.get("count", initial_count)
                    if current_count > initial_count:
                        # New logs appeared, check again
                        continue
        except Exception:
            pass

        await asyncio.sleep(poll_interval)

    # Last attempt to get the response
    try:
        logs_result = await session_any.call_tool("anima.logs", {"tail": 3})
        log_content = getattr(logs_result, "content", None)
        if log_content:
            log_text = getattr(log_content[0], "text", None)
            if isinstance(log_text, str):
                log_data = json.loads(log_text)
                for entry in reversed(log_data.get("entries", [])):
                    if entry.get("event") == "response":
                        return entry.get("details", {}).get("response_text")
    except Exception:
        pass

    return None


# @shell_complexity: Async MCP session with concurrent stdin handling
# @invar:allow function_size: Interactive session loop with concurrent input
async def run_talk_session(
    *,
    socket_path: Path,
    agent_id: str,
    json_output: bool,
    console: Console,
    deps: TalkDeps,
    persona_name: str = "agent",
) -> None:
    """Run the interactive MCP session for the talk command."""
    from anima.shell.cli_commands.talk_session import handle_inline_slash_command
    from anima.shell.cli_commands.talk_session import handle_inline_approval_via_mcp
    from anima.shell.cli_commands.talk_session import handle_inline_switch
    from anima.shell.cli_commands.talk_session import latest_approval_reason

    from mcp import ClientSession
    from mcp.client.streamable_http import streamable_http_client

    stdin_task: asyncio.Task[None] | None = None
    prompt_session_obj = deps.create_prompt_session()
    prompt_session = prompt_session_obj
    prompt_session_renders_input = isinstance(prompt_session_obj, PromptSession)
    loop = asyncio.get_running_loop()
    loop.set_exception_handler(deps.suppress_invalid_state_error)

    console_input = console.input
    uses_default_console_input = (
        getattr(console_input, "__self__", None) is console
        and getattr(console_input, "__func__", None) is Console.input
    )

    stream_state = StreamSessionState(streaming_supported=None)
    supports_braille = supports_braille_spinner(console)
    prompt_state = {"hidden": False}
    prompt_resume_event = asyncio.Event()
    prompt_resume_event.set()

    def _current_prompt_message() -> str | None:
        return "" if prompt_state["hidden"] else PROMPT_TEXT

    def _current_console_prompt() -> str:
        return "" if prompt_state["hidden"] else PROMPT_RICH_PREFIX

    def _start_stdin_reader() -> asyncio.Task[None]:
        if uses_default_console_input:
            return asyncio.create_task(
                read_stdin_loop(
                    prompt_session=prompt_session,
                    input_queue=input_queue,
                    read_input=deps.read_input,
                    prompt_message_provider=_current_prompt_message,
                    resume_event=prompt_resume_event,
                )
            )
        return asyncio.create_task(
            read_stdin_loop_console(
                console=console,
                input_queue=input_queue,
                prompt_rich_prefix=PROMPT_RICH_PREFIX,
                prompt_text_provider=_current_console_prompt,
                resume_event=prompt_resume_event,
            )
        )

    try:
        async with httpx.AsyncClient(
            transport=httpx.AsyncHTTPTransport(uds=str(socket_path)),
            timeout=httpx.Timeout(30.0, read=60.0),
        ) as client:
            async with streamable_http_client(
                "http://localhost/mcp", http_client=client
            ) as (read_stream, write_stream, _get_session_id):
                async with ClientSession(read_stream, write_stream) as session:
                    await session.initialize()
                    was_awaiting_approval = False
                    _prompted_children: set[str] = set()

                    def _print_approval_prompt(reason: str | None = None) -> None:
                        from anima.shell.cli_commands.talk_display import render_system_prompt

                        resolved_reason = reason or latest_approval_reason(
                            agent_id,
                            deps=deps,
                        )
                        prompt_reason = (
                            resolved_reason.strip()
                            if isinstance(resolved_reason, str)
                            and resolved_reason.strip()
                            else "no reason provided"
                        )
                        render_system_prompt(
                            console,
                            f"Agent requests approval:\n{prompt_reason}",
                            ["/approve", "/reject"],
                        )

                    input_queue: asyncio.Queue[str | None] = asyncio.Queue()
                    # Keep prompt_toolkit reader for the default console/input path.
                    # When console.input is monkeypatched/aliased, route through the
                    # simpler fallback loop so tests and custom consoles stay stable.
                    stdin_task = _start_stdin_reader()

                    with patch_stdout(raw=True):
                        while True:
                            try:
                                current_state_result = deps.read_state(agent_id)
                                if isinstance(current_state_result, Success):
                                    current_agent_state = (
                                        current_state_result.unwrap().state
                                    )
                                    if is_terminal(current_agent_state):
                                        await cleanup_stdin(stdin_task)
                                        if json_output:
                                            console.file.write(
                                                json.dumps(
                                                    {
                                                        "event": "agent_terminal",
                                                        "agent_id": agent_id,
                                                        "state": current_agent_state.value,
                                                    }
                                                )
                                                + "\n"
                                            )
                                        else:
                                            from anima.shell.cli_commands.talk_display import render_system_prompt

                                            render_system_prompt(
                                                console,
                                                f"Agent reached terminal state ({current_agent_state.value}).",
                                            )
                                        break
                                    is_awaiting_now = (
                                        current_agent_state
                                        == AgentState.awaiting_approval
                                    )
                                    if (
                                        is_awaiting_now
                                        and not was_awaiting_approval
                                        and not json_output
                                    ):
                                        _print_approval_prompt()
                                    was_awaiting_approval = is_awaiting_now

                                    # Check children for approval requests
                                    if current_agent_state == AgentState.running:
                                        children_awaiting = _find_children_awaiting_approval(
                                            agent_id, deps
                                        )
                                        for child_id, child_persona, child_reason in children_awaiting:
                                            if child_id not in _prompted_children:
                                                _prompted_children.add(child_id)
                                                if json_output:
                                                    console.file.write(
                                                        json.dumps(
                                                            {
                                                                "event": "child_approval_requested",
                                                                "child_id": child_id,
                                                                "persona": child_persona,
                                                                "reason": child_reason,
                                                            }
                                                        )
                                                        + "\n"
                                                    )
                                                else:
                                                    from anima.shell.cli_commands.talk_display import render_system_prompt

                                                    render_system_prompt(
                                                        console,
                                                        f"{child_persona} requests approval (subtask):\n{child_reason}",
                                                        [f"/approve {child_id[:8]}", f"/reject {child_id[:8]}"],
                                                    )

                                try:
                                    message = await asyncio.wait_for(
                                        input_queue.get(), timeout=0.5
                                    )
                                except TimeoutError:
                                    continue
                                if message is None:
                                    await cleanup_stdin(stdin_task)
                                    deps.print_detach(
                                        agent_id=agent_id,
                                        json_output=json_output,
                                        console=console,
                                    )
                                    break

                                if message.strip().lower() == "/quit":
                                    await cleanup_stdin(stdin_task)
                                    deps.print_detach(
                                        agent_id=agent_id,
                                        json_output=json_output,
                                        console=console,
                                    )
                                    break

                                if message.strip().lower() == "/help":
                                    deps.print_help(
                                        json_output=json_output, console=console
                                    )
                                    prompt_state["hidden"] = False
                                    prompt_resume_event.set()
                                    continue

                                inline_command, _ = deps.parse_slash_command(message)
                                if inline_command == "stop":
                                    from anima.shell.cli_commands.talk_session import (
                                        execute_stop_turn,
                                    )

                                    await execute_stop_turn(
                                        session,
                                        json_output,
                                        console,
                                        deps=deps,
                                    )
                                    prompt_state["hidden"] = False
                                    prompt_resume_event.set()
                                    continue

                                inline_command, _ = deps.parse_slash_command(message)
                                if inline_command == "switch":
                                    await handle_inline_switch(
                                        message=message,
                                        agent_id=agent_id,
                                        session=session,
                                        json_output=json_output,
                                        console=console,
                                        deps=deps,
                                    )
                                    prompt_state["hidden"] = False
                                    prompt_resume_event.set()
                                    continue

                                if await handle_inline_approval_via_mcp(
                                    message=message,
                                    agent_id=agent_id,
                                    json_output=json_output,
                                    console=console,
                                    session=session,
                                    deps=deps,
                                ):
                                    # After successful approve, monitor the
                                    # agent's continued processing and display
                                    # its response (Phase 1 doesn't stream
                                    # through anima.interact).
                                    parsed_cmd, _ = deps.parse_slash_command(
                                        message
                                    )
                                    if parsed_cmd == "approve":
                                        post_state = deps.read_state(agent_id)
                                        if (
                                            isinstance(post_state, Success)
                                            and post_state.unwrap().state
                                            == AgentState.running
                                        ):
                                            prompt_state["hidden"] = True
                                            prompt_resume_event.set()
                                            render_thinking_indicator(
                                                console=console,
                                                json_output=json_output,
                                                deps=deps,
                                                queued_count=0,
                                                supports_braille=supports_braille,
                                            )
                                            response_text = (
                                                await _poll_post_approval_response(
                                                    session=session,
                                                    agent_id=agent_id,
                                                    deps=deps,
                                                )
                                            )
                                            from anima.shell.cli_commands.talk_session import (
                                                clear_thinking_indicator,
                                            )

                                            clear_thinking_indicator(
                                                json_output=json_output,
                                                console=console,
                                                deps=deps,
                                            )
                                            if response_text and not json_output:
                                                console.print(
                                                    f"\n[bold]{persona_name}:[/bold] {rich_escape(response_text)}"
                                                )
                                    prompt_state["hidden"] = False
                                    prompt_resume_event.set()
                                    continue

                                if handle_inline_slash_command(
                                    message=message,
                                    agent_id=agent_id,
                                    json_output=json_output,
                                    console=console,
                                    deps=deps,
                                ):
                                    prompt_state["hidden"] = False
                                    prompt_resume_event.set()
                                    continue

                                if not message.strip():
                                    prompt_state["hidden"] = False
                                    prompt_resume_event.set()
                                    continue

                                if json_output:
                                    # Use pure json.dumps() for machine-readable output - no ANSI escapes.
                                    # console.print_json() emits ANSI codes when force_terminal=True.
                                    console.file.write(
                                        json.dumps(
                                            {"event": "user_input", "message": message}
                                        )
                                        + "\n"
                                    )
                                elif not prompt_session_renders_input:
                                    deps.emit_with_prompt_coordination(
                                        emit=lambda: console.print(
                                            f"[dim]--- {rich_escape(str(message))}[/dim]"
                                        ),
                                    )

                                interact_task = asyncio.create_task(
                                    session.call_tool(
                                        "anima.interact", {"message": message}
                                    )
                                )
                                prompt_state["hidden"] = True
                                prompt_resume_event.set()
                                render_thinking_indicator(
                                    console=console,
                                    json_output=json_output,
                                    deps=deps,
                                    queued_count=0,
                                    supports_braille=supports_braille,
                                )

                                stream_task: (
                                    asyncio.Task[tuple[bool, str | None]] | None
                                ) = None
                                stream_complete = asyncio.Event()
                                stream_chunk_rendered: asyncio.Event | None = None

                                if stream_state.streaming_supported is None:
                                    stream_state.streaming_supported = (
                                        await deps.probe_streaming_support(
                                            client, socket_path
                                        )
                                    )

                                if stream_state.streaming_supported:
                                    stream_chunk_rendered = asyncio.Event()
                                    stream_task = asyncio.create_task(
                                        cast(
                                            Coroutine[
                                                Any, Any, tuple[bool, str | None]
                                            ],
                                            cast(Any, deps.consume_sse_stream)(
                                                socket_path=socket_path,
                                                agent_id=agent_id,
                                                json_output=json_output,
                                                console=console,
                                                stream_complete=stream_complete,
                                                chunk_rendered=stream_chunk_rendered,
                                                render_with_prompt=lambda emit: (
                                                    deps.emit_with_prompt_coordination(
                                                        emit=emit,
                                                    )
                                                ),
                                                use_markdown_renderer=True,
                                                persona_name=persona_name,
                                            ),
                                        )
                                    )

                                (
                                    should_return,
                                    had_error,
                                    _was_awaiting_approval_after_wait,
                                ) = await wait_for_interact_or_input_or_stream(
                                    interact_task=interact_task,
                                    stream_task=stream_task,
                                    input_queue=input_queue,
                                    session=session,
                                    agent_id=agent_id,
                                    json_output=json_output,
                                    console=console,
                                    was_awaiting_approval=was_awaiting_approval,
                                    print_approval_prompt=_print_approval_prompt,
                                    stdin_task=cast(asyncio.Task[None], stdin_task),
                                    stream_chunk_rendered=stream_chunk_rendered,
                                    deps=deps,
                                    render_thinking_indicator=lambda queued_count: (
                                        render_thinking_indicator(
                                            console=console,
                                            json_output=json_output,
                                            deps=deps,
                                            queued_count=queued_count,
                                            supports_braille=supports_braille,
                                        )
                                    ),
                                    persona_name=persona_name,
                                )
                                prompt_state["hidden"] = False
                                prompt_resume_event.set()
                                if should_return:
                                    return
                                if had_error:
                                    break

                            except (KeyboardInterrupt, EOFError):
                                await cleanup_stdin(stdin_task)
                                deps.print_detach(
                                    agent_id=agent_id,
                                    json_output=json_output,
                                    console=console,
                                    newline_prefix=True,
                                )
                                break

    except BaseExceptionGroup as eg:
        # Agent process died mid-conversation (socket removed).
        connect_errors, rest = eg.split(
            lambda e: isinstance(e, (httpx.ConnectError, httpx.RemoteProtocolError))
        )
        if connect_errors:
            if not json_output:
                console.print(
                    "[red]Agent disconnected (process may have exited).[/red]"
                )
            else:
                # Use pure json.dumps() for machine-readable output - no ANSI escapes.
                # console.print_json() emits ANSI codes when force_terminal=True.
                console.file.write(
                    json.dumps(
                        {
                            "error": {
                                "code": "AGENT_DISCONNECTED",
                                "message": "Agent process exited unexpectedly",
                            }
                        }
                    )
                    + "\n"
                )
            if rest:
                raise rest
        else:
            raise
    except (httpx.ConnectError, httpx.RemoteProtocolError):
        if not json_output:
            console.print("[red]Agent disconnected (process may have exited).[/red]")
        else:
            # Use pure json.dumps() for machine-readable output - no ANSI escapes.
            # console.print_json() emits ANSI codes when force_terminal=True.
            console.file.write(
                json.dumps(
                    {
                        "error": {
                            "code": "AGENT_DISCONNECTED",
                            "message": "Agent process exited unexpectedly",
                        }
                    }
                )
                + "\n"
            )
    finally:
        await cleanup_stdin(stdin_task)
