"""Ratatoskr Textual TUI shell — interactive primary presenter.

Implements docs/contracts/issues/4.contract.md, as amended in-place by
docs/contracts/issues/6.contract.md (session resolution lifted out of
on_mount into a pre-App.run() async helper; AsyncClient ownership moves
with it).
"""

from __future__ import annotations

import asyncio
import sys
import time as _time
from dataclasses import dataclass
from datetime import datetime as _datetime
from typing import ClassVar, Literal

import httpx
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.theme import Theme
from textual.widgets import (
    Footer,
    Header,
    Input,
    ListItem,
    ListView,
    RichLog,
    Static,
    TabbedContent,
    TabPane,
)

from ratatoskr.cli import USER_AGENT, ParsedArgs, _format_duration_ms, _format_usage
from ratatoskr.first_message import seed_preset_first_message
from ratatoskr.sessions import (
    AgentInfo,
    AgentNotAvailable,
    AgentNotFound,
    AuthScopeDenied,
    BifrostConsumerKeyMissing,
    BifrostHandshakeFailed,
    PersonaNotConfigured,
    SessionApiFailed,
    SessionInfo,
    create_session,
    get_persona_state,
    get_session_bifrost,
    get_session_tools,
    list_agents,
    list_sessions,
)
from ratatoskr.sse_client import (
    AdminEvent,
    AffectUpdate,
    AwaitingLlmFirstToken,
    CancelAlreadyCompleted,
    CancelFailed,
    Cancelled,
    CancelTurnNotFound,
    Done,
    Error,
    Event,
    MalformedSseData,
    MalformedSseId,
    SseConnectFailed,
    SseConnectionDropped,
    Text,
    TextBoundary,
    Thinking,
    ToolResult,
    ToolStart,
    TurnIdFlip,
    WorkerPhase,
    cancel_turn,
    stream_admin_events,
    stream_turn_resilient,
)

# ---- Australis theme (https://github.com/lkraven/australis) ------------------
#
# The Australis Dark color theme, inspired by the Southern Lights. 16 cool-tone
# terminal colors with medium contrast. Preference order: blue > cyan > green
# for primary surfaces; Dawn accents (red/yellow/magenta) used sparingly for
# terminal-event labels (error/cancelled).
#
# **v0.6.3 single deviation from spec**: `$background` is `#000000` (pure
# black), NOT Australis Ice black `#222531`. The Ice black is RGB(34,37,49)
# — blue dominant — and at App-wide scale the cumulative cast reads as
# "the whole app is blue" to operators (even though no single surface is
# "blue" in the strict-color sense). Pure black for the App background
# kills that perception. EVERY OTHER Australis value — Aurora accents,
# Sea darks for chrome (surface/panel), Ice white foreground, Dawn
# accents — stays verbatim per spec.
#
# Mapping to Textual's Theme semantic tokens:
#   primary    = Aurora blue (#6388D8) — focus rings, active selection.
#   secondary  = Aurora cyan (#00b1a8) — secondary highlights.
#   accent     = Aurora bright cyan (#42dcd1) — bright accents.
#   success    = Aurora green (#16B866) — [done] label.
#   warning    = Dawn yellow (#e1c631) — [cancelled] label.
#   error      = Dawn red (#ff491a) — [error] label.
#   foreground = Ice white (#a9bcc3) — default text.
#   background = pure black (#000000) — App background (v0.6.3 deviation).
#   surface    = Sea bright black (#373b46) — raised chrome.
#   panel      = Sea dark 30 (#414751) — borders, separators.

AUSTRALIS_THEME = Theme(
    name="australis",
    primary="#6388D8",
    secondary="#00b1a8",
    accent="#42dcd1",
    success="#16B866",
    warning="#e1c631",
    error="#ff491a",
    foreground="#a9bcc3",
    background="#000000",
    surface="#373b46",
    panel="#414751",
    dark=True,
    variables={
        # Sea contrast palette — usable via $au-dark-50 etc. in TCSS.
        "au-dark-30": "#414751",
        "au-dark-40": "#565f69",
        "au-dark-50": "#6e7882",
        "au-dark-60": "#86929d",
        "au-bright-70": "#9daeb6",
        "au-bright-80": "#b3cbcf",
        "au-bright-white": "#cce7ec",
        "au-bright-blue": "#a4c4ff",
        "au-bright-cyan": "#42dcd1",
        "au-bright-green": "#51e08a",
    },
)


# Direct hex constants for Rich Text styling (Done/Error/Cancelled labels +
# transcript user-prompt echo). Themes set TCSS variables; Rich's RichText
# style strings live outside the theme system, so we resolve to hex here.
_AU_SUCCESS = "#16B866"
_AU_ERROR = "#ff491a"
_AU_WARNING = "#e1c631"
_AU_USER_ECHO = "#42dcd1"  # Aurora bright cyan — operator's voice
_AU_DEMOTED = "#86929d"  # Sea dark 60 — demoted telemetry
_AU_DEMOTED_FAINT = "#6e7882"  # Sea dark 50 — empty-state placeholders


# ---- Issue #12 presenter contract semantics amendment -------------------------
#
# TuiPresenterState replaces the stateless _render_event_to_log with a stateful
# per-turn presenter that coalesces thinking runs into ONE closed RichLog entry
# per run + per-delta live updates on the dedicated thinking-current Static
# widget. One instance per `_stream_turn_worker` invocation.


def _plain_label(event: Event) -> str:
    """Pre-amendment labeled-line shape for INV-009 render-exception fallback.

    Used by `TuiPresenterState.render` ONLY in the except branch, so a failed
    state-based render still produces a readable transcript entry per the
    pre-amendment behavior. Bracketed labels match the historical
    `_render_event_to_log` output verbatim.
    """
    if isinstance(event, Text):
        return event.content
    if isinstance(event, Done):
        return (
            f"[done] turn_id={event.sse_id.turn_id} model={event.model} "
            f"duration_ms={event.duration_ms} usage={event.usage!r}"
        )
    if isinstance(event, Error):
        return (
            f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} "
            f"message={event.message!r}"
        )
    if isinstance(event, Cancelled):
        return (
            f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} "
            f"partial_message_id={event.partial_message_id}"
        )
    if isinstance(event, WorkerPhase):
        return f"[worker_phase] phase={event.phase} turn_id={event.turn_id}"
    if isinstance(event, Thinking):
        return f"[thinking] {event.content[:200]!r}"
    if isinstance(event, TextBoundary):
        return f"[text_boundary] kind={event.kind} char_offset={event.char_offset}"
    if isinstance(event, ToolStart):
        return f"[tool_start] name={event.name} args={event.arguments!r}"
    if isinstance(event, ToolResult):
        return (
            f"[tool_result] name={event.name} duration_ms={event.duration_ms} "
            f"result={event.result!r:.200}"
        )
    return f"[unknown_event] {type(event).__name__}"


def _ts() -> str:
    """HH:MM:SS.fff wall-clock timestamp for debug-pane log lines."""
    now = _datetime.now()
    return now.strftime("%H:%M:%S") + f".{now.microsecond // 1000:03d}"


def _format_admin_event(ev: AdminEvent) -> str:
    """One-line render of an /admin/events envelope for the AdminEvents pane.

    Drops `session_id` from the detail (the pane is already session-scoped) and
    shows HH:MM:SS from the ISO timestamp + the remaining small metadata fields.
    """
    ts = (ev.timestamp or "")[11:19]
    extras = " ".join(f"{k}={v}" for k, v in ev.data.items() if k != "session_id")
    return f"[{ts}] {ev.type} {extras}".rstrip()


def _format_bifrost_state(state: dict) -> list[str]:
    """Render GET /admin/sessions/{id}/bifrost (#176) into BifrostState-pane lines."""
    tools = [t.get("name", "?") for t in state.get("tools", [])]
    caps = state.get("capabilities_granted", [])
    return [
        f"bifrost binding: connected={state.get('connected')} "
        f"consumer={state.get('consumer_id', '?')}",
        f"  endpoint: {state.get('endpoint_url', '?')}",
        f"  caps_granted: {', '.join(caps) or '(none)'}",
        f"  tools ({len(tools)}): {', '.join(tools) or '(none)'}",
    ]


def _format_tool_inventory(tools: dict) -> list[str]:
    """Render GET /sessions/{id}/tools (#183) into Tools-pane inventory lines.

    The merged tool list the LLM saw at turn-fire — distinct from the live
    tool_start/tool_result events that stream into the same pane during a turn.
    """
    builtin = [t.get("name", "?") for t in tools.get("builtin_tools", [])]
    bifrost = [t.get("name", "?") for t in tools.get("bifrost_tools", [])]
    return [
        f"session tool inventory: agent={tools.get('agent_id', '?')} "
        f"builtin={len(builtin)} bifrost={len(bifrost)}",
        f"  builtin: {', '.join(builtin) or '(none)'}",
        f"  bifrost: {', '.join(bifrost) or '(none)'}",
    ]


def _format_persona_header(snapshot: dict) -> str:
    """One-line persona summary for the sticky header widget.

    Shape: `agent_id · dominant_emotion · pad(P, A, D) · N emotions active`.
    Built for at-a-glance scanning above the chat area — concise enough to
    fit one terminal row. Full detail lives in the Persona TabPane.
    """
    pad = snapshot.get("pad") or {}
    emotions = snapshot.get("emotions_active") or []
    dom = snapshot.get("dominant_emotion") or "?"
    pieces = [
        f"{snapshot.get('agent_id', '?')}",
        f"{dom}",
        f"pad({pad.get('pleasure', '?')}, {pad.get('arousal', '?')}, "
        f"{pad.get('dominance', '?')})",
    ]
    if emotions:
        pieces.append(f"{len(emotions)} emotion{'s' if len(emotions) != 1 else ''} active")
    return " · ".join(pieces)


def _format_persona_detail(snapshot: dict) -> str:
    """Multi-line persona detail for the Persona TabPane.

    Renders the full v0.28.0 snapshot shape: dominant_emotion, PAD with
    baseline comparison, mood_drift deltas, active emotions list with
    intensity + decay, last_updated_at footer.
    """
    pad = snapshot.get("pad") or {}
    baseline = snapshot.get("baseline_pad") or {}
    drift = snapshot.get("mood_drift") or {}
    emotions = snapshot.get("emotions_active") or []
    lines: list[str] = []
    agent = snapshot.get("agent_id", "?")
    lines.append(f"Persona snapshot · {agent}")
    lines.append("")
    lines.append(f"Dominant emotion: {snapshot.get('dominant_emotion', '?')}")
    lines.append("")
    lines.append("PAD")
    for axis in ("pleasure", "arousal", "dominance"):
        v = pad.get(axis, "?")
        b = baseline.get(axis, "?")
        delta = ""
        if isinstance(v, (int, float)) and isinstance(b, (int, float)):
            delta = f" (Δ {v - b:+.2f})"
        lines.append(f"  {axis:<10} {v}  baseline {b}{delta}")
    lines.append("")
    if drift:
        lines.append("Mood drift")
        for key in ("valence_delta", "arousal_delta"):
            if key in drift:
                v = drift[key]
                lines.append(f"  {key:<16} {v:+}" if isinstance(v, (int, float))
                             else f"  {key:<16} {v}")
        lines.append("")
    lines.append(f"Active emotions ({len(emotions)})")
    for e in emotions:
        et = e.get("type", "?")
        ei = e.get("intensity", "?")
        decay = e.get("decay_remaining_s")
        decay_str = (
            f"  decay {decay / 60:.1f}m" if isinstance(decay, (int, float)) else ""
        )
        lines.append(f"  {et:<20} intensity {ei}{decay_str}")
    if not emotions:
        lines.append("  (none)")
    last = snapshot.get("last_updated_at")
    if last:
        lines.append("")
        lines.append(f"Last updated: {last}")
    return "\n".join(lines)


def _audit_line(event: Event) -> str:
    """One-line wire-level audit summary for the debug pane.

    v0.10.0: every SSE event arrival lands as one of these in the debug
    pane (Text and Thinking deltas are aggregated into the turn summary
    instead — token-rate per-delta lines would drown the pane). Shape:
    `[HH:MM:SS.fff] event_type sse_id=T:S key=val …`.
    """
    sid = getattr(event, "sse_id", None)
    sid_str = f"{sid.turn_id}:{sid.seq}" if sid is not None else "-"
    kind = type(event).__name__.lower()
    if isinstance(event, WorkerPhase):
        detail = f"phase={event.phase} turn_id={event.turn_id}"
    elif isinstance(event, ToolStart):
        detail = f"name={event.name} args={event.arguments!r:.80}"
    elif isinstance(event, ToolResult):
        detail = f"name={event.name} duration_ms={event.duration_ms}"
    elif isinstance(event, TextBoundary):
        detail = f"kind={event.kind} char_offset={event.char_offset}"
    elif isinstance(event, Done):
        detail = (
            f"turn_id={event.sse_id.turn_id} model={event.model} "
            f"duration_ms={event.duration_ms}"
        )
    elif isinstance(event, Error):
        detail = (
            f"turn_id={event.sse_id.turn_id} code={event.error_code} "
            f"message={event.message!r:.80}"
        )
    elif isinstance(event, Cancelled):
        detail = f"turn_id={event.turn_id} reason={event.reason!r}"
    elif isinstance(event, AwaitingLlmFirstToken):
        # Worldtree #201 / v0.29.0. Compact: turn_id + elapsed in seconds
        # (the heartbeat itself fires every 5s by default; seconds-rounding
        # is the natural unit for operator scanning).
        secs = event.elapsed_ms_since_building_prompt / 1000.0
        detail = f"turn_id={event.turn_id} elapsed={secs:.1f}s"
    elif isinstance(event, AffectUpdate):
        # Worldtree #204 / v0.28.0. status="current" carries the full
        # snapshot; surface dominant_emotion + PAD inline so the operator
        # sees persona drift at a glance. status="scheduled" is
        # lightweight — no PAD, just the appraisal-kickoff marker.
        if event.snapshot is not None:
            pad = event.snapshot.get("pad") or {}
            detail = (
                f"status={event.status} turn_id={event.turn_id} "
                f"dominant_emotion={event.snapshot.get('dominant_emotion')!r} "
                f"pad=({pad.get('pleasure')},{pad.get('arousal')},{pad.get('dominance')})"
            )
        else:
            detail = f"status={event.status} turn_id={event.turn_id}"
    else:  # Text / Thinking handled by counter path; fallback for safety
        detail = ""
    return f"[{_ts()}] {kind} sse_id={sid_str} {detail}".rstrip()


@dataclass(slots=True)
class TuiPresenterState:
    """Per-turn presenter state for TUI mode (issue #12).

    See `docs/contracts/issues/12.contract.md` for the full spec.
    """

    thinking_open: bool = False
    # Thinking-run counter for turn-scoped start/end markers.
    thinking_run_index: int = 0
    # v0.7.1: thinking-content accumulator. Worldtree emits Thinking deltas
    # at token granularity; flushing each delta as its own RichLog line
    # produces per-token-per-newline visual spam. Buffer here and flush
    # only on `\n` boundaries (one written line per natural paragraph) or
    # when the run closes (any leftover tail).
    thinking_chunk_buffer: str = ""
    # v0.9.0: Text accumulator for live Markdown rendering. Worldtree emits
    # Text deltas at token granularity; each delta appends to this buffer
    # and the current_response_widget re-renders Markdown(text_chunk_buffer)
    # in place. On terminal event the widget is finalized + reference clears.
    text_chunk_buffer: str = ""
    # v0.9.0: reference to the Static widget holding the current turn's
    # response Markdown Renderable. None between turns.
    current_response_widget: object = None
    # v0.10.0: per-turn counters for the debug-pane turn-summary line. Text
    # and Thinking events arrive at token rate; emitting per-delta debug
    # lines would drown the pane. Instead we count them and surface
    # aggregated totals when the turn closes.
    text_delta_count: int = 0
    text_byte_count: int = 0
    thinking_delta_count: int = 0
    thinking_byte_count: int = 0
    turn_start_ts: float = 0.0
    # v0.14.0: Worldtree #201 heartbeat surface. First
    # `awaiting_llm_first_token` mounts a Static; subsequent heartbeats
    # update it in place; any non-heartbeat event clears it (the gap
    # closed). awaiting_widget is the Static reference (None when
    # closed); heartbeat_count tracks emissions for the turn-summary.
    awaiting_widget: object = None
    heartbeat_count: int = 0

    def render(
        self,
        event: Event,
        *,
        transcript: VerticalScroll,
        tools_log: RichLog,
        debug_log: RichLog,
        thinking_log: RichLog,
        raw: bool,
        on_persona_snapshot: object = None,
    ) -> None:
        """Render one Worldtree SSE event with the TUI hierarchy + coalescing.

        v0.9.0 routing:
          - `transcript` (VerticalScroll) = chat content: each turn mounts
            child widgets (turn-header / prompt-echo / response Markdown /
            done-label). Live Markdown rendering during Text streaming.
          - `tools_log` (RichLog) = ToolStart + ToolResult.
          - `debug_log` (RichLog) = WorkerPhase + TextBoundary.
          - `thinking_log` (RichLog) = streaming Thinking deltas inline
            (coalesced on `\n`); Rule(start)/Rule(end) wrap each run.

        v0.13.0: optional `on_persona_snapshot` callback receives the
        snapshot dict whenever AffectUpdate(status="current") arrives.
        Lets the App surface the snapshot to the persona-header + Persona
        pane without the presenter needing direct widget access. Default
        None — presenter falls back to audit-only routing.

        Exceptions caught at the presenter boundary (INV-009 fallback).
        """
        assert isinstance(
            event,
            (
                WorkerPhase, Thinking, Text, TextBoundary,
                ToolStart, ToolResult, Done, Error, Cancelled, AffectUpdate,
                AwaitingLlmFirstToken,
            ),
        )
        from rich.text import Text as RichText

        def _dim(s: str) -> RichText:
            """Wrap a demoted-telemetry line in Australis Sea dark-60 grey."""
            return RichText(s, style=_AU_DEMOTED)

        try:
            # v0.10.0: per-event audit log line to debug pane. Text and
            # Thinking arrive at token rate, so we count them rather than
            # emit a line per delta — totals are reported in the turn-
            # summary on Done/Error/Cancelled. Everything else gets one
            # debug-pane line per arrival with timestamp + sse_id + a short
            # event-specific summary, giving the operator a wire-level
            # timeline of what the server sent.
            if isinstance(event, Text):
                if self.text_delta_count == 0:
                    if self.turn_start_ts == 0.0:
                        self.turn_start_ts = _time.monotonic()
                self.text_delta_count += 1
                self.text_byte_count += len(event.content)
            elif isinstance(event, Thinking):
                if self.thinking_delta_count == 0:
                    if self.turn_start_ts == 0.0:
                        self.turn_start_ts = _time.monotonic()
                self.thinking_delta_count += 1
                self.thinking_byte_count += len(event.content)
            else:
                if self.turn_start_ts == 0.0:
                    self.turn_start_ts = _time.monotonic()
                debug_log.write(_dim(_audit_line(event)))
            # v0.11.0 → v0.13.0: AffectUpdate gets the audit line (above)
            # plus a callback to the App so the persona-header + Persona
            # pane refresh from the snapshot. status="scheduled" carries
            # no snapshot — the callback is skipped and the next turn's
            # status="current" lands the actual update.
            if isinstance(event, AffectUpdate):
                if event.snapshot is not None and on_persona_snapshot is not None:
                    try:
                        on_persona_snapshot(event.snapshot)
                    except Exception:
                        # Persona surface failure must not break the SSE
                        # stream — the audit line above already records
                        # the event regardless.
                        pass
                return
            # v0.14.0: AwaitingLlmFirstToken (Worldtree #201) gets the audit
            # line (above) plus a live transcript indicator. First heartbeat
            # mounts a Static; subsequent heartbeats update it in place.
            # Any non-heartbeat event below closes the gap and the indicator
            # is removed (the first text/thinking/done arrived).
            if isinstance(event, AwaitingLlmFirstToken):
                from rich.text import Text as RichText
                self.heartbeat_count += 1
                secs = event.elapsed_ms_since_building_prompt / 1000.0
                label = RichText(
                    f"awaiting first token · {secs:.1f}s",
                    style=_AU_DEMOTED,
                )
                try:
                    if self.awaiting_widget is None:
                        self.awaiting_widget = Static(label, classes="awaiting-label")
                        transcript.mount(self.awaiting_widget)
                    else:
                        self.awaiting_widget.update(label)
                    transcript.scroll_end(animate=False)
                except Exception:
                    pass
                return
            # Any non-heartbeat event past this point means the gap closed —
            # remove the awaiting indicator if it's still mounted.
            if self.awaiting_widget is not None:
                try:
                    self.awaiting_widget.remove()
                except Exception:
                    pass
                self.awaiting_widget = None
            # v0.7.1: Thinking deltas coalesce by newline before flushing.
            # Worldtree emits Thinking events at token granularity; per-delta
            # RichLog writes produce one visual line per token (per-token-per-
            # newline spam). Buffer the deltas and flush only on `\n` (one
            # written line per natural paragraph) or run close.
            if isinstance(event, Thinking):
                from rich.rule import Rule

                if not self.thinking_open:
                    self.thinking_run_index += 1
                    turn_id = event.sse_id.turn_id
                    thinking_log.write(Rule(
                        title=f"turn {turn_id} · thinking #{self.thinking_run_index} start",
                        style=_AU_DEMOTED,
                    ))
                    self.thinking_open = True
                self.thinking_chunk_buffer += event.content
                # Flush every complete line in the buffer. Whatever's after
                # the final `\n` stays buffered for the next delta or close.
                while "\n" in self.thinking_chunk_buffer:
                    line, _, rest = self.thinking_chunk_buffer.partition("\n")
                    if line:  # skip empty lines (blank paragraph separators)
                        thinking_log.write(line)
                    self.thinking_chunk_buffer = rest
                return
            # Non-thinking event: close any open thinking run with Rule(end).
            if self.thinking_open:
                from rich.rule import Rule

                # Flush the tail (content with no trailing `\n`) before the
                # end rule so nothing gets lost on close.
                if self.thinking_chunk_buffer:
                    thinking_log.write(self.thinking_chunk_buffer)
                    self.thinking_chunk_buffer = ""
                turn_id = event.sse_id.turn_id if hasattr(event, "sse_id") else (
                    event.turn_id if hasattr(event, "turn_id") else "?"
                )
                thinking_log.write(Rule(
                    title=f"turn {turn_id} · thinking #{self.thinking_run_index} end",
                    style=_AU_DEMOTED,
                ))
                self.thinking_open = False
            # Now render the non-thinking event itself.
            if isinstance(event, Text):
                # v0.8.1: stream Text deltas into transcript directly,
                # coalesced on `\n`. Same pattern as Thinking (v0.7.1).
                # The pre-v0.8.1 #current-text Static is gone — its dock-
                # bottom growth was overlapping the transcript visually.
                #
                # v0.9.0: Text deltas accumulate in text_chunk_buffer and
                # the current_response_widget renders Markdown(buffer) in
                # place. First Text delta of the turn mounts a fresh Static
                # holding the Markdown Renderable; subsequent deltas update
                # the same widget. Live markdown rendering — no post-Done
                # re-render needed.
                from rich.markdown import Markdown

                self.text_chunk_buffer += event.content
                # --raw bypasses Markdown rendering — useful for debugging
                # the raw text stream surface, and matches the pre-v0.9.0
                # --raw semantics (which dropped the post-Done Markdown re-
                # render). In raw mode the response widget holds plain str.
                rendered = (
                    self.text_chunk_buffer if raw else Markdown(self.text_chunk_buffer)
                )
                if self.current_response_widget is None:
                    self.current_response_widget = Static(
                        rendered, classes="response-md"
                    )
                    transcript.mount(self.current_response_widget)
                else:
                    self.current_response_widget.update(rendered)
                transcript.scroll_end(animate=False)
                return
            if isinstance(event, (Done, Error, Cancelled)):
                # v0.10.0: emit turn-summary to debug pane before clearing
                # counters. Aggregates the per-event totals (Text + Thinking
                # deltas don't get per-event audit lines because they arrive
                # at token rate; the summary surfaces what was elided).
                elapsed_ms = (
                    int((_time.monotonic() - self.turn_start_ts) * 1000)
                    if self.turn_start_ts
                    else 0
                )
                turn_id = (
                    event.sse_id.turn_id
                    if hasattr(event, "sse_id")
                    else getattr(event, "turn_id", "?")
                )
                debug_log.write(_dim(
                    f"[{_ts()}] turn_summary turn_id={turn_id} "
                    f"text_deltas={self.text_delta_count} "
                    f"text_bytes={self.text_byte_count} "
                    f"thinking_deltas={self.thinking_delta_count} "
                    f"thinking_bytes={self.thinking_byte_count} "
                    f"heartbeats={self.heartbeat_count} "
                    f"elapsed_ms={elapsed_ms}"
                ))
                # Terminal event: finalize the response widget (clear ref so
                # the next turn mounts a fresh one). The accumulated text is
                # already rendered as Markdown in the widget — no post-Done
                # re-render, no double-print.
                self.text_chunk_buffer = ""
                self.current_response_widget = None
                # Terminal labels mount as styled Statics. Tinted per outcome
                # (Aurora green / Dawn red / Dawn yellow) for at-a-glance
                # scanning.
                if isinstance(event, Done):
                    transcript.mount(Static(
                        RichText(
                            f"[done] turn_id={event.sse_id.turn_id} "
                            f"model={event.model} "
                            f"duration={_format_duration_ms(event.duration_ms)} "
                            f"usage {_format_usage(event.usage, arrow='→')}",
                            style=_AU_SUCCESS,
                        ),
                        classes="done-label",
                    ))
                elif isinstance(event, Error):
                    transcript.mount(Static(
                        RichText(
                            f"[error] turn_id={event.sse_id.turn_id} "
                            f"code={event.error_code} message={event.message!r}",
                            style=_AU_ERROR,
                        ),
                        classes="error-label",
                    ))
                else:  # Cancelled
                    transcript.mount(Static(
                        RichText(
                            f"[cancelled] turn_id={event.turn_id} "
                            f"reason={event.reason!r} "
                            f"partial_message_id={event.partial_message_id}",
                            style=_AU_WARNING,
                        ),
                        classes="cancelled-label",
                    ))
                transcript.scroll_end(animate=False)
                return
            if isinstance(event, WorkerPhase):
                # v0.5.0: telemetry → Debug pane, not transcript.
                debug_log.write(_dim(
                    f"· worker_phase: phase={event.phase} turn_id={event.turn_id}"
                ))
                return
            if isinstance(event, ToolStart):
                # Issue #13 INV-014: tool events route to the Tools pane.
                tools_log.write(_dim(
                    f"· tool_start: name={event.name} args={event.arguments!r}"
                ))
                return
            if isinstance(event, ToolResult):
                # Issue #13 INV-014: tool events route to the Tools pane.
                tools_log.write(_dim(
                    f"· tool_result: name={event.name} duration_ms={event.duration_ms} "
                    f"result={event.result!r:.200}"
                ))
                return
            if isinstance(event, TextBoundary):
                # v0.5.0: telemetry → Debug pane, not transcript.
                debug_log.write(_dim(
                    f"· text_boundary: kind={event.kind} char_offset={event.char_offset}"
                ))
                return
        except Exception as exc:
            # INV-009 + POST-007 fallback: write pre-amendment plain-label line for
            # the original event AND a render_error line with the class name only
            # (NO exception message — security clause). Volva F1 fix.
            #
            # v0.9.0 routing-under-failure: panes (RichLog) still write Strip
            # lines; transcript (VerticalScroll) mounts a Static instead.
            if isinstance(event, (ToolStart, ToolResult)):
                tools_log.write(_plain_label(event))
                tools_log.write(f"[render_error] {type(exc).__name__}")
            elif isinstance(event, Thinking):
                thinking_log.write(_plain_label(event))
                thinking_log.write(f"[render_error] {type(exc).__name__}")
            elif isinstance(event, (WorkerPhase, TextBoundary)):
                debug_log.write(_plain_label(event))
                debug_log.write(f"[render_error] {type(exc).__name__}")
            else:
                # Transcript-bound event (Text / Done / Error / Cancelled).
                transcript.mount(Static(_plain_label(event), classes="error-label"))
                transcript.mount(
                    Static(f"[render_error] {type(exc).__name__}", classes="error-label")
                )
                transcript.scroll_end(animate=False)


class AgentPickerApp(App[str | None]):
    """Startup agent picker (issue #8). Opens before RatatoskrApp when --new
    is passed without --agent. `run_async()` returns the chosen agent_id (str)
    or None on Esc/Ctrl-D dismissal.

    Architecturally separate from RatatoskrApp (deliberate per issue #8
    INV-007): keeps list_agents failures landing on real stderr before any
    alt-screen opens, preserving issue #6's invariant.
    """

    DEFAULT_CSS = """
    /* v0.6.1: kill Textual's $primary-blue tints everywhere — Header sub-
       widgets (HeaderIcon etc.) have their own $primary tinting that the
       parent `Header { background: $surface }` rule alone doesn't cover.
       Sub-selectors force the cool palette down to every level. */
    Header, HeaderIcon, HeaderTitle, HeaderClock {
        background: $surface;
        color: $au-bright-blue;
    }
    Footer {
        background: $surface;
    }
    /* v0.6.1: scrollbar uses Textual's $primary-tint by default. Force
       Australis Sea darks so the scrollbar gutter doesn't read as a blue
       strip. Applied to ListView (the scrollable widget here). */
    ListView {
        scrollbar-background: $background;
        scrollbar-background-hover: $background;
        scrollbar-background-active: $background;
        scrollbar-color: $au-dark-50;
        scrollbar-color-hover: $au-dark-60;
        scrollbar-color-active: $au-bright-cyan;
    }
    #picker-prompt {
        dock: top;
        height: 1;
        padding: 0 1;
        color: $au-bright-cyan;
        background: $surface;
    }
    #agent-list {
        height: 1fr;
        background: $background;
    }
    /* Multi-line agent items. Each ListItem is auto-height so the full
       description wraps below the agent_id/name line — no truncation.
       v0.6.4: lock bg to $background so Textual's auto background-tint on
       focus doesn't bleed through unwanted color into the non-highlighted
       items. */
    #agent-list > ListItem {
        height: auto;
        padding: 1 1;
        background: $background;
    }
    /* v0.6.4: highlighted item gets Aurora blue background (Textual's
       default $block-cursor-background = $primary). Override only the
       text-color descendants so id-line/desc stay readable on blue. The
       background itself comes from Textual's default ListItem.-highlight
       rule — we removed our previous overriding selectors.

       Textual's class is `-highlight` (single dash). Use plain descendant
       combinator to bypass internal DOM wrappers. */
    #agent-list:focus ListItem.-highlight {
        background: $primary;
    }
    #agent-list:focus ListItem.-highlight .agent-id-line {
        color: $au-bright-white;
        text-style: bold;
    }
    #agent-list:focus ListItem.-highlight .agent-desc {
        color: $au-bright-80;
    }
    /* Default (unhighlighted) item text styling. */
    .agent-id-line {
        color: $au-bright-blue;
        text-style: bold;
    }
    .agent-desc {
        color: $au-bright-70;
    }
    """

    BINDINGS: ClassVar[list[Binding]] = [
        Binding("enter", "pick", "Pick", priority=True),
        Binding("escape", "dismiss", "Cancel", priority=True),
        Binding("ctrl+d", "dismiss", "Cancel", priority=True),
        Binding("ctrl+c", "dismiss", "Cancel", priority=True),
    ]

    def __init__(self, agents: list[AgentInfo]) -> None:
        super().__init__()
        # PRE-002: caller (_resolve_then_run) checks for empty list and emits
        # [no_agents] before constructing the picker.
        assert agents
        self.agents = agents
        self.register_theme(AUSTRALIS_THEME)
        self.theme = "australis"

    def compose(self) -> ComposeResult:
        yield Header()
        yield Static("Pick an agent for the new session:", id="picker-prompt")
        # v0.6.0: each ListItem has two Static children — the id/name line
        # in bold blue + the wrapped description in muted dark-60. No
        # description truncation; tall items breathe so the operator can
        # actually read what each agent does.
        yield ListView(
            *[
                ListItem(
                    Static(f"{a.agent_id} · {a.name}", classes="agent-id-line"),
                    Static(a.description, classes="agent-desc"),
                )
                for a in self.agents
            ],
            id="agent-list",
        )
        yield Footer()

    async def on_mount(self) -> None:
        self.query_one("#agent-list", ListView).focus()

    def action_pick(self) -> None:
        lv = self.query_one("#agent-list", ListView)
        idx = lv.index
        if idx is None:
            return  # nothing highlighted; ignore
        self.exit(self.agents[idx].agent_id)

    def action_dismiss(self) -> None:
        self.exit(None)


def _session_desc(s: SessionInfo) -> str:
    """One-line session summary for the picker's second row."""
    tail = f"session {s.session_id} · last active {s.last_active}"
    if s.message_count is not None:
        tail += f" · {s.message_count} msgs"
    return tail


class SessionPickerApp(App[str | None]):
    """Startup session picker (design-brief §4, slice b2). Opens before
    RatatoskrApp when bare TUI mode resolves >1 session. `run_async()` returns
    the chosen session_id (str) or None on Esc/Ctrl-D/Ctrl-C dismissal.

    Resume-only (design-brief §4 negative clause "no in-app session creation —
    --new flag only"): the picker chooses among EXISTING sessions; starting a
    fresh one is the --new flag's job. Architecturally separate from
    RatatoskrApp (mirrors AgentPickerApp): list_sessions failures + dismissal
    land before any alt-screen opens (preserves #6 INV-001).
    """

    DEFAULT_CSS = """
    Header, HeaderIcon, HeaderTitle, HeaderClock {
        background: $surface;
        color: $au-bright-blue;
    }
    Footer {
        background: $surface;
    }
    ListView {
        scrollbar-background: $background;
        scrollbar-background-hover: $background;
        scrollbar-background-active: $background;
        scrollbar-color: $au-dark-50;
        scrollbar-color-hover: $au-dark-60;
        scrollbar-color-active: $au-bright-cyan;
    }
    #picker-prompt {
        dock: top;
        height: 1;
        padding: 0 1;
        color: $au-bright-cyan;
        background: $surface;
    }
    #session-list {
        height: 1fr;
        background: $background;
    }
    #session-list > ListItem {
        height: auto;
        padding: 1 1;
        background: $background;
    }
    #session-list:focus ListItem.-highlight {
        background: $primary;
    }
    #session-list:focus ListItem.-highlight .session-id-line {
        color: $au-bright-white;
        text-style: bold;
    }
    #session-list:focus ListItem.-highlight .session-desc {
        color: $au-bright-80;
    }
    .session-id-line {
        color: $au-bright-blue;
        text-style: bold;
    }
    .session-desc {
        color: $au-bright-70;
    }
    """

    BINDINGS: ClassVar[list[Binding]] = [
        Binding("enter", "pick", "Resume", priority=True),
        Binding("escape", "dismiss", "Cancel", priority=True),
        Binding("ctrl+d", "dismiss", "Cancel", priority=True),
        Binding("ctrl+c", "dismiss", "Cancel", priority=True),
    ]

    def __init__(self, sessions: list[SessionInfo]) -> None:
        super().__init__()
        # PRE-001: caller (_resolve_then_run) resolves the 0-session and
        # 1-session cases BEFORE constructing the picker.
        assert sessions
        self.sessions = sessions
        self.register_theme(AUSTRALIS_THEME)
        self.theme = "australis"

    def compose(self) -> ComposeResult:
        yield Header()
        yield Static(
            "Pick a session to resume (relaunch with --new for a fresh one):",
            id="picker-prompt",
        )
        yield ListView(
            *[
                ListItem(
                    Static(
                        f"{s.name or s.session_id} · {s.agent_id}",
                        classes="session-id-line",
                    ),
                    Static(_session_desc(s), classes="session-desc"),
                )
                for s in self.sessions
            ],
            id="session-list",
        )
        yield Footer()

    async def on_mount(self) -> None:
        self.query_one("#session-list", ListView).focus()

    def action_pick(self) -> None:
        lv = self.query_one("#session-list", ListView)
        idx = lv.index
        if idx is None:
            return  # nothing highlighted; ignore
        self.exit(self.sessions[idx].session_id)

    def action_dismiss(self) -> None:
        self.exit(None)


class RatatoskrApp(App[int]):
    """Textual TUI shell — single chat pane."""

    # Issue #13 + v0.5.0 follow-up: Horizontal two-column layout per
    # design-brief §5. Left column (2fr) is the **content-only** chat
    # surface — assistant text, user prompt echo, [done]/[error]/[cancelled]
    # terminal labels, post-Done markdown render. Right column (1fr) houses
    # ALL telemetry: live thinking preview docked above TabbedContent;
    # tab strip carries Tools (ToolStart/ToolResult) + Debug (Thinking
    # closed runs + WorkerPhase + TextBoundary).
    #
    # v0.5.0 routing change: thinking-current Static moved from left column
    # to right column header so the left column is genuinely content-only;
    # closed thinking runs go to debug-log instead of transcript.
    #
    # v0.5.0 chrome fix: Header/Footer backgrounds explicitly set to $surface
    # (Sea bright-black #373b46) overriding Textual's default $primary-blue
    # tinting. TabbedContent active-tab tinting also softened.
    #
    # Australis theme variables ($primary/$accent/$au-dark-60/$au-bright-cyan/
    # etc.) carry colors so a future theme swap rebinds centrally.
    DEFAULT_CSS = """
    /* v0.6.1: kill Textual's default $primary-blue tinting on chrome —
       Header sub-widgets (HeaderIcon, HeaderTitle, HeaderClock) each carry
       their own $primary tint that the parent `Header { background }` rule
       doesn't override; sub-selectors force the cool palette down. */
    Header, HeaderIcon, HeaderTitle, HeaderClock {
        background: $surface;
        color: $au-bright-blue;
    }
    Footer {
        background: $surface;
    }
    /* v0.6.1: scrollbars default to $primary-tint blue. Force Sea darks
       on the scrollable widgets (RichLog instances). */
    RichLog {
        scrollbar-background: $background;
        scrollbar-background-hover: $background;
        scrollbar-background-active: $background;
        scrollbar-color: $au-dark-50;
        scrollbar-color-hover: $au-dark-60;
        scrollbar-color-active: $au-bright-cyan;
    }
    #main-row {
        height: 1fr;
    }
    #left-column {
        width: 2fr;
        border-right: solid $panel;
    }
    #right-column {
        width: 1fr;
    }
    /* v0.6.5: thinking-current Static removed; thinking now streams
       directly into thinking-log so the whole pane scrolls naturally. */
    /* v0.9.0: transcript is a VerticalScroll container holding dynamically
       mounted Statics + Markdown widgets per turn. Live Markdown rendering
       replaces the v0.8.x RichLog approach which couldn't render Markdown
       in-flight (only on Done as a re-render → double-print bug). */
    #transcript-scroll {
        height: 1fr;
        background: $background;
        padding: 0 1;
    }
    /* Per-turn mounted widgets carry id-prefix conventions:
       - .turn-header   "── turn N ──" (dim)
       - .prompt-echo   "❯ user input"  (aurora bright cyan)
       - .response-md   Markdown(accumulated_text) — updated live
       - .done-label    "[done] turn_id=…" (aurora green)
       - .error-label   "[error] …"        (dawn red)
       - .cancelled-label "[cancelled] …"  (dawn yellow)
    */
    .turn-header {
        height: auto;
        padding: 0 1;
        color: $au-dark-60;
    }
    .prompt-echo {
        height: auto;
        padding: 0 1;
    }
    .response-md {
        height: auto;
        padding: 0 1;
    }
    .done-label, .error-label, .cancelled-label {
        height: auto;
        padding: 0 1;
    }
    /* v0.14.0: Worldtree #201 — live "awaiting first token · Ns" indicator
       in the transcript during the BuildingPrompt → CallingLLM gap.
       Demoted styling so it reads as ambient progress, not content. */
    .awaiting-label {
        height: auto;
        padding: 0 1;
        color: $au-dark-60;
    }
    /* v0.8.1: #current-text Static removed. Streaming text now coalesces
       on `\n` and writes directly to #transcript (same pattern as v0.7.1
       thinking fix). Eliminates the dock-bottom-growth-overlap bug. */
    #tools-log, #debug-log, #thinking-log, #bifrost-log, #admin-events-log {
        background: $background;
        padding: 0 1;
    }
    /* Tab strip + active-tab underline — kill blue, use Australis cyan. */
    #side-panes > ContentTabs {
        background: $surface;
    }
    #side-panes ContentTab.-active {
        color: $au-bright-cyan;
        text-style: bold;
    }
    #side-panes Underline > .underline--bar {
        color: $au-bright-cyan;
    }
    #prompt {
        dock: bottom;
        border: tall $panel;
    }
    /* v0.6.0: focused border uses Australis bright-cyan instead of $primary
       (Aurora blue) — kills the lingering blue tint the user flagged. */
    #prompt:focus {
        border: tall $au-bright-cyan;
    }
    /* Placeholder text in the Input — dimmer than typed content. */
    #prompt > .input--placeholder {
        color: $au-dark-50;
    }
    #identity {
        dock: bottom;
        height: 1;
        color: $au-bright-blue;
        padding: 0 1;
    }
    #pane-name {
        dock: bottom;
        height: 1;
        color: $au-bright-cyan;
        padding: 0 1;
    }
    #hint {
        dock: bottom;
        height: 1;
        color: $au-dark-60;
        padding: 0 1;
    }
    /* v0.13.0: sticky persona-header — one-line agent persona summary
       above the main row. Empty (height: 0) when the agent has no
       persona surface (PersonaNotConfigured) so the chat layout
       collapses cleanly. */
    #persona-header {
        dock: top;
        height: 1;
        color: $au-bright-80;
        background: $surface;
        padding: 0 1;
    }
    #persona-header.empty {
        display: none;
    }
    """

    BINDINGS: ClassVar[list[Binding]] = [
        Binding("ctrl+c", "interrupt", "Cancel / Exit", priority=True),
        Binding("ctrl+d", "quit", "Exit immediately", priority=True),
        # §5 keybinding family Ctrl+1..5 jumps between side panes
        # without losing Input focus (INV-016).
        Binding("ctrl+1", "focus_tools", "Tools tab", priority=False),
        Binding("ctrl+2", "focus_debug", "Debug tab", priority=False),
        Binding("ctrl+3", "focus_thinking", "Thinking tab", priority=False),
        Binding("ctrl+4", "focus_persona", "Persona tab", priority=False),
    ]

    HINT_IDLE = "Ctrl-C twice to exit"
    HINT_STREAMING = "Ctrl-C to cancel"
    HINT_CANCELLING = "Press Ctrl-C again to exit"

    def __init__(
        self,
        args: ParsedArgs,
        *,
        session_id: str,
        agent_id: str | None,
        client: httpx.AsyncClient,
    ) -> None:
        super().__init__()
        self.args = args
        self.session_id: str = session_id
        self.agent_id: str | None = agent_id
        self.client: httpx.AsyncClient = client
        self.state: Literal["idle", "streaming", "cancelling"] = "idle"
        self.active_turn_id: int | None = None
        self.stream_worker = None
        self.hint: str = self.HINT_IDLE
        self.register_theme(AUSTRALIS_THEME)
        self.theme = "australis"

    def compose(self) -> ComposeResult:
        yield Header()
        # v0.13.0: sticky persona-header docks at the top, above main-row.
        # One-line summary refreshed on each AffectUpdate(status=current).
        # Starts in the .empty CSS class (height collapses to 0) until
        # on_mount's get_persona_state hydration succeeds.
        yield Static("", id="persona-header", classes="empty")
        # v0.6.0 layout: left column is content-only (transcript + streaming
        # text Static + prompt). Right column hosts thinking-current live
        # preview above TabbedContent cycling Tools / Debug / Thinking.
        #
        # The current-text Static buffers in-flight assistant tokens so
        # streaming doesn't spam the RichLog with one line per delta —
        # the operator sees a single growing live line, then on Done the
        # Static clears and the final Markdown body lands in the transcript.
        #
        # markup=False on RichLog so labeled lines render verbatim; the
        # post-Done Markdown() / Rule() renders are Rich Renderables and
        # work without widget-level markup=True.
        with Horizontal(id="main-row"):
            with Vertical(id="left-column"):
                # v0.9.0: transcript is a VerticalScroll holding per-turn
                # mounted widgets (turn header, prompt echo, response Markdown,
                # done label). Live Markdown rendering happens via Static
                # widgets holding `Markdown` Renderables, updated as Text
                # deltas arrive.
                yield VerticalScroll(id="transcript-scroll")
                yield Input(id="prompt", placeholder="Type a message and press Enter")
            with Vertical(id="right-column"):
                with TabbedContent(id="side-panes"):
                    # v0.14.2: min_width=0 disables Textual's 78-cell floor
                    # on RichLog. The right column is 1fr against the left
                    # column's 2fr, so at typical terminal widths the right-
                    # column panes are narrower than 78 cells — and the
                    # default min_width=78 was forcing content to render at
                    # 78 wide and horizontally scroll instead of wrapping at
                    # the actual widget width. With min_width=0, wrap=True
                    # finally takes effect on long lines.
                    with TabPane("Tools", id="tools-tab"):
                        yield RichLog(
                            id="tools-log", wrap=True, markup=False,
                            highlight=False, min_width=0,
                        )
                    with TabPane("Debug", id="debug-tab"):
                        yield RichLog(
                            id="debug-log", wrap=True, markup=False,
                            highlight=False, min_width=0,
                        )
                    with TabPane("Thinking", id="thinking-tab"):
                        # v0.6.5: thinking streams directly into this
                        # RichLog (no separate bottom Static). Each delta
                        # writes a line; Rule(start)/Rule(end) mark run
                        # boundaries. The whole pane scrolls naturally
                        # as content arrives — no more "200-char tail
                        # window scrolling at the bottom".
                        yield RichLog(
                            id="thinking-log", wrap=True, markup=False,
                            highlight=False, min_width=0,
                        )
                    with TabPane("Persona", id="persona-tab"):
                        # v0.13.0: full persona-snapshot detail (PAD,
                        # mood drift, active emotions). Replaced (not
                        # appended) on each AffectUpdate(current) — the
                        # snapshot is absolute state, not incremental.
                        yield RichLog(
                            id="persona-log", wrap=True, markup=False,
                            highlight=False, min_width=0,
                        )
                    with TabPane("Bifrost", id="bifrost-tab"):
                        # #176: admin-scoped Bifrost dispatch state (endpoint,
                        # connected, granted caps, tools) via
                        # GET /admin/sessions/{id}/bifrost. Hydrated on mount
                        # with the admin key; "not configured" when absent.
                        yield RichLog(
                            id="bifrost-log", wrap=True, markup=False,
                            highlight=False, min_width=0,
                        )
                    with TabPane("AdminEvents", id="admin-events-tab"):
                        # #11: live GET /admin/events SSE stream, admin-scoped,
                        # FILTERED to the active session (design-brief §6). A
                        # long-lived worker appends matching lifecycle events;
                        # "not configured" when no admin key is set.
                        yield RichLog(
                            id="admin-events-log", wrap=True, markup=False,
                            highlight=False, min_width=0,
                        )
        # INV-002 + INV-003: visible identity + hint widgets (Footer-area).
        # pane-name widget displays current side-pane name.
        yield Static("", id="identity")
        yield Static("Tools", id="pane-name")
        yield Static(self.HINT_IDLE, id="hint")
        yield Footer()

    async def on_mount(self) -> None:
        """Populate identity widget from pre-resolved state; set idle hint.

        Per issue #6: session resolution + client open happen in `_resolve_then_run`
        BEFORE App.run_async() — only identity-widget population lives here.
        """
        assert self.client is not None and self.session_id is not None
        agent_slot = self.agent_id or "<unknown>"
        identity = f"{agent_slot} · …{self.session_id[-8:]}"
        self.sub_title = identity  # mirror to Header subtitle for redundancy
        self.query_one("#identity", Static).update(identity)
        # v0.5.1 polish: empty-state placeholder lines so the operator sees
        # the pane is intentionally empty (not broken) before any turn fires.
        # Wrapped in Australis dark-50 italic so they read distinctly as
        # placeholder text, not real telemetry. Disappear naturally as the
        # log fills with real events (the placeholders scroll off the top).
        from rich.text import Text as RichText
        placeholder_style = f"{_AU_DEMOTED_FAINT} italic"
        self.query_one("#tools-log", RichLog).write(
            RichText("(no tool events yet — start a turn that uses tools)",
                     style=placeholder_style)
        )
        self.query_one("#debug-log", RichLog).write(
            RichText("(waiting for worker_phase + text_boundary telemetry)",
                     style=placeholder_style)
        )
        self.query_one("#thinking-log", RichLog).write(
            RichText("(no chain-of-thought captured yet — start a turn)",
                     style=placeholder_style)
        )
        self.state = "idle"
        self._set_hint(self.HINT_IDLE)
        # v0.10.0: startup audit so the debug pane carries a complete
        # session bootstrap line (server URL, agent, end_user_id, raw flag,
        # session tail) before the first turn fires.
        self._audit(
            f"app_mounted server={self.args.server_url} agent_id={self.agent_id!r} "
            f"session={self.session_id[-8:]} raw={self.args.raw} "
            f"end_user_id={getattr(self.args, 'end_user_id', None)!r}"
        )
        # v0.13.0: hydrate persona surface on mount via
        # GET /agents/{id}/persona_state. Spawns as a Textual worker so the
        # network call doesn't block mount. Agents without a persona
        # surface (PersonaNotConfigured) get a placeholder + empty header.
        if self.agent_id is not None:
            self.run_worker(self._hydrate_persona())
        # #183: hydrate the Tools pane with the session's tool inventory via
        # GET /sessions/{id}/tools (owner-scoped — consumer key, no admin scope).
        # Unconditional: every session has a tool inventory to introspect.
        self.run_worker(self._hydrate_session_tools())
        # #176: hydrate the BifrostState pane via GET /admin/sessions/{id}/bifrost
        # (admin-scoped). Self-labels "not configured" when no admin key is set,
        # "not bound" for the common unbound-session 404 — always writes at mount.
        self.run_worker(self._hydrate_bifrost_state())
        # #11: long-lived worker streaming GET /admin/events into the AdminEvents
        # pane, filtered to this session. Admin-key-gated; cancelled on app exit.
        self.run_worker(self._stream_admin_events())

    async def _hydrate_persona(self) -> None:
        """Hydrate persona-header + Persona pane via GET /agents/{id}/persona_state.

        Failure modes are absorbed (this is best-effort observability):
          - PersonaNotConfigured: pane shows placeholder, header stays empty
          - AgentNotAvailable / AuthScopeDenied: error placeholder; header empty
          - Network error: error placeholder; header empty
        On 200: header populated, pane shows full detail, audit logged.
        """
        assert self.client is not None and self.agent_id is not None
        try:
            snapshot = await get_persona_state(self.client, self.agent_id)
            self._update_persona_surfaces(snapshot)
            self._audit(
                f"persona_hydrated agent_id={self.agent_id!r} "
                f"dominant_emotion={snapshot.get('dominant_emotion')!r}"
            )
        except PersonaNotConfigured:
            self._set_persona_placeholder(
                f"(persona not configured for {self.agent_id})"
            )
            self._audit(f"persona_not_configured agent_id={self.agent_id!r}")
        except (AgentNotAvailable, AuthScopeDenied, SessionApiFailed, Exception) as exc:
            # Best-effort — never let a persona hydration failure crash the
            # TUI. Surface the error in the persona pane and audit log.
            self._set_persona_placeholder(
                f"(persona hydration failed: {type(exc).__name__})"
            )
            self._audit(
                f"persona_hydration_failed agent_id={self.agent_id!r} "
                f"err={type(exc).__name__}: {exc!s:.120}"
            )

    async def _hydrate_session_tools(self) -> None:
        """Hydrate the Tools pane inventory via GET /sessions/{id}/tools (#183).

        Best-effort observability (mirrors _hydrate_persona): on 200, writes the
        merged tool inventory (builtin + bifrost) the LLM saw at turn-fire into
        the Tools pane + audits; on any failure, audits and moves on — never
        crashes the TUI. Owner-scoped, so reachable with the consumer key.
        """
        assert self.client is not None and self.session_id is not None
        from rich.text import Text as RichText

        try:
            tools = await get_session_tools(self.client, self.session_id)
        except Exception as exc:  # best-effort — never crash the TUI on hydrate
            self._audit(
                f"session_tools_hydration_failed session={self.session_id[-8:]} "
                f"err={type(exc).__name__}: {exc!s:.120}"
            )
            return
        log = self.query_one("#tools-log", RichLog)
        for line in _format_tool_inventory(tools):
            log.write(RichText(line))
        self._audit(
            f"session_tools_hydrated session={self.session_id[-8:]} "
            f"builtin={len(tools.get('builtin_tools', []))} "
            f"bifrost={len(tools.get('bifrost_tools', []))}"
        )

    async def _hydrate_bifrost_state(self) -> None:
        """Hydrate the BifrostState pane via GET /admin/sessions/{id}/bifrost (#176).

        Admin-scoped (admin.sessions.read) — uses `self.args.admin_key`. Best-effort
        (mirrors _hydrate_session_tools): on 200 writes the live binding (endpoint,
        connected, granted caps, tools) + audits; on failure a labeled line + audit,
        never crashes. No admin key → "not configured". 404 session_not_bifrost_bound
        is the routine unbound-session case; 403 means the key lacks the scope.
        """
        assert self.client is not None and self.session_id is not None
        from rich.text import Text as RichText

        log = self.query_one("#bifrost-log", RichLog)
        admin_key = getattr(self.args, "admin_key", None)
        if not admin_key:
            log.write(
                RichText("(admin key not configured — set RATATOSKR_ADMIN_API_KEY)")
            )
            self._audit(
                f"bifrost_state_skipped session={self.session_id[-8:]} reason=no_admin_key"
            )
            return
        try:
            state = await get_session_bifrost(
                self.client, self.session_id, admin_key=admin_key
            )
        except SessionApiFailed as exc:
            label = (
                "(session not bound to Bifrost)"
                if exc.status == 404
                else f"(bifrost state unavailable: HTTP {exc.status})"
            )
            log.write(RichText(label))
            self._audit(
                f"bifrost_state_unavailable session={self.session_id[-8:]} status={exc.status}"
            )
            return
        except Exception as exc:  # best-effort — never crash the TUI on hydrate
            log.write(RichText(f"(bifrost state hydration failed: {type(exc).__name__})"))
            self._audit(
                f"bifrost_state_hydration_failed session={self.session_id[-8:]} "
                f"err={type(exc).__name__}: {exc!s:.120}"
            )
            return
        for line in _format_bifrost_state(state):
            log.write(RichText(line))
        self._audit(
            f"bifrost_state_hydrated session={self.session_id[-8:]} "
            f"connected={state.get('connected')} tools={len(state.get('tools', []))}"
        )

    def _admin_event_matches(self, ev: AdminEvent) -> bool:
        """AdminEvents filter (design-brief §6): active-session events + non-heartbeat
        system.* (stream-integrity signals). Heartbeats are keepalive noise."""
        if ev.type == "system.heartbeat":
            return False
        if ev.type.startswith("system."):
            return True
        return ev.data.get("session_id") == self.session_id

    async def _stream_admin_events(self) -> None:
        """Stream GET /admin/events (admin-scoped) into the AdminEvents pane (#11).

        Long-lived + best-effort (never crashes the TUI). Filtered to the active
        session (design-brief §6): appends matching lifecycle events as they
        arrive. No admin key → "not configured". On connect failure (e.g. 403
        scope-denied) or a mid-stream drop, writes a labeled line and stops.
        """
        assert self.client is not None and self.session_id is not None
        from rich.text import Text as RichText

        log = self.query_one("#admin-events-log", RichLog)
        admin_key = getattr(self.args, "admin_key", None)
        if not admin_key:
            log.write(RichText("(admin key not configured — set RATATOSKR_ADMIN_API_KEY)"))
            self._audit(
                f"admin_events_skipped session={self.session_id[-8:]} reason=no_admin_key"
            )
            return
        try:
            async for ev in stream_admin_events(self.client, admin_key=admin_key):
                if self._admin_event_matches(ev):
                    log.write(RichText(_format_admin_event(ev)))
        except SseConnectFailed as exc:
            log.write(RichText(f"(admin events unavailable: HTTP {exc.status})"))
            self._audit(
                f"admin_events_unavailable session={self.session_id[-8:]} status={exc.status}"
            )
        except Exception as exc:  # drop / best-effort — never crash the TUI
            log.write(RichText(f"(admin events stream ended: {type(exc).__name__})"))
            self._audit(
                f"admin_events_ended session={self.session_id[-8:]} err={type(exc).__name__}"
            )

    def _update_persona_surfaces(self, snapshot: dict) -> None:
        """Update sticky header + Persona pane from a fresh snapshot.

        Called on bootstrap (on_mount) and on each AffectUpdate(current).
        Header gets the compact one-liner; pane gets the full detail.
        """
        from rich.text import Text as RichText
        try:
            header = self.query_one("#persona-header", Static)
            header.update(RichText(_format_persona_header(snapshot)))
            header.remove_class("empty")
        except Exception:
            pass
        try:
            log = self.query_one("#persona-log", RichLog)
            log.clear()
            log.write(_format_persona_detail(snapshot))
        except Exception:
            pass

    def _set_persona_placeholder(self, text: str) -> None:
        """Render an italic-dim placeholder in the Persona pane; keep header empty.

        Used when persona hydration returns PersonaNotConfigured or fails —
        the pane stays usable as documentation of *why* it's empty without
        the sticky header consuming a row for nothing.
        """
        from rich.text import Text as RichText
        try:
            log = self.query_one("#persona-log", RichLog)
            log.clear()
            log.write(RichText(text, style=f"{_AU_DEMOTED_FAINT} italic"))
        except Exception:
            pass

    def _write_turn_headers(self, turn_id: int) -> None:
        """v0.6.0: turn-ID headers across every pane for cross-pane
        correlation. v0.9.0: transcript is a VerticalScroll; mounts a
        Static with rule-style text instead of writing a Rule Renderable
        to RichLog. Other panes still use RichLog.write(Rule).
        """
        from rich.rule import Rule
        from rich.text import Text as RichText

        title = f"turn {turn_id}"
        rule = Rule(title=title, style=_AU_DEMOTED)
        try:
            # Transcript (VerticalScroll): mount a styled Static.
            transcript = self.query_one("#transcript-scroll", VerticalScroll)
            transcript.mount(
                Static(
                    RichText(f"── turn {turn_id} ──", style=_AU_DEMOTED),
                    classes="turn-header",
                )
            )
            # Other panes (RichLog): write the Rule Renderable.
            self.query_one("#tools-log", RichLog).write(rule)
            self.query_one("#debug-log", RichLog).write(rule)
            self.query_one("#thinking-log", RichLog).write(rule)
            transcript.scroll_end(animate=False)
        except Exception:
            # Defensive: widget tree may be tearing down — never let a
            # turn-header write block the SSE consumer.
            pass

    def _set_hint(self, hint: str) -> None:
        """Set the hint state attribute AND update the visible Static widget."""
        self.hint = hint
        try:
            self.query_one("#hint", Static).update(hint)
        except Exception:
            # Widget may be gone during shutdown; ignore.
            pass

    def _audit(self, line: str) -> None:
        """Write a timestamped audit line to the debug pane.

        v0.10.0: shared sink for app-level events that don't pass through
        the presenter — state transitions, worker spawn/cancel, cancel POST
        lifecycle, startup probes. The presenter's per-event audit lives at
        `_audit_line()`; this is its app-side counterpart.
        """
        try:
            from rich.text import Text as RichText
            self.query_one("#debug-log", RichLog).write(
                RichText(f"[{_ts()}] {line}", style=_AU_DEMOTED)
            )
        except Exception:
            # Widget may not exist yet (pre-mount) or be tearing down.
            pass

    def _transition(
        self, new_state: Literal["idle", "streaming", "cancelling"], reason: str
    ) -> None:
        """Set self.state with debug-pane audit log.

        Every state machine transition flows through here so the debug pane
        carries a complete idle→streaming→cancelling→idle timeline with the
        triggering reason. Cheap; safe to call from any context.
        """
        old = self.state
        self.state = new_state
        if old != new_state:
            self._audit(f"state {old} → {new_state} reason={reason}")

    async def on_input_submitted(self, event: Input.Submitted) -> None:
        """Echo user prompt, spawn stream worker; busy notice if not idle.

        v0.9.0: prompt echo mounts as a Static in the transcript VerticalScroll
        (was log.write to RichLog).
        """
        if event.input.id != "prompt":
            return
        transcript = self.query_one("#transcript-scroll", VerticalScroll)
        if self.state != "idle":
            transcript.mount(
                Static("[busy] turn in flight; input ignored", classes="error-label")
            )
            transcript.scroll_end(animate=False)
            event.input.value = ""
            return
        content = event.input.value.strip()
        if not content:
            return
        # v0.4.1 retheme: operator's voice gets Australis bright cyan so it
        # stands out against the default-foreground assistant text below it.
        from rich.text import Text as RichText
        transcript.mount(
            Static(
                RichText(f"❯ {content}", style=_AU_USER_ECHO),  # noqa: RUF001
                classes="prompt-echo",
            )
        )
        transcript.scroll_end(animate=False)
        event.input.value = ""
        self._transition("streaming", "input_submitted")
        self._audit(f"worker_spawn content_len={len(content)}")
        self._set_hint(self.HINT_STREAMING)
        self.stream_worker = self.run_worker(
            self._stream_turn_worker(content), exclusive=True
        )

    async def _stream_turn_worker(self, content: str) -> None:
        """Drive stream_turn, render events via TuiPresenterState.

        v0.9.0: transcript is a VerticalScroll; the presenter's `transcript`
        argument is the container, and the presenter mounts Static / Markdown-
        backed widgets directly. Wire-error labels mount as `error-label`
        Statics into the transcript-scroll.
        """
        assert self.state == "streaming"
        assert self.client is not None
        assert content
        transcript = self.query_one("#transcript-scroll", VerticalScroll)
        tools_log = self.query_one("#tools-log", RichLog)
        debug_log = self.query_one("#debug-log", RichLog)
        thinking_log = self.query_one("#thinking-log", RichLog)
        presenter = TuiPresenterState()

        def _mount_wire_error(label: str) -> None:
            try:
                transcript.mount(Static(label, classes="error-label"))
                transcript.scroll_end(animate=False)
            except Exception:
                pass

        try:
            async for event in stream_turn_resilient(self.client, self.session_id, content):
                if self.active_turn_id is None:
                    self.active_turn_id = event.sse_id.turn_id
                    self._write_turn_headers(self.active_turn_id)
                presenter.render(
                    event,
                    transcript=transcript,
                    tools_log=tools_log,
                    debug_log=debug_log,
                    thinking_log=thinking_log,
                    raw=self.args.raw,
                    on_persona_snapshot=self._update_persona_surfaces,
                )
                if isinstance(event, (Done, Error, Cancelled)):
                    break
        except SseConnectFailed as exc:
            self._audit(f"sse_connect_failed status={exc.status} body={exc.body!r:.120}")
            _mount_wire_error(f"[sse_connect_failed] status={exc.status} body={exc.body!r}")
        except SseConnectionDropped as exc:
            self._audit(f"connection_dropped last_seen={exc.last_seen_sse_id}")
            _mount_wire_error(f"[connection_dropped] last_seen={exc.last_seen_sse_id}")
        except MalformedSseId as exc:
            self._audit(f"malformed_sse_id raw={exc.raw!r}")
            _mount_wire_error(f"[malformed_sse_id] raw={exc.raw!r}")
        except MalformedSseData as exc:
            self._audit(f"malformed_sse_data raw={exc.raw!r:.120}")
            _mount_wire_error(f"[malformed_sse_data] raw={exc.raw!r}")
        except TurnIdFlip as exc:
            self._audit(f"turn_id_flip expected={exc.established} got={exc.got}")
            _mount_wire_error(f"[turn_id_flip] expected={exc.established} got={exc.got}")
        finally:
            self._transition("idle", "worker_finally")
            self.active_turn_id = None
            self._set_hint(self.HINT_IDLE)

    async def on_unmount(self) -> None:
        """No-op — client lifetime is managed by run_tui's async-with (INV-002)."""
        return None

    def action_interrupt(self) -> None:
        """Two-stage Ctrl-C state machine per INV-003."""
        assert self.state in ("idle", "streaming", "cancelling")
        if self.state == "idle":
            self._audit("ctrl_c state=idle action=exit code=0")
            self.exit(0)
        elif self.state == "streaming":
            if self.active_turn_id is None:
                self._audit("ctrl_c state=streaming active_turn_id=None action=force_exit code=3")
                if self.stream_worker is not None:
                    self.stream_worker.cancel()
                self.exit(3)
                return
            self._audit(f"ctrl_c state=streaming turn_id={self.active_turn_id} action=cancel_post")
            self._transition("cancelling", "ctrl_c_cancel_post_issued")
            self._set_hint(self.HINT_CANCELLING)
            transcript = self.query_one("#transcript-scroll", VerticalScroll)
            self.run_worker(
                _cancel_via_sse(
                    self.client, self.session_id, self.active_turn_id,
                    transcript=transcript,
                    audit=self._audit,
                )
            )
        elif self.state == "cancelling":
            self._audit("ctrl_c state=cancelling action=force_exit code=3")
            if self.stream_worker is not None:
                self.stream_worker.cancel()
            self.exit(3)

    def action_quit(self) -> None:
        """Ctrl-D — immediate exit regardless of state."""
        self._audit(f"ctrl_d state={self.state} action=exit code=0")
        if self.stream_worker is not None and not self.stream_worker.is_finished:
            self.stream_worker.cancel()
        self.exit(0)

    def action_focus_tools(self) -> None:
        """Issue #13: Ctrl+1 activates the Tools tab. INV-016 preserves Input focus.

        Current Textual behavior preserves Input focus when TabbedContent.active is
        set programmatically. If a future Textual regresses on that, add an
        explicit `self.query_one('#prompt', Input).focus()` after the assignment
        — `test_ctrl_1_preserves_input_focus` is the regression guard.
        """
        self.query_one("#side-panes", TabbedContent).active = "tools-tab"
        self.query_one("#pane-name", Static).update("Tools")

    def action_focus_debug(self) -> None:
        """v0.5.0: Ctrl+2 activates the Debug tab. INV-016 preserves Input focus."""
        self.query_one("#side-panes", TabbedContent).active = "debug-tab"
        self.query_one("#pane-name", Static).update("Debug")

    def action_focus_thinking(self) -> None:
        """v0.6.0: Ctrl+3 activates the Thinking tab. INV-016 preserves Input focus."""
        self.query_one("#side-panes", TabbedContent).active = "thinking-tab"
        self.query_one("#pane-name", Static).update("Thinking")

    def action_focus_persona(self) -> None:
        """v0.13.0: Ctrl+4 activates the Persona tab. INV-016 preserves Input focus."""
        self.query_one("#side-panes", TabbedContent).active = "persona-tab"
        self.query_one("#pane-name", Static).update("Persona")


def run_tui(args: ParsedArgs) -> int:
    """Sync entry point — delegates to the async resolve-then-run flow.

    Per issue #6: session resolution + AsyncClient open happen BEFORE the
    Textual alt-screen opens, so startup errors land on the operator's real
    stderr instead of getting eaten by the alt-screen teardown.
    """
    # PRE-001: TUI-mode marker (issue #4 contract)
    assert isinstance(args, ParsedArgs) and args.send_content is None
    # PRE-002 (slice b2): --session and --new are mutually exclusive, but NEITHER
    # is now valid — bare TUI mode opens the startup session picker (§4).
    assert not (args.session_id and args.new)
    return asyncio.run(_resolve_then_run(args))


async def _resolve_then_run(args: ParsedArgs) -> int:
    """Pre-flight session resolution then App.run_async() inside one event loop.

    Errors at this layer print to `sys.stderr` (the operator's real terminal)
    and short-circuit BEFORE the alt-screen opens (INV-001). The label format
    + exit codes match `ratatoskr.cli._amain`'s exactly (INV-006), so operators
    see one vocabulary across `--send` and TUI modes.
    """
    # PRE-001 (defense-in-depth; run_tui also asserts at the sync boundary)
    assert isinstance(args, ParsedArgs) and args.send_content is None
    async with httpx.AsyncClient(
        base_url=args.server_url,
        headers={
            "Authorization": f"Bearer {args.api_key}",
            "User-Agent": USER_AGENT,
        },
        # SSE streaming sits idle between events while the LLM thinks.
        # Default 5s read timeout would kill mid-stream; disable it.
        timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0),
    ) as client:
        # Issue #8: startup agent picker — fetch GET /agents and prompt when
        # --new is passed without --agent. list_agents errors land on real
        # stderr before any alt-screen opens (preserves issue #6 INV-001).
        #
        # v0.8.0: merge in local tier-3 agent index. Worldtree's GET /agents
        # doesn't return consumer-defined agents (issue #15 smoke finding);
        # ratatoskr keeps its own JSON-backed index of agents the operator
        # defined via `python -m ratatoskr.tier3 define`. Merged here so the
        # picker shows foundational + local-tier-3 in one list. Dedup by
        # agent_id (remote wins on conflict, since a server-listed agent
        # is the authoritative source).
        chosen_agent_id: str | None = args.agent_id
        # slice b2: bare TUI mode (no --session, no --new) → startup session
        # picker (design-brief §4). Resolve into a concrete session_id BEFORE
        # the new/resume branches. Resume-only: bare + 0 sessions is an error
        # (creating a session is the --new flag's job).
        resolved_session_id: str | None = args.session_id
        if not args.new and args.session_id is None:
            try:
                page = await list_sessions(client)
            except SessionApiFailed as exc:
                sys.stderr.write(
                    f"[session_api_failed] status={exc.status} body={exc.body!r}\n"
                )
                return 20
            except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
                sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
                return 21
            if not page.items:
                sys.stderr.write(
                    "[no_sessions] no sessions to resume; "
                    "launch with --new --agent <id>\n"
                )
                return 14
            if len(page.items) == 1:
                # §4: picker only when >1 — a single session auto-resumes.
                resolved_session_id = page.items[0].session_id
            else:
                resolved_session_id = await SessionPickerApp(page.items).run_async()
                if resolved_session_id is None:
                    return 0  # Esc / Ctrl-D — clean exit, no session opened
        if args.new and args.agent_id is None:
            try:
                agents = await list_agents(client)
            except SessionApiFailed as exc:
                sys.stderr.write(
                    f"[session_api_failed] status={exc.status} body={exc.body!r}\n"
                )
                return 20
            except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
                sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
                return 21
            # v0.8.0: append local tier-3 entries not already in the remote list.
            from ratatoskr.local_agents import load_local_agents

            remote_ids = {a.agent_id for a in agents}
            for entry in load_local_agents():
                if entry.agent_id in remote_ids:
                    continue
                agents.append(AgentInfo(
                    agent_id=entry.agent_id,
                    name=entry.agent_name,
                    description=entry.description,
                    version=None,
                    capabilities=[],
                    supported_models=[],
                    persona_traits={},
                    ui_hints={},
                ))
            if not agents:
                sys.stderr.write("[no_agents] server returned empty agent list\n")
                return 13
            picker = AgentPickerApp(agents)
            chosen_agent_id = await picker.run_async()
            if chosen_agent_id is None:
                return 0  # Esc / Ctrl-D — clean exit, no session created
        if args.new:
            assert chosen_agent_id is not None
            try:
                info = await create_session(
                    client,
                    chosen_agent_id,
                    end_user_id=args.end_user_id,
                    bifrost=args.bifrost,
                    consumer_key=args.consumer_key,
                )
            except AgentNotFound as exc:
                sys.stderr.write(f"[agent_not_found] agent_id={exc.agent_id}\n")
                return 12
            except BifrostConsumerKeyMissing as exc:
                # INV-001/INV-002: bind failures land on real stderr BEFORE the
                # alt-screen opens (mirrors cli._amain exit codes / vocab, INV-006).
                sys.stderr.write(
                    f"[bifrost_consumer_key_missing] {exc} "
                    f"(set RATATOSKR_BIFROST_CONSUMER_KEY)\n"
                )
                return 22
            except BifrostHandshakeFailed as exc:
                sys.stderr.write(
                    f"[bifrost_handshake_failed] bifrost_error={exc.bifrost_error}\n"
                )
                if exc.bifrost_error == "bifrost.auth_rejected":
                    sys.stderr.write(
                        "  bound create requires the consumer key "
                        "(RATATOSKR_BIFROST_CONSUMER_KEY), not WORLDTREE_API_KEY\n"
                    )
                return 23
            except SessionApiFailed as exc:
                sys.stderr.write(
                    f"[session_api_failed] status={exc.status} body={exc.body!r}\n"
                )
                return 20
            except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
                sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
                return 21
            # Issue #17 bound-state indicator (pre-alt-screen, mirrors cli._amain).
            if args.bifrost is not None:
                plane = args.bifrost_plane or "direct"
                sys.stderr.write(
                    f". bifrost: status=bound plane={plane} "
                    f"endpoint={args.bifrost.endpoint_url}\n"
                )
            session_id = info.session_id
            agent_id: str | None = info.agent_id
            # #347 authored first-message: seed the agent's preset opening (best-effort).
            await seed_preset_first_message(client, session_id, chosen_agent_id)
        else:
            assert resolved_session_id is not None
            session_id = resolved_session_id
            agent_id = args.agent_id  # may be None — INV-002 carve-out preserved
        app = RatatoskrApp(args, session_id=session_id, agent_id=agent_id, client=client)
        exit_code = await app.run_async()
        return exit_code or 0


async def _cancel_via_sse(
    client: httpx.AsyncClient,
    session_id: str,
    turn_id: int,
    *,
    transcript: VerticalScroll,
    audit: Callable[[str], None] | None = None,
) -> None:
    """Fire-and-forget cancel; never raises (mirrors cli._cancel_and_log; #3 INV-009).

    v0.9.0: mounts a `[cancel_failed]` Static into the transcript-scroll
    container on failure (was log.write to RichLog).
    v0.10.0: optional `audit` callback (RatatoskrApp._audit) receives one
    line on POST issue + one on POST result, so the debug pane carries the
    full cancel lifecycle. Defaults to no-op for legacy callers.
    """
    assert client is not None
    assert isinstance(turn_id, int) and turn_id > 0
    if audit is not None:
        audit(f"cancel_post issued session_id={session_id} turn_id={turn_id}")
    try:
        await cancel_turn(client, session_id, turn_id)
        if audit is not None:
            audit(f"cancel_post ok turn_id={turn_id}")
    except (CancelFailed, CancelTurnNotFound, CancelAlreadyCompleted, httpx.RequestError) as exc:
        if audit is not None:
            audit(f"cancel_post failed turn_id={turn_id} {type(exc).__name__}: {exc!s:.120}")
        try:
            transcript.mount(Static(
                f"[cancel_failed] {type(exc).__name__}: {exc}",
                classes="error-label",
            ))
            transcript.scroll_end(animate=False)
        except Exception:
            pass
