"""Interactive CLI mode and single-shot execution.""" import asyncio import logging import queue import random import sys from collections.abc import Callable from dataclasses import dataclass from datetime import datetime from typing import TYPE_CHECKING, Any import typer # type: ignore[import-untyped] from prompt_toolkit import PromptSession # type: ignore[import-untyped] from prompt_toolkit.auto_suggest import ( AutoSuggestFromHistory, # type: ignore[import-untyped] ) from prompt_toolkit.completion import ( # type: ignore[import-untyped] Completer, Completion, ) from prompt_toolkit.formatted_text import HTML # type: ignore[import-untyped] from prompt_toolkit.history import FileHistory # type: ignore[import-untyped] from prompt_toolkit.key_binding import KeyBindings # type: ignore[import-untyped] from prompt_toolkit.patch_stdout import patch_stdout # type: ignore[import-untyped] from prompt_toolkit.shortcuts import CompleteStyle # type: ignore[import-untyped] from prompt_toolkit.styles import Style as PtStyle # type: ignore[import-untyped] from rich.markdown import Markdown from rich.markup import escape from rich.panel import Panel from rich.text import Text import EvoScientist.cli.channel as _ch_mod from ..commands.base import Command, CommandContext from ..commands.manager import manager as cmd_manager from ..gateway import ( GraphGateway, GraphTarget, RuntimeGateways, create_runtime_gateways, ) from ..sessions import get_checkpointer, short_thread_id from ..stream.console import console from ..stream.display import _fix_markdown_heading_spacing from . import async_notifier from ._agent_loader import BackgroundAgentLoader, MCPProgressTracker from ._constants import ( DANGEROUS_BANNER_LABEL, DANGEROUS_BANNER_MESSAGE, LOGO_GRADIENT, LOGO_LINES, WELCOME_SLOGANS, build_metadata, ) from .agent import _create_session_workspace, _load_agent, _shorten_path from .channel import ( ChannelMessage, _auto_start_channel, _channels_is_running, _message_queue, _set_channel_response, dispatch_channel_slash_command, ) from .file_mentions import complete_file_mention, resolve_file_mentions from .rich_command_ui import RichCLICommandUI from .status_bar import ( SPINNER_FRAMES, STATUS_BAD, STATUS_BAR_BG, STATUS_CRITICAL, STATUS_DIM, STATUS_GOOD, STATUS_STRONG, STATUS_TEXT, STATUS_WARN, apply_assistant_text_to_snapshot, apply_user_text_to_snapshot, build_session_status_snapshot, build_status_fragments, build_status_text, make_empty_status_snapshot, make_usage_status_snapshot, ) from .tui_interactive import run_textual_interactive from .tui_runtime import resolve_ui_backend, run_streaming _MEMORY_WORKER_SHUTDOWN_WAIT_SECONDS = 120.0 _MEMORY_WORKER_SHUTDOWN_POLL_SECONDS = 0.5 _MEMORY_WORKER_OUTPUT_GRACE_SECONDS = 3.0 _channel_logger = logging.getLogger(__name__) # Keeps references to fire-and-forget coroutines so they aren't GC'd mid-flight. _background_tasks: set[asyncio.Task] = set() if TYPE_CHECKING: from langgraph.graph.state import CompiledStateGraph @dataclass(frozen=True, slots=True) class _StartupSession: """Resolved interactive startup session with a concrete active thread.""" thread_id: str workspace_dir: str | None resumed: bool # ============================================================================= # Banner # ============================================================================= def print_banner( thread_id: str, workspace_dir: str | None = None, memory_dir: str | None = None, mode: str | None = None, model: str | None = None, provider: str | None = None, ui_backend: str | None = None, ): """Print welcome banner with ASCII art logo, info line, and hint.""" for line, color in zip(LOGO_LINES, LOGO_GRADIENT, strict=False): console.print(Text(line, style=f"{color} bold")) info = Text() info.append(" ", style="dim") parts: list[tuple[str, str]] = [] if model: parts.append(("Model: ", model)) if provider: parts.append(("Provider: ", provider)) if mode: parts.append(("Mode: ", mode)) if ui_backend: parts.append(("UI: ", ui_backend)) for i, (label, value) in enumerate(parts): if i > 0: info.append(" ", style="dim") info.append(label, style="dim") info.append(value, style="magenta") # Directory line import os effective_dir = workspace_dir or os.getcwd() home = os.path.expanduser("~") dir_display = ( effective_dir.replace(home, "~", 1) if effective_dir.startswith(home) else effective_dir ) info.append("\n ", style="dim") info.append("Directory: ", style="dim") info.append(dir_display, style="magenta") _nl_key = "Option+Enter" if sys.platform == "darwin" else "Ctrl+J" info.append("\n Enter ", style="#ffe082") info.append("send", style="#ffe082 bold") info.append(f" \u2022 {_nl_key} ", style="#ffe082") info.append("newline", style="#ffe082 bold") info.append(" \u2022 Type ", style="#ffe082") info.append("/", style="#ffe082 bold") info.append(" for commands", style="#ffe082") info.append(" \u2022 ", style="#ffe082") info.append("@ files", style="#ffe082 bold") info.append(" \u2022 Ctrl+C ", style="#ffe082") info.append("interrupt", style="#ffe082 bold") console.print(info) print_dangerous_warning() def print_dangerous_warning() -> None: """Print an unmissable warning when dangerous (real-filesystem) mode is on.""" try: from ..config import get_effective_config if not get_effective_config().dangerous_mode: return except Exception: return warn = Text() warn.append(f"\n \u26a0 {DANGEROUS_BANNER_LABEL}", style="bold white on red") warn.append(f" {DANGEROUS_BANNER_MESSAGE}", style="bold red") console.print(warn) # ============================================================================= # Slash-command completer # ============================================================================= _COMPLETION_STYLE = PtStyle.from_dict( { "completion-menu": "bg:default noreverse nounderline noitalic", "completion-menu.completion": "bg:default #888888 noreverse", "completion-menu.completion.current": "bg:default default bold noreverse", "completion-menu.meta.completion": "bg:default #888888 noreverse", "completion-menu.meta.completion.current": "bg:default default bold noreverse", "scrollbar.background": "bg:default", "scrollbar.button": "bg:default", "status-bar": f"bg:{STATUS_BAR_BG} {STATUS_TEXT}", "status-bar-strong": f"bg:{STATUS_BAR_BG} {STATUS_STRONG} bold", "status-bar-dim": f"bg:{STATUS_BAR_BG} {STATUS_DIM}", "status-bar-good": f"bg:{STATUS_BAR_BG} {STATUS_GOOD} bold", "status-bar-warn": f"bg:{STATUS_BAR_BG} {STATUS_WARN} bold", "status-bar-bad": f"bg:{STATUS_BAR_BG} {STATUS_BAD} bold", "status-bar-critical": f"bg:{STATUS_BAR_BG} {STATUS_CRITICAL} bold", } ) class SlashCommandCompleter(Completer): """Autocomplete for slash commands and ``@file`` mentions. ``workspace_getter`` is invoked on every keystroke so ``@file`` suggestions automatically follow ``/new`` / ``/resume`` workspace changes without having to poke the completer from the callbacks. """ def __init__( self, workspace_getter: Callable[[], str | None] | None = None, ) -> None: """Initialise the completer. Args: workspace_getter: Callable returning the current workspace directory for ``@file`` completions. Called on every keystroke so suggestions stay in sync after ``/new``. """ self._workspace_getter = workspace_getter or (lambda: None) def get_completions(self, document, complete_event): """Yield prompt_toolkit completions for slash commands and ``@file``.""" text = document.text_before_cursor workspace_dir = self._workspace_getter() # Slash command / subcommand completions take priority if text.startswith("/"): from ..commands._completion_engine import compute_completions result = compute_completions(text, len(text)) if result.kind != "empty" and result.candidates: for c in result.candidates: start_pos = c.replace_start - len(text) yield Completion( c.text, start_position=start_pos, display_meta=c.description, ) return # @file mention completion (only for non-command input) if "@" in text: candidates = complete_file_mention(text, workspace_dir) if candidates: import re as _re m = _re.search(r'@"[^"\n]*$|@[^\s"\']*$', text) start = -len(m.group(0)) if m else 0 for path, type_hint in candidates: yield Completion(path, start_position=start, display_meta=type_hint) return async def _resolve_startup_session( requested_thread_id: str | None, *, workspace_dir: str | None, graph_gateway: GraphGateway, config: Any, ) -> _StartupSession: """Resolve/create the initial CLI session before shared REPL state exists.""" if not requested_thread_id: return _StartupSession( thread_id=await graph_gateway.create_thread( GraphTarget(workspace_dir=workspace_dir) ), workspace_dir=workspace_dir, resumed=False, ) resolution = await graph_gateway.resolve_thread(requested_thread_id) if resolution.thread_id is None: if resolution.matches: console.print( f"[yellow]Ambiguous thread ID '{escape(requested_thread_id)}'. " "Matches:[/yellow]" ) for match in resolution.matches: console.print(f" [cyan]{match}[/cyan]") else: console.print( f"[red]Thread '{escape(requested_thread_id)}' not found.[/red]" ) return _StartupSession( thread_id=await graph_gateway.create_thread( GraphTarget(workspace_dir=workspace_dir) ), workspace_dir=workspace_dir, resumed=False, ) resolved_thread_id = resolution.thread_id metadata = await graph_gateway.get_thread_metadata(resolved_thread_id) resolved_workspace = (metadata or {}).get("workspace_dir") or workspace_dir if resolved_workspace: from ..langgraph_dev.manager import WorkspaceMismatchError from .commands import _sync_background_agent_server_workspace try: await _sync_background_agent_server_workspace( config, workspace_dir=resolved_workspace, ) except WorkspaceMismatchError as exc: console.print(f"[red]{exc}[/red]") raise typer.Exit(1) from exc return _StartupSession( thread_id=resolved_thread_id, workspace_dir=resolved_workspace, resumed=True, ) # ============================================================================= # Interactive & single-shot modes # ============================================================================= def cmd_interactive( show_thinking: bool = True, channel_send_thinking: bool = True, workspace_dir: str | None = None, workspace_fixed: bool = False, mode: str | None = None, model: str | None = None, provider: str | None = None, run_name: str | None = None, thread_id: str | None = None, ui_backend: str = "cli", config=None, ) -> None: """Interactive conversation mode with streaming output. The persistent ``AsyncSqliteSaver`` checkpointer is opened here and shared for the entire interactive session lifetime. Args: show_thinking: Whether to display thinking panels channel_send_thinking: Whether channels should receive thinking messages workspace_dir: Per-session workspace directory path workspace_fixed: If True, /new keeps the same workspace directory mode: Workspace mode ('daemon' or 'run'), displayed in banner model: Model name to display in banner provider: LLM provider name to display in banner run_name: Optional run name for /new session deduplication thread_id: Optional thread ID to resume a previous session ui_backend: UI backend ('cli' or 'tui') """ import nest_asyncio nest_asyncio.apply() resolved_ui_backend = resolve_ui_backend(ui_backend, warn_fallback=True) if resolved_ui_backend == "tui": from functools import partial load_agent = partial(_load_agent, config=config) run_textual_interactive( show_thinking=show_thinking, channel_send_thinking=channel_send_thinking, workspace_dir=workspace_dir, workspace_fixed=workspace_fixed, mode=mode, model=model, provider=provider, run_name=run_name, thread_id=thread_id, load_agent=load_agent, create_session_workspace=_create_session_workspace, config=config, ) return from .. import paths memory_dir = str(paths.MEMORIES_DIR) paths.DATA_DIR.mkdir(parents=True, exist_ok=True) history_file = str(paths.DATA_DIR / "history") # Key bindings: Enter submits, Alt+Enter (Option+Enter) inserts newline _kb = KeyBindings() @_kb.add("escape", "enter") # Alt+Enter / Option+Enter on macOS def _insert_newline(event): event.current_buffer.insert_text("\n") @_kb.add("enter") def _submit(event): event.current_buffer.validate_and_handle() session = PromptSession( history=FileHistory(history_file), auto_suggest=AutoSuggestFromHistory(), completer=SlashCommandCompleter( workspace_getter=lambda: state["workspace_dir"], ), complete_style=CompleteStyle.COLUMN, complete_while_typing=True, style=_COMPLETION_STYLE, multiline=True, key_bindings=_kb, ) def _print_separator(): """Print a horizontal separator line spanning the terminal width.""" width = console.size.width console.print(Text("\u2500" * width, style="dim")) from ..commands.base import ChannelRuntime channel_runtime = ChannelRuntime() progress_tracker = MCPProgressTracker() def _on_mcp_progress(event: str, server: str, detail: str) -> None: """Record progress + print the inline ✓/✗ line. Runs on the MCP worker thread; ``console.print`` while the main loop is inside ``patch_stdout`` lands above the prompt safely. """ new_state = progress_tracker.record(event, server, detail) if new_state == "ok": console.print( f"[green]\u2713[/green] [dim]MCP[/dim] [bold]{server}[/bold] " f"[dim]({detail} tools)[/dim]" ) elif new_state == "error": console.print( f"[red]\u2717[/red] [dim]MCP[/dim] [bold]{server}[/bold] " f"[red]failed:[/red] {escape(detail)}" ) agent_loader = BackgroundAgentLoader( _load_agent, on_progress=_on_mcp_progress, ) runtime_gateways = create_runtime_gateways() graph_gateway = runtime_gateways.graph_gateway requested_thread_id = thread_id # Mutable state for async loop state: dict[str, Any] = { "workspace_dir": workspace_dir, "running": True, "resumed": False, "ui_backend": resolved_ui_backend, "status_started_at": datetime.now(), "status_base_snapshot": make_empty_status_snapshot(model), "status_snapshot": make_empty_status_snapshot(model), "status_streaming_text": "", "status_last_input_tokens": None, } def _on_status_after_compact(input_tokens: int) -> None: """Mirror inline /compact post-update: refresh both fields so the next status render reflects the reduced context immediately. ``_refresh_status_snapshot`` is invoked by the dispatch block once the command finishes (since it's async).""" state["status_last_input_tokens"] = input_tokens state["status_base_snapshot"] = make_usage_status_snapshot( input_tokens, model_name=model, ) # ``rich_ui`` is constructed inside ``_async_main_loop`` so the # lifecycle callbacks can close over ``checkpointer`` from # ``get_checkpointer()``. def _start_agent_load(checkpointer) -> None: progress_tracker.prime() agent_loader.start( workspace_dir=state["workspace_dir"], checkpointer=checkpointer, config=config, ) async def _await_agent_ready() -> "CompiledStateGraph": """Await the agent load and apply CLI-side post-load side effects. Raises when called before ``_start_agent_load``: reloading here would drop the SQLite checkpointer and silently lose persistence. """ try: agent = await agent_loader.await_ready() except RuntimeError as exc: if "before start()" in str(exc): raise RuntimeError( "_await_agent_ready called before _start_agent_load — " "the checkpointer reference is not available here." ) from exc raise await _refresh_status_snapshot(reset_streaming_text=True) if _channels_is_running(): channel_runtime.bind(agent, state["thread_id"]) return agent def _rebuild_status_snapshot() -> None: """Compose the visible snapshot from thread state + live output.""" state["status_snapshot"] = apply_assistant_text_to_snapshot( state["status_base_snapshot"], state["status_streaming_text"], ) def _set_status_streaming_text(text: str | None) -> None: """Update the in-flight assistant overlay used by the status bar.""" new_text = text or "" if new_text == state["status_streaming_text"]: return state["status_streaming_text"] = new_text _rebuild_status_snapshot() async def _refresh_status_snapshot( pending_user_text: str | None = None, *, reset_streaming_text: bool = True, ) -> None: """Recompute the persistent status-bar snapshot for the active thread.""" pending = (pending_user_text or "").strip() if pending: if state["status_last_input_tokens"] is not None: state["status_base_snapshot"] = apply_user_text_to_snapshot( make_usage_status_snapshot( state["status_last_input_tokens"], model_name=model, ), pending, ) else: state["status_base_snapshot"] = await build_session_status_snapshot( state["thread_id"], model_name=model, pending_user_text=pending, graph_gateway=graph_gateway, ) elif state["status_last_input_tokens"] is not None: state["status_base_snapshot"] = make_usage_status_snapshot( state["status_last_input_tokens"], model_name=model, ) else: state["status_base_snapshot"] = await build_session_status_snapshot( state["thread_id"], model_name=model, graph_gateway=graph_gateway, ) if reset_streaming_text: state["status_streaming_text"] = "" _rebuild_status_snapshot() def _bottom_toolbar(): """Render the persistent bottom status bar for prompt_toolkit.""" try: from prompt_toolkit.application import get_app width = get_app().output.get_size().columns except Exception: width = console.size.width fragments = build_status_fragments( state["status_snapshot"], state["status_started_at"], width, ) if agent_loader.is_pending: # Per-server ✓/✗ lines are printed above the prompt by # `_on_mcp_progress`; this just shows the animated summary. done, total = progress_tracker.totals() frame = SPINNER_FRAMES[ int(datetime.now().timestamp() * 10) % len(SPINNER_FRAMES) ] label = ( f"{frame} Loading MCP tools {done}/{total} " if total and width >= 60 else f"{frame} Loading MCP tools " ) fragments = [ ("class:status-bar-warn", label), ("class:status-bar-dim", "│ "), *fragments, ] return fragments def _stream_status_footer(): """Render the live Rich footer used during streaming output.""" return build_status_text( state["status_snapshot"], state["status_started_at"], console.size.width, ) async def _handle_stream_status_event(event_type: str, stream_state) -> None: """Keep the CLI status bar aligned with live stream progress.""" if event_type == "usage_stats": last_input_tokens = getattr(stream_state, "last_input_tokens", 0) if last_input_tokens > 0: state["status_last_input_tokens"] = last_input_tokens state["status_base_snapshot"] = make_usage_status_snapshot( last_input_tokens, model_name=model, ) _rebuild_status_snapshot() elif event_type == "text": _set_status_streaming_text(stream_state.response_text) elif event_type in ("done", "error"): _set_status_streaming_text("") async def _render_history(thread_id: str): """Display conversation history for a resumed session.""" messages = await graph_gateway.get_thread_messages(thread_id) if not messages: return HISTORY_WINDOW = 50 # Only human and ai messages; skip tool/system display = [m for m in messages if getattr(m, "type", None) in ("human", "ai")] if len(display) > HISTORY_WINDOW: skipped = len(display) - HISTORY_WINDOW display = display[-HISTORY_WINDOW:] console.print(f"[dim]── ... {skipped} earlier messages ──[/dim]") else: console.print("[dim]── Conversation history ──[/dim]") for msg in display: msg_type = getattr(msg, "type", None) content = getattr(msg, "content", "") or "" if msg_type == "human": # Extract text from multimodal list if isinstance(content, list): parts = [ b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text" ] content = " ".join(parts) if parts else "" content = content.strip() if content: console.print( Text.assemble(("\u276f ", "bold blue"), (content, "")) ) elif msg_type == "ai": thinking_text = "" text_content = "" if isinstance(content, list): for block in content: if not isinstance(block, dict): continue if block.get("type") == "thinking": thinking_text += block.get("thinking", "") elif block.get("type") == "text": text_content += block.get("text", "") else: text_content = content or "" text_content = text_content.strip() # Thinking panel (only when show_thinking is enabled) if thinking_text.strip() and show_thinking: console.print( Panel( thinking_text.strip(), title="[bold blue]\U0001f4ad Thinking[/bold blue]", border_style="blue", expand=False, ) ) # AI response — full Markdown rendering if text_content: console.print(Markdown(_fix_markdown_heading_spacing(text_content))) # Skip tool messages — verbose and not useful in replay console.print("[dim]── End of history ──[/dim]") console.print() async def _async_main_loop(): """Async main loop with prompt_async and channel queue checking.""" nonlocal model async with get_checkpointer() as checkpointer: startup = await _resolve_startup_session( requested_thread_id, workspace_dir=state["workspace_dir"], graph_gateway=graph_gateway, config=config, ) state["thread_id"] = startup.thread_id state["workspace_dir"] = startup.workspace_dir state["resumed"] = startup.resumed if startup.resumed: state["status_started_at"] = datetime.now() state["status_last_input_tokens"] = None # Lifecycle callbacks (new / resume) need ``checkpointer`` # in scope — define the ``rich_ui`` adapter here rather than # at the outer function level. def _print_pending_skill_proposals_notice() -> None: from .commands import _pending_skill_proposals_message message = _pending_skill_proposals_message(state.get("workspace_dir")) if message: console.print(message, style="yellow") async def _on_start_new_session() -> None: """NewCommand callback — rotate workspace (if not fixed), issue a new thread id, reset session-scoped status fields, and kick off background agent reload. The dispatch block refreshes the status bar post-execute (symmetric with /compact).""" _ch_mod.forget_channel_origin(state.get("thread_id")) if not workspace_fixed: state["workspace_dir"] = _create_session_workspace(run_name) state["thread_id"] = await graph_gateway.create_thread( GraphTarget(workspace_dir=state["workspace_dir"]) ) state["resumed"] = False state["status_started_at"] = datetime.now() state["status_last_input_tokens"] = None _start_agent_load(checkpointer) console.print( f"[green]New session:[/green] [yellow]{state['thread_id']}[/yellow]" ) if state["workspace_dir"]: console.print( f"[dim]Workspace:[/dim] [cyan]" f"{_shorten_path(state['workspace_dir'])}[/cyan]\n" ) _print_pending_skill_proposals_notice() async def _on_handle_session_resume( thread_id: str, workspace_dir: str | None ) -> None: """ResumeCommand callback — after the command resolves the thread id + restores workspace from metadata, this callback mutates REPL state, reloads the agent, and renders conversation history.""" if workspace_dir: # Sync the langgraph dev subprocess to the resumed # workspace so background workers and deployed sub-agents # don't operate on the previous workspace's files. The # manager auto-detects the change and restarts when needed. # Restart can take 10-15s — show a spinner so the user # doesn't think the CLI is frozen, and run the sync call # in a worker thread so the asyncio event loop keeps # serving channel polls / MCP heartbeats during the wait. # # State mutation happens AFTER this sync succeeds so a # WorkspaceMismatchError leaves the session's existing # workspace_dir / thread_id untouched. from ..langgraph_dev.manager import WorkspaceMismatchError from .commands import _sync_background_agent_server_workspace try: await _sync_background_agent_server_workspace( config, workspace_dir=workspace_dir, ) except WorkspaceMismatchError as exc: # Another EvoSci process owns the langgraph dev # server for a different workspace. Abort the # resume without mutating session state. Raise so # command UIs, including channel UI, report failure # instead of continuing with success/history output. raise RuntimeError(str(exc)) from exc state["workspace_dir"] = workspace_dir if thread_id != state.get("thread_id"): # Only drop the origin on a real thread change — resuming # the already-active thread must keep its live origin so a # later async-notifier turn still forwards to the channel. _ch_mod.forget_channel_origin(state.get("thread_id")) state["thread_id"] = thread_id state["resumed"] = True state["status_started_at"] = datetime.now() state["status_last_input_tokens"] = None _start_agent_load(checkpointer) await _refresh_status_snapshot(reset_streaming_text=True) console.print( f"[green]Resumed session:[/green] [yellow]{thread_id}[/yellow]" ) if state["workspace_dir"]: console.print( f"[dim]Workspace:[/dim] [cyan]" f"{_shorten_path(state['workspace_dir'])}[/cyan]" ) console.print() await _render_history(thread_id) _print_pending_skill_proposals_notice() # Rich CLI collapses ``request_quit`` / ``force_quit`` into the # same "break the prompt loop" effect — there's no equivalent # of the TUI's double-press-to-confirm quit distinction. A # shared ``_stop`` helper makes the intentional symmetry # explicit instead of silently duplicating a lambda. def _stop() -> None: state["running"] = False rich_ui = RichCLICommandUI( console, on_request_quit=_stop, on_force_quit=_stop, on_clear_chat=lambda: console.clear(), on_status_after_compact=_on_status_after_compact, on_start_new_session=_on_start_new_session, on_handle_session_resume=_on_handle_session_resume, ) # Kick off agent construction (MCP tool enumeration is the # slow part) in the background so the banner and prompt can # appear immediately. The status bar shows a spinner while # this is in flight; submitting a message awaits the result. _start_agent_load(checkpointer) await _refresh_status_snapshot(reset_streaming_text=True) # Print banner if state["resumed"]: print_banner( state["thread_id"], state["workspace_dir"], memory_dir, mode, model, provider, state["ui_backend"], ) console.print( f"[green]Resumed session [yellow]{state['thread_id']}[/yellow][/green]\n" ) await _render_history(state["thread_id"]) else: print_banner( state["thread_id"], state["workspace_dir"], memory_dir, mode, model, provider, state["ui_backend"], ) _print_pending_skill_proposals_notice() # ---- Channel queue processing (bus → main thread) ---- async def _process_channel_message(msg: ChannelMessage) -> None: """Process a single channel message with real-time streaming. Clears the waiting prompt line and reprints the message as if the user typed it after ❯, then streams the agent response with Rich Live display. Display: ❯ message content [channel: Received from sender] ───────────────── (real-time streaming output) [channel: Replied to sender] ───────────────── """ if not _ch_mod._claim_or_complete_channel_request(msg): return _ch_mod.remember_channel_origin(state["thread_id"], msg) try: # Clear the waiting ❯ prompt line sys.stdout.write("\r\033[2K") sys.stdout.flush() # Reprint as if user typed it after ❯ prompt_line = Text() prompt_line.append("\u276f ", style="bold blue") prompt_line.append(msg.content) console.print(prompt_line) rx = Text() rx.append(f"[{msg.channel_type}: Received from ", style="dim") rx.append(msg.sender, style="cyan") rx.append("]", style="dim") console.print(rx) _print_separator() def _send_to_channel(coro, label: str, timeout: int = 15) -> None: """Schedule an async channel send on the bus loop.""" loop = _ch_mod._bus_loop if not loop: return try: asyncio.run_coroutine_threadsafe(coro, loop).result( timeout=timeout ) except Exception as e: _channel_logger.debug(f"{label} send failed: {e}") def _send_thinking_to_channel(thinking: str) -> None: ch = msg.channel_ref if ch and ch.send_thinking: _send_to_channel( ch.send_thinking_message( sender=msg.chat_id, thinking=thinking, metadata=msg.metadata, ), "Thinking", ) def _send_todo_to_channel(items: list[dict]) -> None: from ..channels.consumer import _format_todo_list if msg.channel_ref: _send_to_channel( msg.channel_ref.send_todo_message( sender=msg.chat_id, content=_format_todo_list(items), metadata=msg.metadata, ), "Todo", ) def _send_media_to_channel(file_path: str) -> None: if msg.channel_ref: _send_to_channel( msg.channel_ref.send_media( recipient=msg.chat_id, file_path=file_path, metadata=msg.metadata, ), "Media", timeout=30, ) def _channel_hitl_prompt( action_requests: list, ) -> list[dict] | None: """Send HITL approval prompt to channel user and wait for reply.""" return _ch_mod.channel_hitl_prompt(action_requests, msg) def _channel_ask_user(ask_user_data: dict) -> dict: """Send ask_user questions to channel user and wait for reply.""" return _ch_mod.channel_ask_user_prompt(ask_user_data, msg) # ---- Slash command dispatch (cmd_manager, not the agent) ---- # Mirrors the TUI's behavior so ``/evoskills``, ``/mcp list`` # etc. sent via iMessage actually execute instead of being # fed to the LLM as a plain prompt. async def _on_channel_cmd_completed( ctx: CommandContext, original_agent: Any, cmd: Command ) -> None: """Mirror the REPL adoption block at ``interactive.py:1005-1030`` so ``/model`` and similar state-mutating commands invoked via a channel actually rebind the running session and keep the status bar in sync.""" nonlocal model agent_swapped = ( ctx.agent is not None and ctx.agent is not original_agent ) if agent_swapped: from ..EvoScientist import _ensure_config agent_loader.adopt(ctx.agent) cfg = _ensure_config() model = cfg.model state["status_base_snapshot"] = make_empty_status_snapshot( model ) # Rebind the runtime whenever the agent OR # thread_id may have moved — ``/new`` and # ``/resume`` rotate ``state["thread_id"]`` # without swapping the agent, and the bus # expects both to stay in sync (matches the # serve-mode hook contract). if _channels_is_running(): runtime_agent = ( ctx.agent if ctx.agent is not None else agent_loader.agent ) if runtime_agent is not None: channel_runtime.bind(runtime_agent, state["thread_id"]) # ``/new`` rotates ``state["thread_id"]`` / workspace, # ``/compact`` reduces token usage — both need the # status snapshot re-rendered even when the agent # didn't swap. ``/resume`` refreshes inline in its # own async callback. if agent_swapped or cmd.name in ( "/compact", "/new", ): await _refresh_status_snapshot(reset_streaming_text=True) _slash_handled = await dispatch_channel_slash_command( msg, agent=agent_loader.agent, thread_id=state["thread_id"], workspace_dir=state["workspace_dir"], checkpointer=checkpointer, append_system=lambda t, s="dim": console.print(t, style=s), start_new_session_cb=_on_start_new_session, handle_session_resume_cb=_on_handle_session_resume, await_agent_ready=_await_agent_ready, on_cmd_completed=_on_channel_cmd_completed, channel_runtime=channel_runtime, graph_gateway=runtime_gateways.graph_gateway, ) if _slash_handled: # A channel-issued /new or /resume rotates the thread # inside the dispatch above; re-bind the now-current # thread to this channel so async-notifier turns on it # still forward back here. _ch_mod.remember_channel_origin(state["thread_id"], msg) _print_separator() sys.stdout.write("\033[34;1m❯\033[0m ") sys.stdout.flush() return try: ready_agent = await _await_agent_ready() meta = build_metadata(state["workspace_dir"], model) await _refresh_status_snapshot( msg.content, reset_streaming_text=True ) response = run_streaming( ui_backend=state["ui_backend"], agent=ready_agent, message=msg.content, thread_id=state["thread_id"], show_thinking=show_thinking, interactive=True, metadata=meta, on_thinking=_send_thinking_to_channel, on_todo=_send_todo_to_channel, on_file_write=_send_media_to_channel, hitl_prompt_fn=_channel_hitl_prompt, ask_user_prompt_fn=_channel_ask_user, on_stream_event=_handle_stream_status_event, status_footer_builder=_stream_status_footer, cancel_scope=_ch_mod._channel_message_cancel_scope(msg), gateway=runtime_gateways.graph_gateway, ) except Exception as e: response = f"Error: {e}" console.print(f"[red]Channel error: {e}[/red]") _set_channel_response(msg.msg_id, response) await _refresh_status_snapshot(reset_streaming_text=True) tx = Text() tx.append(f"[{msg.channel_type}: Replied to ", style="dim") tx.append(msg.sender, style="cyan") tx.append("]", style="dim") console.print(tx) _print_separator() # Redraw the ❯ prompt on a new line after separator sys.stdout.write("\033[34;1m\u276f\033[0m ") sys.stdout.flush() finally: _ch_mod._complete_channel_request(msg.msg_id) async def _inject_notification_message( text: str, notifs: list, *, target_thread_id: str | None, ) -> None: """Inject a batched async-task notification as a synthetic user message. Renders one compact tool-result-style line per task (matching the TaskList spinner aesthetic) for the human operator. The LLM receives the full structured ``text`` from ``format_batch_message`` unchanged — only the screen visual is simplified. """ from EvoScientist.cli.async_notifier import format_notification_lines for line_text, line_style in format_notification_lines(notifs): console.print(line_text, style=line_style, markup=False) meta = build_metadata(state["workspace_dir"], model) await _refresh_status_snapshot(text, reset_streaming_text=True) ready_agent = await _await_agent_ready() response = run_streaming( ui_backend=state["ui_backend"], agent=ready_agent, message=text, # Falls back to live state["thread_id"] if no override is # passed (legacy / direct-call paths). Dedup reader has no # fallback and returns {} for a falsey id; the asymmetry # is intentional — we'd rather inject into the live thread # than drop the notification entirely. thread_id=target_thread_id or state["thread_id"], show_thinking=show_thinking, interactive=True, metadata=meta, on_stream_event=_handle_stream_status_event, status_footer_builder=_stream_status_footer, gateway=runtime_gateways.graph_gateway, ) _notif_tid = target_thread_id or state["thread_id"] if _ch_mod.publish_to_channel_origin(_notif_tid, response): # Forwarded to a channel — print the same closing # "Replied to" line a normal channel turn shows, so the # forwarded block reads as terminated on screen. _origin = _ch_mod.get_channel_origin(_notif_tid) if _origin is not None: tx = Text() tx.append(f"[{_origin.channel_type}: Replied to ", style="dim") tx.append(_origin.sender or _origin.chat_id, style="cyan") tx.append("]", style="dim") console.print(tx) await _refresh_status_snapshot(reset_streaming_text=True) console.print() _print_separator() sys.stdout.write("\033[34;1m❯\033[0m ") sys.stdout.flush() async def _empty_async_tasks() -> async_notifier.AsyncTasksState: return {} async def _read_current_async_tasks( target_thread_id: str, ) -> async_notifier.AsyncTasksState: """Snapshot async_tasks from the active agent state for dedup. Uses ``agent_loader.agent`` (the currently loaded agent) and ``target_thread_id`` (the thread id captured at the start of ``consume_notifications`` — frozen so a mid-consume ``/new`` cannot make us read the wrong thread's state). """ agent = agent_loader.agent if agent is None: return {} try: return await async_notifier.read_async_tasks_from_gateway( runtime_gateways.graph_gateway, GraphTarget( local_graph=agent, workspace_dir=state["workspace_dir"], ), target_thread_id, ) except Exception: return {} async def _check_channel_queue() -> None: """Poll the channel + notification queues and dispatch.""" while True: try: msg = _message_queue.get_nowait() except queue.Empty: msg = None if msg is not None: await _process_channel_message(msg) continue # check queues again immediately # Notification path (only when no channel message was pending). # Wrap in try/except so an exception in dedup/inject can't # kill the poller task — channel + notification dispatch # would silently die otherwise (Fix #4). current_tid = state.get("thread_id") if async_notifier.has_pending_notifications(current_tid): read_async_tasks_state = ( (lambda _tid=current_tid: _read_current_async_tasks(_tid)) if current_tid else _empty_async_tasks ) try: await async_notifier.consume_notifications( run_message=lambda text, notifs, _tid=current_tid: ( _inject_notification_message( text, notifs, target_thread_id=_tid ) ), read_async_tasks_state=read_async_tasks_state, current_thread_id=current_tid, ) except Exception: _channel_logger.warning( "async-notifier consume failed", exc_info=True ) continue await asyncio.sleep(0.1) queue_task = asyncio.create_task(_check_channel_queue()) # Startup hint console.print( Text( " EvoScientist is your research buddy.\n" " Tell it about your taste before cooking some meal!", style="yellow", ) ) # Auto-start channel if enabled in config. Needs the agent # bound before the bus starts polling, so schedule it as a # background coroutine that waits for the loader first. from ..config import load_config _channel_cfg = load_config() if ( _channel_cfg and _channel_cfg.channel_enabled and not _channels_is_running() ): async def _deferred_auto_start_channel(cfg): try: agent = await _await_agent_ready() except Exception as e: console.print( f"[red]Channel auto-start skipped: agent load failed:[/red] " f"{escape(str(e))}" ) return if not _channels_is_running(): _auto_start_channel( agent, state["thread_id"], cfg, send_thinking=channel_send_thinking, runtime=channel_runtime, ) _auto_start_task = asyncio.create_task( _deferred_auto_start_channel(_channel_cfg) ) _background_tasks.add(_auto_start_task) _auto_start_task.add_done_callback(_background_tasks.discard) # Update check — non-blocking, runs in background thread import concurrent.futures _update_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) def _show_update_hint() -> None: try: from ..update_check import _installed_version, is_update_available available, latest = is_update_available() if available: current = _installed_version() console.print( Text( f" Update available: v{latest} (current: v{current}).\n" " Run: uv tool upgrade EvoScientist", style="yellow", ) ) except Exception: pass _update_executor.submit(_show_update_hint) # Slogan — after channels, right before user input console.print( Text(f" {random.choice(WELCOME_SLOGANS)}", style="dim italic") ) console.print() try: _print_separator() while state["running"]: try: # ``patch_stdout`` routes stray ``print`` / # ``console.print`` calls — including the MCP # progress callback firing from a worker thread — # above the live prompt instead of over it. with patch_stdout(raw=True): user_input = await session.prompt_async( HTML("\u276f "), bottom_toolbar=_bottom_toolbar, refresh_interval=1.0, ) user_input = user_input.strip() if not user_input: # Erase the empty prompt line so it looks like nothing happened sys.stdout.write("\033[A\033[2K\r") sys.stdout.flush() continue _print_separator() # ==== Shared CommandManager dispatch ==== # Every registered slash command routes through the # manager. Unresolved input (non-slash, typo) falls # through to the agent message path below. _parsed = cmd_manager.resolve(user_input) if _parsed is not None: _cmd, _cmd_args = _parsed _agent_for_ctx: Any = agent_loader.agent if _cmd.needs_agent(_cmd_args): _agent_for_ctx = await _await_agent_ready() ctx = CommandContext( agent=_agent_for_ctx, thread_id=state["thread_id"], ui=rich_ui, workspace_dir=state["workspace_dir"], checkpointer=checkpointer, config=config, input_tokens_hint=state.get("status_last_input_tokens"), channel_runtime=channel_runtime, graph_gateway=runtime_gateways.graph_gateway, ) await cmd_manager.execute(user_input, ctx) # ExitCommand signals quit via ``force_quit`` → # callback flips ``state["running"]`` to False. if not state["running"]: break # Agent swap (e.g. /model successfully built a # new agent): adopt into loader + reset status # snapshot + sync channel runtime. agent_swapped = ( ctx.agent is not None and ctx.agent is not _agent_for_ctx ) if agent_swapped: from ..EvoScientist import _ensure_config agent_loader.adopt(ctx.agent) cfg = _ensure_config() model = cfg.model state["status_base_snapshot"] = ( make_empty_status_snapshot(model) ) # Rebind the runtime whenever the agent OR # thread_id may have moved — ``/new`` / # ``/resume`` rotate ``state["thread_id"]`` # without swapping the agent, and the bus # expects both to stay in sync. if _channels_is_running(): runtime_agent = ( ctx.agent if ctx.agent is not None else agent_loader.agent ) if runtime_agent is not None: channel_runtime.bind( runtime_agent, state["thread_id"] ) # Commands that mutate status fields need an # async refresh here (/compact + /new use sync # callbacks; /model swaps the agent). /resume # awaits its own refresh inline inside the # async callback. if agent_swapped or _cmd.name in ("/compact", "/new"): await _refresh_status_snapshot( reset_streaming_text=True, ) continue # Unknown slash command (typo) — short-circuit so # it doesn't get forwarded to the agent, which # would waste tokens interpreting the nonsense. if user_input.lstrip().startswith("/"): bad_cmd = user_input.split(None, 1)[0] console.print( f"[red]Unknown command:[/red] {escape(bad_cmd)}" ) console.print( "[dim]Type /help to see available commands.[/dim]" ) console.print() continue # Resolve @file mentions — inject file contents inline _, message_to_send, file_warnings = resolve_file_mentions( user_input, state["workspace_dir"] ) # Stream agent response with metadata for persistence # Warnings printed here so they appear just before the # model response, not before the user input echo. for w in file_warnings: console.print(f"[yellow]⚠ {escape(w)}[/yellow]") console.print() ready_agent = await _await_agent_ready() meta = build_metadata(state["workspace_dir"], model) await _refresh_status_snapshot( message_to_send, reset_streaming_text=True ) run_streaming( ui_backend=state["ui_backend"], agent=ready_agent, message=message_to_send, thread_id=state["thread_id"], show_thinking=show_thinking, interactive=True, metadata=meta, on_stream_event=_handle_stream_status_event, status_footer_builder=_stream_status_footer, gateway=runtime_gateways.graph_gateway, ) await _refresh_status_snapshot(reset_streaming_text=True) console.print() _print_separator() except KeyboardInterrupt: console.print() state["running"] = False break except EOFError: # Handle Ctrl+D console.print() state["running"] = False break except Exception as e: error_msg = str(e) if ( "authentication" in error_msg.lower() or "api_key" in error_msg.lower() ): console.print("[red]Error: API key not configured.[/red]") console.print( "[dim]Run [bold]EvoSci onboard[/bold] to set up your API key.[/dim]" ) state["running"] = False break else: console.print(f"[red]Error: {escape(str(e))}[/red]") finally: queue_task.cancel() try: await queue_task except asyncio.CancelledError: pass # Best-effort: guard so a DB lookup failure here can't # shadow the original exception exiting _async_main_loop. current_tid = state.get("thread_id") if current_tid: try: if await graph_gateway.thread_exists(current_tid): state["resume_hint_thread_id"] = current_tid except Exception: _channel_logger.debug( "resume-hint thread_exists lookup failed", exc_info=True, ) # Run the async main loop from .resume_hint import print_resume_hint try: asyncio.run(_async_main_loop()) except KeyboardInterrupt: console.print() finally: try: print_resume_hint(state.get("resume_hint_thread_id"), console=console) except Exception: _channel_logger.debug("print_resume_hint failed", exc_info=True) def cmd_run( agent: "CompiledStateGraph", prompt: str, thread_id: str, show_thinking: bool = True, workspace_dir: str | None = None, model: str | None = None, ui_backend: str = "cli", *, runtime_gateways: RuntimeGateways, ) -> None: """Single-shot execution with streaming display. Args: agent: Compiled agent graph prompt: User prompt thread_id: Thread ID for conversation persistence. show_thinking: Whether to display thinking panels workspace_dir: Per-session workspace directory path model: Model name for checkpoint metadata ui_backend: UI backend ('cli' or 'tui') """ width = console.size.width sep = Text("\u2500" * width, style="dim") console.print(sep) console.print(Text(f"> {prompt}")) console.print(sep) console.print(f"[dim]Thread: {short_thread_id(thread_id)}[/dim]") if workspace_dir: console.print(f"[dim]Workspace: {_shorten_path(workspace_dir)}[/dim]") console.print() meta = build_metadata(workspace_dir, model) try: run_streaming( ui_backend=resolve_ui_backend(ui_backend, warn_fallback=True), agent=agent, message=prompt, thread_id=thread_id, show_thinking=show_thinking, interactive=False, metadata=meta, gateway=runtime_gateways.graph_gateway, ) _wait_for_memory_workers_before_exit() except Exception as e: error_msg = str(e) if "authentication" in error_msg.lower() or "api_key" in error_msg.lower(): console.print("[red]Error: API key not configured.[/red]") console.print( "[dim]Run [bold]EvoSci onboard[/bold] to set up your API key.[/dim]" ) raise typer.Exit(1) from e else: console.print(f"[red]Error: {e}[/red]") raise def _wait_for_memory_workers_before_exit( *, timeout_seconds: float = _MEMORY_WORKER_SHUTDOWN_WAIT_SECONDS, ) -> None: """Let one-shot CLI runs persist post-run memory before atexit cleanup.""" try: from ..memory.worker_activity import ( MemoryActivityPhase, MemoryWorkerStatusSnapshot, wait_for_memory_pipeline_idle, ) except Exception: return announced = False def print_saved(observed: MemoryWorkerStatusSnapshot) -> None: saved = [] if observed.observations_recorded: saved.append(f"{observed.observations_recorded} observation(s)") if observed.profile_updates: saved.append(f"{observed.profile_updates} profile update(s)") if saved: console.print(f"[dim]EvoMemory saved {', '.join(saved)}.[/dim]") def print_waiting(phase: MemoryActivityPhase) -> None: nonlocal announced if not announced: console.print(f"[dim]Waiting for EvoMemory {phase}...[/dim]") announced = True def print_timeout(phase: MemoryActivityPhase) -> None: console.print(f"[dim]EvoMemory {phase} is still running; shutting down.[/dim]") wait_for_memory_pipeline_idle( timeout_seconds=timeout_seconds, poll_seconds=_MEMORY_WORKER_SHUTDOWN_POLL_SECONDS, output_grace_seconds=_MEMORY_WORKER_OUTPUT_GRACE_SECONDS, on_saved=print_saved, on_waiting=print_waiting, on_timeout=print_timeout, )