From e2563e2e744d532f255f4f4bf4a1ea1563d96011 Mon Sep 17 00:00:00 2001
From: chengyongru <61816729+chengyongru@users.noreply.github.com>
Date: Thu, 30 Jul 2026 15:01:35 +0800
Subject: [PATCH] refactor(cli): split commands into focused modules (#5175)
---
nanobot/agent/loop.py | 6 +-
nanobot/cli/agent.py | 352 +++
nanobot/cli/commands.py | 2628 +----------------
nanobot/cli/gateway_runtime.py | 828 ++++++
nanobot/cli/log_control.py | 12 +
nanobot/cli/provider.py | 372 +++
nanobot/cli/runtime_config.py | 185 ++
nanobot/cli/terminal.py | 428 +++
nanobot/cli/webui.py | 261 ++
nanobot/cli/webui_support.py | 498 ++++
nanobot/command/builtin.py | 2 +-
tests/agent/test_auto_compact.py | 8 +-
tests/agent/test_loop_consolidation_tokens.py | 4 +-
.../test_loop_direct_websocket_status.py | 2 +-
tests/agent/test_loop_progress.py | 6 +-
tests/agent/test_loop_save_turn.py | 2 +-
tests/agent/test_session_model_runtime.py | 10 +-
tests/agent/test_unified_session.py | 6 +-
tests/cli/test_cli_input.py | 56 +-
tests/cli/test_commands.py | 252 +-
tests/cli/test_config_diagnostics.py | 2 +-
tests/cli/test_interactive_retry_wait.py | 42 +-
tests/cli/test_safe_file_history.py | 7 +-
tests/command/test_router_dispatchable.py | 2 +-
24 files changed, 3190 insertions(+), 2781 deletions(-)
create mode 100644 nanobot/cli/agent.py
create mode 100644 nanobot/cli/gateway_runtime.py
create mode 100644 nanobot/cli/log_control.py
create mode 100644 nanobot/cli/provider.py
create mode 100644 nanobot/cli/runtime_config.py
create mode 100644 nanobot/cli/terminal.py
create mode 100644 nanobot/cli/webui.py
create mode 100644 nanobot/cli/webui_support.py
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index e31e66c03..3c0f9eab8 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -1103,7 +1103,7 @@ class AgentLoop:
return
self._next_idle_compact_check_at = now + self._idle_compact_check_interval_s
self.auto_compact.check_expired(
- self._schedule_background,
+ self.schedule_background,
self.runtime_for_session,
active_session_keys=self._pending_queues.keys(),
)
@@ -1336,7 +1336,7 @@ class AgentLoop:
if errors:
raise BaseExceptionGroup("failed to close agent resources", errors)
- def _schedule_background(self, coro: Coroutine[Any, Any, Any]) -> None:
+ def schedule_background(self, coro: Coroutine[Any, Any, Any]) -> None:
"""Schedule a coroutine as a tracked background task (drained on shutdown)."""
task = asyncio.create_task(coro)
self._background_tasks.add(task)
@@ -1752,7 +1752,7 @@ class AgentLoop:
session.enforce_file_cap(
on_archive=partial(self.context.memory.raw_archive, session_key=ctx.session_key)
)
- self._schedule_background(
+ self.schedule_background(
self.consolidator.maybe_consolidate_by_tokens(
session,
runtime=runtime,
diff --git a/nanobot/cli/agent.py b/nanobot/cli/agent.py
new file mode 100644
index 000000000..854383763
--- /dev/null
+++ b/nanobot/cli/agent.py
@@ -0,0 +1,352 @@
+"""Direct and interactive agent CLI command."""
+
+import asyncio
+import signal
+import sys
+from collections.abc import Awaitable, Callable
+from types import FrameType
+from typing import Any
+
+import typer
+from rich.console import Console
+
+from nanobot import __logo__
+from nanobot.agent.hooks import create_file_edit_activity_hook
+from nanobot.agent.loop import AgentLoop
+from nanobot.bus.outbound_events import (
+ StreamDeltaEvent,
+ StreamedResponseEvent,
+ StreamEndEvent,
+ outbound_event_from_message,
+)
+from nanobot.cli import terminal as cli_terminal
+from nanobot.cli.log_control import _set_nanobot_logs
+from nanobot.cli.runtime_config import (
+ _load_runtime_config,
+ _migrate_cron_store,
+ _model_display,
+ _print_agent_start_error,
+)
+from nanobot.cli.stream import StreamRenderer, ThinkingSpinner
+from nanobot.config.paths import is_default_workspace
+from nanobot.utils.helpers import (
+ sanitize_surrogates as _sanitize_surrogates,
+)
+from nanobot.utils.helpers import (
+ sync_workspace_templates,
+)
+from nanobot.utils.restart import (
+ consume_restart_notice_from_env,
+ format_restart_completed_message,
+ should_show_cli_restart_notice,
+)
+
+console = Console()
+
+
+def agent(
+ message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
+ session_id: str = typer.Option("cli:direct", "--session", "-s", help="Session ID"),
+ workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
+ config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
+ markdown: bool = typer.Option(
+ True,
+ "--markdown/--no-markdown",
+ help="Render assistant output as Markdown",
+ ),
+ logs: bool = typer.Option(
+ False,
+ "--logs/--no-logs",
+ help="Show nanobot runtime logs during chat",
+ ),
+):
+ """Interact with the agent directly."""
+ from nanobot.bus.queue import MessageBus
+ from nanobot.cron.service import CronService
+ from nanobot.providers.factory import make_provider
+ from nanobot.providers.image_generation import image_gen_provider_configs
+
+ runtime_config = _load_runtime_config(config, workspace)
+ try:
+ provider = make_provider(runtime_config)
+ except ValueError as exc:
+ _print_agent_start_error(exc)
+ raise typer.Exit(1) from exc
+
+ sync_workspace_templates(runtime_config.workspace_path)
+
+ bus = MessageBus()
+
+ # Preserve existing single-workspace installs, but keep custom workspaces clean.
+ if is_default_workspace(runtime_config.workspace_path):
+ _migrate_cron_store(runtime_config)
+
+ # Create cron service with workspace-scoped store
+ cron_store_path = runtime_config.workspace_path / "cron" / "jobs.json"
+ cron = CronService(cron_store_path)
+
+ _set_nanobot_logs(logs)
+
+ try:
+ agent_loop = AgentLoop.from_config(
+ runtime_config,
+ bus,
+ provider=provider,
+ cron_service=cron,
+ image_generation_provider_configs=image_gen_provider_configs(runtime_config),
+ hook_factories=[create_file_edit_activity_hook],
+ )
+ except ValueError as exc:
+ _print_agent_start_error(exc)
+ raise typer.Exit(1) from exc
+ restart_notice = consume_restart_notice_from_env()
+ if restart_notice and should_show_cli_restart_notice(restart_notice, session_id):
+ cli_terminal._print_agent_response(
+ format_restart_completed_message(restart_notice.started_at_raw),
+ render_markdown=False,
+ )
+
+ # Shared reference for progress callbacks
+ _thinking: ThinkingSpinner | None = None
+
+ def _make_progress(
+ renderer: StreamRenderer | None = None,
+ ) -> Callable[..., Awaitable[None]]:
+ reasoning_buffer = cli_terminal._ReasoningBuffer()
+
+ async def _cli_progress(
+ content: str,
+ *,
+ tool_hint: bool = False,
+ reasoning: bool = False,
+ **_kwargs: Any,
+ ) -> None:
+ ch = agent_loop.channels_config
+
+ if _kwargs.get("reasoning_end"):
+ if ch and not ch.show_reasoning:
+ reasoning_buffer.clear()
+ else:
+ cli_terminal._flush_cli_reasoning(reasoning_buffer, _thinking, renderer)
+ return
+
+ if reasoning:
+ if ch and not ch.show_reasoning:
+ reasoning_buffer.clear()
+ return
+ text = reasoning_buffer.add(content)
+ if text:
+ cli_terminal._print_cli_reasoning(text, _thinking, renderer)
+ return
+ if ch and tool_hint and not ch.send_tool_hints:
+ return
+ if ch and not tool_hint and not ch.send_progress:
+ return
+ cli_terminal._print_cli_progress_line(content, _thinking, renderer)
+
+ return _cli_progress
+
+ if message:
+ # Single message mode — direct call, no bus needed
+ async def run_once() -> None:
+ renderer = StreamRenderer(
+ render_markdown=markdown,
+ bot_name=runtime_config.agents.defaults.bot_name,
+ bot_icon=runtime_config.agents.defaults.bot_icon,
+ )
+ response = await agent_loop.process_direct(
+ message,
+ session_id,
+ on_progress=_make_progress(renderer),
+ on_stream=renderer.on_delta,
+ on_stream_end=renderer.on_end,
+ )
+ if not renderer.streamed:
+ await renderer.close()
+ print_kwargs: dict[str, Any] = {}
+ if renderer.header_printed:
+ print_kwargs["show_header"] = False
+ cli_terminal._print_agent_response(
+ response.content if response else "",
+ render_markdown=markdown,
+ metadata=response.metadata if response else None,
+ **print_kwargs,
+ )
+ await agent_loop.close_mcp()
+
+ asyncio.run(run_once())
+ else:
+ # Interactive mode — route through bus like other channels
+ from nanobot.bus.events import InboundMessage
+
+ cli_terminal._init_prompt_session()
+ _model, _preset_tag = _model_display(runtime_config)
+ _icon = runtime_config.agents.defaults.bot_icon or __logo__
+ console.print(
+ f"{_icon} Interactive mode [bold blue]({_model})[/bold blue]{_preset_tag} "
+ "— type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit\n"
+ )
+
+ if ":" in session_id:
+ cli_channel, cli_chat_id = session_id.split(":", 1)
+ else:
+ cli_channel, cli_chat_id = "cli", session_id
+
+ def _handle_signal(signum: int, _frame: FrameType | None) -> None:
+ sig_name = signal.Signals(signum).name
+ cli_terminal._restore_terminal()
+ console.print(f"\nReceived {sig_name}, goodbye!")
+ sys.exit(0)
+
+ signal.signal(signal.SIGINT, _handle_signal)
+ signal.signal(signal.SIGTERM, _handle_signal)
+ # SIGHUP is not available on Windows
+ if hasattr(signal, "SIGHUP"):
+ signal.signal(signal.SIGHUP, _handle_signal)
+ # Ignore SIGPIPE to prevent silent process termination when writing to closed pipes
+ # SIGPIPE is not available on Windows
+ if hasattr(signal, "SIGPIPE"):
+ signal.signal(signal.SIGPIPE, signal.SIG_IGN)
+
+ async def run_interactive() -> None:
+ bus_task = asyncio.create_task(agent_loop.run())
+ turn_done = asyncio.Event()
+ turn_done.set()
+ turn_response: list[Any] = []
+ renderer: StreamRenderer | None = None
+ reasoning_buffer = cli_terminal._ReasoningBuffer()
+
+ async def _consume_outbound() -> None:
+ while True:
+ try:
+ msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
+ event = outbound_event_from_message(msg)
+
+ if isinstance(event, StreamDeltaEvent):
+ if renderer:
+ await renderer.on_delta(msg.content)
+ continue
+ if isinstance(event, StreamEndEvent):
+ if renderer:
+ await renderer.on_end(
+ resuming=event.resuming,
+ )
+ continue
+ if isinstance(event, StreamedResponseEvent):
+ if msg.content and renderer and not renderer.streamed:
+ await renderer.close()
+ print_kwargs: dict[str, Any] = {}
+ if renderer.header_printed:
+ print_kwargs["show_header"] = False
+ cli_terminal._print_agent_response(
+ msg.content,
+ render_markdown=markdown,
+ metadata=msg.metadata,
+ **print_kwargs,
+ )
+ turn_done.set()
+ continue
+
+ if await cli_terminal._maybe_print_interactive_progress(
+ msg,
+ None,
+ agent_loop.channels_config,
+ renderer,
+ reasoning_buffer,
+ ):
+ continue
+
+ if not turn_done.is_set():
+ if msg.content:
+ turn_response.append(msg)
+ turn_done.set()
+ elif msg.content:
+ await cli_terminal._print_interactive_response(
+ msg.content,
+ render_markdown=markdown,
+ metadata=msg.metadata,
+ )
+
+ except asyncio.TimeoutError:
+ continue
+ except asyncio.CancelledError:
+ break
+
+ outbound_task = asyncio.create_task(_consume_outbound())
+
+ try:
+ while True:
+ try:
+ cli_terminal._flush_pending_tty_input()
+ # Stop spinner before user input to avoid prompt_toolkit conflicts
+ if renderer:
+ renderer.stop_for_input()
+ user_input = _sanitize_surrogates(
+ await cli_terminal._read_interactive_input_async()
+ )
+ command = user_input.strip()
+ if not command:
+ continue
+
+ if cli_terminal._is_exit_command(command):
+ cli_terminal._restore_terminal()
+ console.print("\nGoodbye!")
+ break
+
+ turn_done.clear()
+ turn_response.clear()
+ reasoning_buffer.clear()
+ renderer = StreamRenderer(
+ render_markdown=markdown,
+ bot_name=runtime_config.agents.defaults.bot_name,
+ bot_icon=runtime_config.agents.defaults.bot_icon,
+ )
+
+ await bus.publish_inbound(
+ InboundMessage(
+ channel=cli_channel,
+ sender_id="user",
+ chat_id=cli_chat_id,
+ content=user_input,
+ metadata={"_wants_stream": True},
+ )
+ )
+
+ await turn_done.wait()
+
+ if turn_response:
+ response_msg = turn_response[0]
+ content = response_msg.content
+ meta = response_msg.metadata
+ if content and not isinstance(
+ response_msg.event,
+ StreamedResponseEvent,
+ ):
+ if renderer:
+ await renderer.close()
+ print_kwargs: dict[str, Any] = {}
+ if renderer and renderer.header_printed:
+ print_kwargs["show_header"] = False
+ cli_terminal._print_agent_response(
+ content,
+ render_markdown=markdown,
+ metadata=meta,
+ **print_kwargs,
+ )
+ elif renderer and not renderer.streamed:
+ await renderer.close()
+ except KeyboardInterrupt:
+ cli_terminal._restore_terminal()
+ console.print("\nGoodbye!")
+ break
+ except EOFError:
+ cli_terminal._restore_terminal()
+ console.print("\nGoodbye!")
+ break
+ finally:
+ agent_loop.stop()
+ outbound_task.cancel()
+ await asyncio.gather(bus_task, outbound_task, return_exceptions=True)
+ await agent_loop.close_mcp()
+
+ asyncio.run(run_interactive())
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 8f00728c3..efd97c627 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -1,23 +1,13 @@
"""CLI commands for nanobot."""
-# pyright: reportConstantRedefinition=false, reportMissingTypeStubs=false, reportPrivateUsage=false, reportUnusedFunction=false
+# pyright: reportConstantRedefinition=false, reportMissingTypeStubs=false, reportPrivateUsage=false, reportUnusedFunction=false, reportUnusedImport=false
import asyncio
import os
-import select
-import signal
import sys
-import time
-from collections.abc import Awaitable, Callable, Coroutine, Iterable
-from contextlib import nullcontext, suppress
+from contextlib import suppress
from pathlib import Path
-from types import FrameType
-from typing import TYPE_CHECKING, Any, Literal, cast
-
-if TYPE_CHECKING:
- from nanobot.gateway.runtime import GatewayRuntime
- from nanobot.providers.registry import ProviderSpec
-
+from typing import Any, cast
# Force UTF-8 encoding for Windows console
if sys.platform == "win32":
@@ -50,24 +40,7 @@ _log_handler_id = logger.add(
)
-def _set_nanobot_logs(enabled: bool) -> None:
- if enabled:
- logger.enable("nanobot")
- else:
- logger.disable("nanobot")
-
-
-from prompt_toolkit import PromptSession, print_formatted_text # noqa: E402
-from prompt_toolkit.application import run_in_terminal # noqa: E402
-from prompt_toolkit.formatted_text import ANSI, HTML # noqa: E402
-from prompt_toolkit.history import FileHistory # noqa: E402
-from prompt_toolkit.key_binding import KeyBindings # noqa: E402
-from prompt_toolkit.key_binding.key_processor import KeyPressEvent # noqa: E402
-from prompt_toolkit.keys import Keys # noqa: E402
-from prompt_toolkit.patch_stdout import patch_stdout # noqa: E402
-from pydantic import ValidationError # noqa: E402
from rich.console import Console # noqa: E402
-from rich.markdown import Markdown # noqa: E402
from rich.markup import escape # noqa: E402
from rich.table import Table # noqa: E402
from rich.text import Text # noqa: E402
@@ -76,157 +49,35 @@ from nanobot import __logo__, __version__ # noqa: E402
from nanobot import optional_features as feature_support # noqa: E402
from nanobot.agent.hooks import create_file_edit_activity_hook # noqa: E402
from nanobot.agent.loop import AgentLoop # noqa: E402
-from nanobot.bus.outbound_events import ( # noqa: E402
- ProgressEvent,
- RetryWaitEvent,
- StreamDeltaEvent,
- StreamedResponseEvent,
- StreamEndEvent,
- outbound_event_from_message,
-)
+from nanobot.cli import terminal as cli_terminal # noqa: E402
+from nanobot.cli.agent import agent # noqa: E402
from nanobot.cli.gateway import create_gateway_app # noqa: E402
-from nanobot.cli.stream import StreamRenderer, ThinkingSpinner # noqa: E402
-from nanobot.config.paths import get_workspace_path, is_default_workspace # noqa: E402
+from nanobot.cli.gateway_runtime import _run_gateway # noqa: E402
+from nanobot.cli.log_control import _set_nanobot_logs # noqa: E402
+from nanobot.cli.provider import provider_app # noqa: E402
+from nanobot.cli.runtime_config import ( # noqa: E402
+ _load_inspection_config,
+ _load_runtime_config,
+ _model_display,
+ _print_config_error,
+ _print_model_setup_steps,
+ _provider_setup_error,
+)
+from nanobot.cli.webui import webui # noqa: E402
+from nanobot.cli.webui_support import ( # noqa: E402
+ _prepare_webui_bundle_for_gateway,
+ _validate_gateway_startup,
+)
+from nanobot.config.paths import get_workspace_path # noqa: E402
from nanobot.config.schema import Config # noqa: E402
from nanobot.security.network import is_loopback_host # noqa: E402
-from nanobot.session.keys import ( # noqa: E402
- UNIFIED_SESSION_KEY,
- last_channel_from_metadata,
-)
-from nanobot.utils.evaluator import evaluate_response, resolve_evaluator_prompt # noqa: E402
-from nanobot.utils.helpers import ( # noqa: E402
- sanitize_surrogates as _sanitize_surrogates,
-)
+from nanobot.utils.helpers import sanitize_surrogates as _sanitize_surrogates # noqa: E402,F401
from nanobot.utils.helpers import ( # noqa: E402
sync_workspace_templates,
)
-from nanobot.utils.restart import ( # noqa: E402
- consume_restart_notice_from_env,
- format_restart_completed_message,
- should_show_cli_restart_notice,
-)
-from nanobot.webui.build import ( # noqa: E402
- BuildMode,
- WebUIBuildError,
- ensure_webui_bundle,
-)
-from nanobot.webui.sidebar_state import read_webui_sidebar_state # noqa: E402
-
-def _signal_name(signum: int) -> str:
- with suppress(ValueError):
- return signal.Signals(signum).name
- return f"signal {signum}"
-
-
-def _ensure_interactive_tty_mode() -> None:
- """Restore interactive line input after a raw-mode TTY leak."""
- try:
- fd = sys.stdin.fileno()
- if not os.isatty(fd):
- return
- except Exception:
- return
-
- with suppress(Exception):
- import termios
-
- attrs = termios.tcgetattr(fd)
- required_lflag = termios.ISIG | termios.ICANON | termios.ECHO
- blocked_input_flags = getattr(termios, "IGNCR", 0) | getattr(termios, "INLCR", 0)
- if (
- (attrs[3] & required_lflag) == required_lflag
- and attrs[0] & termios.ICRNL
- and not attrs[0] & blocked_input_flags
- ):
- return
- attrs[0] = (attrs[0] | termios.ICRNL) & ~blocked_input_flags
- attrs[3] |= required_lflag
- termios.tcsetattr(fd, termios.TCSANOW, attrs)
- termios.tcflush(fd, termios.TCIFLUSH)
- logger.debug("Restored foreground gateway TTY mode")
-
-
-def _install_gateway_shutdown_handlers(
- loop: asyncio.AbstractEventLoop,
- shutdown_event: asyncio.Event,
- tasks: list[asyncio.Task[Any]],
- print_status: Callable[[str], None],
-) -> Callable[[], None]:
- """Install foreground gateway signal handlers and return a restore callback."""
- loop_signals: list[int] = []
- previous_handlers: list[tuple[int, Any]] = []
- shutdown_requested = False
-
- def request_shutdown(signum: int) -> None:
- nonlocal shutdown_requested
- sig_name = _signal_name(signum)
- if shutdown_requested:
- logger.warning("Forcing gateway shutdown after repeated {}", sig_name)
- for task in tasks:
- if not task.done():
- task.cancel()
- return
- shutdown_requested = True
- logger.info("Gateway shutdown requested by {}", sig_name)
- print_status("\nShutting down... Press Ctrl+C again to force.")
- shutdown_event.set()
-
- for signum in (signal.SIGINT, signal.SIGTERM):
- try:
- loop.add_signal_handler(signum, request_shutdown, signum)
- except (NotImplementedError, RuntimeError, ValueError):
- try:
- previous = signal.getsignal(signum)
- signal.signal(signum, lambda sig, _frame: request_shutdown(sig))
- except (RuntimeError, ValueError):
- logger.debug("Could not install gateway handler for {}", _signal_name(signum))
- continue
- previous_handlers.append((signum, previous))
- else:
- loop_signals.append(signum)
-
- def restore() -> None:
- for signum in loop_signals:
- with suppress(NotImplementedError, RuntimeError, ValueError):
- loop.remove_signal_handler(signum)
- for signum, handler in previous_handlers:
- with suppress(RuntimeError, ValueError):
- signal.signal(signum, handler)
-
- return restore
-
-
-def _advance_dream_cursor_if_behind(memory: Any) -> None:
- latest = memory.get_latest_cursor()
- if memory.get_last_dream_cursor() < latest:
- memory.set_last_dream_cursor(latest)
-
-
-def _commit_dream_changes(memory: Any) -> str | None:
- """Commit durable Dream edits, without entering the commit path for a no-op run."""
- if not memory.git.is_initialized():
- return None
- diff_body = memory.dream_content_diff()
- if not diff_body:
- return None
- message = memory.build_dream_commit_message(
- "dream: periodic memory consolidation",
- diff_body,
- )
- return memory.git.auto_commit(message)
-
-
-class SafeFileHistory(FileHistory):
- """FileHistory subclass that sanitizes surrogate characters on write.
-
- On Windows, special Unicode input (emoji, mixed-script) can produce
- surrogate characters that crash prompt_toolkit's file write.
- See issue #2846.
- """
-
- def store_string(self, string: str) -> None:
- super().store_string(_sanitize_surrogates(string))
+# Backward-compatible import for callers that used the former module location.
+SafeFileHistory = cli_terminal.SafeFileHistory
app = typer.Typer(
@@ -237,402 +88,6 @@ app = typer.Typer(
)
console = Console()
-EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"}
-_REASONING_SENTENCE_ENDINGS = (".", "!", "?", "。", "!", "?")
-_REASONING_FLUSH_CHARS = 60
-
-_HEARTBEAT_PREAMBLE = (
- "[Your response will be delivered directly to the user's messaging app. "
- "Output ONLY the final user-facing message. Never reference internal "
- "files (HEARTBEAT.md, AWARENESS.md, etc.), your instructions, or your "
- "decision process. If nothing needs reporting, respond with just "
- "'All clear.' and nothing else.]\n\n"
-)
-
-
-def _heartbeat_has_active_tasks(content: str) -> bool:
- """True if HEARTBEAT.md has task lines, ignoring headers, blanks and comments."""
- in_comment = False
- in_active_section: bool = False
- for line in content.splitlines():
- stripped = line.strip()
- if in_comment:
- if "-->" in stripped:
- in_comment = False
- continue
- if not stripped or stripped.startswith("#"):
- if stripped.startswith("##") and not stripped.startswith("###"):
- heading = stripped.lstrip("#").strip().lower()
- in_active_section = heading.startswith("active tasks")
- continue
- if stripped.startswith("" not in stripped[4:]:
- in_comment = True
- continue
- if in_active_section is False:
- continue
- return True
- return False
-
-
-def _pick_heartbeat_target_from_sessions(
- *,
- enabled_channels: Iterable[str],
- sessions: Iterable[dict[str, Any]],
- archived_keys: Iterable[str],
- unified_session_metadata: dict[str, Any] | None = None,
-) -> tuple[str, str]:
- enabled = set(enabled_channels)
- archived = set(archived_keys)
- for item in sessions:
- key = item.get("key") or ""
- if key in archived:
- continue
- if key == UNIFIED_SESSION_KEY:
- route = last_channel_from_metadata(unified_session_metadata)
- if route is not None:
- channel, chat_id = route
- if channel not in {"cli", "system"} and channel in enabled:
- return channel, chat_id
- continue
- if ":" not in key:
- continue
- channel, chat_id = key.split(":", 1)
- if channel in {"cli", "system"}:
- continue
- if channel in enabled and chat_id:
- return channel, chat_id
- return "cli", "direct"
-
-
-# ---------------------------------------------------------------------------
-# CLI input: prompt_toolkit for editing, paste, history, and display
-# ---------------------------------------------------------------------------
-
-_PROMPT_SESSION: PromptSession[str] | None = None
-_saved_term_attrs: list[Any] | None = None # original termios settings, restored on exit
-
-
-def _flush_pending_tty_input() -> None:
- """Drop unread keypresses typed while the model was generating output."""
- try:
- fd = sys.stdin.fileno()
- if not os.isatty(fd):
- return
- except Exception:
- return
-
- with suppress(Exception):
- import termios
-
- termios.tcflush(fd, termios.TCIFLUSH)
- return
-
- with suppress(Exception):
- while True:
- ready, _, _ = select.select([fd], [], [], 0)
- if not ready:
- break
- if not os.read(fd, 4096):
- break
-
-
-def _restore_terminal() -> None:
- """Restore terminal to its original state (echo, line buffering, etc.)."""
- if _saved_term_attrs is None:
- return
- with suppress(Exception):
- import termios
-
- termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _saved_term_attrs)
-
-
-def _build_cli_key_bindings() -> KeyBindings:
- """Key bindings for the interactive prompt.
-
- Behaviour:
- * Enter -> submit the current input (keeps the familiar
- single-line Enter-to-send feel even though the buffer
- is multiline-capable).
- * Alt+Enter -> insert a newline for multi-line input.
- * Shift+Enter -> insert a newline on terminals that emit the CSI-u
- (kitty / fixterms) keyboard-protocol encoding for it.
- """
- # prompt_toolkit does not recognize CSI-u, so register its Shift+Enter
- # sequence as a best-effort addition without overriding existing mappings.
- with suppress(Exception):
- from prompt_toolkit.input import ansi_escape_sequences as _aes
-
- _aes.ANSI_SEQUENCES.setdefault("\x1b[13;2u", Keys.ControlF3)
-
- kb = KeyBindings()
-
- @kb.add("enter")
- def _(event: KeyPressEvent) -> None:
- event.current_buffer.validate_and_handle()
-
- @kb.add("escape", "enter") # Alt+Enter / Meta+Enter (ESC + CR, "\x1b\r")
- def _(event: KeyPressEvent) -> None:
- event.current_buffer.insert_text("\n")
-
- # LF-as-Enter terminals send Alt+Enter as ESC + LF rather than ESC + CR.
- @kb.add("escape", Keys.ControlJ) # Alt+Enter on LF-as-Enter terminals
- def _(event: KeyPressEvent) -> None:
- event.current_buffer.insert_text("\n")
-
- @kb.add(Keys.ControlF3) # Shift+Enter on CSI-u capable terminals
- def _(event: KeyPressEvent) -> None:
- event.current_buffer.insert_text("\n")
-
- return kb
-
-
-def _init_prompt_session() -> None:
- """Create the prompt_toolkit session with persistent file history."""
- global _PROMPT_SESSION, _saved_term_attrs
-
- # Save terminal state so we can restore it on exit
- with suppress(Exception):
- import termios
-
- _saved_term_attrs = termios.tcgetattr(sys.stdin.fileno())
-
- from nanobot.config.paths import get_cli_history_path
-
- history_file = get_cli_history_path()
- history_file.parent.mkdir(parents=True, exist_ok=True)
-
- _PROMPT_SESSION = PromptSession(
- history=SafeFileHistory(str(history_file)),
- enable_open_in_editor=False,
- # Multiline-capable buffer; Enter still submits via the custom key
- # bindings, while Alt+Enter adds a newline.
- multiline=True,
- key_bindings=_build_cli_key_bindings(),
- )
-
-
-def _make_console() -> Console:
- return Console(file=sys.stdout)
-
-
-def _render_interactive_ansi(render_fn: Callable[[Console], None]) -> str:
- """Render Rich output to ANSI so prompt_toolkit can print it safely."""
- ansi_console = Console(
- force_terminal=sys.stdout.isatty(),
- color_system=cast(
- Literal["auto", "standard", "256", "truecolor", "windows"],
- console.color_system or "standard",
- ),
- width=console.width,
- )
- with ansi_console.capture() as capture:
- render_fn(ansi_console)
- return capture.get()
-
-
-def _print_agent_response(
- response: str,
- render_markdown: bool,
- metadata: dict[str, Any] | None = None,
- show_header: bool = True,
-) -> None:
- """Render assistant response with consistent terminal styling."""
- console = _make_console()
- content = response or ""
- body = _response_renderable(content, render_markdown, metadata)
- if show_header:
- console.print()
- console.print(f"[cyan]{__logo__} nanobot[/cyan]")
- console.print(body)
- console.print()
-
-
-def _response_renderable(
- content: str, render_markdown: bool, metadata: dict[str, Any] | None = None
-) -> Text | Markdown:
- """Render plain-text command output without markdown collapsing newlines."""
- if not render_markdown:
- return Text(content)
- if (metadata or {}).get("render_as") == "text":
- return Text(content)
- return Markdown(content)
-
-
-async def _print_interactive_line(text: str) -> None:
- """Print async interactive updates with prompt_toolkit-safe Rich styling."""
- def _write() -> None:
- ansi = _render_interactive_ansi(
- lambda c: c.print(f" [dim]↳ {text}[/dim]")
- )
- print_formatted_text(ANSI(ansi), end="")
-
- await run_in_terminal(_write)
-
-
-async def _print_interactive_response(
- response: str,
- render_markdown: bool,
- metadata: dict[str, Any] | None = None,
-) -> None:
- """Print async interactive replies with prompt_toolkit-safe Rich styling."""
- def _write() -> None:
- content = response or ""
-
- def _render(target: Console) -> None:
- target.print()
- target.print(f"[cyan]{__logo__} nanobot[/cyan]")
- target.print(_response_renderable(content, render_markdown, metadata))
- target.print()
-
- ansi = _render_interactive_ansi(_render)
- print_formatted_text(ANSI(ansi), end="")
-
- await run_in_terminal(_write)
-
-
-def _print_cli_progress_line(text: str, thinking: ThinkingSpinner | None, renderer: StreamRenderer | None = None) -> None:
- """Print a CLI progress line, pausing the spinner if needed."""
- if not text.strip():
- return
- target = renderer.console if renderer else console
- pause = renderer.pause_spinner() if renderer else (thinking.pause() if thinking else nullcontext())
- with pause:
- if renderer:
- renderer.ensure_header()
- target.print(f" [dim]↳ {text}[/dim]")
-
-
-class _ReasoningBuffer:
- def __init__(self) -> None:
- self._text = ""
-
- def add(self, text: str) -> str | None:
- if not text:
- return None
- self._text += text
- if self._should_flush(text):
- return self.flush()
- return None
-
- def flush(self) -> str | None:
- text = self._text.strip()
- self._text = ""
- return text or None
-
- def clear(self) -> None:
- self._text = ""
-
- def _should_flush(self, text: str) -> bool:
- stripped = text.rstrip()
- return (
- "\n" in text
- or stripped.endswith(_REASONING_SENTENCE_ENDINGS)
- or len(self._text) >= _REASONING_FLUSH_CHARS
- )
-
-
-def _print_cli_reasoning(text: str, thinking: ThinkingSpinner | None, renderer: StreamRenderer | None = None) -> None:
- """Print reasoning/thinking content in a distinct style."""
- if not text.strip():
- return
- target = renderer.console if renderer else console
- pause = renderer.pause_spinner() if renderer else (thinking.pause() if thinking else nullcontext())
- with pause:
- if renderer:
- renderer.ensure_header()
- target.print(f"[dim italic]✻ {text}[/dim italic]")
-
-
-def _flush_cli_reasoning(
- reasoning_buffer: _ReasoningBuffer,
- thinking: ThinkingSpinner | None,
- renderer: StreamRenderer | None = None,
-) -> None:
- text = reasoning_buffer.flush()
- if text:
- _print_cli_reasoning(text, thinking, renderer)
-
-
-async def _print_interactive_progress_line(text: str, thinking: ThinkingSpinner | None, renderer: StreamRenderer | None = None) -> None:
- """Print an interactive progress line, pausing the spinner if needed."""
- if not text.strip():
- return
- if renderer:
- with renderer.pause_spinner():
- renderer.ensure_header()
- renderer.console.print(f" [dim]↳ {text}[/dim]")
- else:
- with thinking.pause() if thinking else nullcontext():
- await _print_interactive_line(text)
-
-
-async def _maybe_print_interactive_progress(
- msg: Any,
- thinking: ThinkingSpinner | None,
- channels_config: Any,
- renderer: StreamRenderer | None = None,
- reasoning_buffer: _ReasoningBuffer | None = None,
-) -> bool:
- event = outbound_event_from_message(msg)
- if isinstance(event, RetryWaitEvent):
- await _print_interactive_progress_line(msg.content, thinking, renderer)
- return True
-
- if not isinstance(event, ProgressEvent):
- return False
-
- reasoning_buffer = reasoning_buffer or _ReasoningBuffer()
-
- if event.reasoning_end:
- if channels_config and not channels_config.show_reasoning:
- reasoning_buffer.clear()
- else:
- _flush_cli_reasoning(reasoning_buffer, thinking, renderer)
- return True
-
- is_tool_hint = event.tool_hint
- is_reasoning = event.reasoning or event.reasoning_delta
- if is_reasoning:
- if channels_config and not channels_config.show_reasoning:
- reasoning_buffer.clear()
- return True
- text = reasoning_buffer.add(msg.content)
- if text:
- _print_cli_reasoning(text, thinking, renderer)
- return True
- if channels_config and is_tool_hint and not channels_config.send_tool_hints:
- return True
- if channels_config and not is_tool_hint and not channels_config.send_progress:
- return True
-
- await _print_interactive_progress_line(msg.content, thinking, renderer)
- return True
-
-
-def _is_exit_command(command: str) -> bool:
- """Return True when input should end interactive chat."""
- return command.lower() in EXIT_COMMANDS
-
-
-async def _read_interactive_input_async() -> str:
- """Read user input using prompt_toolkit (handles paste, history, display).
-
- prompt_toolkit natively handles:
- - Multiline paste (bracketed paste mode)
- - History navigation (up/down arrows)
- - Clean display (no ghost characters or artifacts)
- """
- if _PROMPT_SESSION is None:
- raise RuntimeError("Call _init_prompt_session() first")
- try:
- with patch_stdout():
- return await _PROMPT_SESSION.prompt_async(
- HTML("You: "),
- )
- except EOFError as exc:
- raise KeyboardInterrupt from exc
-
def version_callback(value: bool):
if value:
@@ -809,113 +264,6 @@ def _print_enable_options(
console.print(table)
-def _model_display(config: Config) -> tuple[str, str]:
- """Return (resolved_model_name, preset_tag) for display strings."""
- resolved = config.resolve_preset()
- name = config.agents.defaults.model_preset
- tag = f" (preset: {name})" if name else ""
- return resolved.model, tag
-
-
-def _print_config_error(error: Exception) -> None:
- """Render a configuration failure without exposing traceback internals."""
- from nanobot.config.errors import ConfigLoadError
-
- console.print(Text(str(error), style="red"))
- if isinstance(error, ConfigLoadError):
- command = _status_command(error.path)
- console.print(f"[dim]Check again after editing: {escape(command)}[/dim]")
-
-
-def _print_runtime_config_validation_error(
- error: ValidationError,
- *,
- config_path: Path,
- summary: str,
- path_prefix: tuple[str | int, ...],
- retry_command: str,
-) -> None:
- """Render a runtime-owned Pydantic config error without exposing input values."""
- from nanobot.config.errors import ConfigIssue, ConfigLoadError, validation_issues
-
- issues = tuple(
- ConfigIssue(
- path=(*path_prefix, *issue.path),
- message=issue.message,
- )
- for issue in validation_issues(error)
- )
- diagnostic = ConfigLoadError(
- config_path,
- kind="invalid_schema",
- summary=summary,
- issues=issues,
- )
- console.print(Text(str(diagnostic), style="red"))
- console.print(f"[dim]Fix the listed setting, then retry: {escape(retry_command)}[/dim]")
-
-
-def _status_command(config_path: Path) -> str:
- return f'nanobot status --config "{config_path}"'
-
-
-def _print_model_setup_steps(config_path: Path) -> None:
- """Show the shortest setup routes shared by Status and Agent startup."""
- config_arg = f'--config "{config_path}"'
- console.print(
- f" WebUI: run [cyan]nanobot webui {escape(config_arg)}[/cyan], "
- "then open Settings → Models"
- )
- console.print(f" CLI: run [cyan]nanobot onboard --wizard {escape(config_arg)}[/cyan]")
- console.print(f" Check: [cyan]{escape(_status_command(config_path))}[/cyan]")
-
-
-def _print_agent_start_error(error: ValueError) -> None:
- from nanobot.config.loader import get_config_path
-
- console.print(Text(f"Agent cannot start: {error}", style="red"))
- console.print("Complete provider/model setup:")
- _print_model_setup_steps(get_config_path())
-
-
-def _load_config_for_cli(
- config_path: Path | None = None,
- *,
- resolve_env: bool = False,
-) -> Config:
- """Load CLI configuration and turn expected failures into a clean exit."""
- from nanobot.config.errors import ConfigLoadError
- from nanobot.config.loader import load_config, resolve_config_env_vars
-
- try:
- loaded = load_config(config_path)
- if resolve_env:
- loaded = resolve_config_env_vars(loaded)
- return loaded
- except ConfigLoadError as exc:
- _print_config_error(exc)
- raise typer.Exit(1) from exc
-
-
-def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config:
- """Load config and optionally override the active workspace."""
- from nanobot.config.loader import set_config_path
-
- config_path = None
- if config:
- config_path = Path(config).expanduser().resolve()
- if not config_path.exists():
- console.print(f"[red]Error: Config file not found: {config_path}[/red]")
- raise typer.Exit(1)
- set_config_path(config_path)
- console.print(f"[dim]Using config: {config_path}[/dim]")
-
- loaded = _load_config_for_cli(config_path, resolve_env=True)
- if workspace:
- loaded.agents.defaults.workspace = workspace
- return loaded
-
-
def _read_trigger_cli_message(message: str | None) -> str:
"""Read a trigger message from an argument or stdin."""
if message and message.strip():
@@ -931,505 +279,6 @@ def _read_trigger_cli_message(message: str | None) -> str:
raise typer.Exit(1)
-def _load_inspection_config(
- config: str | None = None,
- workspace: str | None = None,
-) -> tuple[Path, Config]:
- """Load config for diagnostic commands without resolving secret env refs."""
- from nanobot.config.errors import ConfigLoadError
- from nanobot.config.loader import get_config_path, load_config, set_config_path
-
- config_path = None
- if config:
- config_path = Path(config).expanduser().resolve(strict=False)
- set_config_path(config_path)
- console.print(f"[dim]Using config: {config_path}[/dim]")
-
- display_path = config_path or get_config_path()
- try:
- loaded = load_config(config_path)
- except ConfigLoadError as exc:
- _print_config_error(exc)
- raise typer.Exit(1) from exc
- except ValueError as exc:
- console.print(f"[red]Error: {exc}[/red]")
- raise typer.Exit(1) from exc
- if workspace:
- loaded.agents.defaults.workspace = workspace
- return display_path, loaded
-
-
-def _confirm_webui_action(message: str, *, yes: bool) -> None:
- """Confirm a WebUI first-run mutation or fail clearly in non-interactive shells."""
- if yes:
- return
- if not _cli_can_prompt():
- console.print(
- "[red]Error: WebUI setup needs confirmation. Re-run with --yes or use "
- "`nanobot onboard --wizard`.[/red]"
- )
- raise typer.Exit(1)
- if not typer.confirm(message, default=True):
- console.print("[yellow]WebUI setup cancelled.[/yellow]")
- raise typer.Exit(1)
-
-
-def _cli_can_prompt() -> bool:
- try:
- return sys.stdin.isatty()
- except Exception:
- return False
-
-
-def _webui_build_mode_for_interactive(*, yes: bool = False) -> BuildMode:
- if yes:
- return "auto"
- return "prompt" if _cli_can_prompt() else "warn"
-
-
-def _resolve_webui_config_path(config: str | None) -> Path:
- """Resolve the config path used by ``nanobot webui`` and bind loader state."""
- from nanobot.config.loader import get_config_path, set_config_path
-
- if not config:
- return get_config_path()
- config_path = Path(config).expanduser().resolve(strict=False)
- set_config_path(config_path)
- console.print(f"[dim]Using config: {config_path}[/dim]")
- return config_path
-
-
-def _load_webui_setup_config(config_path: Path) -> Config:
- """Load config for first-run mutation without resolving env-var placeholders."""
- return _load_config_for_cli(config_path)
-
-
-def _provider_setup_error(config: Config) -> str | None:
- """Return a local provider/model configuration error, or None."""
- from nanobot.providers.factory import validate_provider_setup
-
- try:
- validate_provider_setup(config)
- except ValueError as exc:
- return str(exc)
- return None
-
-
-def _webui_config_dict(config: Config) -> dict[str, Any]:
- """Return the current WebSocket config as a mutable alias-key dictionary."""
- from nanobot.channels.websocket.runtime import WebSocketConfig
-
- current: Any = getattr(config.channels, "websocket", None) or {}
- model = WebSocketConfig.model_validate(current)
- return model.model_dump(by_alias=True, exclude_none=True)
-
-
-def _webui_channel_enabled(config: Config) -> bool:
- from nanobot.channels.websocket.runtime import WebSocketConfig
-
- current: Any = getattr(config.channels, "websocket", None) or {}
- return bool(WebSocketConfig.model_validate(current).enabled)
-
-
-def _validate_gateway_startup(config: Config) -> str | None:
- """Validate gateway startup and return a provider error recoverable through WebUI."""
- from nanobot.config.loader import get_config_path
-
- config_path = get_config_path()
- try:
- webui_config = _webui_config_dict(config)
- except ValidationError as exc:
- retry_command = f'nanobot gateway --config "{config_path}"'
- _print_runtime_config_validation_error(
- exc,
- config_path=config_path,
- summary="Gateway configuration is invalid.",
- path_prefix=("channels", "websocket"),
- retry_command=retry_command,
- )
- raise typer.Exit(1) from exc
-
- provider_error = _provider_setup_error(config)
- if not provider_error:
- return None
-
- if bool(webui_config["enabled"]):
- console.print(
- Text(f"Provider/model setup is incomplete: {provider_error}", style="yellow")
- )
- console.print(
- "Gateway will start so you can configure a provider and model "
- "in WebUI Settings → Models."
- )
- browser_url = _webui_browser_url(config)
- webui_url = browser_url.split("/#/", 1)[0]
- console.print(Text(f"WebUI: {webui_url}", style="cyan"))
- if browser_url != webui_url:
- secret_key = (
- "tokenIssueSecret"
- if str(webui_config.get("tokenIssueSecret") or "").strip()
- else "token"
- )
- console.print(
- Text(
- f"If prompted, enter the configured channels.websocket.{secret_key} "
- f"value (see {config_path}).",
- style="dim",
- )
- )
- return provider_error
-
- console.print(Text(f"Gateway cannot start: {provider_error}", style="red"))
- console.print("Complete provider/model setup:")
- _print_model_setup_steps(config_path)
- raise typer.Exit(1)
-
-
-def _prepare_webui_bundle_for_gateway(
- config: Config,
- *,
- mode: BuildMode,
- webui_static_dist: bool = True,
-) -> None:
- """Refresh or warn about stale bundled WebUI assets before gateway startup."""
- if not webui_static_dist or not _webui_channel_enabled(config):
- return
-
- def _print(message: str) -> None:
- console.print(f"[yellow]{escape(message)}[/yellow]")
-
- def _confirm(message: str) -> bool:
- return typer.confirm(message, default=True)
-
- try:
- ensure_webui_bundle(
- mode=mode,
- confirm=_confirm if mode == "prompt" else None,
- output=_print,
- )
- except WebUIBuildError as exc:
- if mode == "warn":
- console.print(f"[yellow]Warning: {escape(str(exc))}[/yellow]")
- return
- console.print(f"[red]Error: {escape(str(exc))}[/red]")
- raise typer.Exit(1) from exc
-
-
-def _host_for_local_browser(host: str) -> str:
- """Map bind hosts to a browser-openable local host."""
- if host in {"0.0.0.0", ""}:
- return "127.0.0.1"
- if host == "::":
- return "[::1]"
- if ":" in host and not host.startswith("["):
- return f"[{host}]"
- return host
-
-
-def _gateway_health_url(host: str, port: int) -> str:
- """Return a health URL that can be opened from this device."""
- return f"http://{_host_for_local_browser(host)}:{port}/health"
-
-
-def _gateway_health_bind_note(host: str) -> str:
- """Describe a non-local bind without presenting it as a usable URL."""
- return "" if is_loopback_host(host) else f" [dim](listening on {host})[/dim]"
-
-
-_GATEWAY_HEALTH_MAX_CONNECTIONS = 64
-_GATEWAY_HEALTH_READ_TIMEOUT_SECONDS = 2.0
-
-
-def _print_gateway_health_endpoint(host: str, port: int) -> None:
- """Print a usable health URL and make non-loopback binds explicit."""
- console.print(
- f"[green]✓[/green] Health endpoint: {_gateway_health_url(host, port)}"
- f"{_gateway_health_bind_note(host)}"
- )
- if is_loopback_host(host):
- return
-
- console.print(
- "[yellow]Warning: the unauthenticated health endpoint is listening beyond loopback "
- "and may be reachable from other devices. "
- f"Keep port {port} private or protect it with a firewall or reverse proxy.[/yellow]"
- )
-
-
-def _webui_bootstrap_secret(config: Config) -> str:
- ws_cfg = _webui_config_dict(config)
- return str(ws_cfg.get("tokenIssueSecret") or ws_cfg.get("token") or "").strip()
-
-
-def _webui_browser_url(config: Config) -> str:
- from urllib.parse import quote
-
- ws_cfg = _webui_config_dict(config)
- host = _host_for_local_browser(str(ws_cfg.get("host") or "127.0.0.1"))
- port = int(ws_cfg.get("port") or 8765)
- base_url = f"http://{host}:{port}"
- secret = _webui_bootstrap_secret(config)
- if not secret:
- return base_url
- return f"{base_url}/#/?bootstrapSecret={quote(secret, safe='')}"
-
-
-def _webui_display_url(url: str) -> str:
- marker = "bootstrapSecret="
- if marker not in url:
- return url
- prefix, _ = url.split(marker, 1)
- return f"{prefix}{marker}"
-
-
-def _ensure_local_webui_channel(config: Config, *, port: int | None, yes: bool) -> tuple[bool, bool]:
- """Enable the local WebUI channel with safe localhost defaults."""
- from nanobot.channels.websocket.runtime import WebSocketConfig
-
- current: Any = getattr(config.channels, "websocket", None) or {}
- model = WebSocketConfig.model_validate(current)
- changed = False
- generated_secret = False
-
- needs_enable = not model.enabled
- needs_port = port is not None and model.port != port
- needs_secret = not model.token_issue_secret.strip() and not model.token.strip()
- if not needs_enable and not needs_port and not needs_secret:
- return False, False
-
- target_port = port if port is not None else model.port
- console.print()
- console.print("[bold]Local WebUI setup[/bold]")
- console.print(f" URL: [cyan]http://127.0.0.1:{target_port}[/cyan]")
- console.print(" Bind: [cyan]127.0.0.1 only[/cyan] (not exposed to your LAN)")
- console.print(" Auth: generated WebUI bootstrap secret stored in config")
- console.print(
- " LAN access requires an explicit host change plus a WebUI password in config."
- )
- _confirm_webui_action("Update the local WebUI channel in this config?", yes=yes)
-
- if not model.enabled:
- model.enabled = True
- changed = True
- if model.host != "127.0.0.1":
- model.host = "127.0.0.1"
- changed = True
- if port is not None and model.port != port:
- model.port = port
- changed = True
- if not model.websocket_requires_token:
- model.websocket_requires_token = True
- changed = True
- if needs_secret:
- import secrets
-
- model.token_issue_secret = secrets.token_urlsafe(32)
- changed = True
- generated_secret = True
-
- setattr(config.channels, "websocket", model.model_dump(by_alias=True, exclude_none=True))
- return changed, generated_secret
-
-
-def _warn_webui_bind_scope(config: Config) -> None:
- ws_cfg = _webui_config_dict(config)
- host = str(ws_cfg.get("host") or "127.0.0.1")
- if host in {"127.0.0.1", "localhost", "::1"}:
- return
- console.print(
- "[yellow]Warning: WebUI is configured to bind outside localhost. "
- "Keep tokenIssueSecret set and use this only on trusted networks.[/yellow]"
- )
-
-
-def _wait_for_webui(url: str, *, timeout_s: float = 5.0) -> None:
- """Best-effort wait for the WebUI listener before opening a browser."""
- import time
- from urllib.parse import urlparse
-
- parsed = urlparse(url)
- host = parsed.hostname or "127.0.0.1"
- port = parsed.port or (443 if parsed.scheme == "https" else 80)
- deadline = time.monotonic() + timeout_s
- while time.monotonic() < deadline:
- if _tcp_endpoint_reachable(host, port, timeout_s=0.2):
- return
- time.sleep(0.1)
-
-
-def _tcp_endpoint_reachable(host: str, port: int, *, timeout_s: float = 0.25) -> bool:
- """Return whether a local TCP endpoint accepts connections."""
- import socket
-
- try:
- with socket.create_connection((host, port), timeout=timeout_s):
- return True
- except OSError:
- return False
-
-
-def _gateway_health_ready(host: str, port: int, *, timeout_s: float = 0.4) -> bool:
- """Return whether the nanobot gateway health endpoint responds OK."""
- import json
- import urllib.error
- import urllib.request
-
- browser_host = _host_for_local_browser(host)
- try:
- with urllib.request.urlopen(
- f"http://{browser_host}:{port}/health",
- timeout=timeout_s,
- ) as response:
- if response.status != 200:
- return False
- body = response.read(1024)
- except (OSError, urllib.error.URLError, TimeoutError, ValueError):
- return False
-
- try:
- payload = json.loads(body.decode("utf-8"))
- except (UnicodeDecodeError, json.JSONDecodeError):
- return False
- return payload.get("status") == "ok"
-
-
-def _webui_endpoint_reachable(url: str, *, timeout_s: float = 0.25) -> bool:
- """Return whether the WebUI URL's TCP endpoint is already listening."""
- from urllib.parse import urlparse
-
- parsed = urlparse(url)
- host = parsed.hostname or "127.0.0.1"
- port = parsed.port or (443 if parsed.scheme == "https" else 80)
- return _tcp_endpoint_reachable(host, port, timeout_s=timeout_s)
-
-
-def _print_foreground_port_conflict(
- *,
- webui_url: str,
- gateway_host: str,
- gateway_port: int,
-) -> None:
- console.print(
- "[red]Error: nanobot cannot start because one of its local ports is already in use.[/red]"
- )
- console.print(f" WebUI: [cyan]{webui_url}[/cyan]")
- console.print(
- f" Gateway health: [cyan]http://{_host_for_local_browser(gateway_host)}:{gateway_port}/health[/cyan]"
- )
- console.print()
- console.print("If this is an existing nanobot instance, use it or stop it first:")
- console.print(" [cyan]nanobot gateway status[/cyan]")
- console.print(" [cyan]nanobot gateway stop[/cyan]")
- console.print("Or choose different ports with [cyan]--port[/cyan] and [cyan]--gateway-port[/cyan].")
-
-
-def _open_webui_browser(url: str, *, wait: bool = True) -> None:
- """Open the WebUI in the user's default browser, with a copyable fallback."""
- import webbrowser
-
- if wait:
- _wait_for_webui(url)
- display_url = _webui_display_url(url)
- try:
- webbrowser.open(url)
- console.print(f"[green]✓[/green] Opened WebUI: [cyan]{display_url}[/cyan]")
- except Exception as exc:
- console.print(f"[yellow]Could not open browser ({exc}); visit {display_url}[/yellow]")
-
-
-def _print_webui_foreground_lifecycle(*, attached: bool) -> None:
- """Explain how the browser and gateway lifecycles differ."""
- console.print()
- if attached:
- console.print("[green]nanobot is attached to the existing gateway.[/green]")
- else:
- console.print("[green]nanobot is running in this terminal.[/green]")
- console.print("[dim]Closing the browser does not stop channels or automations.[/dim]")
- console.print("[dim]Press Ctrl+C here to stop nanobot.[/dim]")
-
-
-def _attach_to_background_gateway(runtime: "GatewayRuntime") -> None:
- """Keep a foreground WebUI command attached to a managed gateway."""
- _print_webui_foreground_lifecycle(attached=True)
- try:
- while runtime.status().running:
- time.sleep(0.5)
- except KeyboardInterrupt:
- console.print("\n[yellow]Stopping nanobot...[/yellow]")
- result = runtime.stop()
- if result.ok or result.message == "gateway_not_running":
- console.print("[green]Gateway stopped.[/green]")
- return
- console.print(f"[red]Gateway could not be stopped: {result.message}[/red]")
- raise typer.Exit(1)
-
- console.print("[yellow]Gateway stopped.[/yellow]")
-
-
-def _gateway_instance_command(
- subcommand: str,
- *,
- config_path: Path,
- workspace: str | None,
-) -> str:
- """Return a copyable gateway command for the same config/workspace instance."""
- import shlex
-
- parts = ["nanobot", "gateway", subcommand, "--config", str(config_path)]
- if workspace:
- workspace_path = str(Path(workspace).expanduser().resolve(strict=False))
- parts.extend(["--workspace", workspace_path])
- return " ".join(shlex.quote(part) for part in parts)
-
-
-def _run_quick_start_for_webui(
- config: Config,
- *,
- yes: bool,
- config_path: Path,
-) -> Config:
- """Offer the existing Quick Start flow when provider setup is missing."""
- if yes:
- console.print(
- "[red]Error: provider/model setup is incomplete, and --yes cannot answer "
- "provider credentials.[/red]"
- )
- console.print("Complete provider/model setup:")
- _print_model_setup_steps(config_path)
- raise typer.Exit(1)
-
- console.print()
- console.print("[yellow]Model provider setup is not ready.[/yellow]")
- console.print("Quick Start will ask for provider, API key/base URL, model, and WebUI password.")
- _confirm_webui_action("Run Quick Start now?", yes=False)
-
- from nanobot.cli.onboard import run_quick_start_onboard
-
- try:
- result = run_quick_start_onboard(config)
- except RuntimeError as exc:
- console.print(f"[red]Error: {exc}[/red]")
- console.print("[yellow]Run `nanobot onboard --wizard` after installing wizard dependencies.[/yellow]")
- raise typer.Exit(1) from exc
- if not result.should_save:
- console.print("[yellow]Quick Start cancelled. No changes were saved.[/yellow]")
- raise typer.Exit(1)
- return result.config
-
-
-def _migrate_cron_store(config: "Config") -> None:
- """One-time migration: move legacy global cron store into the workspace."""
- from nanobot.config.paths import get_cron_dir
-
- legacy_path = get_cron_dir() / "jobs.json"
- new_path = config.workspace_path / "cron" / "jobs.json"
- if legacy_path.is_file() and not new_path.exists():
- new_path.parent.mkdir(parents=True, exist_ok=True)
- import shutil
-
- shutil.move(str(legacy_path), str(new_path))
-
-
@app.command()
def trigger(
trigger_id: str = typer.Argument(..., help="Trigger ID returned by /trigger"),
@@ -1550,224 +399,7 @@ def serve(
# WebUI Launcher
# ============================================================================
-
-@app.command()
-def webui(
- port: int | None = typer.Option(None, "--port", "-p", help="WebUI port"),
- gateway_port: int | None = typer.Option(
- None,
- "--gateway-port",
- help="Gateway health port",
- ),
- workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
- config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
- background: bool = typer.Option(
- False,
- "--background",
- help="Keep the gateway running after this command exits",
- ),
- no_open: bool = typer.Option(False, "--no-open", help="Do not open a browser"),
- yes: bool = typer.Option(
- False,
- "--yes",
- "-y",
- help="Apply safe local WebUI defaults without prompting",
- ),
-) -> None:
- """Prepare the local WebUI, start the gateway, and open the browser workbench."""
- from nanobot.config.loader import resolve_config_env_vars, save_config
- from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions
-
- _ensure_interactive_tty_mode()
- config_path = _resolve_webui_config_path(config)
- created_config = not config_path.exists()
- if created_config:
- console.print(f"[yellow]No config found at {config_path}.[/yellow]")
- _confirm_webui_action("Create a nanobot config and workspace now?", yes=yes)
-
- setup_config = _load_webui_setup_config(config_path)
- if workspace:
- setup_config.agents.defaults.workspace = workspace
-
- try:
- resolved_setup_config = resolve_config_env_vars(
- setup_config.model_copy(deep=True),
- config_path=config_path,
- )
- except ValueError as exc:
- _print_config_error(exc)
- raise typer.Exit(1) from exc
-
- provider_error = _provider_setup_error(resolved_setup_config)
- settings_setup_error = provider_error if provider_error and created_config else None
- if settings_setup_error:
- console.print(f"[yellow]Model setup is incomplete: {provider_error}[/yellow]")
- console.print("Configure a provider and model in WebUI Settings → Models.")
- if background:
- console.print(
- "[red]First-time WebUI setup must run in the foreground. "
- "Run `nanobot webui` without --background.[/red]"
- )
- raise typer.Exit(1)
- elif provider_error:
- console.print(f"[dim]Provider check: {provider_error}[/dim]")
- setup_config = _run_quick_start_for_webui(
- setup_config,
- yes=yes,
- config_path=config_path,
- )
- if workspace:
- setup_config.agents.defaults.workspace = workspace
-
- try:
- changed_webui, generated_bootstrap_secret = _ensure_local_webui_channel(
- setup_config,
- port=port,
- yes=yes,
- )
- _warn_webui_bind_scope(setup_config)
- webui_url = _webui_browser_url(setup_config)
- except ValidationError as exc:
- retry_command = f'nanobot webui --config "{config_path}"'
- _print_runtime_config_validation_error(
- exc,
- config_path=config_path,
- summary="WebUI configuration is invalid.",
- path_prefix=("channels", "websocket"),
- retry_command=retry_command,
- )
- raise typer.Exit(1) from exc
- except ValueError as exc:
- console.print(f"[red]Error: invalid WebUI channel config: {exc}[/red]")
- raise typer.Exit(1) from exc
-
- if created_config or provider_error or changed_webui or workspace:
- save_config(setup_config, config_path)
- console.print(f"[green]✓[/green] Saved config: {config_path}")
-
- workspace_path = get_workspace_path(setup_config.workspace_path)
- workspace_path.mkdir(parents=True, exist_ok=True)
- sync_workspace_templates(workspace_path)
-
- runtime_config = _load_runtime_config(str(config_path), workspace)
- effective_gateway_port = gateway_port if gateway_port is not None else runtime_config.gateway.port
-
- console.print()
- console.print(f"WebUI: [cyan]{_webui_display_url(webui_url)}[/cyan]")
- gateway_health_url = _gateway_health_url(
- runtime_config.gateway.host,
- effective_gateway_port,
- )
- console.print(
- f"Gateway health: [cyan]{gateway_health_url}[/cyan]"
- f"{_gateway_health_bind_note(runtime_config.gateway.host)}"
- )
- if no_open:
- console.print("[dim]Browser opening disabled by --no-open.[/dim]")
- if generated_bootstrap_secret:
- console.print(
- "[yellow]A WebUI bootstrap secret was generated and saved in this config.[/yellow]"
- )
- console.print(
- "[dim]Open the WebUI and enter channels.websocket.tokenIssueSecret from "
- f"{config_path}, or rerun without --no-open to open the authenticated URL.[/dim]"
- )
-
- webui_bundle_mode = _webui_build_mode_for_interactive(yes=yes)
-
- config_arg = str(config_path)
- workspace_arg = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None
- runtime = GatewayRuntime(
- paths=GatewayRuntimePaths.for_instance(
- data_dir=config_path.parent,
- workspace=workspace_arg,
- config_path=config_arg,
- )
- )
- start_options = GatewayStartOptions(
- port=effective_gateway_port,
- workspace=workspace_arg,
- config_path=config_arg,
- )
-
- if background:
- _prepare_webui_bundle_for_gateway(runtime_config, mode=webui_bundle_mode)
- result = runtime.start_background(start_options)
- restarted = False
- restart_attempted = False
- if not result.ok and result.message == "gateway_already_running" and changed_webui:
- restart_attempted = True
- console.print("[yellow]WebUI config changed; restarting the background gateway.[/yellow]")
- result = runtime.restart(start_options, timeout_s=20)
- restarted = result.ok
- if not result.ok and (restart_attempted or result.message != "gateway_already_running"):
- action = "restarted" if restart_attempted else "started"
- console.print(f"[yellow]Gateway was not {action}: {result.message}[/yellow]")
- console.print(f"Logs: {result.status.log_path}")
- raise typer.Exit(1)
- if restarted:
- console.print("[green]Gateway restarted in the background.[/green]")
- elif result.ok:
- console.print("[green]Gateway started in the background.[/green]")
- else:
- console.print("[yellow]Gateway is already running in the background.[/yellow]")
- console.print(
- "Manage this instance: "
- f"[cyan]{_gateway_instance_command('status', config_path=config_path, workspace=workspace)}[/cyan]"
- )
- console.print(
- "View logs: "
- f"[cyan]{_gateway_instance_command('logs', config_path=config_path, workspace=workspace)}[/cyan]"
- )
- console.print("[dim]Closing the browser does not stop channels or automations.[/dim]")
- console.print(
- "Stop nanobot: "
- f"[cyan]{_gateway_instance_command('stop', config_path=config_path, workspace=workspace)}[/cyan]"
- )
- if not no_open:
- _open_webui_browser(webui_url)
- return
-
- gateway_ready = _gateway_health_ready(runtime_config.gateway.host, effective_gateway_port)
- webui_ready = _webui_endpoint_reachable(webui_url)
- if gateway_ready and webui_ready:
- console.print("[yellow]Gateway is already running; attaching to the existing WebUI.[/yellow]")
- console.print(
- "Restart the gateway if you need it to pick up local source changes: "
- f"[cyan]{_gateway_instance_command('restart', config_path=config_path, workspace=workspace)}[/cyan]"
- )
- if not no_open:
- _open_webui_browser(webui_url, wait=False)
- if runtime.status().running:
- _attach_to_background_gateway(runtime)
- else:
- console.print(
- "[yellow]This gateway is controlled by another foreground command. "
- "Stop it from that terminal.[/yellow]"
- )
- return
-
- gateway_port_taken = gateway_ready or _tcp_endpoint_reachable(
- _host_for_local_browser(runtime_config.gateway.host),
- effective_gateway_port,
- )
- webui_port_taken = webui_ready
- if gateway_port_taken or webui_port_taken:
- _print_foreground_port_conflict(
- webui_url=webui_url,
- gateway_host=runtime_config.gateway.host,
- gateway_port=effective_gateway_port,
- )
- raise typer.Exit(1)
-
- _print_webui_foreground_lifecycle(attached=False)
- _run_gateway(
- runtime_config,
- port=effective_gateway_port,
- open_browser_url=None if no_open else webui_url,
- webui_bundle_mode=webui_bundle_mode,
- unconfigured_provider_error=settings_setup_error,
- )
+app.command(name="webui")(webui)
# ============================================================================
@@ -1775,633 +407,6 @@ def webui(
# ============================================================================
-def _run_gateway(
- config: Config,
- *,
- port: int | None = None,
- open_browser_url: str | None = None,
- webui_static_dist: bool = True,
- webui_bundle_mode: BuildMode = "warn",
- webui_runtime_surface: str = "browser",
- webui_runtime_capabilities: dict[str, Any] | None = None,
- health_server_enabled: bool = True,
- unconfigured_provider_error: str | None = None,
-) -> None:
- """Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
- from nanobot.agent.model_presets import load_model_preset_catalog
- from nanobot.agent.tools.message import MessageTool
- from nanobot.agent.turn_delivery import TurnDeliveryFactory
- from nanobot.bus.queue import MessageBus
- from nanobot.bus.runtime_events import RuntimeEventBus
- from nanobot.channels.manager import ChannelManager
- from nanobot.config.watcher import watch_config_file
- from nanobot.cron.bound_runner import run_bound_cron_job
- from nanobot.cron.service import CronJobSkippedError, CronService
- from nanobot.cron.session_turns import is_bound_cron_job
- from nanobot.cron.types import CronJob
- from nanobot.providers.factory import (
- ProviderSnapshot,
- build_provider_snapshot,
- build_unconfigured_provider_snapshot,
- load_provider_snapshot,
- )
- from nanobot.providers.fallback_provider import FallbackProvider
- from nanobot.providers.image_generation import image_gen_provider_configs
- from nanobot.session.manager import SessionManager
- from nanobot.session.webui_turns import (
- WebuiTurnCoordinator,
- WebuiTurnRoutePolicy,
- build_webui_fallback_model_observer,
- )
- from nanobot.triggers.local_runner import run_local_trigger_queue
- from nanobot.triggers.local_store import LocalTriggerStore
- from nanobot.webui.token_usage import TokenUsageHook
-
- port = port if port is not None else config.gateway.port
- webui_url = _webui_browser_url(config)
- gateway_host_for_browser = _host_for_local_browser(config.gateway.host)
- if health_server_enabled and _tcp_endpoint_reachable(gateway_host_for_browser, port):
- _print_foreground_port_conflict(
- webui_url=webui_url,
- gateway_host=config.gateway.host,
- gateway_port=port,
- )
- raise typer.Exit(1)
- if _webui_channel_enabled(config) and _webui_endpoint_reachable(webui_url):
- _print_foreground_port_conflict(
- webui_url=webui_url,
- gateway_host=config.gateway.host,
- gateway_port=port,
- )
- raise typer.Exit(1)
-
- console.print(f"{__logo__} Starting nanobot gateway version {__version__} on port {port}...")
- _prepare_webui_bundle_for_gateway(
- config,
- mode=webui_bundle_mode,
- webui_static_dist=webui_static_dist,
- )
- sync_workspace_templates(config.workspace_path)
- bus = MessageBus()
- runtime_events = RuntimeEventBus()
- fallback_model_observer = build_webui_fallback_model_observer(bus)
-
- def _observe_fallback_models(snapshot: ProviderSnapshot) -> ProviderSnapshot:
- if isinstance(snapshot.provider, FallbackProvider):
- snapshot.provider.set_fallback_model_observer(fallback_model_observer)
- return snapshot
-
- def _load_gateway_provider_snapshot(
- *args: Any,
- **kwargs: Any,
- ) -> ProviderSnapshot:
- try:
- return _observe_fallback_models(load_provider_snapshot(*args, **kwargs))
- except ValueError as exc:
- if unconfigured_provider_error is None:
- raise
- return build_unconfigured_provider_snapshot(config, str(exc))
-
- if unconfigured_provider_error is not None:
- provider_snapshot = build_unconfigured_provider_snapshot(
- config,
- unconfigured_provider_error,
- )
- else:
- try:
- provider_snapshot = _observe_fallback_models(build_provider_snapshot(config))
- except ValueError as exc:
- console.print(f"[red]Error: {exc}[/red]")
- raise typer.Exit(1) from exc
- session_manager = SessionManager(config.workspace_path)
-
- # Self-heal the gateway state file with the current PID after any restart.
- from nanobot.config.loader import get_config_path
- from nanobot.gateway.runtime import GatewayRuntime, GatewayRuntimePaths
-
- config_path = str(get_config_path().resolve(strict=False))
- GatewayRuntime.refresh_state_pid(
- paths=GatewayRuntimePaths.for_instance(
- workspace=str(config.workspace_path)
- if not is_default_workspace(config.workspace_path)
- else None,
- config_path=config_path,
- )
- )
-
- # Preserve existing single-workspace installs, but keep custom workspaces clean.
- if is_default_workspace(config.workspace_path):
- _migrate_cron_store(config)
-
- # Create cron service with workspace-scoped store
- cron_store_path = config.workspace_path / "cron" / "jobs.json"
- cron = CronService(cron_store_path)
- trigger_store = LocalTriggerStore(config.workspace_path)
-
- turn_delivery_factory = TurnDeliveryFactory(
- bus,
- runtime_events,
- route_policy=WebuiTurnRoutePolicy(session_manager),
- )
-
- # Create agent with cron service
- agent = AgentLoop.from_config(
- config, bus,
- provider=provider_snapshot.provider,
- model=provider_snapshot.model,
- context_window_tokens=provider_snapshot.context_window_tokens,
- cron_service=cron,
- session_manager=session_manager,
- image_generation_provider_configs=image_gen_provider_configs(config),
- provider_snapshot_loader=_load_gateway_provider_snapshot,
- preset_catalog_loader=load_model_preset_catalog,
- runtime_events=runtime_events,
- turn_delivery_factory=turn_delivery_factory,
- provider_signature=provider_snapshot.signature,
- hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)],
- local_trigger_store=trigger_store,
- hook_factories=[create_file_edit_activity_hook],
- )
- def _schedule_webui_background(awaitable: Awaitable[None]) -> None:
- agent._schedule_background(cast(Coroutine[Any, Any, None], awaitable))
-
- webui_turn_coordinator = WebuiTurnCoordinator(
- bus=bus,
- sessions=session_manager,
- schedule_background=_schedule_webui_background,
- )
- webui_turn_coordinator.subscribe(runtime_events)
- from nanobot.bus.events import OutboundMessage
- from nanobot.session.keys import session_key_for_channel
-
- def _channel_session_key(channel: str, chat_id: str) -> str:
- return session_key_for_channel(
- channel,
- chat_id,
- unified_session=config.agents.defaults.unified_session,
- )
-
- async def _deliver_to_channel(
- msg: OutboundMessage, *, record: bool = False, session_key: str | None = None,
- ) -> None:
- """Publish a user-visible message and mirror it into that channel's session."""
- metadata = dict(msg.metadata or {})
- record = record or bool(metadata.pop("_record_channel_delivery", False))
- if metadata != (msg.metadata or {}):
- msg = OutboundMessage(
- channel=msg.channel,
- chat_id=msg.chat_id,
- content=msg.content,
- reply_to=msg.reply_to,
- media=msg.media,
- metadata=metadata,
- buttons=msg.buttons,
- )
- if (
- record
- and msg.channel != "cli"
- and msg.content.strip()
- and hasattr(session_manager, "get_or_create")
- and hasattr(session_manager, "save")
- ):
- key = session_key or _channel_session_key(msg.channel, msg.chat_id)
- session = session_manager.get_or_create(key)
- extra: dict[str, Any] = {"_channel_delivery": True}
- if msg.media:
- extra["media"] = list(msg.media)
- session.add_message("assistant", msg.content, **extra)
- session_manager.save(session)
- await bus.publish_outbound(msg)
-
- message_tool = agent.tools.get("message")
- if isinstance(message_tool, MessageTool):
- message_tool.set_send_callback(_deliver_to_channel)
-
- # Set cron callback (needs agent)
- async def on_cron_job(job: CronJob) -> str | None:
- """Execute a cron job through the agent."""
- async def _silent(*_args: Any, **_kwargs: Any) -> None:
- pass
-
- # Dream is an internal job — run directly, not through the agent loop.
- if job.name == "dream":
- from nanobot.agent.memory import DreamRunProgress, MemoryStore
-
- dream_session_key = MemoryStore.dream_session_key
- prune_dream_sessions = MemoryStore.prune_dream_sessions
-
- store = agent.context.memory
- progress = DreamRunProgress()
- resp = None
- diff_body = ""
- try:
- result = store.build_dream_prompt()
- if result is None:
- logger.info("Dream: nothing to process")
- return None
- prompt, last_cursor = result
- key = dream_session_key()
- dream_runtime = agent.dream_runtime()
- resp = await agent.process_direct(
- prompt,
- session_key=key,
- ephemeral=True,
- tools=store.build_dream_tools(),
- on_progress=progress,
- runtime=dream_runtime,
- )
- # The real file delta grounds the audit record; clean completion
- # decides whether this history batch has finished processing.
- diff_body = store.dream_content_diff()
- completed = MemoryStore.dream_run_completed(
- resp,
- had_tool_errors=progress.had_tool_errors,
- )
- if completed:
- store.set_last_dream_cursor(last_cursor)
- if diff_body:
- logger.info(
- "Dream cron job completed, cursor advanced to {}",
- last_cursor,
- )
- else:
- logger.info(
- "Dream cron job completed with no memory changes; "
- "cursor advanced to {}",
- last_cursor,
- )
- else:
- logger.warning(
- "Dream cron job did not complete; cursor remains at {}",
- store.get_last_dream_cursor(),
- )
- except Exception:
- logger.exception("Dream cron job failed")
- finally:
- from nanobot.webui.token_usage import record_response_token_usage
-
- record_response_token_usage(
- resp,
- source="dream",
- timezone_name=config.agents.defaults.timezone,
- )
- sha = _commit_dream_changes(store)
- if sha:
- logger.info("Dream commit: {}", sha)
- store.compact_history()
- prune_dream_sessions(agent.sessions.sessions_dir)
- return None
-
- # Heartbeat is a system job that checks HEARTBEAT.md for active tasks.
- if job.name == "heartbeat":
- heartbeat_file = config.workspace_path / "HEARTBEAT.md"
- try:
- content = heartbeat_file.read_text(encoding="utf-8")
- except OSError:
- logger.debug("Heartbeat: HEARTBEAT.md missing")
- return None
- if not _heartbeat_has_active_tasks(content):
- logger.debug("Heartbeat: HEARTBEAT.md has no active tasks")
- return None
-
- channel, chat_id = _pick_heartbeat_target()
- if channel == "cli":
- return None
-
- prompt = (
- _HEARTBEAT_PREAMBLE
- + f"You are executing periodic heartbeat tasks. Read the active tasks below, perform each one, and report what you did:\n\n{content}"
- )
-
- # Internal check: funnel all output through the post-run gate so the
- # turn can't deliver directly via the message tool and skip it.
- suppress_token = None
- if isinstance(message_tool, MessageTool):
- suppress_token = message_tool.set_suppress_delivery(True)
- try:
- resp = await agent.process_direct(
- prompt,
- session_key="heartbeat",
- channel=channel,
- chat_id=chat_id,
- on_progress=_silent,
- )
- finally:
- if isinstance(message_tool, MessageTool) and suppress_token is not None:
- message_tool.reset_suppress_delivery(suppress_token)
-
- # Keep a small tail of heartbeat history so the loop stays bounded.
- session = agent.sessions.get_or_create("heartbeat")
- session.retain_recent_legal_suffix(hb_cfg.keep_recent_messages)
- agent.sessions.save(session)
-
- if not resp or not resp.content:
- return
-
- response = resp.content
-
- evaluator_prompt = resolve_evaluator_prompt(config.workspace_path)
-
- # Fail closed: stay silent on evaluator failure instead of notifying.
- should_notify = await evaluate_response(
- response=response,
- task_context=prompt,
- provider=agent.provider,
- model=agent.model,
- evaluator_prompt=evaluator_prompt,
- default_notify=False,
- )
-
- if should_notify:
- logger.info("Heartbeat: completed, delivering response")
- await _deliver_to_channel(
- OutboundMessage(channel=channel, chat_id=chat_id, content=response),
- record=True,
- )
- else:
- logger.info("Heartbeat: silenced by post-run evaluation")
- return response
-
- if is_bound_cron_job(job):
- return await run_bound_cron_job(job, agent=agent, cron=cron)
-
- reason = "unbound agent cron job must be recreated from a chat session"
- logger.warning(
- "Cron: skipped unbound agent job '{}' ({}): {}",
- job.name,
- job.id,
- reason,
- )
- raise CronJobSkippedError(reason)
-
- cron.on_job = on_cron_job
-
- def _webui_runtime_model_name() -> str | None:
- return agent.model.strip() or None
-
- def _webui_skill_state_action(disabled_skills: set[str]) -> None:
- config.agents.defaults.disabled_skills = sorted(disabled_skills)
- agent.context.skills.disabled_skills = set(disabled_skills)
- agent.subagents.disabled_skills = set(disabled_skills)
-
- # Create channel manager (forwards SessionManager so the WebSocket channel
- # can serve the embedded webui's REST surface).
- channels = ChannelManager(
- config,
- bus,
- session_manager=session_manager,
- cron_service=cron,
- local_trigger_store=trigger_store,
- webui_runtime_model_name=_webui_runtime_model_name,
- webui_cron_pending_job_ids=agent.pending_cron_job_ids_for_session,
- webui_local_trigger_pending_ids=agent.pending_local_trigger_ids_for_session,
- webui_static_dist=webui_static_dist,
- webui_runtime_surface=webui_runtime_surface,
- webui_runtime_capabilities=webui_runtime_capabilities,
- webui_skill_state_action=_webui_skill_state_action,
- )
-
- def _pick_heartbeat_target() -> tuple[str, str]:
- """Pick a routable channel/chat target for heartbeat-triggered messages."""
- sidebar_state = read_webui_sidebar_state()
- unified_metadata = None
- if config.agents.defaults.unified_session:
- record = session_manager.read_session_metadata(UNIFIED_SESSION_KEY)
- if isinstance(record, dict) and isinstance(record.get("metadata"), dict):
- unified_metadata = record["metadata"]
- return _pick_heartbeat_target_from_sessions(
- enabled_channels=channels.enabled_channels,
- sessions=session_manager.list_sessions(),
- archived_keys=sidebar_state.get("archived_keys", []),
- unified_session_metadata=unified_metadata,
- )
-
- if channels.enabled_channels:
- console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
- else:
- console.print("[yellow]Warning: No channels enabled[/yellow]")
-
- cron_status = cron.status()
- cron_job_count = cast(int, cron_status["jobs"])
- if cron_job_count > 0:
- console.print(f"[green]✓[/green] Cron: {cron_job_count} scheduled jobs")
-
- hb_cfg = config.gateway.heartbeat
- if hb_cfg.enabled:
- console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s")
- else:
- console.print("[yellow]✗[/yellow] Heartbeat: disabled")
-
- async def _health_server(host: str, health_port: int) -> None:
- """Lightweight HTTP health endpoint on the gateway port."""
- import json as _json
-
- connection_slots = asyncio.Semaphore(_GATEWAY_HEALTH_MAX_CONNECTIONS)
-
- async def handle(
- reader: asyncio.StreamReader,
- writer: asyncio.StreamWriter,
- ) -> None:
- if connection_slots.locked():
- writer.close()
- return
-
- async with connection_slots:
- try:
- data = await asyncio.wait_for(
- reader.read(4096),
- timeout=_GATEWAY_HEALTH_READ_TIMEOUT_SECONDS,
- )
- request_line = data.split(b"\r\n", 1)[0].decode(
- "utf-8", errors="replace",
- )
- method, path = "", ""
- parts = request_line.split(" ")
- if len(parts) >= 2:
- method, path = parts[0], parts[1]
-
- if method == "GET" and path == "/health":
- body = _json.dumps({"status": "ok"})
- status = "200 OK"
- content_type = "application/json"
- else:
- body = "Not Found"
- status = "404 Not Found"
- content_type = "text/plain"
-
- resp = (
- f"HTTP/1.0 {status}\r\n"
- f"Content-Type: {content_type}\r\n"
- f"Content-Length: {len(body)}\r\n"
- "Connection: close\r\n"
- f"\r\n{body}"
- )
- writer.write(resp.encode())
- await writer.drain()
- except (asyncio.TimeoutError, ConnectionError):
- pass
- finally:
- writer.close()
-
- server = await asyncio.start_server(handle, host, health_port)
- _print_gateway_health_endpoint(host, health_port)
- async with server:
- await server.serve_forever()
- # Register Dream system job (idempotent on restart)
- from nanobot.cron.types import CronJob, CronPayload, CronSchedule
- dream_cfg = config.agents.defaults.dream
- if dream_cfg.enabled:
- cron.register_system_job(CronJob(
- id="dream",
- name="dream",
- schedule=dream_cfg.build_schedule(config.agents.defaults.timezone),
- payload=CronPayload(kind="system_event"),
- ))
- console.print(f"[green]✓[/green] Dream: {dream_cfg.describe_schedule()}")
- else:
- console.print("[yellow]○[/yellow] Dream: disabled")
- _advance_dream_cursor_if_behind(agent.context.memory)
-
- # Register Heartbeat system job (idempotent on restart)
- if hb_cfg.enabled:
- cron.register_system_job(CronJob(
- id="heartbeat",
- name="heartbeat",
- schedule=CronSchedule(
- kind="every",
- every_ms=hb_cfg.interval_s * 1000,
- tz=config.agents.defaults.timezone,
- ),
- payload=CronPayload(kind="system_event"),
- ))
-
- async def _open_browser_when_ready() -> None:
- """Wait for the gateway to bind, then point the user's browser at the webui."""
- if not open_browser_url:
- return
- import webbrowser
- from urllib.parse import urlparse
-
- parsed = urlparse(open_browser_url)
- target_host = parsed.hostname or config.gateway.host or "127.0.0.1"
- target_port = parsed.port or port
- # Channels start asynchronously; a short poll lets us avoid racing the bind.
- for _ in range(40): # ~4s max
- try:
- _reader, writer = await asyncio.open_connection(
- target_host,
- target_port,
- )
- writer.close()
- with suppress(Exception):
- await writer.wait_closed()
- break
- except OSError:
- await asyncio.sleep(0.1)
- try:
- webbrowser.open(open_browser_url)
- console.print(f"[green]✓[/green] Opened browser at {open_browser_url}")
- except Exception as e:
- console.print(f"[yellow]Could not open browser ({e}); visit {open_browser_url}[/yellow]")
-
- async def run() -> None:
- tasks: list[asyncio.Task[Any]] = []
- shutdown_task: asyncio.Task[Any] | None = None
- runtime_tasks: asyncio.Future[list[Any]] | None = None
- runtime_tasks_drained = False
- shutdown_event = asyncio.Event()
- _ensure_interactive_tty_mode()
- restore_shutdown_handlers = _install_gateway_shutdown_handlers(
- asyncio.get_running_loop(),
- shutdown_event,
- tasks,
- console.print,
- )
- try:
- await cron.start()
- # Re-read once on first admission to close the watcher subscription window.
- agent.runtime_resolver.invalidate()
- tasks = [
- asyncio.create_task(
- watch_config_file(
- Path(config_path),
- lambda: agent.invalidate_runtime_config(),
- ),
- name="nanobot-config-watcher",
- ),
- asyncio.create_task(agent.run(), name="nanobot-agent-loop"),
- asyncio.create_task(channels.start_all(), name="nanobot-channels"),
- asyncio.create_task(
- run_local_trigger_queue(
- store=trigger_store,
- submit_turn=agent.submit_local_trigger_turn,
- is_channel_enabled=lambda name: channels.get_channel(name) is not None,
- ),
- name="nanobot-local-triggers",
- ),
- ]
- if health_server_enabled:
- tasks.append(asyncio.create_task(
- _health_server(config.gateway.host, port),
- name="nanobot-health-server",
- ))
- if open_browser_url:
- tasks.append(asyncio.create_task(
- _open_browser_when_ready(),
- name="nanobot-open-browser",
- ))
- runtime_tasks = asyncio.gather(*tasks)
- shutdown_task = asyncio.create_task(
- shutdown_event.wait(),
- name="nanobot-gateway-shutdown",
- )
- done, _pending = await asyncio.wait(
- {runtime_tasks, shutdown_task},
- return_when=asyncio.FIRST_COMPLETED,
- )
- if runtime_tasks in done:
- runtime_tasks_drained = True
- await runtime_tasks
- else:
- runtime_tasks.cancel()
- except KeyboardInterrupt:
- console.print("\nShutting down...")
- except Exception:
- import traceback
-
- console.print("\n[red]Error: Gateway crashed unexpectedly[/red]")
- console.print(traceback.format_exc())
- finally:
- try:
- if shutdown_task and not shutdown_task.done():
- shutdown_task.cancel()
- with suppress(asyncio.CancelledError):
- await shutdown_task
- cron.stop()
- agent.stop()
- # Some SDKs swallow task cancellation while attempting to reconnect.
- # Close channel transports before waiting for their runners to exit.
- await channels.stop_all()
- for task in tasks:
- if not task.done():
- task.cancel()
- if tasks:
- await asyncio.gather(*tasks, return_exceptions=True)
- if runtime_tasks is not None and not runtime_tasks_drained:
- with suppress(asyncio.CancelledError, Exception):
- await runtime_tasks
- # Flush all cached sessions to durable storage before exit.
- # This prevents data loss on filesystems with write-back
- # caching (rclone VFS, NFS, FUSE mounts, etc.).
- flushed = agent.sessions.flush_all()
- if flushed:
- logger.info("Shutdown: flushed {} session(s) to disk", flushed)
- finally:
- restore_shutdown_handlers()
-
- asyncio.run(run())
-
-
app.add_typer(
create_gateway_app(
console=console,
@@ -2423,285 +428,7 @@ app.add_typer(
# ============================================================================
-@app.command()
-def agent(
- message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
- session_id: str = typer.Option("cli:direct", "--session", "-s", help="Session ID"),
- workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
- config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
- markdown: bool = typer.Option(True, "--markdown/--no-markdown", help="Render assistant output as Markdown"),
- logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
-):
- """Interact with the agent directly."""
- from nanobot.bus.queue import MessageBus
- from nanobot.cron.service import CronService
- from nanobot.providers.factory import make_provider
- from nanobot.providers.image_generation import image_gen_provider_configs
-
- runtime_config = _load_runtime_config(config, workspace)
- try:
- provider = make_provider(runtime_config)
- except ValueError as exc:
- _print_agent_start_error(exc)
- raise typer.Exit(1) from exc
-
- sync_workspace_templates(runtime_config.workspace_path)
-
- bus = MessageBus()
-
- # Preserve existing single-workspace installs, but keep custom workspaces clean.
- if is_default_workspace(runtime_config.workspace_path):
- _migrate_cron_store(runtime_config)
-
- # Create cron service with workspace-scoped store
- cron_store_path = runtime_config.workspace_path / "cron" / "jobs.json"
- cron = CronService(cron_store_path)
-
- _set_nanobot_logs(logs)
-
- try:
- agent_loop = AgentLoop.from_config(
- runtime_config, bus,
- provider=provider,
- cron_service=cron,
- image_generation_provider_configs=image_gen_provider_configs(runtime_config),
- hook_factories=[create_file_edit_activity_hook],
- )
- except ValueError as exc:
- _print_agent_start_error(exc)
- raise typer.Exit(1) from exc
- restart_notice = consume_restart_notice_from_env()
- if restart_notice and should_show_cli_restart_notice(restart_notice, session_id):
- _print_agent_response(
- format_restart_completed_message(restart_notice.started_at_raw),
- render_markdown=False,
- )
-
- # Shared reference for progress callbacks
- _thinking: ThinkingSpinner | None = None
-
- def _make_progress(
- renderer: StreamRenderer | None = None,
- ) -> Callable[..., Awaitable[None]]:
- reasoning_buffer = _ReasoningBuffer()
-
- async def _cli_progress(content: str, *, tool_hint: bool = False, reasoning: bool = False, **_kwargs: Any) -> None:
- ch = agent_loop.channels_config
-
- if _kwargs.get("reasoning_end"):
- if ch and not ch.show_reasoning:
- reasoning_buffer.clear()
- else:
- _flush_cli_reasoning(reasoning_buffer, _thinking, renderer)
- return
-
- if reasoning:
- if ch and not ch.show_reasoning:
- reasoning_buffer.clear()
- return
- text = reasoning_buffer.add(content)
- if text:
- _print_cli_reasoning(text, _thinking, renderer)
- return
- if ch and tool_hint and not ch.send_tool_hints:
- return
- if ch and not tool_hint and not ch.send_progress:
- return
- _print_cli_progress_line(content, _thinking, renderer)
- return _cli_progress
-
- if message:
- # Single message mode — direct call, no bus needed
- async def run_once() -> None:
- renderer = StreamRenderer(
- render_markdown=markdown,
- bot_name=runtime_config.agents.defaults.bot_name,
- bot_icon=runtime_config.agents.defaults.bot_icon,
- )
- response = await agent_loop.process_direct(
- message, session_id,
- on_progress=_make_progress(renderer),
- on_stream=renderer.on_delta,
- on_stream_end=renderer.on_end,
- )
- if not renderer.streamed:
- await renderer.close()
- print_kwargs: dict[str, Any] = {}
- if renderer.header_printed:
- print_kwargs["show_header"] = False
- _print_agent_response(
- response.content if response else "",
- render_markdown=markdown,
- metadata=response.metadata if response else None,
- **print_kwargs,
- )
- await agent_loop.close_mcp()
-
- asyncio.run(run_once())
- else:
- # Interactive mode — route through bus like other channels
- from nanobot.bus.events import InboundMessage
- _init_prompt_session()
- _model, _preset_tag = _model_display(runtime_config)
- _icon = runtime_config.agents.defaults.bot_icon or __logo__
- console.print(f"{_icon} Interactive mode [bold blue]({_model})[/bold blue]{_preset_tag} — type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit\n")
-
- if ":" in session_id:
- cli_channel, cli_chat_id = session_id.split(":", 1)
- else:
- cli_channel, cli_chat_id = "cli", session_id
-
- def _handle_signal(signum: int, _frame: FrameType | None) -> None:
- sig_name = signal.Signals(signum).name
- _restore_terminal()
- console.print(f"\nReceived {sig_name}, goodbye!")
- sys.exit(0)
-
- signal.signal(signal.SIGINT, _handle_signal)
- signal.signal(signal.SIGTERM, _handle_signal)
- # SIGHUP is not available on Windows
- if hasattr(signal, 'SIGHUP'):
- signal.signal(signal.SIGHUP, _handle_signal)
- # Ignore SIGPIPE to prevent silent process termination when writing to closed pipes
- # SIGPIPE is not available on Windows
- if hasattr(signal, 'SIGPIPE'):
- signal.signal(signal.SIGPIPE, signal.SIG_IGN)
-
- async def run_interactive() -> None:
- bus_task = asyncio.create_task(agent_loop.run())
- turn_done = asyncio.Event()
- turn_done.set()
- turn_response: list[Any] = []
- renderer: StreamRenderer | None = None
- reasoning_buffer = _ReasoningBuffer()
-
- async def _consume_outbound() -> None:
- while True:
- try:
- msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
- event = outbound_event_from_message(msg)
-
- if isinstance(event, StreamDeltaEvent):
- if renderer:
- await renderer.on_delta(msg.content)
- continue
- if isinstance(event, StreamEndEvent):
- if renderer:
- await renderer.on_end(
- resuming=event.resuming,
- )
- continue
- if isinstance(event, StreamedResponseEvent):
- if msg.content and renderer and not renderer.streamed:
- await renderer.close()
- print_kwargs: dict[str, Any] = {}
- if renderer.header_printed:
- print_kwargs["show_header"] = False
- _print_agent_response(
- msg.content,
- render_markdown=markdown,
- metadata=msg.metadata,
- **print_kwargs,
- )
- turn_done.set()
- continue
-
- if await _maybe_print_interactive_progress(
- msg,
- None,
- agent_loop.channels_config,
- renderer,
- reasoning_buffer,
- ):
- continue
-
- if not turn_done.is_set():
- if msg.content:
- turn_response.append(msg)
- turn_done.set()
- elif msg.content:
- await _print_interactive_response(
- msg.content,
- render_markdown=markdown,
- metadata=msg.metadata,
- )
-
- except asyncio.TimeoutError:
- continue
- except asyncio.CancelledError:
- break
-
- outbound_task = asyncio.create_task(_consume_outbound())
-
- try:
- while True:
- try:
- _flush_pending_tty_input()
- # Stop spinner before user input to avoid prompt_toolkit conflicts
- if renderer:
- renderer.stop_for_input()
- user_input = _sanitize_surrogates(await _read_interactive_input_async())
- command = user_input.strip()
- if not command:
- continue
-
- if _is_exit_command(command):
- _restore_terminal()
- console.print("\nGoodbye!")
- break
-
- turn_done.clear()
- turn_response.clear()
- reasoning_buffer.clear()
- renderer = StreamRenderer(
- render_markdown=markdown,
- bot_name=runtime_config.agents.defaults.bot_name,
- bot_icon=runtime_config.agents.defaults.bot_icon,
- )
-
- await bus.publish_inbound(InboundMessage(
- channel=cli_channel,
- sender_id="user",
- chat_id=cli_chat_id,
- content=user_input,
- metadata={"_wants_stream": True},
- ))
-
- await turn_done.wait()
-
- if turn_response:
- response_msg = turn_response[0]
- content = response_msg.content
- meta = response_msg.metadata
- if content and not isinstance(response_msg.event, StreamedResponseEvent):
- if renderer:
- await renderer.close()
- print_kwargs: dict[str, Any] = {}
- if renderer and renderer.header_printed:
- print_kwargs["show_header"] = False
- _print_agent_response(
- content,
- render_markdown=markdown,
- metadata=meta,
- **print_kwargs,
- )
- elif renderer and not renderer.streamed:
- await renderer.close()
- except KeyboardInterrupt:
- _restore_terminal()
- console.print("\nGoodbye!")
- break
- except EOFError:
- _restore_terminal()
- console.print("\nGoodbye!")
- break
- finally:
- agent_loop.stop()
- outbound_task.cancel()
- await asyncio.gather(bus_task, outbound_task, return_exceptions=True)
- await agent_loop.close_mcp()
-
- asyncio.run(run_interactive())
+app.command(name="agent")(agent)
# ============================================================================
@@ -2934,309 +661,8 @@ def status(
# OAuth Login
# ============================================================================
-provider_app = typer.Typer(help="Manage providers")
app.add_typer(provider_app, name="provider")
-_LOGIN_HANDLERS: dict[str, Callable[[], None]] = {}
-_LOGOUT_HANDLERS: dict[str, Callable[[], None]] = {}
-
-_PROVIDER_DISPLAY: dict[str, str] = {
- "openai_codex": "OpenAI Codex",
- "xai_grok": "xAI Grok",
- "github_copilot": "GitHub Copilot",
-}
-
-_OAUTH_PROVIDER_DEFAULT_MODELS: dict[str, str] = {
- "openai_codex": "openai-codex/gpt-5.6-sol",
- "xai_grok": "xai-grok/grok-4.5",
- "github_copilot": "github-copilot/gpt-5.4-mini",
-}
-
-
-def _register_login(
- name: str,
-) -> Callable[[Callable[[], None]], Callable[[], None]]:
- """Register an OAuth login handler."""
- def decorator(fn: Callable[[], None]) -> Callable[[], None]:
- _LOGIN_HANDLERS[name] = fn
- return fn
-
- return decorator
-
-
-def _register_logout(
- name: str,
-) -> Callable[[Callable[[], None]], Callable[[], None]]:
- """Register an OAuth logout handler."""
- def decorator(fn: Callable[[], None]) -> Callable[[], None]:
- _LOGOUT_HANDLERS[name] = fn
- return fn
- return decorator
-
-
-def _resolve_oauth_provider(provider: str) -> "ProviderSpec":
- """Resolve and validate an OAuth provider configuration."""
- from nanobot.providers.registry import PROVIDERS
-
- key = provider.replace("-", "_")
- spec = next((s for s in PROVIDERS if s.name == key and s.is_oauth), None)
- if not spec:
- names = ", ".join(s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth)
- console.print(f"[red]Unknown OAuth provider: {provider}[/red] Supported: {names}")
- raise typer.Exit(1)
- return spec
-
-
-def _set_oauth_provider_as_main(
- provider_name: str,
- *,
- model: str | None = None,
- config_path: str | None = None,
-) -> None:
- """Persist an OAuth provider as the active agent provider."""
- from nanobot.config.loader import get_config_path, load_config, save_config, set_config_path
-
- resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None
- if resolved_config_path is not None and get_config_path() != resolved_config_path:
- set_config_path(resolved_config_path)
- console.print(f"[dim]Using config: {resolved_config_path}[/dim]")
-
- config = load_config(resolved_config_path)
- selected_model = (model or "").strip() or _OAUTH_PROVIDER_DEFAULT_MODELS[provider_name]
- config.agents.defaults.model_preset = None
- config.agents.defaults.provider = provider_name
- config.agents.defaults.model = selected_model
- if provider_name == "xai_grok" and selected_model == "xai-grok/grok-4.5":
- config.agents.defaults.context_window_tokens = 500_000
- save_config(config, resolved_config_path)
-
- saved_path = resolved_config_path or get_config_path()
- console.print(
- f"[green]✓ Set {provider_name.replace('_', '-')} as the main provider[/green] "
- f"[dim]{selected_model}[/dim]"
- )
- console.print(f"[dim]Saved: {saved_path}[/dim]")
-
-
-@provider_app.command("login")
-def provider_login(
- provider: str = typer.Argument(
- ...,
- help="OAuth provider (e.g. 'openai-codex', 'xai-grok', 'github-copilot')",
- ),
- set_main: bool = typer.Option(
- False,
- "--set-main",
- "--main",
- help="Set this OAuth provider as the active agent provider after login",
- ),
- model: str | None = typer.Option(
- None,
- "--model",
- "-m",
- help="Model to use when setting this provider as the active provider",
- ),
- config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
-):
- """Authenticate with an OAuth provider."""
- spec = _resolve_oauth_provider(provider)
-
- handler = _LOGIN_HANDLERS.get(spec.name)
- if not handler:
- console.print(f"[red]Login not implemented for {spec.label}[/red]")
- raise typer.Exit(1)
-
- if config:
- from nanobot.config.loader import set_config_path
-
- resolved_config_path = Path(config).expanduser().resolve()
- set_config_path(resolved_config_path)
- console.print(f"[dim]Using config: {resolved_config_path}[/dim]")
-
- console.print(f"{__logo__} OAuth Login - {spec.label}\n")
- handler()
- if set_main or model:
- _set_oauth_provider_as_main(spec.name, model=model, config_path=config)
-
-
-@provider_app.command("logout")
-def provider_logout(
- provider: str = typer.Argument(
- ...,
- help="OAuth provider (e.g. 'openai-codex', 'xai-grok', 'github-copilot')",
- ),
- config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
-):
- """Log out from an OAuth provider."""
- spec = _resolve_oauth_provider(provider)
-
- handler = _LOGOUT_HANDLERS.get(spec.name)
- if not handler:
- console.print(f"[red]Logout not implemented for {spec.label}[/red]")
- raise typer.Exit(1)
-
- if config:
- from nanobot.config.loader import set_config_path
-
- resolved_config_path = Path(config).expanduser().resolve()
- set_config_path(resolved_config_path)
- console.print(f"[dim]Using config: {resolved_config_path}[/dim]")
-
- console.print(f"{__logo__} OAuth Logout - {spec.label}\n")
- handler()
-
-
-@_register_login("openai_codex")
-def _login_openai_codex() -> None:
- try:
- from oauth_cli_kit import get_token, login_oauth_interactive
-
- from nanobot.config.loader import load_config, resolve_config_env_vars
-
- proxy = None
- try:
- proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None
- except ValueError as e:
- console.print(f"[red]{e}[/red]")
- raise typer.Exit(1) from e
- token = None
- with suppress(Exception):
- token = get_token(proxy=proxy)
- if not (token and token.access):
- console.print("[cyan]Starting interactive OAuth login...[/cyan]\n")
- token = login_oauth_interactive(
- print_fn=lambda s: console.print(s),
- prompt_fn=lambda s: typer.prompt(s),
- proxy=proxy,
- )
- if not (token and token.access):
- console.print("[red]✗ Authentication failed[/red]")
- raise typer.Exit(1)
- console.print(f"[green]✓ Authenticated with OpenAI Codex[/green] [dim]{token.account_id}[/dim]")
- except ImportError:
- console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
- raise typer.Exit(1)
-
-
-@_register_logout("openai_codex")
-def _logout_openai_codex() -> None:
- """Clear local OAuth credentials for OpenAI Codex."""
- try:
- from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER
- from oauth_cli_kit.storage import FileTokenStorage
- except ImportError:
- console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
- raise typer.Exit(1)
-
- storage = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename)
- _delete_oauth_files(storage.get_token_path(), _PROVIDER_DISPLAY["openai_codex"])
-
-
-@_register_login("xai_grok")
-def _login_xai_grok() -> None:
- """Authenticate with xAI using the Grok subscription OAuth contract."""
- from nanobot.config.loader import load_config, resolve_config_env_vars
- from nanobot.providers.xai_oauth import get_xai_oauth_token, login_xai_oauth
-
- try:
- proxy = resolve_config_env_vars(load_config()).providers.xai_grok.proxy or None
- except ValueError as exc:
- console.print(f"[red]{exc}[/red]")
- raise typer.Exit(1) from exc
-
- token = None
- with suppress(Exception):
- token = get_xai_oauth_token(proxy=proxy)
- if not (token and token.access):
- console.print(
- "[cyan]Starting xAI browser sign-in for your X Premium / Grok subscription...[/cyan]\n"
- )
- try:
- token = login_xai_oauth(
- print_fn=lambda message: console.print(message),
- prompt_fn=lambda prompt: typer.prompt(prompt),
- proxy=proxy,
- )
- except Exception as exc:
- console.print(f"[red]Authentication error: {exc}[/red]")
- raise typer.Exit(1) from exc
- account = token.account_id or "xAI account"
- console.print(f"[green]✓ Authenticated with xAI[/green] [dim]{account}[/dim]")
- console.print(
- "[dim]Hosted X Search is enabled automatically when the selected model supports it.[/dim]"
- )
-
-
-@_register_logout("xai_grok")
-def _logout_xai_grok() -> None:
- """Clear local xAI OAuth credentials for this nanobot instance."""
- from nanobot.providers.xai_oauth import get_xai_oauth_storage_path, logout_xai_oauth
-
- token_path = get_xai_oauth_storage_path()
- provider_label = _PROVIDER_DISPLAY["xai_grok"]
- if logout_xai_oauth():
- console.print(f"[green]✓ Logged out from {provider_label}[/green]")
- console.print(f"[dim]Removed: {token_path}[/dim]")
- else:
- console.print(f"[yellow]! No local OAuth credentials found for {provider_label}[/yellow]")
-
-
-@_register_logout("github_copilot")
-def _logout_github_copilot() -> None:
- """Clear local OAuth credentials for GitHub Copilot."""
- try:
- from nanobot.providers.github_copilot_provider import get_storage
- except ImportError:
- console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
- raise typer.Exit(1)
-
- storage = get_storage()
- _delete_oauth_files(storage.get_token_path(), _PROVIDER_DISPLAY["github_copilot"])
-
-
-def _delete_oauth_files(token_path: Path, provider_label: str) -> None:
- """Delete OAuth token and lock files, reporting the result."""
- removed_paths: list[Path] = []
- skipped: list[tuple[Path, OSError]] = []
- for path in (token_path, token_path.with_suffix(".lock")):
- try:
- path.unlink()
- except FileNotFoundError:
- continue
- except OSError as exc:
- skipped.append((path, exc))
- continue
- removed_paths.append(path)
-
- if not removed_paths and not skipped:
- console.print(f"[yellow]! No local OAuth credentials found for {provider_label}[/yellow]")
- return
-
- if removed_paths:
- console.print(f"[green]✓ Logged out from {provider_label}[/green]")
- for path in removed_paths:
- console.print(f"[dim]Removed: {path}[/dim]")
- for path, exc in skipped:
- console.print(f"[yellow]! Could not remove {path}: {exc}[/yellow]")
-
-
-@_register_login("github_copilot")
-def _login_github_copilot() -> None:
- try:
- from nanobot.providers.github_copilot_provider import login_github_copilot
-
- console.print("[cyan]Starting GitHub Copilot device flow...[/cyan]\n")
- token = login_github_copilot(
- print_fn=lambda s: console.print(s),
- prompt_fn=lambda s: typer.prompt(s),
- )
- account = token.account_id or "GitHub"
- console.print(f"[green]✓ Authenticated with GitHub Copilot[/green] [dim]{account}[/dim]")
- except Exception as e:
- console.print(f"[red]Authentication error: {e}[/red]")
- raise typer.Exit(1)
-
-
if __name__ == "__main__":
app()
diff --git a/nanobot/cli/gateway_runtime.py b/nanobot/cli/gateway_runtime.py
new file mode 100644
index 000000000..718b138ec
--- /dev/null
+++ b/nanobot/cli/gateway_runtime.py
@@ -0,0 +1,828 @@
+"""Foreground gateway runtime and lifecycle helpers."""
+
+import asyncio
+import signal
+from collections.abc import Awaitable, Callable, Coroutine, Iterable
+from contextlib import suppress
+from pathlib import Path
+from typing import Any, cast
+
+import typer
+from loguru import logger
+from rich.console import Console
+
+from nanobot import __logo__, __version__
+from nanobot.agent.hooks import create_file_edit_activity_hook
+from nanobot.agent.loop import AgentLoop
+from nanobot.cli import terminal as cli_terminal
+from nanobot.cli.runtime_config import _migrate_cron_store
+from nanobot.cli.webui_support import (
+ _gateway_health_bind_note,
+ _gateway_health_url,
+ _host_for_local_browser,
+ _prepare_webui_bundle_for_gateway,
+ _print_foreground_port_conflict,
+ _tcp_endpoint_reachable,
+ _webui_browser_url,
+ _webui_channel_enabled,
+ _webui_endpoint_reachable,
+)
+from nanobot.config.paths import is_default_workspace
+from nanobot.config.schema import Config
+from nanobot.security.network import is_loopback_host
+from nanobot.session.keys import UNIFIED_SESSION_KEY, last_channel_from_metadata
+from nanobot.utils.evaluator import evaluate_response, resolve_evaluator_prompt
+from nanobot.utils.helpers import sync_workspace_templates
+from nanobot.webui.build import BuildMode
+from nanobot.webui.sidebar_state import read_webui_sidebar_state
+
+__all__ = ["_run_gateway"]
+
+console = Console()
+
+
+def _signal_name(signum: int) -> str:
+ with suppress(ValueError):
+ return signal.Signals(signum).name
+ return f"signal {signum}"
+
+
+def _install_gateway_shutdown_handlers(
+ loop: asyncio.AbstractEventLoop,
+ shutdown_event: asyncio.Event,
+ tasks: list[asyncio.Task[Any]],
+ print_status: Callable[[str], None],
+) -> Callable[[], None]:
+ """Install foreground gateway signal handlers and return a restore callback."""
+ loop_signals: list[int] = []
+ previous_handlers: list[tuple[int, Any]] = []
+ shutdown_requested = False
+
+ def request_shutdown(signum: int) -> None:
+ nonlocal shutdown_requested
+ sig_name = _signal_name(signum)
+ if shutdown_requested:
+ logger.warning("Forcing gateway shutdown after repeated {}", sig_name)
+ for task in tasks:
+ if not task.done():
+ task.cancel()
+ return
+ shutdown_requested = True
+ logger.info("Gateway shutdown requested by {}", sig_name)
+ print_status("\nShutting down... Press Ctrl+C again to force.")
+ shutdown_event.set()
+
+ for signum in (signal.SIGINT, signal.SIGTERM):
+ try:
+ loop.add_signal_handler(signum, request_shutdown, signum)
+ except (NotImplementedError, RuntimeError, ValueError):
+ try:
+ previous = signal.getsignal(signum)
+ signal.signal(signum, lambda sig, _frame: request_shutdown(sig))
+ except (RuntimeError, ValueError):
+ logger.debug("Could not install gateway handler for {}", _signal_name(signum))
+ continue
+ previous_handlers.append((signum, previous))
+ else:
+ loop_signals.append(signum)
+
+ def restore() -> None:
+ for signum in loop_signals:
+ with suppress(NotImplementedError, RuntimeError, ValueError):
+ loop.remove_signal_handler(signum)
+ for signum, handler in previous_handlers:
+ with suppress(RuntimeError, ValueError):
+ signal.signal(signum, handler)
+
+ return restore
+
+
+def _advance_dream_cursor_if_behind(memory: Any) -> None:
+ latest = memory.get_latest_cursor()
+ if memory.get_last_dream_cursor() < latest:
+ memory.set_last_dream_cursor(latest)
+
+
+def _commit_dream_changes(memory: Any) -> str | None:
+ """Commit durable Dream edits, without entering the commit path for a no-op run."""
+ if not memory.git.is_initialized():
+ return None
+ diff_body = memory.dream_content_diff()
+ if not diff_body:
+ return None
+ message = memory.build_dream_commit_message(
+ "dream: periodic memory consolidation",
+ diff_body,
+ )
+ return memory.git.auto_commit(message)
+
+
+_HEARTBEAT_PREAMBLE = (
+ "[Your response will be delivered directly to the user's messaging app. "
+ "Output ONLY the final user-facing message. Never reference internal "
+ "files (HEARTBEAT.md, AWARENESS.md, etc.), your instructions, or your "
+ "decision process. If nothing needs reporting, respond with just "
+ "'All clear.' and nothing else.]\n\n"
+)
+
+
+def _heartbeat_has_active_tasks(content: str) -> bool:
+ """True if HEARTBEAT.md has task lines, ignoring headers, blanks and comments."""
+ in_comment = False
+ in_active_section: bool = False
+ for line in content.splitlines():
+ stripped = line.strip()
+ if in_comment:
+ if "-->" in stripped:
+ in_comment = False
+ continue
+ if not stripped or stripped.startswith("#"):
+ if stripped.startswith("##") and not stripped.startswith("###"):
+ heading = stripped.lstrip("#").strip().lower()
+ in_active_section = heading.startswith("active tasks")
+ continue
+ if stripped.startswith("" not in stripped[4:]:
+ in_comment = True
+ continue
+ if in_active_section is False:
+ continue
+ return True
+ return False
+
+
+def _pick_heartbeat_target_from_sessions(
+ *,
+ enabled_channels: Iterable[str],
+ sessions: Iterable[dict[str, Any]],
+ archived_keys: Iterable[str],
+ unified_session_metadata: dict[str, Any] | None = None,
+) -> tuple[str, str]:
+ enabled = set(enabled_channels)
+ archived = set(archived_keys)
+ for item in sessions:
+ key = item.get("key") or ""
+ if key in archived:
+ continue
+ if key == UNIFIED_SESSION_KEY:
+ route = last_channel_from_metadata(unified_session_metadata)
+ if route is not None:
+ channel, chat_id = route
+ if channel not in {"cli", "system"} and channel in enabled:
+ return channel, chat_id
+ continue
+ if ":" not in key:
+ continue
+ channel, chat_id = key.split(":", 1)
+ if channel in {"cli", "system"}:
+ continue
+ if channel in enabled and chat_id:
+ return channel, chat_id
+ return "cli", "direct"
+
+
+_GATEWAY_HEALTH_MAX_CONNECTIONS = 64
+_GATEWAY_HEALTH_READ_TIMEOUT_SECONDS = 2.0
+
+
+def _print_gateway_health_endpoint(host: str, port: int) -> None:
+ """Print a usable health URL and make non-loopback binds explicit."""
+ console.print(
+ f"[green]✓[/green] Health endpoint: {_gateway_health_url(host, port)}"
+ f"{_gateway_health_bind_note(host)}"
+ )
+ if is_loopback_host(host):
+ return
+
+ console.print(
+ "[yellow]Warning: the unauthenticated health endpoint is listening beyond loopback "
+ "and may be reachable from other devices. "
+ f"Keep port {port} private or protect it with a firewall or reverse proxy.[/yellow]"
+ )
+
+
+def _run_gateway(
+ config: Config,
+ *,
+ port: int | None = None,
+ open_browser_url: str | None = None,
+ webui_static_dist: bool = True,
+ webui_bundle_mode: BuildMode = "warn",
+ webui_runtime_surface: str = "browser",
+ webui_runtime_capabilities: dict[str, Any] | None = None,
+ health_server_enabled: bool = True,
+ unconfigured_provider_error: str | None = None,
+) -> None:
+ """Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
+ from nanobot.agent.model_presets import load_model_preset_catalog
+ from nanobot.agent.tools.message import MessageTool
+ from nanobot.agent.turn_delivery import TurnDeliveryFactory
+ from nanobot.bus.queue import MessageBus
+ from nanobot.bus.runtime_events import RuntimeEventBus
+ from nanobot.channels.manager import ChannelManager
+ from nanobot.config.watcher import watch_config_file
+ from nanobot.cron.bound_runner import run_bound_cron_job
+ from nanobot.cron.service import CronJobSkippedError, CronService
+ from nanobot.cron.session_turns import is_bound_cron_job
+ from nanobot.cron.types import CronJob
+ from nanobot.providers.factory import (
+ ProviderSnapshot,
+ build_provider_snapshot,
+ build_unconfigured_provider_snapshot,
+ load_provider_snapshot,
+ )
+ from nanobot.providers.fallback_provider import FallbackProvider
+ from nanobot.providers.image_generation import image_gen_provider_configs
+ from nanobot.session.manager import SessionManager
+ from nanobot.session.webui_turns import (
+ WebuiTurnCoordinator,
+ WebuiTurnRoutePolicy,
+ build_webui_fallback_model_observer,
+ )
+ from nanobot.triggers.local_runner import run_local_trigger_queue
+ from nanobot.triggers.local_store import LocalTriggerStore
+ from nanobot.webui.token_usage import TokenUsageHook
+
+ port = port if port is not None else config.gateway.port
+ webui_url = _webui_browser_url(config)
+ gateway_host_for_browser = _host_for_local_browser(config.gateway.host)
+ if health_server_enabled and _tcp_endpoint_reachable(gateway_host_for_browser, port):
+ _print_foreground_port_conflict(
+ webui_url=webui_url,
+ gateway_host=config.gateway.host,
+ gateway_port=port,
+ )
+ raise typer.Exit(1)
+ if _webui_channel_enabled(config) and _webui_endpoint_reachable(webui_url):
+ _print_foreground_port_conflict(
+ webui_url=webui_url,
+ gateway_host=config.gateway.host,
+ gateway_port=port,
+ )
+ raise typer.Exit(1)
+
+ console.print(f"{__logo__} Starting nanobot gateway version {__version__} on port {port}...")
+ _prepare_webui_bundle_for_gateway(
+ config,
+ mode=webui_bundle_mode,
+ webui_static_dist=webui_static_dist,
+ )
+ sync_workspace_templates(config.workspace_path)
+ bus = MessageBus()
+ runtime_events = RuntimeEventBus()
+ fallback_model_observer = build_webui_fallback_model_observer(bus)
+
+ def _observe_fallback_models(snapshot: ProviderSnapshot) -> ProviderSnapshot:
+ if isinstance(snapshot.provider, FallbackProvider):
+ snapshot.provider.set_fallback_model_observer(fallback_model_observer)
+ return snapshot
+
+ def _load_gateway_provider_snapshot(
+ *args: Any,
+ **kwargs: Any,
+ ) -> ProviderSnapshot:
+ try:
+ return _observe_fallback_models(load_provider_snapshot(*args, **kwargs))
+ except ValueError as exc:
+ if unconfigured_provider_error is None:
+ raise
+ return build_unconfigured_provider_snapshot(config, str(exc))
+
+ if unconfigured_provider_error is not None:
+ provider_snapshot = build_unconfigured_provider_snapshot(
+ config,
+ unconfigured_provider_error,
+ )
+ else:
+ try:
+ provider_snapshot = _observe_fallback_models(build_provider_snapshot(config))
+ except ValueError as exc:
+ console.print(f"[red]Error: {exc}[/red]")
+ raise typer.Exit(1) from exc
+ session_manager = SessionManager(config.workspace_path)
+
+ # Self-heal the gateway state file with the current PID after any restart.
+ from nanobot.config.loader import get_config_path
+ from nanobot.gateway.runtime import GatewayRuntime, GatewayRuntimePaths
+
+ config_path = str(get_config_path().resolve(strict=False))
+ GatewayRuntime.refresh_state_pid(
+ paths=GatewayRuntimePaths.for_instance(
+ workspace=str(config.workspace_path)
+ if not is_default_workspace(config.workspace_path)
+ else None,
+ config_path=config_path,
+ )
+ )
+
+ # Preserve existing single-workspace installs, but keep custom workspaces clean.
+ if is_default_workspace(config.workspace_path):
+ _migrate_cron_store(config)
+
+ # Create cron service with workspace-scoped store
+ cron_store_path = config.workspace_path / "cron" / "jobs.json"
+ cron = CronService(cron_store_path)
+ trigger_store = LocalTriggerStore(config.workspace_path)
+
+ turn_delivery_factory = TurnDeliveryFactory(
+ bus,
+ runtime_events,
+ route_policy=WebuiTurnRoutePolicy(session_manager),
+ )
+
+ # Create agent with cron service
+ agent = AgentLoop.from_config(
+ config, bus,
+ provider=provider_snapshot.provider,
+ model=provider_snapshot.model,
+ context_window_tokens=provider_snapshot.context_window_tokens,
+ cron_service=cron,
+ session_manager=session_manager,
+ image_generation_provider_configs=image_gen_provider_configs(config),
+ provider_snapshot_loader=_load_gateway_provider_snapshot,
+ preset_catalog_loader=load_model_preset_catalog,
+ runtime_events=runtime_events,
+ turn_delivery_factory=turn_delivery_factory,
+ provider_signature=provider_snapshot.signature,
+ hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)],
+ local_trigger_store=trigger_store,
+ hook_factories=[create_file_edit_activity_hook],
+ )
+ def _schedule_webui_background(awaitable: Awaitable[None]) -> None:
+ agent.schedule_background(cast(Coroutine[Any, Any, None], awaitable))
+
+ webui_turn_coordinator = WebuiTurnCoordinator(
+ bus=bus,
+ sessions=session_manager,
+ schedule_background=_schedule_webui_background,
+ )
+ webui_turn_coordinator.subscribe(runtime_events)
+ from nanobot.bus.events import OutboundMessage
+ from nanobot.session.keys import session_key_for_channel
+
+ def _channel_session_key(channel: str, chat_id: str) -> str:
+ return session_key_for_channel(
+ channel,
+ chat_id,
+ unified_session=config.agents.defaults.unified_session,
+ )
+
+ async def _deliver_to_channel(
+ msg: OutboundMessage, *, record: bool = False, session_key: str | None = None,
+ ) -> None:
+ """Publish a user-visible message and mirror it into that channel's session."""
+ metadata = dict(msg.metadata or {})
+ record = record or bool(metadata.pop("_record_channel_delivery", False))
+ if metadata != (msg.metadata or {}):
+ msg = OutboundMessage(
+ channel=msg.channel,
+ chat_id=msg.chat_id,
+ content=msg.content,
+ reply_to=msg.reply_to,
+ media=msg.media,
+ metadata=metadata,
+ buttons=msg.buttons,
+ )
+ if (
+ record
+ and msg.channel != "cli"
+ and msg.content.strip()
+ and hasattr(session_manager, "get_or_create")
+ and hasattr(session_manager, "save")
+ ):
+ key = session_key or _channel_session_key(msg.channel, msg.chat_id)
+ session = session_manager.get_or_create(key)
+ extra: dict[str, Any] = {"_channel_delivery": True}
+ if msg.media:
+ extra["media"] = list(msg.media)
+ session.add_message("assistant", msg.content, **extra)
+ session_manager.save(session)
+ await bus.publish_outbound(msg)
+
+ message_tool = agent.tools.get("message")
+ if isinstance(message_tool, MessageTool):
+ message_tool.set_send_callback(_deliver_to_channel)
+
+ # Set cron callback (needs agent)
+ async def on_cron_job(job: CronJob) -> str | None:
+ """Execute a cron job through the agent."""
+ async def _silent(*_args: Any, **_kwargs: Any) -> None:
+ pass
+
+ # Dream is an internal job — run directly, not through the agent loop.
+ if job.name == "dream":
+ from nanobot.agent.memory import DreamRunProgress, MemoryStore
+
+ dream_session_key = MemoryStore.dream_session_key
+ prune_dream_sessions = MemoryStore.prune_dream_sessions
+
+ store = agent.context.memory
+ progress = DreamRunProgress()
+ resp = None
+ diff_body = ""
+ try:
+ result = store.build_dream_prompt()
+ if result is None:
+ logger.info("Dream: nothing to process")
+ return None
+ prompt, last_cursor = result
+ key = dream_session_key()
+ dream_runtime = agent.dream_runtime()
+ resp = await agent.process_direct(
+ prompt,
+ session_key=key,
+ ephemeral=True,
+ tools=store.build_dream_tools(),
+ on_progress=progress,
+ runtime=dream_runtime,
+ )
+ # The real file delta grounds the audit record; clean completion
+ # decides whether this history batch has finished processing.
+ diff_body = store.dream_content_diff()
+ completed = MemoryStore.dream_run_completed(
+ resp,
+ had_tool_errors=progress.had_tool_errors,
+ )
+ if completed:
+ store.set_last_dream_cursor(last_cursor)
+ if diff_body:
+ logger.info(
+ "Dream cron job completed, cursor advanced to {}",
+ last_cursor,
+ )
+ else:
+ logger.info(
+ "Dream cron job completed with no memory changes; "
+ "cursor advanced to {}",
+ last_cursor,
+ )
+ else:
+ logger.warning(
+ "Dream cron job did not complete; cursor remains at {}",
+ store.get_last_dream_cursor(),
+ )
+ except Exception:
+ logger.exception("Dream cron job failed")
+ finally:
+ from nanobot.webui.token_usage import record_response_token_usage
+
+ record_response_token_usage(
+ resp,
+ source="dream",
+ timezone_name=config.agents.defaults.timezone,
+ )
+ sha = _commit_dream_changes(store)
+ if sha:
+ logger.info("Dream commit: {}", sha)
+ store.compact_history()
+ prune_dream_sessions(agent.sessions.sessions_dir)
+ return None
+
+ # Heartbeat is a system job that checks HEARTBEAT.md for active tasks.
+ if job.name == "heartbeat":
+ heartbeat_file = config.workspace_path / "HEARTBEAT.md"
+ try:
+ content = heartbeat_file.read_text(encoding="utf-8")
+ except OSError:
+ logger.debug("Heartbeat: HEARTBEAT.md missing")
+ return None
+ if not _heartbeat_has_active_tasks(content):
+ logger.debug("Heartbeat: HEARTBEAT.md has no active tasks")
+ return None
+
+ channel, chat_id = _pick_heartbeat_target()
+ if channel == "cli":
+ return None
+
+ prompt = (
+ _HEARTBEAT_PREAMBLE
+ + f"You are executing periodic heartbeat tasks. Read the active tasks below, perform each one, and report what you did:\n\n{content}"
+ )
+
+ # Internal check: funnel all output through the post-run gate so the
+ # turn can't deliver directly via the message tool and skip it.
+ suppress_token = None
+ if isinstance(message_tool, MessageTool):
+ suppress_token = message_tool.set_suppress_delivery(True)
+ try:
+ resp = await agent.process_direct(
+ prompt,
+ session_key="heartbeat",
+ channel=channel,
+ chat_id=chat_id,
+ on_progress=_silent,
+ )
+ finally:
+ if isinstance(message_tool, MessageTool) and suppress_token is not None:
+ message_tool.reset_suppress_delivery(suppress_token)
+
+ # Keep a small tail of heartbeat history so the loop stays bounded.
+ session = agent.sessions.get_or_create("heartbeat")
+ session.retain_recent_legal_suffix(hb_cfg.keep_recent_messages)
+ agent.sessions.save(session)
+
+ if not resp or not resp.content:
+ return
+
+ response = resp.content
+
+ evaluator_prompt = resolve_evaluator_prompt(config.workspace_path)
+
+ # Fail closed: stay silent on evaluator failure instead of notifying.
+ should_notify = await evaluate_response(
+ response=response,
+ task_context=prompt,
+ provider=agent.provider,
+ model=agent.model,
+ evaluator_prompt=evaluator_prompt,
+ default_notify=False,
+ )
+
+ if should_notify:
+ logger.info("Heartbeat: completed, delivering response")
+ await _deliver_to_channel(
+ OutboundMessage(channel=channel, chat_id=chat_id, content=response),
+ record=True,
+ )
+ else:
+ logger.info("Heartbeat: silenced by post-run evaluation")
+ return response
+
+ if is_bound_cron_job(job):
+ return await run_bound_cron_job(job, agent=agent, cron=cron)
+
+ reason = "unbound agent cron job must be recreated from a chat session"
+ logger.warning(
+ "Cron: skipped unbound agent job '{}' ({}): {}",
+ job.name,
+ job.id,
+ reason,
+ )
+ raise CronJobSkippedError(reason)
+
+ cron.on_job = on_cron_job
+
+ def _webui_runtime_model_name() -> str | None:
+ return agent.model.strip() or None
+
+ def _webui_skill_state_action(disabled_skills: set[str]) -> None:
+ config.agents.defaults.disabled_skills = sorted(disabled_skills)
+ agent.context.skills.disabled_skills = set(disabled_skills)
+ agent.subagents.disabled_skills = set(disabled_skills)
+
+ # Create channel manager (forwards SessionManager so the WebSocket channel
+ # can serve the embedded webui's REST surface).
+ channels = ChannelManager(
+ config,
+ bus,
+ session_manager=session_manager,
+ cron_service=cron,
+ local_trigger_store=trigger_store,
+ webui_runtime_model_name=_webui_runtime_model_name,
+ webui_cron_pending_job_ids=agent.pending_cron_job_ids_for_session,
+ webui_local_trigger_pending_ids=agent.pending_local_trigger_ids_for_session,
+ webui_static_dist=webui_static_dist,
+ webui_runtime_surface=webui_runtime_surface,
+ webui_runtime_capabilities=webui_runtime_capabilities,
+ webui_skill_state_action=_webui_skill_state_action,
+ )
+
+ def _pick_heartbeat_target() -> tuple[str, str]:
+ """Pick a routable channel/chat target for heartbeat-triggered messages."""
+ sidebar_state = read_webui_sidebar_state()
+ unified_metadata = None
+ if config.agents.defaults.unified_session:
+ record = session_manager.read_session_metadata(UNIFIED_SESSION_KEY)
+ if isinstance(record, dict) and isinstance(record.get("metadata"), dict):
+ unified_metadata = record["metadata"]
+ return _pick_heartbeat_target_from_sessions(
+ enabled_channels=channels.enabled_channels,
+ sessions=session_manager.list_sessions(),
+ archived_keys=sidebar_state.get("archived_keys", []),
+ unified_session_metadata=unified_metadata,
+ )
+
+ if channels.enabled_channels:
+ console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
+ else:
+ console.print("[yellow]Warning: No channels enabled[/yellow]")
+
+ cron_status = cron.status()
+ cron_job_count = cast(int, cron_status["jobs"])
+ if cron_job_count > 0:
+ console.print(f"[green]✓[/green] Cron: {cron_job_count} scheduled jobs")
+
+ hb_cfg = config.gateway.heartbeat
+ if hb_cfg.enabled:
+ console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s")
+ else:
+ console.print("[yellow]✗[/yellow] Heartbeat: disabled")
+
+ async def _health_server(host: str, health_port: int) -> None:
+ """Lightweight HTTP health endpoint on the gateway port."""
+ import json as _json
+
+ connection_slots = asyncio.Semaphore(_GATEWAY_HEALTH_MAX_CONNECTIONS)
+
+ async def handle(
+ reader: asyncio.StreamReader,
+ writer: asyncio.StreamWriter,
+ ) -> None:
+ if connection_slots.locked():
+ writer.close()
+ return
+
+ async with connection_slots:
+ try:
+ data = await asyncio.wait_for(
+ reader.read(4096),
+ timeout=_GATEWAY_HEALTH_READ_TIMEOUT_SECONDS,
+ )
+ request_line = data.split(b"\r\n", 1)[0].decode(
+ "utf-8", errors="replace",
+ )
+ method, path = "", ""
+ parts = request_line.split(" ")
+ if len(parts) >= 2:
+ method, path = parts[0], parts[1]
+
+ if method == "GET" and path == "/health":
+ body = _json.dumps({"status": "ok"})
+ status = "200 OK"
+ content_type = "application/json"
+ else:
+ body = "Not Found"
+ status = "404 Not Found"
+ content_type = "text/plain"
+
+ resp = (
+ f"HTTP/1.0 {status}\r\n"
+ f"Content-Type: {content_type}\r\n"
+ f"Content-Length: {len(body)}\r\n"
+ "Connection: close\r\n"
+ f"\r\n{body}"
+ )
+ writer.write(resp.encode())
+ await writer.drain()
+ except (asyncio.TimeoutError, ConnectionError):
+ pass
+ finally:
+ writer.close()
+
+ server = await asyncio.start_server(handle, host, health_port)
+ _print_gateway_health_endpoint(host, health_port)
+ async with server:
+ await server.serve_forever()
+ # Register Dream system job (idempotent on restart)
+ from nanobot.cron.types import CronJob, CronPayload, CronSchedule
+ dream_cfg = config.agents.defaults.dream
+ if dream_cfg.enabled:
+ cron.register_system_job(CronJob(
+ id="dream",
+ name="dream",
+ schedule=dream_cfg.build_schedule(config.agents.defaults.timezone),
+ payload=CronPayload(kind="system_event"),
+ ))
+ console.print(f"[green]✓[/green] Dream: {dream_cfg.describe_schedule()}")
+ else:
+ console.print("[yellow]○[/yellow] Dream: disabled")
+ _advance_dream_cursor_if_behind(agent.context.memory)
+
+ # Register Heartbeat system job (idempotent on restart)
+ if hb_cfg.enabled:
+ cron.register_system_job(CronJob(
+ id="heartbeat",
+ name="heartbeat",
+ schedule=CronSchedule(
+ kind="every",
+ every_ms=hb_cfg.interval_s * 1000,
+ tz=config.agents.defaults.timezone,
+ ),
+ payload=CronPayload(kind="system_event"),
+ ))
+
+ async def _open_browser_when_ready() -> None:
+ """Wait for the gateway to bind, then point the user's browser at the webui."""
+ if not open_browser_url:
+ return
+ import webbrowser
+ from urllib.parse import urlparse
+
+ parsed = urlparse(open_browser_url)
+ target_host = parsed.hostname or config.gateway.host or "127.0.0.1"
+ target_port = parsed.port or port
+ # Channels start asynchronously; a short poll lets us avoid racing the bind.
+ for _ in range(40): # ~4s max
+ try:
+ _reader, writer = await asyncio.open_connection(
+ target_host,
+ target_port,
+ )
+ writer.close()
+ with suppress(Exception):
+ await writer.wait_closed()
+ break
+ except OSError:
+ await asyncio.sleep(0.1)
+ try:
+ webbrowser.open(open_browser_url)
+ console.print(f"[green]✓[/green] Opened browser at {open_browser_url}")
+ except Exception as e:
+ console.print(f"[yellow]Could not open browser ({e}); visit {open_browser_url}[/yellow]")
+
+ async def run() -> None:
+ tasks: list[asyncio.Task[Any]] = []
+ shutdown_task: asyncio.Task[Any] | None = None
+ runtime_tasks: asyncio.Future[list[Any]] | None = None
+ runtime_tasks_drained = False
+ shutdown_event = asyncio.Event()
+ cli_terminal._ensure_interactive_tty_mode()
+ restore_shutdown_handlers = _install_gateway_shutdown_handlers(
+ asyncio.get_running_loop(),
+ shutdown_event,
+ tasks,
+ console.print,
+ )
+ try:
+ await cron.start()
+ # Re-read once on first admission to close the watcher subscription window.
+ agent.runtime_resolver.invalidate()
+ tasks = [
+ asyncio.create_task(
+ watch_config_file(
+ Path(config_path),
+ lambda: agent.invalidate_runtime_config(),
+ ),
+ name="nanobot-config-watcher",
+ ),
+ asyncio.create_task(agent.run(), name="nanobot-agent-loop"),
+ asyncio.create_task(channels.start_all(), name="nanobot-channels"),
+ asyncio.create_task(
+ run_local_trigger_queue(
+ store=trigger_store,
+ submit_turn=agent.submit_local_trigger_turn,
+ is_channel_enabled=lambda name: channels.get_channel(name) is not None,
+ ),
+ name="nanobot-local-triggers",
+ ),
+ ]
+ if health_server_enabled:
+ tasks.append(asyncio.create_task(
+ _health_server(config.gateway.host, port),
+ name="nanobot-health-server",
+ ))
+ if open_browser_url:
+ tasks.append(asyncio.create_task(
+ _open_browser_when_ready(),
+ name="nanobot-open-browser",
+ ))
+ runtime_tasks = asyncio.gather(*tasks)
+ shutdown_task = asyncio.create_task(
+ shutdown_event.wait(),
+ name="nanobot-gateway-shutdown",
+ )
+ done, _pending = await asyncio.wait(
+ {runtime_tasks, shutdown_task},
+ return_when=asyncio.FIRST_COMPLETED,
+ )
+ if runtime_tasks in done:
+ runtime_tasks_drained = True
+ await runtime_tasks
+ else:
+ runtime_tasks.cancel()
+ except KeyboardInterrupt:
+ console.print("\nShutting down...")
+ except Exception:
+ import traceback
+
+ console.print("\n[red]Error: Gateway crashed unexpectedly[/red]")
+ console.print(traceback.format_exc())
+ finally:
+ try:
+ if shutdown_task and not shutdown_task.done():
+ shutdown_task.cancel()
+ with suppress(asyncio.CancelledError):
+ await shutdown_task
+ cron.stop()
+ agent.stop()
+ # Some SDKs swallow task cancellation while attempting to reconnect.
+ # Close channel transports before waiting for their runners to exit.
+ await channels.stop_all()
+ for task in tasks:
+ if not task.done():
+ task.cancel()
+ if tasks:
+ await asyncio.gather(*tasks, return_exceptions=True)
+ if runtime_tasks is not None and not runtime_tasks_drained:
+ with suppress(asyncio.CancelledError, Exception):
+ await runtime_tasks
+ # Flush all cached sessions to durable storage before exit.
+ # This prevents data loss on filesystems with write-back
+ # caching (rclone VFS, NFS, FUSE mounts, etc.).
+ flushed = agent.sessions.flush_all()
+ if flushed:
+ logger.info("Shutdown: flushed {} session(s) to disk", flushed)
+ finally:
+ restore_shutdown_handlers()
+
+ asyncio.run(run())
diff --git a/nanobot/cli/log_control.py b/nanobot/cli/log_control.py
new file mode 100644
index 000000000..9c68a8669
--- /dev/null
+++ b/nanobot/cli/log_control.py
@@ -0,0 +1,12 @@
+"""Runtime log visibility controls shared by CLI commands."""
+
+from loguru import logger
+
+__all__ = ["_set_nanobot_logs"]
+
+
+def _set_nanobot_logs(enabled: bool) -> None:
+ if enabled:
+ logger.enable("nanobot")
+ else:
+ logger.disable("nanobot")
diff --git a/nanobot/cli/provider.py b/nanobot/cli/provider.py
new file mode 100644
index 000000000..3d2c0a626
--- /dev/null
+++ b/nanobot/cli/provider.py
@@ -0,0 +1,372 @@
+"""Typer commands for OAuth provider authentication."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from contextlib import suppress
+from importlib import import_module
+from pathlib import Path
+from typing import TYPE_CHECKING, Protocol, cast
+
+import typer
+from rich.console import Console
+
+from nanobot import __logo__
+
+if TYPE_CHECKING:
+ from nanobot.providers.registry import ProviderSpec
+
+
+console = Console()
+provider_app = typer.Typer(help="Manage providers")
+
+_PROVIDER_DISPLAY: dict[str, str] = {
+ "openai_codex": "OpenAI Codex",
+ "xai_grok": "xAI Grok",
+ "github_copilot": "GitHub Copilot",
+}
+
+_OAUTH_PROVIDER_DEFAULT_MODELS: dict[str, str] = {
+ "openai_codex": "openai-codex/gpt-5.6-sol",
+ "xai_grok": "xai-grok/grok-4.5",
+ "github_copilot": "github-copilot/gpt-5.4-mini",
+}
+
+
+class _OAuthToken(Protocol):
+ access: str | None
+ account_id: str | None
+
+
+class _GetOAuthToken(Protocol):
+ def __call__(self, *, proxy: str | None = None) -> _OAuthToken | None: ...
+
+
+class _LoginOAuthInteractive(Protocol):
+ def __call__(
+ self,
+ *,
+ print_fn: Callable[[str], None],
+ prompt_fn: Callable[[str], str],
+ proxy: str | None = None,
+ ) -> _OAuthToken | None: ...
+
+
+class _OAuthProviderConfig(Protocol):
+ token_filename: str
+
+
+class _TokenStorage(Protocol):
+ def get_token_path(self) -> Path: ...
+
+
+class _FileTokenStorageFactory(Protocol):
+ def __call__(self, *, token_filename: str) -> _TokenStorage: ...
+
+
+def _required_module_attribute(module_name: str, attribute: str) -> object:
+ """Load an optional dependency attribute with import-compatible errors."""
+ module = import_module(module_name)
+ try:
+ return getattr(module, attribute)
+ except AttributeError as exc:
+ raise ImportError(f"{module_name}.{attribute} is unavailable") from exc
+
+
+def _load_openai_oauth_client() -> tuple[_GetOAuthToken, _LoginOAuthInteractive]:
+ """Load the optional untyped OAuth client behind a typed boundary."""
+ return (
+ cast(_GetOAuthToken, _required_module_attribute("oauth_cli_kit", "get_token")),
+ cast(
+ _LoginOAuthInteractive,
+ _required_module_attribute("oauth_cli_kit", "login_oauth_interactive"),
+ ),
+ )
+
+
+def _load_openai_oauth_storage() -> tuple[_OAuthProviderConfig, _FileTokenStorageFactory]:
+ """Load the optional untyped OAuth storage API behind a typed boundary."""
+ return (
+ cast(
+ _OAuthProviderConfig,
+ _required_module_attribute(
+ "oauth_cli_kit.providers",
+ "OPENAI_CODEX_PROVIDER",
+ ),
+ ),
+ cast(
+ _FileTokenStorageFactory,
+ _required_module_attribute("oauth_cli_kit.storage", "FileTokenStorage"),
+ ),
+ )
+
+
+def _resolve_oauth_provider(provider: str) -> ProviderSpec:
+ """Resolve and validate an OAuth provider configuration."""
+ from nanobot.providers.registry import PROVIDERS
+
+ key = provider.replace("-", "_")
+ spec = next((s for s in PROVIDERS if s.name == key and s.is_oauth), None)
+ if not spec:
+ names = ", ".join(s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth)
+ console.print(f"[red]Unknown OAuth provider: {provider}[/red] Supported: {names}")
+ raise typer.Exit(1)
+ return spec
+
+
+def _set_oauth_provider_as_main(
+ provider_name: str,
+ *,
+ model: str | None = None,
+ config_path: str | None = None,
+) -> None:
+ """Persist an OAuth provider as the active agent provider."""
+ from nanobot.config.loader import get_config_path, load_config, save_config, set_config_path
+
+ resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None
+ if resolved_config_path is not None and get_config_path() != resolved_config_path:
+ set_config_path(resolved_config_path)
+ console.print(f"[dim]Using config: {resolved_config_path}[/dim]")
+
+ config = load_config(resolved_config_path)
+ selected_model = (model or "").strip() or _OAUTH_PROVIDER_DEFAULT_MODELS[provider_name]
+ config.agents.defaults.model_preset = None
+ config.agents.defaults.provider = provider_name
+ config.agents.defaults.model = selected_model
+ if provider_name == "xai_grok" and selected_model == "xai-grok/grok-4.5":
+ config.agents.defaults.context_window_tokens = 500_000
+ save_config(config, resolved_config_path)
+
+ saved_path = resolved_config_path or get_config_path()
+ console.print(
+ f"[green]✓ Set {provider_name.replace('_', '-')} as the main provider[/green] "
+ f"[dim]{selected_model}[/dim]"
+ )
+ console.print(f"[dim]Saved: {saved_path}[/dim]")
+
+
+@provider_app.command("login")
+def provider_login(
+ provider: str = typer.Argument(
+ ...,
+ help="OAuth provider (e.g. 'openai-codex', 'xai-grok', 'github-copilot')",
+ ),
+ set_main: bool = typer.Option(
+ False,
+ "--set-main",
+ "--main",
+ help="Set this OAuth provider as the active agent provider after login",
+ ),
+ model: str | None = typer.Option(
+ None,
+ "--model",
+ "-m",
+ help="Model to use when setting this provider as the active provider",
+ ),
+ config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
+):
+ """Authenticate with an OAuth provider."""
+ spec = _resolve_oauth_provider(provider)
+
+ handler = _LOGIN_HANDLERS.get(spec.name)
+ if not handler:
+ console.print(f"[red]Login not implemented for {spec.label}[/red]")
+ raise typer.Exit(1)
+
+ if config:
+ from nanobot.config.loader import set_config_path
+
+ resolved_config_path = Path(config).expanduser().resolve()
+ set_config_path(resolved_config_path)
+ console.print(f"[dim]Using config: {resolved_config_path}[/dim]")
+
+ console.print(f"{__logo__} OAuth Login - {spec.label}\n")
+ handler()
+ if set_main or model:
+ _set_oauth_provider_as_main(spec.name, model=model, config_path=config)
+
+
+@provider_app.command("logout")
+def provider_logout(
+ provider: str = typer.Argument(
+ ...,
+ help="OAuth provider (e.g. 'openai-codex', 'xai-grok', 'github-copilot')",
+ ),
+ config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
+):
+ """Log out from an OAuth provider."""
+ spec = _resolve_oauth_provider(provider)
+
+ handler = _LOGOUT_HANDLERS.get(spec.name)
+ if not handler:
+ console.print(f"[red]Logout not implemented for {spec.label}[/red]")
+ raise typer.Exit(1)
+
+ if config:
+ from nanobot.config.loader import set_config_path
+
+ resolved_config_path = Path(config).expanduser().resolve()
+ set_config_path(resolved_config_path)
+ console.print(f"[dim]Using config: {resolved_config_path}[/dim]")
+
+ console.print(f"{__logo__} OAuth Logout - {spec.label}\n")
+ handler()
+
+
+def _login_openai_codex() -> None:
+ try:
+ from nanobot.config.loader import load_config, resolve_config_env_vars
+
+ get_token, login_oauth_interactive = _load_openai_oauth_client()
+ proxy = None
+ try:
+ proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None
+ except ValueError as e:
+ console.print(f"[red]{e}[/red]")
+ raise typer.Exit(1) from e
+ token = None
+ with suppress(Exception):
+ token = get_token(proxy=proxy)
+ if not (token and token.access):
+ console.print("[cyan]Starting interactive OAuth login...[/cyan]\n")
+ token = login_oauth_interactive(
+ print_fn=lambda s: console.print(s),
+ prompt_fn=lambda s: typer.prompt(s),
+ proxy=proxy,
+ )
+ if not (token and token.access):
+ console.print("[red]✗ Authentication failed[/red]")
+ raise typer.Exit(1)
+ console.print(
+ f"[green]✓ Authenticated with OpenAI Codex[/green] [dim]{token.account_id}[/dim]"
+ )
+ except ImportError:
+ console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
+ raise typer.Exit(1)
+
+
+def _logout_openai_codex() -> None:
+ """Clear local OAuth credentials for OpenAI Codex."""
+ try:
+ provider_config, storage_factory = _load_openai_oauth_storage()
+ except ImportError:
+ console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
+ raise typer.Exit(1)
+
+ storage = storage_factory(token_filename=provider_config.token_filename)
+ _delete_oauth_files(storage.get_token_path(), _PROVIDER_DISPLAY["openai_codex"])
+
+
+def _login_xai_grok() -> None:
+ """Authenticate with xAI using the Grok subscription OAuth contract."""
+ from nanobot.config.loader import load_config, resolve_config_env_vars
+ from nanobot.providers.xai_oauth import get_xai_oauth_token, login_xai_oauth
+
+ try:
+ proxy = resolve_config_env_vars(load_config()).providers.xai_grok.proxy or None
+ except ValueError as exc:
+ console.print(f"[red]{exc}[/red]")
+ raise typer.Exit(1) from exc
+
+ token = None
+ with suppress(Exception):
+ token = get_xai_oauth_token(proxy=proxy)
+ if not (token and token.access):
+ console.print(
+ "[cyan]Starting xAI browser sign-in for your X Premium / Grok subscription...[/cyan]\n"
+ )
+ try:
+ token = login_xai_oauth(
+ print_fn=lambda message: console.print(message),
+ prompt_fn=lambda prompt: typer.prompt(prompt),
+ proxy=proxy,
+ )
+ except Exception as exc:
+ console.print(f"[red]Authentication error: {exc}[/red]")
+ raise typer.Exit(1) from exc
+ account = token.account_id or "xAI account"
+ console.print(f"[green]✓ Authenticated with xAI[/green] [dim]{account}[/dim]")
+ console.print(
+ "[dim]Hosted X Search is enabled automatically when the selected model supports it.[/dim]"
+ )
+
+
+def _logout_xai_grok() -> None:
+ """Clear local xAI OAuth credentials for this nanobot instance."""
+ from nanobot.providers.xai_oauth import get_xai_oauth_storage_path, logout_xai_oauth
+
+ token_path = get_xai_oauth_storage_path()
+ provider_label = _PROVIDER_DISPLAY["xai_grok"]
+ if logout_xai_oauth():
+ console.print(f"[green]✓ Logged out from {provider_label}[/green]")
+ console.print(f"[dim]Removed: {token_path}[/dim]")
+ else:
+ console.print(f"[yellow]! No local OAuth credentials found for {provider_label}[/yellow]")
+
+
+def _logout_github_copilot() -> None:
+ """Clear local OAuth credentials for GitHub Copilot."""
+ try:
+ from nanobot.providers.github_copilot_provider import get_storage
+ except ImportError:
+ console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
+ raise typer.Exit(1)
+
+ storage = get_storage()
+ _delete_oauth_files(storage.get_token_path(), _PROVIDER_DISPLAY["github_copilot"])
+
+
+def _delete_oauth_files(token_path: Path, provider_label: str) -> None:
+ """Delete OAuth token and lock files, reporting the result."""
+ removed_paths: list[Path] = []
+ skipped: list[tuple[Path, OSError]] = []
+ for path in (token_path, token_path.with_suffix(".lock")):
+ try:
+ path.unlink()
+ except FileNotFoundError:
+ continue
+ except OSError as exc:
+ skipped.append((path, exc))
+ continue
+ removed_paths.append(path)
+
+ if not removed_paths and not skipped:
+ console.print(f"[yellow]! No local OAuth credentials found for {provider_label}[/yellow]")
+ return
+
+ if removed_paths:
+ console.print(f"[green]✓ Logged out from {provider_label}[/green]")
+ for path in removed_paths:
+ console.print(f"[dim]Removed: {path}[/dim]")
+ for path, exc in skipped:
+ console.print(f"[yellow]! Could not remove {path}: {exc}[/yellow]")
+
+
+def _login_github_copilot() -> None:
+ try:
+ from nanobot.providers.github_copilot_provider import login_github_copilot
+
+ console.print("[cyan]Starting GitHub Copilot device flow...[/cyan]\n")
+ token = login_github_copilot(
+ print_fn=lambda s: console.print(s),
+ prompt_fn=lambda s: typer.prompt(s),
+ )
+ account = token.account_id or "GitHub"
+ console.print(
+ f"[green]✓ Authenticated with GitHub Copilot[/green] [dim]{account}[/dim]"
+ )
+ except Exception as e:
+ console.print(f"[red]Authentication error: {e}[/red]")
+ raise typer.Exit(1)
+
+
+_LOGIN_HANDLERS: dict[str, Callable[[], None]] = {
+ "openai_codex": _login_openai_codex,
+ "xai_grok": _login_xai_grok,
+ "github_copilot": _login_github_copilot,
+}
+_LOGOUT_HANDLERS: dict[str, Callable[[], None]] = {
+ "openai_codex": _logout_openai_codex,
+ "xai_grok": _logout_xai_grok,
+ "github_copilot": _logout_github_copilot,
+}
diff --git a/nanobot/cli/runtime_config.py b/nanobot/cli/runtime_config.py
new file mode 100644
index 000000000..3614eb7e2
--- /dev/null
+++ b/nanobot/cli/runtime_config.py
@@ -0,0 +1,185 @@
+"""Configuration loading and diagnostics shared by CLI commands."""
+
+from pathlib import Path
+
+import typer
+from pydantic import ValidationError
+from rich.console import Console
+from rich.markup import escape
+from rich.text import Text
+
+from nanobot.config.schema import Config
+
+__all__ = [
+ "_load_config_for_cli",
+ "_load_inspection_config",
+ "_load_runtime_config",
+ "_migrate_cron_store",
+ "_model_display",
+ "_print_agent_start_error",
+ "_print_config_error",
+ "_print_model_setup_steps",
+ "_print_runtime_config_validation_error",
+ "_provider_setup_error",
+]
+
+console = Console()
+
+
+def _model_display(config: Config) -> tuple[str, str]:
+ """Return (resolved_model_name, preset_tag) for display strings."""
+ resolved = config.resolve_preset()
+ name = config.agents.defaults.model_preset
+ tag = f" (preset: {name})" if name else ""
+ return resolved.model, tag
+
+
+def _print_config_error(error: Exception) -> None:
+ """Render a configuration failure without exposing traceback internals."""
+ from nanobot.config.errors import ConfigLoadError
+
+ console.print(Text(str(error), style="red"))
+ if isinstance(error, ConfigLoadError):
+ command = _status_command(error.path)
+ console.print(f"[dim]Check again after editing: {escape(command)}[/dim]")
+
+
+def _print_runtime_config_validation_error(
+ error: ValidationError,
+ *,
+ config_path: Path,
+ summary: str,
+ path_prefix: tuple[str | int, ...],
+ retry_command: str,
+) -> None:
+ """Render a runtime-owned Pydantic config error without exposing input values."""
+ from nanobot.config.errors import ConfigIssue, ConfigLoadError, validation_issues
+
+ issues = tuple(
+ ConfigIssue(
+ path=(*path_prefix, *issue.path),
+ message=issue.message,
+ )
+ for issue in validation_issues(error)
+ )
+ diagnostic = ConfigLoadError(
+ config_path,
+ kind="invalid_schema",
+ summary=summary,
+ issues=issues,
+ )
+ console.print(Text(str(diagnostic), style="red"))
+ console.print(f"[dim]Fix the listed setting, then retry: {escape(retry_command)}[/dim]")
+
+
+def _status_command(config_path: Path) -> str:
+ return f'nanobot status --config "{config_path}"'
+
+
+def _print_model_setup_steps(config_path: Path) -> None:
+ """Show the shortest setup routes shared by Status and Agent startup."""
+ config_arg = f'--config "{config_path}"'
+ console.print(
+ f" WebUI: run [cyan]nanobot webui {escape(config_arg)}[/cyan], "
+ "then open Settings → Models"
+ )
+ console.print(f" CLI: run [cyan]nanobot onboard --wizard {escape(config_arg)}[/cyan]")
+ console.print(f" Check: [cyan]{escape(_status_command(config_path))}[/cyan]")
+
+
+def _print_agent_start_error(error: ValueError) -> None:
+ from nanobot.config.loader import get_config_path
+
+ console.print(Text(f"Agent cannot start: {error}", style="red"))
+ console.print("Complete provider/model setup:")
+ _print_model_setup_steps(get_config_path())
+
+
+def _load_config_for_cli(
+ config_path: Path | None = None,
+ *,
+ resolve_env: bool = False,
+) -> Config:
+ """Load CLI configuration and turn expected failures into a clean exit."""
+ from nanobot.config.errors import ConfigLoadError
+ from nanobot.config.loader import load_config, resolve_config_env_vars
+
+ try:
+ loaded = load_config(config_path)
+ if resolve_env:
+ loaded = resolve_config_env_vars(loaded)
+ return loaded
+ except ConfigLoadError as exc:
+ _print_config_error(exc)
+ raise typer.Exit(1) from exc
+
+
+def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config:
+ """Load config and optionally override the active workspace."""
+ from nanobot.config.loader import set_config_path
+
+ config_path = None
+ if config:
+ config_path = Path(config).expanduser().resolve()
+ if not config_path.exists():
+ console.print(f"[red]Error: Config file not found: {config_path}[/red]")
+ raise typer.Exit(1)
+ set_config_path(config_path)
+ console.print(f"[dim]Using config: {config_path}[/dim]")
+
+ loaded = _load_config_for_cli(config_path, resolve_env=True)
+ if workspace:
+ loaded.agents.defaults.workspace = workspace
+ return loaded
+
+
+def _load_inspection_config(
+ config: str | None = None,
+ workspace: str | None = None,
+) -> tuple[Path, Config]:
+ """Load config for diagnostic commands without resolving secret env refs."""
+ from nanobot.config.errors import ConfigLoadError
+ from nanobot.config.loader import get_config_path, load_config, set_config_path
+
+ config_path = None
+ if config:
+ config_path = Path(config).expanduser().resolve(strict=False)
+ set_config_path(config_path)
+ console.print(f"[dim]Using config: {config_path}[/dim]")
+
+ display_path = config_path or get_config_path()
+ try:
+ loaded = load_config(config_path)
+ except ConfigLoadError as exc:
+ _print_config_error(exc)
+ raise typer.Exit(1) from exc
+ except ValueError as exc:
+ console.print(f"[red]Error: {exc}[/red]")
+ raise typer.Exit(1) from exc
+ if workspace:
+ loaded.agents.defaults.workspace = workspace
+ return display_path, loaded
+
+
+def _migrate_cron_store(config: "Config") -> None:
+ """One-time migration: move legacy global cron store into the workspace."""
+ from nanobot.config.paths import get_cron_dir
+
+ legacy_path = get_cron_dir() / "jobs.json"
+ new_path = config.workspace_path / "cron" / "jobs.json"
+ if legacy_path.is_file() and not new_path.exists():
+ new_path.parent.mkdir(parents=True, exist_ok=True)
+ import shutil
+
+ shutil.move(str(legacy_path), str(new_path))
+
+
+def _provider_setup_error(config: Config) -> str | None:
+ """Return a local provider/model configuration error, or None."""
+ from nanobot.providers.factory import validate_provider_setup
+
+ try:
+ validate_provider_setup(config)
+ except ValueError as exc:
+ return str(exc)
+ return None
diff --git a/nanobot/cli/terminal.py b/nanobot/cli/terminal.py
new file mode 100644
index 000000000..3ec728732
--- /dev/null
+++ b/nanobot/cli/terminal.py
@@ -0,0 +1,428 @@
+"""Terminal input and rendering helpers for the interactive CLI."""
+
+from __future__ import annotations
+
+import os
+import select
+import sys
+from collections.abc import Callable
+from contextlib import nullcontext, suppress
+from typing import Any, Literal, cast
+
+from loguru import logger
+from prompt_toolkit import PromptSession, print_formatted_text
+from prompt_toolkit.application import run_in_terminal
+from prompt_toolkit.formatted_text import ANSI, HTML
+from prompt_toolkit.history import FileHistory
+from prompt_toolkit.key_binding import KeyBindings
+from prompt_toolkit.key_binding.key_processor import KeyPressEvent
+from prompt_toolkit.keys import Keys
+from prompt_toolkit.patch_stdout import patch_stdout
+from rich.console import Console
+from rich.markdown import Markdown
+from rich.text import Text
+
+from nanobot import __logo__
+from nanobot.bus.outbound_events import (
+ ProgressEvent,
+ RetryWaitEvent,
+ outbound_event_from_message,
+)
+from nanobot.cli.stream import StreamRenderer, ThinkingSpinner
+from nanobot.utils.helpers import sanitize_surrogates as _sanitize_surrogates
+
+__all__ = [
+ "_ReasoningBuffer",
+ "_ensure_interactive_tty_mode",
+ "_flush_cli_reasoning",
+ "_flush_pending_tty_input",
+ "_init_prompt_session",
+ "_is_exit_command",
+ "_maybe_print_interactive_progress",
+ "_print_agent_response",
+ "_print_cli_progress_line",
+ "_print_cli_reasoning",
+ "_print_interactive_response",
+ "_read_interactive_input_async",
+ "_restore_terminal",
+]
+
+console = Console()
+EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"}
+_REASONING_SENTENCE_ENDINGS = (".", "!", "?", "。", "!", "?")
+_REASONING_FLUSH_CHARS = 60
+_prompt_session: PromptSession[str] | None = None
+_saved_term_attrs: list[Any] | None = None
+
+
+def _ensure_interactive_tty_mode() -> None:
+ """Restore interactive line input after a raw-mode TTY leak."""
+ try:
+ fd = sys.stdin.fileno()
+ if not os.isatty(fd):
+ return
+ except Exception:
+ return
+
+ with suppress(Exception):
+ import termios
+
+ attrs = termios.tcgetattr(fd)
+ required_lflag = termios.ISIG | termios.ICANON | termios.ECHO
+ blocked_input_flags = getattr(termios, "IGNCR", 0) | getattr(termios, "INLCR", 0)
+ if (
+ (attrs[3] & required_lflag) == required_lflag
+ and attrs[0] & termios.ICRNL
+ and not attrs[0] & blocked_input_flags
+ ):
+ return
+ attrs[0] = (attrs[0] | termios.ICRNL) & ~blocked_input_flags
+ attrs[3] |= required_lflag
+ termios.tcsetattr(fd, termios.TCSANOW, attrs)
+ termios.tcflush(fd, termios.TCIFLUSH)
+ logger.debug("Restored foreground gateway TTY mode")
+
+
+class SafeFileHistory(FileHistory):
+ """FileHistory subclass that sanitizes surrogate characters on write.
+
+ On Windows, special Unicode input (emoji, mixed-script) can produce
+ surrogate characters that crash prompt_toolkit's file write.
+ See issue #2846.
+ """
+
+ def store_string(self, string: str) -> None:
+ super().store_string(_sanitize_surrogates(string))
+
+
+def _flush_pending_tty_input() -> None:
+ """Drop unread keypresses typed while the model was generating output."""
+ try:
+ fd = sys.stdin.fileno()
+ if not os.isatty(fd):
+ return
+ except Exception:
+ return
+
+ with suppress(Exception):
+ import termios
+
+ termios.tcflush(fd, termios.TCIFLUSH)
+ return
+
+ with suppress(Exception):
+ while True:
+ ready, _, _ = select.select([fd], [], [], 0)
+ if not ready:
+ break
+ if not os.read(fd, 4096):
+ break
+
+
+def _restore_terminal() -> None:
+ """Restore terminal to its original state (echo, line buffering, etc.)."""
+ if _saved_term_attrs is None:
+ return
+ with suppress(Exception):
+ import termios
+
+ termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _saved_term_attrs)
+
+
+def _build_cli_key_bindings() -> KeyBindings:
+ """Key bindings for the interactive prompt.
+
+ Behaviour:
+ * Enter -> submit the current input (keeps the familiar
+ single-line Enter-to-send feel even though the buffer
+ is multiline-capable).
+ * Alt+Enter -> insert a newline for multi-line input.
+ * Shift+Enter -> insert a newline on terminals that emit the CSI-u
+ (kitty / fixterms) keyboard-protocol encoding for it.
+ """
+ # prompt_toolkit does not recognize CSI-u, so register its Shift+Enter
+ # sequence as a best-effort addition without overriding existing mappings.
+ with suppress(Exception):
+ from prompt_toolkit.input import ansi_escape_sequences as _aes
+
+ _aes.ANSI_SEQUENCES.setdefault("\x1b[13;2u", Keys.ControlF3)
+
+ kb = KeyBindings()
+
+ @kb.add("enter")
+ def _(event: KeyPressEvent) -> None:
+ event.current_buffer.validate_and_handle()
+
+ @kb.add("escape", "enter") # Alt+Enter / Meta+Enter (ESC + CR, "\x1b\r")
+ def _(event: KeyPressEvent) -> None:
+ event.current_buffer.insert_text("\n")
+
+ # LF-as-Enter terminals send Alt+Enter as ESC + LF rather than ESC + CR.
+ @kb.add("escape", Keys.ControlJ) # Alt+Enter on LF-as-Enter terminals
+ def _(event: KeyPressEvent) -> None:
+ event.current_buffer.insert_text("\n")
+
+ @kb.add(Keys.ControlF3) # Shift+Enter on CSI-u capable terminals
+ def _(event: KeyPressEvent) -> None:
+ event.current_buffer.insert_text("\n")
+
+ return kb
+
+
+def _init_prompt_session() -> None:
+ """Create the prompt_toolkit session with persistent file history."""
+ global _prompt_session, _saved_term_attrs
+
+ # Save terminal state so we can restore it on exit
+ with suppress(Exception):
+ import termios
+
+ _saved_term_attrs = termios.tcgetattr(sys.stdin.fileno())
+
+ from nanobot.config.paths import get_cli_history_path
+
+ history_file = get_cli_history_path()
+ history_file.parent.mkdir(parents=True, exist_ok=True)
+
+ _prompt_session = PromptSession(
+ history=SafeFileHistory(str(history_file)),
+ enable_open_in_editor=False,
+ # Multiline-capable buffer; Enter still submits via the custom key
+ # bindings, while Alt+Enter adds a newline.
+ multiline=True,
+ key_bindings=_build_cli_key_bindings(),
+ )
+
+
+def _make_console() -> Console:
+ return Console(file=sys.stdout)
+
+
+def _render_interactive_ansi(render_fn: Callable[[Console], None]) -> str:
+ """Render Rich output to ANSI so prompt_toolkit can print it safely."""
+ ansi_console = Console(
+ force_terminal=sys.stdout.isatty(),
+ color_system=cast(
+ Literal["auto", "standard", "256", "truecolor", "windows"],
+ console.color_system or "standard",
+ ),
+ width=console.width,
+ )
+ with ansi_console.capture() as capture:
+ render_fn(ansi_console)
+ return capture.get()
+
+
+def _print_agent_response(
+ response: str,
+ render_markdown: bool,
+ metadata: dict[str, Any] | None = None,
+ show_header: bool = True,
+) -> None:
+ """Render assistant response with consistent terminal styling."""
+ console = _make_console()
+ content = response or ""
+ body = _response_renderable(content, render_markdown, metadata)
+ if show_header:
+ console.print()
+ console.print(f"[cyan]{__logo__} nanobot[/cyan]")
+ console.print(body)
+ console.print()
+
+
+def _response_renderable(
+ content: str, render_markdown: bool, metadata: dict[str, Any] | None = None
+) -> Text | Markdown:
+ """Render plain-text command output without markdown collapsing newlines."""
+ if not render_markdown:
+ return Text(content)
+ if (metadata or {}).get("render_as") == "text":
+ return Text(content)
+ return Markdown(content)
+
+
+async def _print_interactive_line(text: str) -> None:
+ """Print async interactive updates with prompt_toolkit-safe Rich styling."""
+
+ def _write() -> None:
+ ansi = _render_interactive_ansi(lambda c: c.print(f" [dim]↳ {text}[/dim]"))
+ print_formatted_text(ANSI(ansi), end="")
+
+ await run_in_terminal(_write)
+
+
+async def _print_interactive_response(
+ response: str,
+ render_markdown: bool,
+ metadata: dict[str, Any] | None = None,
+) -> None:
+ """Print async interactive replies with prompt_toolkit-safe Rich styling."""
+
+ def _write() -> None:
+ content = response or ""
+
+ def _render(target: Console) -> None:
+ target.print()
+ target.print(f"[cyan]{__logo__} nanobot[/cyan]")
+ target.print(_response_renderable(content, render_markdown, metadata))
+ target.print()
+
+ ansi = _render_interactive_ansi(_render)
+ print_formatted_text(ANSI(ansi), end="")
+
+ await run_in_terminal(_write)
+
+
+def _print_cli_progress_line(
+ text: str,
+ thinking: ThinkingSpinner | None,
+ renderer: StreamRenderer | None = None,
+) -> None:
+ """Print a CLI progress line, pausing the spinner if needed."""
+ if not text.strip():
+ return
+ target = renderer.console if renderer else console
+ pause = renderer.pause_spinner() if renderer else (thinking.pause() if thinking else nullcontext())
+ with pause:
+ if renderer:
+ renderer.ensure_header()
+ target.print(f" [dim]↳ {text}[/dim]")
+
+
+class _ReasoningBuffer:
+ def __init__(self) -> None:
+ self._text = ""
+
+ def add(self, text: str) -> str | None:
+ if not text:
+ return None
+ self._text += text
+ if self._should_flush(text):
+ return self.flush()
+ return None
+
+ def flush(self) -> str | None:
+ text = self._text.strip()
+ self._text = ""
+ return text or None
+
+ def clear(self) -> None:
+ self._text = ""
+
+ def _should_flush(self, text: str) -> bool:
+ stripped = text.rstrip()
+ return (
+ "\n" in text
+ or stripped.endswith(_REASONING_SENTENCE_ENDINGS)
+ or len(self._text) >= _REASONING_FLUSH_CHARS
+ )
+
+
+def _print_cli_reasoning(
+ text: str,
+ thinking: ThinkingSpinner | None,
+ renderer: StreamRenderer | None = None,
+) -> None:
+ """Print reasoning/thinking content in a distinct style."""
+ if not text.strip():
+ return
+ target = renderer.console if renderer else console
+ pause = renderer.pause_spinner() if renderer else (thinking.pause() if thinking else nullcontext())
+ with pause:
+ if renderer:
+ renderer.ensure_header()
+ target.print(f"[dim italic]✻ {text}[/dim italic]")
+
+
+def _flush_cli_reasoning(
+ reasoning_buffer: _ReasoningBuffer,
+ thinking: ThinkingSpinner | None,
+ renderer: StreamRenderer | None = None,
+) -> None:
+ text = reasoning_buffer.flush()
+ if text:
+ _print_cli_reasoning(text, thinking, renderer)
+
+
+async def _print_interactive_progress_line(
+ text: str,
+ thinking: ThinkingSpinner | None,
+ renderer: StreamRenderer | None = None,
+) -> None:
+ """Print an interactive progress line, pausing the spinner if needed."""
+ if not text.strip():
+ return
+ if renderer:
+ with renderer.pause_spinner():
+ renderer.ensure_header()
+ renderer.console.print(f" [dim]↳ {text}[/dim]")
+ else:
+ with thinking.pause() if thinking else nullcontext():
+ await _print_interactive_line(text)
+
+
+async def _maybe_print_interactive_progress(
+ msg: Any,
+ thinking: ThinkingSpinner | None,
+ channels_config: Any,
+ renderer: StreamRenderer | None = None,
+ reasoning_buffer: _ReasoningBuffer | None = None,
+) -> bool:
+ event = outbound_event_from_message(msg)
+ if isinstance(event, RetryWaitEvent):
+ await _print_interactive_progress_line(msg.content, thinking, renderer)
+ return True
+
+ if not isinstance(event, ProgressEvent):
+ return False
+
+ reasoning_buffer = reasoning_buffer or _ReasoningBuffer()
+
+ if event.reasoning_end:
+ if channels_config and not channels_config.show_reasoning:
+ reasoning_buffer.clear()
+ else:
+ _flush_cli_reasoning(reasoning_buffer, thinking, renderer)
+ return True
+
+ is_tool_hint = event.tool_hint
+ is_reasoning = event.reasoning or event.reasoning_delta
+ if is_reasoning:
+ if channels_config and not channels_config.show_reasoning:
+ reasoning_buffer.clear()
+ return True
+ text = reasoning_buffer.add(msg.content)
+ if text:
+ _print_cli_reasoning(text, thinking, renderer)
+ return True
+ if channels_config and is_tool_hint and not channels_config.send_tool_hints:
+ return True
+ if channels_config and not is_tool_hint and not channels_config.send_progress:
+ return True
+
+ await _print_interactive_progress_line(msg.content, thinking, renderer)
+ return True
+
+
+def _is_exit_command(command: str) -> bool:
+ """Return True when input should end interactive chat."""
+ return command.lower() in EXIT_COMMANDS
+
+
+async def _read_interactive_input_async() -> str:
+ """Read user input using prompt_toolkit (handles paste, history, display).
+
+ prompt_toolkit natively handles:
+ - Multiline paste (bracketed paste mode)
+ - History navigation (up/down arrows)
+ - Clean display (no ghost characters or artifacts)
+ """
+ if _prompt_session is None:
+ raise RuntimeError("Call _init_prompt_session() first")
+ try:
+ with patch_stdout():
+ return await _prompt_session.prompt_async(
+ HTML("You: "),
+ )
+ except EOFError as exc:
+ raise KeyboardInterrupt from exc
diff --git a/nanobot/cli/webui.py b/nanobot/cli/webui.py
new file mode 100644
index 000000000..b06e940c9
--- /dev/null
+++ b/nanobot/cli/webui.py
@@ -0,0 +1,261 @@
+"""WebUI CLI command."""
+
+from pathlib import Path
+
+import typer
+from pydantic import ValidationError
+from rich.console import Console
+
+from nanobot.cli import terminal as cli_terminal
+from nanobot.cli.gateway_runtime import _run_gateway
+from nanobot.cli.runtime_config import (
+ _load_runtime_config,
+ _print_config_error,
+ _print_runtime_config_validation_error,
+ _provider_setup_error,
+)
+from nanobot.cli.webui_support import (
+ _attach_to_background_gateway,
+ _confirm_webui_action,
+ _ensure_local_webui_channel,
+ _gateway_health_bind_note,
+ _gateway_health_ready,
+ _gateway_health_url,
+ _gateway_instance_command,
+ _host_for_local_browser,
+ _load_webui_setup_config,
+ _open_webui_browser,
+ _prepare_webui_bundle_for_gateway,
+ _print_foreground_port_conflict,
+ _print_webui_foreground_lifecycle,
+ _resolve_webui_config_path,
+ _run_quick_start_for_webui,
+ _tcp_endpoint_reachable,
+ _warn_webui_bind_scope,
+ _webui_browser_url,
+ _webui_build_mode_for_interactive,
+ _webui_display_url,
+ _webui_endpoint_reachable,
+)
+from nanobot.config.paths import get_workspace_path
+from nanobot.utils.helpers import sync_workspace_templates
+
+console = Console()
+
+
+def webui(
+ port: int | None = typer.Option(None, "--port", "-p", help="WebUI port"),
+ gateway_port: int | None = typer.Option(
+ None,
+ "--gateway-port",
+ help="Gateway health port",
+ ),
+ workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
+ config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
+ background: bool = typer.Option(
+ False,
+ "--background",
+ help="Keep the gateway running after this command exits",
+ ),
+ no_open: bool = typer.Option(False, "--no-open", help="Do not open a browser"),
+ yes: bool = typer.Option(
+ False,
+ "--yes",
+ "-y",
+ help="Apply safe local WebUI defaults without prompting",
+ ),
+) -> None:
+ """Prepare the local WebUI, start the gateway, and open the browser workbench."""
+ from nanobot.config.loader import resolve_config_env_vars, save_config
+ from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions
+
+ cli_terminal._ensure_interactive_tty_mode()
+ config_path = _resolve_webui_config_path(config)
+ created_config = not config_path.exists()
+ if created_config:
+ console.print(f"[yellow]No config found at {config_path}.[/yellow]")
+ _confirm_webui_action("Create a nanobot config and workspace now?", yes=yes)
+
+ setup_config = _load_webui_setup_config(config_path)
+ if workspace:
+ setup_config.agents.defaults.workspace = workspace
+
+ try:
+ resolved_setup_config = resolve_config_env_vars(
+ setup_config.model_copy(deep=True),
+ config_path=config_path,
+ )
+ except ValueError as exc:
+ _print_config_error(exc)
+ raise typer.Exit(1) from exc
+
+ provider_error = _provider_setup_error(resolved_setup_config)
+ settings_setup_error = provider_error if provider_error and created_config else None
+ if settings_setup_error:
+ console.print(f"[yellow]Model setup is incomplete: {provider_error}[/yellow]")
+ console.print("Configure a provider and model in WebUI Settings → Models.")
+ if background:
+ console.print(
+ "[red]First-time WebUI setup must run in the foreground. "
+ "Run `nanobot webui` without --background.[/red]"
+ )
+ raise typer.Exit(1)
+ elif provider_error:
+ console.print(f"[dim]Provider check: {provider_error}[/dim]")
+ setup_config = _run_quick_start_for_webui(
+ setup_config,
+ yes=yes,
+ config_path=config_path,
+ )
+ if workspace:
+ setup_config.agents.defaults.workspace = workspace
+
+ try:
+ changed_webui, generated_bootstrap_secret = _ensure_local_webui_channel(
+ setup_config,
+ port=port,
+ yes=yes,
+ )
+ _warn_webui_bind_scope(setup_config)
+ webui_url = _webui_browser_url(setup_config)
+ except ValidationError as exc:
+ retry_command = f'nanobot webui --config "{config_path}"'
+ _print_runtime_config_validation_error(
+ exc,
+ config_path=config_path,
+ summary="WebUI configuration is invalid.",
+ path_prefix=("channels", "websocket"),
+ retry_command=retry_command,
+ )
+ raise typer.Exit(1) from exc
+ except ValueError as exc:
+ console.print(f"[red]Error: invalid WebUI channel config: {exc}[/red]")
+ raise typer.Exit(1) from exc
+
+ if created_config or provider_error or changed_webui or workspace:
+ save_config(setup_config, config_path)
+ console.print(f"[green]✓[/green] Saved config: {config_path}")
+
+ workspace_path = get_workspace_path(setup_config.workspace_path)
+ workspace_path.mkdir(parents=True, exist_ok=True)
+ sync_workspace_templates(workspace_path)
+
+ runtime_config = _load_runtime_config(str(config_path), workspace)
+ effective_gateway_port = gateway_port if gateway_port is not None else runtime_config.gateway.port
+
+ console.print()
+ console.print(f"WebUI: [cyan]{_webui_display_url(webui_url)}[/cyan]")
+ gateway_health_url = _gateway_health_url(
+ runtime_config.gateway.host,
+ effective_gateway_port,
+ )
+ console.print(
+ f"Gateway health: [cyan]{gateway_health_url}[/cyan]"
+ f"{_gateway_health_bind_note(runtime_config.gateway.host)}"
+ )
+ if no_open:
+ console.print("[dim]Browser opening disabled by --no-open.[/dim]")
+ if generated_bootstrap_secret:
+ console.print(
+ "[yellow]A WebUI bootstrap secret was generated and saved in this config.[/yellow]"
+ )
+ console.print(
+ "[dim]Open the WebUI and enter channels.websocket.tokenIssueSecret from "
+ f"{config_path}, or rerun without --no-open to open the authenticated URL.[/dim]"
+ )
+
+ webui_bundle_mode = _webui_build_mode_for_interactive(yes=yes)
+
+ config_arg = str(config_path)
+ workspace_arg = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None
+ runtime = GatewayRuntime(
+ paths=GatewayRuntimePaths.for_instance(
+ data_dir=config_path.parent,
+ workspace=workspace_arg,
+ config_path=config_arg,
+ )
+ )
+ start_options = GatewayStartOptions(
+ port=effective_gateway_port,
+ workspace=workspace_arg,
+ config_path=config_arg,
+ )
+
+ if background:
+ _prepare_webui_bundle_for_gateway(runtime_config, mode=webui_bundle_mode)
+ result = runtime.start_background(start_options)
+ restarted = False
+ restart_attempted = False
+ if not result.ok and result.message == "gateway_already_running" and changed_webui:
+ restart_attempted = True
+ console.print("[yellow]WebUI config changed; restarting the background gateway.[/yellow]")
+ result = runtime.restart(start_options, timeout_s=20)
+ restarted = result.ok
+ if not result.ok and (restart_attempted or result.message != "gateway_already_running"):
+ action = "restarted" if restart_attempted else "started"
+ console.print(f"[yellow]Gateway was not {action}: {result.message}[/yellow]")
+ console.print(f"Logs: {result.status.log_path}")
+ raise typer.Exit(1)
+ if restarted:
+ console.print("[green]Gateway restarted in the background.[/green]")
+ elif result.ok:
+ console.print("[green]Gateway started in the background.[/green]")
+ else:
+ console.print("[yellow]Gateway is already running in the background.[/yellow]")
+ console.print(
+ "Manage this instance: "
+ f"[cyan]{_gateway_instance_command('status', config_path=config_path, workspace=workspace)}[/cyan]"
+ )
+ console.print(
+ "View logs: "
+ f"[cyan]{_gateway_instance_command('logs', config_path=config_path, workspace=workspace)}[/cyan]"
+ )
+ console.print("[dim]Closing the browser does not stop channels or automations.[/dim]")
+ console.print(
+ "Stop nanobot: "
+ f"[cyan]{_gateway_instance_command('stop', config_path=config_path, workspace=workspace)}[/cyan]"
+ )
+ if not no_open:
+ _open_webui_browser(webui_url)
+ return
+
+ gateway_ready = _gateway_health_ready(runtime_config.gateway.host, effective_gateway_port)
+ webui_ready = _webui_endpoint_reachable(webui_url)
+ if gateway_ready and webui_ready:
+ console.print("[yellow]Gateway is already running; attaching to the existing WebUI.[/yellow]")
+ console.print(
+ "Restart the gateway if you need it to pick up local source changes: "
+ f"[cyan]{_gateway_instance_command('restart', config_path=config_path, workspace=workspace)}[/cyan]"
+ )
+ if not no_open:
+ _open_webui_browser(webui_url, wait=False)
+ if runtime.status().running:
+ _attach_to_background_gateway(runtime)
+ else:
+ console.print(
+ "[yellow]This gateway is controlled by another foreground command. "
+ "Stop it from that terminal.[/yellow]"
+ )
+ return
+
+ gateway_port_taken = gateway_ready or _tcp_endpoint_reachable(
+ _host_for_local_browser(runtime_config.gateway.host),
+ effective_gateway_port,
+ )
+ webui_port_taken = webui_ready
+ if gateway_port_taken or webui_port_taken:
+ _print_foreground_port_conflict(
+ webui_url=webui_url,
+ gateway_host=runtime_config.gateway.host,
+ gateway_port=effective_gateway_port,
+ )
+ raise typer.Exit(1)
+
+ _print_webui_foreground_lifecycle(attached=False)
+ _run_gateway(
+ runtime_config,
+ port=effective_gateway_port,
+ open_browser_url=None if no_open else webui_url,
+ webui_bundle_mode=webui_bundle_mode,
+ unconfigured_provider_error=settings_setup_error,
+ )
diff --git a/nanobot/cli/webui_support.py b/nanobot/cli/webui_support.py
new file mode 100644
index 000000000..58432d6a1
--- /dev/null
+++ b/nanobot/cli/webui_support.py
@@ -0,0 +1,498 @@
+"""Shared WebUI setup, URL, health, and browser helpers."""
+
+import sys
+import time
+from pathlib import Path
+from typing import TYPE_CHECKING, Any
+
+import typer
+from pydantic import ValidationError
+from rich.console import Console
+from rich.markup import escape
+from rich.text import Text
+
+from nanobot.cli.runtime_config import (
+ _load_config_for_cli,
+ _print_model_setup_steps,
+ _print_runtime_config_validation_error,
+ _provider_setup_error,
+)
+from nanobot.config.schema import Config
+from nanobot.security.network import is_loopback_host
+from nanobot.webui.build import (
+ BuildMode,
+ WebUIBuildError,
+ ensure_webui_bundle,
+)
+
+if TYPE_CHECKING:
+ from nanobot.gateway.runtime import GatewayRuntime
+
+__all__ = [
+ "_attach_to_background_gateway",
+ "_confirm_webui_action",
+ "_ensure_local_webui_channel",
+ "_gateway_health_bind_note",
+ "_gateway_health_ready",
+ "_gateway_health_url",
+ "_gateway_instance_command",
+ "_host_for_local_browser",
+ "_load_webui_setup_config",
+ "_open_webui_browser",
+ "_prepare_webui_bundle_for_gateway",
+ "_print_foreground_port_conflict",
+ "_print_webui_foreground_lifecycle",
+ "_resolve_webui_config_path",
+ "_run_quick_start_for_webui",
+ "_tcp_endpoint_reachable",
+ "_validate_gateway_startup",
+ "_warn_webui_bind_scope",
+ "_webui_browser_url",
+ "_webui_build_mode_for_interactive",
+ "_webui_channel_enabled",
+ "_webui_display_url",
+ "_webui_endpoint_reachable",
+]
+
+console = Console()
+
+
+def _confirm_webui_action(message: str, *, yes: bool) -> None:
+ """Confirm a WebUI first-run mutation or fail clearly in non-interactive shells."""
+ if yes:
+ return
+ if not _cli_can_prompt():
+ console.print(
+ "[red]Error: WebUI setup needs confirmation. Re-run with --yes or use "
+ "`nanobot onboard --wizard`.[/red]"
+ )
+ raise typer.Exit(1)
+ if not typer.confirm(message, default=True):
+ console.print("[yellow]WebUI setup cancelled.[/yellow]")
+ raise typer.Exit(1)
+
+
+def _cli_can_prompt() -> bool:
+ try:
+ return sys.stdin.isatty()
+ except Exception:
+ return False
+
+
+def _webui_build_mode_for_interactive(*, yes: bool = False) -> BuildMode:
+ if yes:
+ return "auto"
+ return "prompt" if _cli_can_prompt() else "warn"
+
+
+def _resolve_webui_config_path(config: str | None) -> Path:
+ """Resolve the config path used by ``nanobot webui`` and bind loader state."""
+ from nanobot.config.loader import get_config_path, set_config_path
+
+ if not config:
+ return get_config_path()
+ config_path = Path(config).expanduser().resolve(strict=False)
+ set_config_path(config_path)
+ console.print(f"[dim]Using config: {config_path}[/dim]")
+ return config_path
+
+
+def _load_webui_setup_config(config_path: Path) -> Config:
+ """Load config for first-run mutation without resolving env-var placeholders."""
+ return _load_config_for_cli(config_path)
+
+
+def _webui_config_dict(config: Config) -> dict[str, Any]:
+ """Return the current WebSocket config as a mutable alias-key dictionary."""
+ from nanobot.channels.websocket.runtime import WebSocketConfig
+
+ current: Any = getattr(config.channels, "websocket", None) or {}
+ model = WebSocketConfig.model_validate(current)
+ return model.model_dump(by_alias=True, exclude_none=True)
+
+
+def _webui_channel_enabled(config: Config) -> bool:
+ from nanobot.channels.websocket.runtime import WebSocketConfig
+
+ current: Any = getattr(config.channels, "websocket", None) or {}
+ return bool(WebSocketConfig.model_validate(current).enabled)
+
+
+def _validate_gateway_startup(config: Config) -> str | None:
+ """Validate gateway startup and return a provider error recoverable through WebUI."""
+ from nanobot.config.loader import get_config_path
+
+ config_path = get_config_path()
+ try:
+ webui_config = _webui_config_dict(config)
+ except ValidationError as exc:
+ retry_command = f'nanobot gateway --config "{config_path}"'
+ _print_runtime_config_validation_error(
+ exc,
+ config_path=config_path,
+ summary="Gateway configuration is invalid.",
+ path_prefix=("channels", "websocket"),
+ retry_command=retry_command,
+ )
+ raise typer.Exit(1) from exc
+
+ provider_error = _provider_setup_error(config)
+ if not provider_error:
+ return None
+
+ if bool(webui_config["enabled"]):
+ console.print(
+ Text(f"Provider/model setup is incomplete: {provider_error}", style="yellow")
+ )
+ console.print(
+ "Gateway will start so you can configure a provider and model "
+ "in WebUI Settings → Models."
+ )
+ browser_url = _webui_browser_url(config)
+ webui_url = browser_url.split("/#/", 1)[0]
+ console.print(Text(f"WebUI: {webui_url}", style="cyan"))
+ if browser_url != webui_url:
+ secret_key = (
+ "tokenIssueSecret"
+ if str(webui_config.get("tokenIssueSecret") or "").strip()
+ else "token"
+ )
+ console.print(
+ Text(
+ f"If prompted, enter the configured channels.websocket.{secret_key} "
+ f"value (see {config_path}).",
+ style="dim",
+ )
+ )
+ return provider_error
+
+ console.print(Text(f"Gateway cannot start: {provider_error}", style="red"))
+ console.print("Complete provider/model setup:")
+ _print_model_setup_steps(config_path)
+ raise typer.Exit(1)
+
+
+def _prepare_webui_bundle_for_gateway(
+ config: Config,
+ *,
+ mode: BuildMode,
+ webui_static_dist: bool = True,
+) -> None:
+ """Refresh or warn about stale bundled WebUI assets before gateway startup."""
+ if not webui_static_dist or not _webui_channel_enabled(config):
+ return
+
+ def _print(message: str) -> None:
+ console.print(f"[yellow]{escape(message)}[/yellow]")
+
+ def _confirm(message: str) -> bool:
+ return typer.confirm(message, default=True)
+
+ try:
+ ensure_webui_bundle(
+ mode=mode,
+ confirm=_confirm if mode == "prompt" else None,
+ output=_print,
+ )
+ except WebUIBuildError as exc:
+ if mode == "warn":
+ console.print(f"[yellow]Warning: {escape(str(exc))}[/yellow]")
+ return
+ console.print(f"[red]Error: {escape(str(exc))}[/red]")
+ raise typer.Exit(1) from exc
+
+
+def _host_for_local_browser(host: str) -> str:
+ """Map bind hosts to a browser-openable local host."""
+ if host in {"0.0.0.0", ""}:
+ return "127.0.0.1"
+ if host == "::":
+ return "[::1]"
+ if ":" in host and not host.startswith("["):
+ return f"[{host}]"
+ return host
+
+
+def _gateway_health_url(host: str, port: int) -> str:
+ """Return a health URL that can be opened from this device."""
+ return f"http://{_host_for_local_browser(host)}:{port}/health"
+
+
+def _gateway_health_bind_note(host: str) -> str:
+ """Describe a non-local bind without presenting it as a usable URL."""
+ return "" if is_loopback_host(host) else f" [dim](listening on {host})[/dim]"
+
+
+def _webui_bootstrap_secret(config: Config) -> str:
+ ws_cfg = _webui_config_dict(config)
+ return str(ws_cfg.get("tokenIssueSecret") or ws_cfg.get("token") or "").strip()
+
+
+def _webui_browser_url(config: Config) -> str:
+ from urllib.parse import quote
+
+ ws_cfg = _webui_config_dict(config)
+ host = _host_for_local_browser(str(ws_cfg.get("host") or "127.0.0.1"))
+ port = int(ws_cfg.get("port") or 8765)
+ base_url = f"http://{host}:{port}"
+ secret = _webui_bootstrap_secret(config)
+ if not secret:
+ return base_url
+ return f"{base_url}/#/?bootstrapSecret={quote(secret, safe='')}"
+
+
+def _webui_display_url(url: str) -> str:
+ marker = "bootstrapSecret="
+ if marker not in url:
+ return url
+ prefix, _ = url.split(marker, 1)
+ return f"{prefix}{marker}"
+
+
+def _ensure_local_webui_channel(
+ config: Config,
+ *,
+ port: int | None,
+ yes: bool,
+) -> tuple[bool, bool]:
+ """Enable the local WebUI channel with safe localhost defaults."""
+ from nanobot.channels.websocket.runtime import WebSocketConfig
+
+ current: Any = getattr(config.channels, "websocket", None) or {}
+ model = WebSocketConfig.model_validate(current)
+ changed = False
+ generated_secret = False
+
+ needs_enable = not model.enabled
+ needs_port = port is not None and model.port != port
+ needs_secret = not model.token_issue_secret.strip() and not model.token.strip()
+ if not needs_enable and not needs_port and not needs_secret:
+ return False, False
+
+ target_port = port if port is not None else model.port
+ console.print()
+ console.print("[bold]Local WebUI setup[/bold]")
+ console.print(f" URL: [cyan]http://127.0.0.1:{target_port}[/cyan]")
+ console.print(" Bind: [cyan]127.0.0.1 only[/cyan] (not exposed to your LAN)")
+ console.print(" Auth: generated WebUI bootstrap secret stored in config")
+ console.print(
+ " LAN access requires an explicit host change plus a WebUI password in config."
+ )
+ _confirm_webui_action("Update the local WebUI channel in this config?", yes=yes)
+
+ if not model.enabled:
+ model.enabled = True
+ changed = True
+ if model.host != "127.0.0.1":
+ model.host = "127.0.0.1"
+ changed = True
+ if port is not None and model.port != port:
+ model.port = port
+ changed = True
+ if not model.websocket_requires_token:
+ model.websocket_requires_token = True
+ changed = True
+ if needs_secret:
+ import secrets
+
+ model.token_issue_secret = secrets.token_urlsafe(32)
+ changed = True
+ generated_secret = True
+
+ setattr(config.channels, "websocket", model.model_dump(by_alias=True, exclude_none=True))
+ return changed, generated_secret
+
+
+def _warn_webui_bind_scope(config: Config) -> None:
+ ws_cfg = _webui_config_dict(config)
+ host = str(ws_cfg.get("host") or "127.0.0.1")
+ if host in {"127.0.0.1", "localhost", "::1"}:
+ return
+ console.print(
+ "[yellow]Warning: WebUI is configured to bind outside localhost. "
+ "Keep tokenIssueSecret set and use this only on trusted networks.[/yellow]"
+ )
+
+
+def _wait_for_webui(url: str, *, timeout_s: float = 5.0) -> None:
+ """Best-effort wait for the WebUI listener before opening a browser."""
+ import time
+ from urllib.parse import urlparse
+
+ parsed = urlparse(url)
+ host = parsed.hostname or "127.0.0.1"
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
+ deadline = time.monotonic() + timeout_s
+ while time.monotonic() < deadline:
+ if _tcp_endpoint_reachable(host, port, timeout_s=0.2):
+ return
+ time.sleep(0.1)
+
+
+def _tcp_endpoint_reachable(host: str, port: int, *, timeout_s: float = 0.25) -> bool:
+ """Return whether a local TCP endpoint accepts connections."""
+ import socket
+
+ try:
+ with socket.create_connection((host, port), timeout=timeout_s):
+ return True
+ except OSError:
+ return False
+
+
+def _gateway_health_ready(host: str, port: int, *, timeout_s: float = 0.4) -> bool:
+ """Return whether the nanobot gateway health endpoint responds OK."""
+ import json
+ import urllib.error
+ import urllib.request
+
+ browser_host = _host_for_local_browser(host)
+ try:
+ with urllib.request.urlopen(
+ f"http://{browser_host}:{port}/health",
+ timeout=timeout_s,
+ ) as response:
+ if response.status != 200:
+ return False
+ body = response.read(1024)
+ except (OSError, urllib.error.URLError, TimeoutError, ValueError):
+ return False
+
+ try:
+ payload = json.loads(body.decode("utf-8"))
+ except (UnicodeDecodeError, json.JSONDecodeError):
+ return False
+ return payload.get("status") == "ok"
+
+
+def _webui_endpoint_reachable(url: str, *, timeout_s: float = 0.25) -> bool:
+ """Return whether the WebUI URL's TCP endpoint is already listening."""
+ from urllib.parse import urlparse
+
+ parsed = urlparse(url)
+ host = parsed.hostname or "127.0.0.1"
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
+ return _tcp_endpoint_reachable(host, port, timeout_s=timeout_s)
+
+
+def _print_foreground_port_conflict(
+ *,
+ webui_url: str,
+ gateway_host: str,
+ gateway_port: int,
+) -> None:
+ console.print(
+ "[red]Error: nanobot cannot start because one of its local ports is already in use.[/red]"
+ )
+ console.print(f" WebUI: [cyan]{webui_url}[/cyan]")
+ console.print(
+ f" Gateway health: "
+ f"[cyan]http://{_host_for_local_browser(gateway_host)}:{gateway_port}/health[/cyan]"
+ )
+ console.print()
+ console.print("If this is an existing nanobot instance, use it or stop it first:")
+ console.print(" [cyan]nanobot gateway status[/cyan]")
+ console.print(" [cyan]nanobot gateway stop[/cyan]")
+ console.print(
+ "Or choose different ports with [cyan]--port[/cyan] "
+ "and [cyan]--gateway-port[/cyan]."
+ )
+
+
+def _open_webui_browser(url: str, *, wait: bool = True) -> None:
+ """Open the WebUI in the user's default browser, with a copyable fallback."""
+ import webbrowser
+
+ if wait:
+ _wait_for_webui(url)
+ display_url = _webui_display_url(url)
+ try:
+ webbrowser.open(url)
+ console.print(f"[green]✓[/green] Opened WebUI: [cyan]{display_url}[/cyan]")
+ except Exception as exc:
+ console.print(f"[yellow]Could not open browser ({exc}); visit {display_url}[/yellow]")
+
+
+def _print_webui_foreground_lifecycle(*, attached: bool) -> None:
+ """Explain how the browser and gateway lifecycles differ."""
+ console.print()
+ if attached:
+ console.print("[green]nanobot is attached to the existing gateway.[/green]")
+ else:
+ console.print("[green]nanobot is running in this terminal.[/green]")
+ console.print("[dim]Closing the browser does not stop channels or automations.[/dim]")
+ console.print("[dim]Press Ctrl+C here to stop nanobot.[/dim]")
+
+
+def _attach_to_background_gateway(runtime: "GatewayRuntime") -> None:
+ """Keep a foreground WebUI command attached to a managed gateway."""
+ _print_webui_foreground_lifecycle(attached=True)
+ try:
+ while runtime.status().running:
+ time.sleep(0.5)
+ except KeyboardInterrupt:
+ console.print("\n[yellow]Stopping nanobot...[/yellow]")
+ result = runtime.stop()
+ if result.ok or result.message == "gateway_not_running":
+ console.print("[green]Gateway stopped.[/green]")
+ return
+ console.print(f"[red]Gateway could not be stopped: {result.message}[/red]")
+ raise typer.Exit(1)
+
+ console.print("[yellow]Gateway stopped.[/yellow]")
+
+
+def _gateway_instance_command(
+ subcommand: str,
+ *,
+ config_path: Path,
+ workspace: str | None,
+) -> str:
+ """Return a copyable gateway command for the same config/workspace instance."""
+ import shlex
+
+ parts = ["nanobot", "gateway", subcommand, "--config", str(config_path)]
+ if workspace:
+ workspace_path = str(Path(workspace).expanduser().resolve(strict=False))
+ parts.extend(["--workspace", workspace_path])
+ return " ".join(shlex.quote(part) for part in parts)
+
+
+def _run_quick_start_for_webui(
+ config: Config,
+ *,
+ yes: bool,
+ config_path: Path,
+) -> Config:
+ """Offer the existing Quick Start flow when provider setup is missing."""
+ if yes:
+ console.print(
+ "[red]Error: provider/model setup is incomplete, and --yes cannot answer "
+ "provider credentials.[/red]"
+ )
+ console.print("Complete provider/model setup:")
+ _print_model_setup_steps(config_path)
+ raise typer.Exit(1)
+
+ console.print()
+ console.print("[yellow]Model provider setup is not ready.[/yellow]")
+ console.print(
+ "Quick Start will ask for provider, API key/base URL, model, and WebUI password."
+ )
+ _confirm_webui_action("Run Quick Start now?", yes=False)
+
+ from nanobot.cli.onboard import run_quick_start_onboard
+
+ try:
+ result = run_quick_start_onboard(config)
+ except RuntimeError as exc:
+ console.print(f"[red]Error: {exc}[/red]")
+ console.print(
+ "[yellow]Run `nanobot onboard --wizard` "
+ "after installing wizard dependencies.[/yellow]"
+ )
+ raise typer.Exit(1) from exc
+ if not result.should_save:
+ console.print("[yellow]Quick Start cancelled. No changes were saved.[/yellow]")
+ raise typer.Exit(1)
+ return result.config
diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py
index 6a5ac1a5f..15e1fdfa4 100644
--- a/nanobot/command/builtin.py
+++ b/nanobot/command/builtin.py
@@ -311,7 +311,7 @@ async def cmd_new(ctx: CommandContext) -> OutboundMessage:
loop.sessions.save(session)
loop.sessions.invalidate(session.key)
if snapshot and runtime is not None:
- loop._schedule_background( # pyright: ignore[reportPrivateUsage]
+ loop.schedule_background(
loop.consolidator.archive( # pyright: ignore[reportUnknownMemberType]
snapshot,
runtime=runtime,
diff --git a/tests/agent/test_auto_compact.py b/tests/agent/test_auto_compact.py
index 4a53a711c..47cb4a3a9 100644
--- a/tests/agent/test_auto_compact.py
+++ b/tests/agent/test_auto_compact.py
@@ -356,7 +356,7 @@ class TestAutoCompact:
loop.sessions.save(s2)
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
- loop.auto_compact.check_expired(loop._schedule_background, loop.runtime_for_session)
+ loop.auto_compact.check_expired(loop.schedule_background, loop.runtime_for_session)
await _drain_background_tasks(loop)
active_after = loop.sessions.get_or_create("cli:active")
@@ -836,7 +836,7 @@ class TestProactiveAutoCompact:
async def _run_check_expired(loop, active_session_keys=()):
"""Helper: run check_expired via callback and wait for background tasks."""
loop.auto_compact.check_expired(
- loop._schedule_background,
+ loop.schedule_background,
loop.runtime_for_session,
active_session_keys=active_session_keys,
)
@@ -976,12 +976,12 @@ class TestProactiveAutoCompact:
loop.consolidator.compact_idle_session = _slow_compact
# First call starts archiving via callback
- loop.auto_compact.check_expired(loop._schedule_background, loop.runtime_for_session)
+ loop.auto_compact.check_expired(loop.schedule_background, loop.runtime_for_session)
await started.wait()
assert archive_count == 1
# Second call should skip (key is in _archiving)
- loop.auto_compact.check_expired(loop._schedule_background, loop.runtime_for_session)
+ loop.auto_compact.check_expired(loop.schedule_background, loop.runtime_for_session)
assert archive_count == 1
# Clean up
diff --git a/tests/agent/test_loop_consolidation_tokens.py b/tests/agent/test_loop_consolidation_tokens.py
index d60404fe1..512e8ebcb 100644
--- a/tests/agent/test_loop_consolidation_tokens.py
+++ b/tests/agent/test_loop_consolidation_tokens.py
@@ -215,7 +215,7 @@ async def test_preflight_consolidation_receives_pending_summary(tmp_path) -> Non
return_value=(session, "Previous conversation summary: earlier context")
) # type: ignore[method-assign]
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=None) # type: ignore[method-assign]
- loop._schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
+ loop.schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
runtime = loop.llm_runtime()
await loop.process_direct("hello", session_key="cli:test", runtime=runtime)
@@ -252,7 +252,7 @@ async def test_preflight_consolidation_before_llm_call(tmp_path, monkeypatch) ->
return LLMResponse(content="ok", tool_calls=[])
loop.provider.chat_with_retry = track_llm
loop.provider.chat_stream_with_retry = track_llm
- loop._schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
+ loop.schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
session = loop.sessions.get_or_create("cli:test")
session.messages = [
diff --git a/tests/agent/test_loop_direct_websocket_status.py b/tests/agent/test_loop_direct_websocket_status.py
index 4f0a908c0..9bbb0d91e 100644
--- a/tests/agent/test_loop_direct_websocket_status.py
+++ b/tests/agent/test_loop_direct_websocket_status.py
@@ -33,7 +33,7 @@ def _make_loop(tmp_path):
WebuiTurnCoordinator(
bus=bus,
sessions=loop.sessions,
- schedule_background=lambda coro: loop._schedule_background(coro),
+ schedule_background=lambda coro: loop.schedule_background(coro),
).subscribe(loop.runtime_events)
loop.turn_delivery_factory.route_policy = WebuiTurnRoutePolicy(loop.sessions)
loop.tools.get_definitions = MagicMock(return_value=[])
diff --git a/tests/agent/test_loop_progress.py b/tests/agent/test_loop_progress.py
index e67fbfbef..cb80be1cb 100644
--- a/tests/agent/test_loop_progress.py
+++ b/tests/agent/test_loop_progress.py
@@ -52,7 +52,7 @@ def _attach_webui_runtime_events(loop: AgentLoop, bus: MessageBus) -> None:
coordinator = WebuiTurnCoordinator(
bus=bus,
sessions=loop.sessions,
- schedule_background=lambda coro: loop._schedule_background(coro),
+ schedule_background=lambda coro: loop.schedule_background(coro),
)
coordinator.subscribe(loop.runtime_events)
@@ -1203,7 +1203,7 @@ class TestToolEventProgress:
elif hasattr(coro, "close"):
coro.close()
- loop._schedule_background = schedule_background # type: ignore[method-assign]
+ loop.schedule_background = schedule_background # type: ignore[method-assign]
await loop._dispatch(InboundMessage(
channel="websocket",
@@ -1249,7 +1249,7 @@ class TestToolEventProgress:
fake_title_after_turn,
)
scheduled: list[object] = []
- loop._schedule_background = scheduled.append # type: ignore[method-assign]
+ loop.schedule_background = scheduled.append # type: ignore[method-assign]
await loop._dispatch(InboundMessage(
channel="websocket",
diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py
index 1562db325..b613b057e 100644
--- a/tests/agent/test_loop_save_turn.py
+++ b/tests/agent/test_loop_save_turn.py
@@ -78,7 +78,7 @@ def _make_full_loop(tmp_path: Path) -> AgentLoop:
WebuiTurnCoordinator(
bus=loop.bus,
sessions=loop.sessions,
- schedule_background=lambda coro: loop._schedule_background(coro),
+ schedule_background=lambda coro: loop.schedule_background(coro),
).subscribe(loop.runtime_events)
return loop
diff --git a/tests/agent/test_session_model_runtime.py b/tests/agent/test_session_model_runtime.py
index c59457f9d..276067ea2 100644
--- a/tests/agent/test_session_model_runtime.py
+++ b/tests/agent/test_session_model_runtime.py
@@ -65,7 +65,7 @@ async def test_sessions_run_concurrently_with_isolated_model_presets(tmp_path) -
model_presets=presets,
preset_snapshot_loader=load_preset,
)
- loop._schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
+ loop.schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
loop.set_session_model_preset("sdk:fast", "fast")
loop.set_session_model_preset("sdk:deep", "deep")
@@ -116,7 +116,7 @@ async def test_removed_session_model_preset_falls_back_and_clears_metadata(tmp_p
model="base-model",
context_window_tokens=8_000,
)
- loop._schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
+ loop.schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
session_key = "sdk:removed-preset"
session = loop.sessions.get_or_create(session_key)
session.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = "removed"
@@ -161,7 +161,7 @@ async def test_streamed_sdk_resolves_session_runtime_after_lock_admission(tmp_pa
model_presets=presets,
preset_snapshot_loader=load_preset,
)
- loop._schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
+ loop.schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
session_key = "sdk:queued"
loop.set_session_model_preset(session_key, "fast")
@@ -198,7 +198,7 @@ async def test_sdk_custom_model_preset_metadata_does_not_select_runtime(
model="base-model",
context_window_tokens=8_000,
)
- loop._schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
+ loop.schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
bot = Nanobot(loop)
await bot.sessions.ingest(
@@ -239,7 +239,7 @@ async def test_sdk_invalid_internal_model_preset_metadata_fails_explicitly(
model="base-model",
context_window_tokens=8_000,
)
- loop._schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
+ loop.schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
bot = Nanobot(loop)
await bot.sessions.ingest(
diff --git a/tests/agent/test_unified_session.py b/tests/agent/test_unified_session.py
index 456c5e79b..7c0656ec2 100644
--- a/tests/agent/test_unified_session.py
+++ b/tests/agent/test_unified_session.py
@@ -246,7 +246,7 @@ class TestCmdNewUnifiedSession:
assert len(sessions.get_or_create("unified:default").messages) == 2
expected_snapshot = list(shared.messages)
- # _schedule_background is a *sync* method that schedules a coroutine via
+ # schedule_background is a *sync* method that schedules a coroutine via
# asyncio.create_task(). Mirror that exactly so the coroutine is consumed
# and no RuntimeWarning is emitted.
admitted_runtime = MagicMock(name="admitted_runtime")
@@ -255,8 +255,8 @@ class TestCmdNewUnifiedSession:
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
_cancel_active_tasks=AsyncMock(return_value=0),
llm_runtime=MagicMock(return_value=MagicMock()),
+ schedule_background=lambda coro: asyncio.ensure_future(coro),
)
- loop._schedule_background = lambda coro: asyncio.ensure_future(coro)
msg = InboundMessage(
channel="telegram", sender_id="user1", chat_id="111", content="/new",
@@ -303,8 +303,8 @@ class TestCmdNewUnifiedSession:
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
_cancel_active_tasks=AsyncMock(return_value=0),
runtime_for_session=MagicMock(return_value=MagicMock()),
+ schedule_background=lambda coro: asyncio.ensure_future(coro),
)
- loop._schedule_background = lambda coro: asyncio.ensure_future(coro)
msg = InboundMessage(
channel="telegram", sender_id="user1", chat_id="111", content="/new",
diff --git a/tests/cli/test_cli_input.py b/tests/cli/test_cli_input.py
index 5322b3aac..f296be31c 100644
--- a/tests/cli/test_cli_input.py
+++ b/tests/cli/test_cli_input.py
@@ -5,8 +5,8 @@ from unittest.mock import AsyncMock, MagicMock, call, patch
import pytest
from prompt_toolkit.formatted_text import HTML
-from nanobot.cli import commands
from nanobot.cli import stream as stream_mod
+from nanobot.cli import terminal
@pytest.fixture
@@ -14,8 +14,8 @@ def mock_prompt_session():
"""Mock the global prompt session."""
mock_session = MagicMock()
mock_session.prompt_async = AsyncMock()
- with patch("nanobot.cli.commands._PROMPT_SESSION", mock_session), \
- patch("nanobot.cli.commands.patch_stdout"):
+ with patch("nanobot.cli.terminal._prompt_session", mock_session), \
+ patch("nanobot.cli.terminal.patch_stdout"):
yield mock_session
@@ -24,7 +24,7 @@ async def test_read_interactive_input_async_returns_input(mock_prompt_session):
"""Test that _read_interactive_input_async returns the user input from prompt_session."""
mock_prompt_session.prompt_async.return_value = "hello world"
- result = await commands._read_interactive_input_async()
+ result = await terminal._read_interactive_input_async()
assert result == "hello world"
mock_prompt_session.prompt_async.assert_called_once()
@@ -38,23 +38,23 @@ async def test_read_interactive_input_async_handles_eof(mock_prompt_session):
mock_prompt_session.prompt_async.side_effect = EOFError()
with pytest.raises(KeyboardInterrupt):
- await commands._read_interactive_input_async()
+ await terminal._read_interactive_input_async()
def test_init_prompt_session_creates_session():
"""Test that _init_prompt_session initializes the global session."""
# Ensure global is None before test
- commands._PROMPT_SESSION = None
+ terminal._prompt_session = None
- with patch("nanobot.cli.commands.PromptSession") as mock_session_cls, \
- patch("nanobot.cli.commands.FileHistory"), \
+ with patch("nanobot.cli.terminal.PromptSession") as mock_session_cls, \
+ patch("nanobot.cli.terminal.FileHistory"), \
patch("pathlib.Path.home") as mock_home:
mock_home.return_value = MagicMock()
- commands._init_prompt_session()
+ terminal._init_prompt_session()
- assert commands._PROMPT_SESSION is not None
+ assert terminal._prompt_session is not None
mock_session_cls.assert_called_once()
_, kwargs = mock_session_cls.call_args
# Buffer is multiline-capable so Alt+Enter can insert newlines;
@@ -68,7 +68,7 @@ def test_cli_key_bindings_enter_submits_and_alt_enter_newlines():
"""Enter submits the buffer; Alt+Enter inserts a newline."""
from prompt_toolkit.keys import Keys
- kb = commands._build_cli_key_bindings()
+ kb = terminal._build_cli_key_bindings()
def _keys(binding):
return tuple(getattr(k, "value", k) for k in binding.keys)
@@ -102,8 +102,8 @@ async def test_raw_lf_enter_still_submits_like_wsl_terminals():
with create_pipe_input() as pipe_input:
with create_app_session(input=pipe_input, output=DummyOutput()):
- commands._init_prompt_session()
- session = commands._PROMPT_SESSION
+ terminal._init_prompt_session()
+ session = terminal._prompt_session
pipe_input.send_text("hello\x0aworld\r")
result = await session.prompt_async("> ")
@@ -119,8 +119,8 @@ async def test_alt_enter_inserts_newline_on_lf_terminals():
with create_pipe_input() as pipe_input:
with create_app_session(input=pipe_input, output=DummyOutput()):
- commands._init_prompt_session()
- session = commands._PROMPT_SESSION
+ terminal._init_prompt_session()
+ session = terminal._prompt_session
pipe_input.send_text("foo\x1b\x0abar\r")
result = await session.prompt_async("> ")
@@ -136,8 +136,8 @@ async def test_csi_u_shift_enter_inserts_newline_not_raw_escape():
with create_pipe_input() as pipe_input:
with create_app_session(input=pipe_input, output=DummyOutput()):
- commands._init_prompt_session()
- session = commands._PROMPT_SESSION
+ terminal._init_prompt_session()
+ session = terminal._prompt_session
pipe_input.send_text("foo\x1b[13;2ubar\r")
result = await session.prompt_async("> ")
@@ -173,10 +173,10 @@ def test_print_cli_progress_line_pauses_spinner_before_printing():
mock_console = MagicMock()
mock_console.status.return_value = spinner
- with patch.object(commands.console, "print", side_effect=lambda *_args, **_kwargs: order.append("print")):
+ with patch.object(terminal.console, "print", side_effect=lambda *_args, **_kwargs: order.append("print")):
thinking = stream_mod.ThinkingSpinner(console=mock_console)
with thinking:
- commands._print_cli_progress_line("tool running", thinking)
+ terminal._print_cli_progress_line("tool running", thinking)
assert order == ["start", "stop", "print", "start", "stop"]
@@ -224,7 +224,7 @@ def test_print_cli_progress_line_opens_renderer_header_before_trace():
renderer.ensure_header.side_effect = lambda: order.append("header")
renderer.pause_spinner.return_value = nullcontext()
- commands._print_cli_progress_line("tool running", None, renderer)
+ terminal._print_cli_progress_line("tool running", None, renderer)
assert order == ["header", "print"]
@@ -235,7 +235,7 @@ def test_print_cli_progress_line_stops_live_before_trace():
renderer = stream_mod.StreamRenderer(show_spinner=False)
renderer._live = mock_live
- commands._print_cli_progress_line("tool running", None, renderer)
+ terminal._print_cli_progress_line("tool running", None, renderer)
mock_live.stop.assert_called_once()
assert renderer._live is None
@@ -254,10 +254,10 @@ async def test_print_interactive_progress_line_pauses_spinner_before_printing():
async def fake_print(_text: str) -> None:
order.append("print")
- with patch("nanobot.cli.commands._print_interactive_line", side_effect=fake_print):
+ with patch("nanobot.cli.terminal._print_interactive_line", side_effect=fake_print):
thinking = stream_mod.ThinkingSpinner(console=mock_console)
with thinking:
- await commands._print_interactive_progress_line("tool running", thinking)
+ await terminal._print_interactive_progress_line("tool running", thinking)
assert order == ["start", "stop", "print", "start", "stop"]
@@ -269,7 +269,7 @@ def test_response_renderable_uses_text_for_explicit_plain_rendering():
"📊 Tokens: 20639 in / 29 out"
)
- renderable = commands._response_renderable(
+ renderable = terminal._response_renderable(
status,
render_markdown=True,
metadata={"render_as": "text"},
@@ -279,7 +279,7 @@ def test_response_renderable_uses_text_for_explicit_plain_rendering():
def test_response_renderable_preserves_normal_markdown_rendering():
- renderable = commands._response_renderable("**bold**", render_markdown=True)
+ renderable = terminal._response_renderable("**bold**", render_markdown=True)
assert renderable.__class__.__name__ == "Markdown"
@@ -287,7 +287,7 @@ def test_response_renderable_preserves_normal_markdown_rendering():
def test_response_renderable_without_metadata_keeps_markdown_path():
help_text = "🐈 nanobot commands:\n/status — Show bot status\n/help — Show available commands"
- renderable = commands._response_renderable(help_text, render_markdown=True)
+ renderable = terminal._response_renderable(help_text, render_markdown=True)
assert renderable.__class__.__name__ == "Markdown"
@@ -389,9 +389,9 @@ def test_render_interactive_ansi_force_terminal_follows_isatty():
captured["console"] = c
with patch.object(sys.stdout, "isatty", return_value=True):
- commands._render_interactive_ansi(render_fn)
+ terminal._render_interactive_ansi(render_fn)
assert captured["console"]._force_terminal is True
with patch.object(sys.stdout, "isatty", return_value=False):
- commands._render_interactive_ansi(render_fn)
+ terminal._render_interactive_ansi(render_fn)
assert captured["console"]._force_terminal is False
diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py
index e29a2c2bb..5cee63135 100644
--- a/tests/cli/test_commands.py
+++ b/tests/cli/test_commands.py
@@ -17,6 +17,11 @@ from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.turn_delivery import TurnDeliveryFactory
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.cli import commands as cli_commands
+from nanobot.cli import gateway_runtime as cli_gateway_runtime
+from nanobot.cli import provider as provider_commands
+from nanobot.cli import terminal as cli_terminal
+from nanobot.cli import webui as cli_webui
+from nanobot.cli import webui_support as cli_webui_support
from nanobot.cli.commands import app
from nanobot.config.schema import Config
from nanobot.cron.service import CronJobSkippedError
@@ -113,7 +118,7 @@ def test_gateway_signal_handler_first_signal_stops_and_second_forces() -> None:
task = asyncio.create_task(never.wait())
output: list[str] = []
- restore = cli_commands._install_gateway_shutdown_handlers(
+ restore = cli_gateway_runtime._install_gateway_shutdown_handlers(
loop, shutdown_event, [task], output.append,
)
try:
@@ -161,8 +166,8 @@ def test_interactive_tty_mode_restores_line_input(monkeypatch) -> None:
attrs[3] &= ~(termios.ISIG | termios.ICANON | termios.ECHO)
termios.tcsetattr(slave_fd, termios.TCSANOW, attrs)
- monkeypatch.setattr(cli_commands.sys, "stdin", _Stdin())
- cli_commands._ensure_interactive_tty_mode()
+ monkeypatch.setattr(cli_terminal.sys, "stdin", _Stdin())
+ cli_terminal._ensure_interactive_tty_mode()
restored = termios.tcgetattr(slave_fd)
assert restored[0] & termios.ICRNL
@@ -179,24 +184,24 @@ def test_webui_restores_tty_before_loading_config(monkeypatch, tmp_path: Path) -
config_file = tmp_path / "config.json"
config_file.write_text("{}", encoding="utf-8")
calls: list[str] = []
- original_resolve = cli_commands._resolve_webui_config_path
+ original_resolve = cli_webui._resolve_webui_config_path
monkeypatch.setattr(
- cli_commands,
+ cli_terminal,
"_ensure_interactive_tty_mode",
lambda: calls.append("tty"),
)
monkeypatch.setattr(
- cli_commands,
+ cli_webui,
"_resolve_webui_config_path",
lambda path: calls.append("config") or original_resolve(path),
)
_patch_webui_provider_ready(monkeypatch)
- monkeypatch.setattr(cli_commands, "sync_workspace_templates", lambda _path: None)
- monkeypatch.setattr(cli_commands, "_gateway_health_ready", lambda *_args, **_kwargs: False)
- monkeypatch.setattr(cli_commands, "_webui_endpoint_reachable", lambda *_args, **_kwargs: False)
- monkeypatch.setattr(cli_commands, "_tcp_endpoint_reachable", lambda *_args, **_kwargs: False)
- monkeypatch.setattr(cli_commands, "_run_gateway", lambda *_args, **_kwargs: None)
+ monkeypatch.setattr(cli_webui, "sync_workspace_templates", lambda _path: None)
+ monkeypatch.setattr(cli_webui, "_gateway_health_ready", lambda *_args, **_kwargs: False)
+ monkeypatch.setattr(cli_webui, "_webui_endpoint_reachable", lambda *_args, **_kwargs: False)
+ monkeypatch.setattr(cli_webui, "_tcp_endpoint_reachable", lambda *_args, **_kwargs: False)
+ monkeypatch.setattr(cli_webui, "_run_gateway", lambda *_args, **_kwargs: None)
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes", "--no-open"])
@@ -209,11 +214,11 @@ def test_disabled_dream_cursor_only_advances_when_behind(tmp_path) -> None:
store.append_history("first")
store.append_history("second")
- cli_commands._advance_dream_cursor_if_behind(store)
+ cli_gateway_runtime._advance_dream_cursor_if_behind(store)
assert store.get_last_dream_cursor() == 2
store.set_last_dream_cursor(10)
- cli_commands._advance_dream_cursor_if_behind(store)
+ cli_gateway_runtime._advance_dream_cursor_if_behind(store)
assert store.get_last_dream_cursor() == 10
@@ -225,7 +230,7 @@ def test_commit_dream_changes_skips_noop_run(tmp_path) -> None:
store.git.auto_commit("initial")
store.git.auto_commit = MagicMock(wraps=store.git.auto_commit)
- assert cli_commands._commit_dream_changes(store) is None
+ assert cli_gateway_runtime._commit_dream_changes(store) is None
store.git.auto_commit.assert_not_called()
@@ -238,7 +243,7 @@ def test_commit_dream_changes_commits_real_edits(tmp_path) -> None:
store.write_memory("# Memory\n- Research notes")
store.git.auto_commit = MagicMock(wraps=store.git.auto_commit)
- sha = cli_commands._commit_dream_changes(store)
+ sha = cli_gateway_runtime._commit_dream_changes(store)
assert sha is not None
store.git.auto_commit.assert_called_once()
@@ -493,7 +498,7 @@ def test_openai_codex_oauth_default_matches_curated_flagship():
assert spec is not None
assert spec.builtin_models
- assert cli_commands._OAUTH_PROVIDER_DEFAULT_MODELS["openai_codex"] == (
+ assert provider_commands._OAUTH_PROVIDER_DEFAULT_MODELS["openai_codex"] == (
spec.builtin_models[0].id
)
@@ -671,16 +676,28 @@ def test_provider_login_rejects_unknown_provider():
assert "Unknown OAuth provider" in result.stdout
+def test_provider_login_openai_codex_handles_missing_oauth_symbol(monkeypatch):
+ import oauth_cli_kit
+
+ monkeypatch.delattr(oauth_cli_kit, "get_token")
+
+ result = runner.invoke(app, ["provider", "login", "openai-codex"])
+
+ assert result.exit_code == 1
+ assert "oauth_cli_kit not installed" in result.stdout
+ assert result.exception is not None
+
+
def test_provider_login_can_set_openai_codex_as_main_provider(tmp_path):
config_path = tmp_path / "config.json"
called = False
- original = cli_commands._LOGIN_HANDLERS["openai_codex"]
+ original = provider_commands._LOGIN_HANDLERS["openai_codex"]
def fake_login() -> None:
nonlocal called
called = True
- cli_commands._LOGIN_HANDLERS["openai_codex"] = fake_login
+ provider_commands._LOGIN_HANDLERS["openai_codex"] = fake_login
try:
result = runner.invoke(
app,
@@ -694,7 +711,7 @@ def test_provider_login_can_set_openai_codex_as_main_provider(tmp_path):
],
)
finally:
- cli_commands._LOGIN_HANDLERS["openai_codex"] = original
+ provider_commands._LOGIN_HANDLERS["openai_codex"] = original
assert result.exit_code == 0
assert called is True
@@ -709,8 +726,8 @@ def test_provider_login_can_set_openai_codex_as_main_provider(tmp_path):
def test_provider_login_can_set_github_copilot_as_main_provider(tmp_path):
config_path = tmp_path / "config.json"
- original = cli_commands._LOGIN_HANDLERS["github_copilot"]
- cli_commands._LOGIN_HANDLERS["github_copilot"] = lambda: None
+ original = provider_commands._LOGIN_HANDLERS["github_copilot"]
+ provider_commands._LOGIN_HANDLERS["github_copilot"] = lambda: None
try:
result = runner.invoke(
app,
@@ -724,7 +741,7 @@ def test_provider_login_can_set_github_copilot_as_main_provider(tmp_path):
],
)
finally:
- cli_commands._LOGIN_HANDLERS["github_copilot"] = original
+ provider_commands._LOGIN_HANDLERS["github_copilot"] = original
assert result.exit_code == 0
assert "Set github-copilot as the main provider" in result.stdout
@@ -738,8 +755,8 @@ def test_provider_login_can_set_github_copilot_as_main_provider(tmp_path):
def test_provider_login_can_set_xai_grok_as_main_provider(tmp_path):
config_path = tmp_path / "config.json"
- original = cli_commands._LOGIN_HANDLERS["xai_grok"]
- cli_commands._LOGIN_HANDLERS["xai_grok"] = lambda: None
+ original = provider_commands._LOGIN_HANDLERS["xai_grok"]
+ provider_commands._LOGIN_HANDLERS["xai_grok"] = lambda: None
try:
result = runner.invoke(
app,
@@ -753,7 +770,7 @@ def test_provider_login_can_set_xai_grok_as_main_provider(tmp_path):
],
)
finally:
- cli_commands._LOGIN_HANDLERS["xai_grok"] = original
+ provider_commands._LOGIN_HANDLERS["xai_grok"] = original
assert result.exit_code == 0
assert "Set xai-grok as the main provider" in result.stdout
@@ -768,8 +785,8 @@ def test_provider_login_can_set_xai_grok_as_main_provider(tmp_path):
def test_provider_login_model_implies_set_main_provider(tmp_path):
config_path = tmp_path / "config.json"
- original = cli_commands._LOGIN_HANDLERS["github_copilot"]
- cli_commands._LOGIN_HANDLERS["github_copilot"] = lambda: None
+ original = provider_commands._LOGIN_HANDLERS["github_copilot"]
+ provider_commands._LOGIN_HANDLERS["github_copilot"] = lambda: None
try:
result = runner.invoke(
app,
@@ -784,7 +801,7 @@ def test_provider_login_model_implies_set_main_provider(tmp_path):
],
)
finally:
- cli_commands._LOGIN_HANDLERS["github_copilot"] = original
+ provider_commands._LOGIN_HANDLERS["github_copilot"] = original
assert result.exit_code == 0
assert "Set github-copilot as the main provider" in result.stdout
@@ -1470,12 +1487,12 @@ def mock_agent_runtime(tmp_path):
with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \
patch("nanobot.config.loader.resolve_config_env_vars", side_effect=lambda c: c), \
- patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \
+ patch("nanobot.cli.agent.sync_workspace_templates") as mock_sync_templates, \
patch("nanobot.providers.factory.make_provider", return_value=_fake_provider()), \
- patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \
+ patch("nanobot.cli.terminal._print_agent_response") as mock_print_response, \
patch("nanobot.bus.queue.MessageBus"), \
patch("nanobot.cron.service.CronService"), \
- patch("nanobot.cli.commands.AgentLoop.from_config") as mock_from_config:
+ patch("nanobot.cli.agent.AgentLoop.from_config") as mock_from_config:
agent_loop = MagicMock()
agent_loop.channels_config = None
agent_loop.process_direct = AsyncMock(
@@ -1544,7 +1561,7 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
lambda path: seen.__setitem__("config_path", path),
)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
- monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
+ monkeypatch.setattr("nanobot.cli.agent.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
monkeypatch.setattr("nanobot.cron.service.CronService", lambda _store: object())
@@ -1562,8 +1579,8 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
async def close_mcp(self) -> None:
return None
- monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
- monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
+ monkeypatch.setattr("nanobot.cli.agent.AgentLoop", _FakeAgentLoop)
+ monkeypatch.setattr("nanobot.cli.terminal._print_agent_response", lambda *_args, **_kwargs: None)
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
@@ -1582,7 +1599,7 @@ def test_agent_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Pa
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
- monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
+ monkeypatch.setattr("nanobot.cli.agent.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
@@ -1604,8 +1621,8 @@ def test_agent_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Pa
return None
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
- monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
- monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
+ monkeypatch.setattr("nanobot.cli.agent.AgentLoop", _FakeAgentLoop)
+ monkeypatch.setattr("nanobot.cli.terminal._print_agent_response", lambda *_args, **_kwargs: None)
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
@@ -1631,7 +1648,7 @@ def test_agent_workspace_override_does_not_migrate_legacy_cron(
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
- monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
+ monkeypatch.setattr("nanobot.cli.agent.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
@@ -1654,8 +1671,8 @@ def test_agent_workspace_override_does_not_migrate_legacy_cron(
return None
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
- monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
- monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
+ monkeypatch.setattr("nanobot.cli.agent.AgentLoop", _FakeAgentLoop)
+ monkeypatch.setattr("nanobot.cli.terminal._print_agent_response", lambda *_args, **_kwargs: None)
result = runner.invoke(
app,
@@ -1687,7 +1704,7 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron(
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
- monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
+ monkeypatch.setattr("nanobot.cli.agent.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
@@ -1710,9 +1727,9 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron(
return None
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
- monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
+ monkeypatch.setattr("nanobot.cli.agent.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr(
- "nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None
+ "nanobot.cli.terminal._print_agent_response", lambda *_args, **_kwargs: None
)
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
@@ -1774,20 +1791,20 @@ def test_heartbeat_retains_recent_messages_by_default():
],
)
def test_heartbeat_has_active_tasks(content, expected):
- from nanobot.cli.commands import _heartbeat_has_active_tasks
+ from nanobot.cli.gateway_runtime import _heartbeat_has_active_tasks
assert _heartbeat_has_active_tasks(content) is expected
def test_heartbeat_skips_bundled_template():
- from nanobot.cli.commands import _heartbeat_has_active_tasks
+ from nanobot.cli.gateway_runtime import _heartbeat_has_active_tasks
from nanobot.utils.helpers import load_bundled_template
assert _heartbeat_has_active_tasks(load_bundled_template("HEARTBEAT.md")) is False
def test_heartbeat_target_skips_archived_webui_sessions():
- from nanobot.cli.commands import _pick_heartbeat_target_from_sessions
+ from nanobot.cli.gateway_runtime import _pick_heartbeat_target_from_sessions
target = _pick_heartbeat_target_from_sessions(
enabled_channels=["websocket"],
@@ -1802,7 +1819,7 @@ def test_heartbeat_target_skips_archived_webui_sessions():
def test_heartbeat_target_uses_last_channel_for_unified_session():
- from nanobot.cli.commands import _pick_heartbeat_target_from_sessions
+ from nanobot.cli.gateway_runtime import _pick_heartbeat_target_from_sessions
from nanobot.session.keys import LAST_CHANNEL_METADATA_KEY, UNIFIED_SESSION_KEY
target = _pick_heartbeat_target_from_sessions(
@@ -1824,7 +1841,7 @@ def test_heartbeat_target_uses_last_channel_for_unified_session():
],
)
def test_heartbeat_target_rejects_unroutable_unified_metadata(metadata):
- from nanobot.cli.commands import _pick_heartbeat_target_from_sessions
+ from nanobot.cli.gateway_runtime import _pick_heartbeat_target_from_sessions
from nanobot.session.keys import UNIFIED_SESSION_KEY
target = _pick_heartbeat_target_from_sessions(
@@ -1865,9 +1882,17 @@ def _patch_webui_provider_ready(monkeypatch) -> None:
def _patch_gateway_ports_free(monkeypatch) -> None:
- monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_a, **_kw: False)
- monkeypatch.setattr("nanobot.cli.commands._tcp_endpoint_reachable", lambda *_a, **_kw: False)
- monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_a, **_kw: False)
+ monkeypatch.setattr("nanobot.cli.webui._gateway_health_ready", lambda *_a, **_kw: False)
+ monkeypatch.setattr("nanobot.cli.webui._tcp_endpoint_reachable", lambda *_a, **_kw: False)
+ monkeypatch.setattr("nanobot.cli.webui._webui_endpoint_reachable", lambda *_a, **_kw: False)
+ monkeypatch.setattr(
+ "nanobot.cli.gateway_runtime._tcp_endpoint_reachable",
+ lambda *_a, **_kw: False,
+ )
+ monkeypatch.setattr(
+ "nanobot.cli.gateway_runtime._webui_endpoint_reachable",
+ lambda *_a, **_kw: False,
+ )
def _patch_cli_command_runtime(
@@ -1894,6 +1919,14 @@ def _patch_cli_command_runtime(
"nanobot.cli.commands.sync_workspace_templates",
sync_templates or (lambda _path: None),
)
+ monkeypatch.setattr(
+ "nanobot.cli.webui.sync_workspace_templates",
+ sync_templates or (lambda _path: None),
+ )
+ monkeypatch.setattr(
+ "nanobot.cli.gateway_runtime.sync_workspace_templates",
+ sync_templates or (lambda _path: None),
+ )
monkeypatch.setattr(
"nanobot.providers.factory.make_provider",
provider_factory,
@@ -1907,7 +1940,7 @@ def _patch_cli_command_runtime(
lambda _config_path=None: _test_provider_snapshot(provider_factory(config), config),
)
monkeypatch.setattr(
- "nanobot.cli.commands._provider_setup_error",
+ "nanobot.cli.webui_support._provider_setup_error",
lambda _config: None,
)
_patch_gateway_ports_free(monkeypatch)
@@ -2008,10 +2041,10 @@ def test_heartbeat_empty_response_still_retains_recent_messages(
session_manager=_FakeSessionManager,
cron_service=_FakeCron,
)
- monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
+ monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
- monkeypatch.setattr("nanobot.cli.commands.read_webui_sidebar_state", lambda: {})
- monkeypatch.setattr("nanobot.cli.commands.evaluate_response", _unexpected_evaluator)
+ monkeypatch.setattr("nanobot.cli.gateway_runtime.read_webui_sidebar_state", lambda: {})
+ monkeypatch.setattr("nanobot.cli.gateway_runtime.evaluate_response", _unexpected_evaluator)
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
@@ -2034,7 +2067,7 @@ def test_webui_yes_creates_config_and_enables_local_websocket(
seen: dict[str, object] = {}
_patch_webui_provider_ready(monkeypatch)
monkeypatch.setattr(
- "nanobot.cli.commands.sync_workspace_templates",
+ "nanobot.cli.webui.sync_workspace_templates",
lambda path: seen.__setitem__("templates", path),
)
@@ -2042,7 +2075,7 @@ def test_webui_yes_creates_config_and_enables_local_websocket(
seen["gateway_config"] = config
seen["gateway_kwargs"] = kwargs
- monkeypatch.setattr("nanobot.cli.commands._run_gateway", _fake_run_gateway)
+ monkeypatch.setattr("nanobot.cli.webui._run_gateway", _fake_run_gateway)
result = runner.invoke(
app,
@@ -2091,13 +2124,17 @@ def test_webui_yes_starts_first_run_without_provider_setup(monkeypatch, tmp_path
seen: dict[str, object] = {}
monkeypatch.setattr(
- "nanobot.cli.commands._provider_setup_error",
+ "nanobot.cli.webui_support._provider_setup_error",
+ lambda _config: "No API key configured for provider 'custom'.",
+ )
+ monkeypatch.setattr(
+ "nanobot.cli.webui._provider_setup_error",
lambda _config: "No API key configured for provider 'custom'.",
)
_patch_gateway_ports_free(monkeypatch)
- monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
+ monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr(
- "nanobot.cli.commands._run_gateway",
+ "nanobot.cli.webui._run_gateway",
lambda config, **kwargs: seen.update(config=config, **kwargs),
)
@@ -2135,7 +2172,7 @@ def test_webui_missing_runtime_env_fails_before_starting_gateway(
encoding="utf-8",
)
monkeypatch.setattr(
- "nanobot.cli.commands._run_gateway",
+ "nanobot.cli.webui._run_gateway",
lambda *_args, **_kwargs: pytest.fail("gateway must not start with unresolved config"),
)
@@ -2189,9 +2226,9 @@ def test_webui_background_starts_runtime_and_opens_browser(monkeypatch, tmp_path
config_file.write_text("{}")
seen: dict[str, object] = {}
_patch_webui_provider_ready(monkeypatch)
- monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
+ monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr(
- "nanobot.cli.commands._prepare_webui_bundle_for_gateway",
+ "nanobot.cli.webui._prepare_webui_bundle_for_gateway",
lambda *_args, **_kwargs: None,
)
@@ -2213,7 +2250,7 @@ def test_webui_background_starts_runtime_and_opens_browser(monkeypatch, tmp_path
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
monkeypatch.setattr(
- "nanobot.cli.commands._open_webui_browser",
+ "nanobot.cli.webui._open_webui_browser",
lambda url: seen.__setitem__("opened_url", url),
)
@@ -2256,7 +2293,7 @@ def test_open_webui_browser_redacts_bootstrap_secret(monkeypatch, capsys) -> Non
url = "http://127.0.0.1:8765/#/?bootstrapSecret=super-secret"
monkeypatch.setattr("webbrowser.open", lambda value: opened.append(value))
- cli_commands._open_webui_browser(url, wait=False)
+ cli_webui_support._open_webui_browser(url, wait=False)
assert opened == [url]
output = _strip_ansi(capsys.readouterr().out)
@@ -2275,9 +2312,9 @@ def test_webui_background_restarts_when_config_changes_and_gateway_is_running(
config_file.write_text("{}")
seen: dict[str, object] = {}
_patch_webui_provider_ready(monkeypatch)
- monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
+ monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr(
- "nanobot.cli.commands._prepare_webui_bundle_for_gateway",
+ "nanobot.cli.webui._prepare_webui_bundle_for_gateway",
lambda *_args, **_kwargs: None,
)
@@ -2306,7 +2343,7 @@ def test_webui_background_restarts_when_config_changes_and_gateway_is_running(
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
monkeypatch.setattr(
- "nanobot.cli.commands._open_webui_browser",
+ "nanobot.cli.webui._open_webui_browser",
lambda url: seen.__setitem__("opened_url", url),
)
@@ -2347,15 +2384,15 @@ def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_
config_file.write_text("{}")
seen: dict[str, object] = {}
_patch_webui_provider_ready(monkeypatch)
- monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
- monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_args, **_kwargs: True)
- monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_args, **_kwargs: True)
+ monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
+ monkeypatch.setattr("nanobot.cli.webui._gateway_health_ready", lambda *_args, **_kwargs: True)
+ monkeypatch.setattr("nanobot.cli.webui._webui_endpoint_reachable", lambda *_args, **_kwargs: True)
monkeypatch.setattr(
- "nanobot.cli.commands._open_webui_browser",
+ "nanobot.cli.webui._open_webui_browser",
lambda url, **kwargs: seen.update({"opened_url": url, "open_kwargs": kwargs}),
)
monkeypatch.setattr(
- "nanobot.cli.commands._run_gateway",
+ "nanobot.cli.webui._run_gateway",
lambda *_args, **_kwargs: pytest.fail("existing gateway should be reused"),
)
@@ -2368,7 +2405,7 @@ def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
monkeypatch.setattr(
- "nanobot.cli.commands._attach_to_background_gateway",
+ "nanobot.cli.webui._attach_to_background_gateway",
lambda runtime: seen.__setitem__("attached_runtime", runtime),
)
@@ -2401,9 +2438,9 @@ def test_attach_to_background_gateway_stops_on_ctrl_c(monkeypatch, capsys) -> No
def _interrupt(_seconds: float) -> None:
raise KeyboardInterrupt
- monkeypatch.setattr("nanobot.cli.commands.time.sleep", _interrupt)
+ monkeypatch.setattr("nanobot.cli.webui_support.time.sleep", _interrupt)
- cli_commands._attach_to_background_gateway(_FakeRuntime())
+ cli_webui_support._attach_to_background_gateway(_FakeRuntime())
assert stopped is True
output = capsys.readouterr().out
@@ -2416,12 +2453,12 @@ def test_webui_foreground_does_not_claim_unmanaged_gateway(monkeypatch, tmp_path
config_file = tmp_path / "config.json"
config_file.write_text("{}")
_patch_webui_provider_ready(monkeypatch)
- monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
- monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_args: True)
- monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_args: True)
- monkeypatch.setattr("nanobot.cli.commands._open_webui_browser", lambda *_args, **_kwargs: None)
+ monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
+ monkeypatch.setattr("nanobot.cli.webui._gateway_health_ready", lambda *_args: True)
+ monkeypatch.setattr("nanobot.cli.webui._webui_endpoint_reachable", lambda *_args: True)
+ monkeypatch.setattr("nanobot.cli.webui._open_webui_browser", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
- "nanobot.cli.commands._attach_to_background_gateway",
+ "nanobot.cli.webui._attach_to_background_gateway",
lambda _runtime: pytest.fail("unmanaged gateway must not be attached"),
)
@@ -2444,12 +2481,12 @@ def test_webui_foreground_refuses_occupied_webui_port(monkeypatch, tmp_path: Pat
config_file = tmp_path / "config.json"
config_file.write_text("{}")
_patch_webui_provider_ready(monkeypatch)
- monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
- monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_args, **_kwargs: False)
- monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_args, **_kwargs: True)
- monkeypatch.setattr("nanobot.cli.commands._tcp_endpoint_reachable", lambda *_args, **_kwargs: False)
+ monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
+ monkeypatch.setattr("nanobot.cli.webui._gateway_health_ready", lambda *_args, **_kwargs: False)
+ monkeypatch.setattr("nanobot.cli.webui._webui_endpoint_reachable", lambda *_args, **_kwargs: True)
+ monkeypatch.setattr("nanobot.cli.webui._tcp_endpoint_reachable", lambda *_args, **_kwargs: False)
monkeypatch.setattr(
- "nanobot.cli.commands._run_gateway",
+ "nanobot.cli.webui._run_gateway",
lambda *_args, **_kwargs: pytest.fail("gateway should not start on occupied ports"),
)
@@ -2596,9 +2633,9 @@ def test_gateway_unbound_agent_cron_is_skipped(
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
- monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
+ monkeypatch.setattr("nanobot.cli.gateway_runtime.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
- monkeypatch.setattr("nanobot.cli.commands._provider_setup_error", lambda _config: None)
+ monkeypatch.setattr("nanobot.cli.webui_support._provider_setup_error", lambda _config: None)
_patch_gateway_ports_free(monkeypatch)
monkeypatch.setattr(
"nanobot.providers.factory.build_provider_snapshot",
@@ -2672,10 +2709,10 @@ def test_gateway_unbound_agent_cron_is_skipped(
raise AssertionError("unbound cron job must not be evaluated for delivery")
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
- monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
+ monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup)
monkeypatch.setattr(
- "nanobot.cli.commands.evaluate_response",
+ "nanobot.cli.gateway_runtime.evaluate_response",
_capture_evaluate_response,
)
@@ -2724,9 +2761,9 @@ def test_gateway_bound_cron_runs_as_session_turn(
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
- monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
+ monkeypatch.setattr("nanobot.cli.gateway_runtime.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
- monkeypatch.setattr("nanobot.cli.commands._provider_setup_error", lambda _config: None)
+ monkeypatch.setattr("nanobot.cli.webui_support._provider_setup_error", lambda _config: None)
_patch_gateway_ports_free(monkeypatch)
monkeypatch.setattr(
"nanobot.providers.factory.build_provider_snapshot",
@@ -2788,9 +2825,9 @@ def test_gateway_bound_cron_runs_as_session_turn(
raise AssertionError("bound cron must not use legacy response evaluator")
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
- monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
+ monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup)
- monkeypatch.setattr("nanobot.cli.commands.evaluate_response", _unexpected_evaluator)
+ monkeypatch.setattr("nanobot.cli.gateway_runtime.evaluate_response", _unexpected_evaluator)
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
assert isinstance(result.exception, _StopGatewayError)
@@ -2984,7 +3021,7 @@ def test_gateway_local_trigger_queue_submits_agent_turns(
self.runtime_resolver = MagicMock()
seen["agent"] = self
- def _schedule_background(self, _coro) -> None:
+ def schedule_background(self, _coro) -> None:
return None
async def run(self) -> None:
@@ -3016,7 +3053,7 @@ def test_gateway_local_trigger_queue_submits_agent_turns(
seen["local_trigger_queue_kwargs"] = kwargs
raise _StopGatewayError("stop")
- monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
+ monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
monkeypatch.setattr(
"nanobot.triggers.local_runner.run_local_trigger_queue",
@@ -3124,7 +3161,7 @@ def test_gateway_custom_config_workspace_does_not_migrate_legacy_cron(
def test_migrate_cron_store_moves_legacy_file(tmp_path: Path) -> None:
"""Legacy global jobs.json is moved into the workspace on first run."""
- from nanobot.cli.commands import _migrate_cron_store
+ from nanobot.cli.runtime_config import _migrate_cron_store
legacy_dir = tmp_path / "global" / "cron"
legacy_dir.mkdir(parents=True)
@@ -3145,7 +3182,7 @@ def test_migrate_cron_store_moves_legacy_file(tmp_path: Path) -> None:
def test_migrate_cron_store_skips_when_workspace_file_exists(tmp_path: Path) -> None:
"""Migration does not overwrite an existing workspace cron store."""
- from nanobot.cli.commands import _migrate_cron_store
+ from nanobot.cli.runtime_config import _migrate_cron_store
legacy_dir = tmp_path / "global" / "cron"
legacy_dir.mkdir(parents=True)
@@ -3312,7 +3349,7 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
message_bus=lambda: object(),
session_manager=lambda _workspace: object(),
)
- monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
+ monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService)
monkeypatch.setattr("asyncio.start_server", _fake_start_server)
@@ -3363,13 +3400,14 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
async def read(self, _size: int) -> bytes:
nonlocal started
started += 1
- if started == cli_commands._GATEWAY_HEALTH_MAX_CONNECTIONS:
+ if started == cli_gateway_runtime._GATEWAY_HEALTH_MAX_CONNECTIONS:
all_started.set()
await release.wait()
return b"GET /health HTTP/1.1\r\n\r\n"
active_writers = [
- _FakeWriter() for _ in range(cli_commands._GATEWAY_HEALTH_MAX_CONNECTIONS)
+ _FakeWriter()
+ for _ in range(cli_gateway_runtime._GATEWAY_HEALTH_MAX_CONNECTIONS)
]
active_tasks = [
asyncio.create_task(health_handler(_BlockingReader(), writer))
@@ -3394,7 +3432,11 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
async def read(self, _size: int) -> bytes:
await asyncio.Event().wait()
- monkeypatch.setattr(cli_commands, "_GATEWAY_HEALTH_READ_TIMEOUT_SECONDS", 0.01)
+ monkeypatch.setattr(
+ cli_gateway_runtime,
+ "_GATEWAY_HEALTH_READ_TIMEOUT_SECONDS",
+ 0.01,
+ )
timed_out_writer = _FakeWriter()
asyncio.run(health_handler(_NeverRespondingReader(), timed_out_writer))
assert timed_out_writer.closed is True
@@ -3485,7 +3527,7 @@ def test_gateway_shutdown_lets_agent_task_own_mcp_cleanup(
message_bus=lambda: object(),
session_manager=lambda _workspace: object(),
)
- monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
+ monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService)
monkeypatch.setattr("asyncio.start_server", _fake_start_server)
@@ -3601,12 +3643,12 @@ def test_gateway_shutdown_event_exits_forever_runtime_tasks(
message_bus=lambda: object(),
session_manager=lambda _workspace: object(),
)
- monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
+ monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService)
monkeypatch.setattr("asyncio.start_server", _fake_start_server)
monkeypatch.setattr(
- "nanobot.cli.commands._install_gateway_shutdown_handlers",
+ "nanobot.cli.gateway_runtime._install_gateway_shutdown_handlers",
_fake_install_shutdown_handlers,
)
diff --git a/tests/cli/test_config_diagnostics.py b/tests/cli/test_config_diagnostics.py
index 93c6da5b0..c3e09f901 100644
--- a/tests/cli/test_config_diagnostics.py
+++ b/tests/cli/test_config_diagnostics.py
@@ -413,7 +413,7 @@ def test_gateway_missing_provider_managed_start_for_webui_setup(
monkeypatch.setattr(GatewayRuntime, "start_background", fake_start_background)
monkeypatch.setattr(GatewayRuntime, "restart", fake_restart)
monkeypatch.setattr(
- "nanobot.cli.commands.ensure_webui_bundle",
+ "nanobot.cli.webui_support.ensure_webui_bundle",
lambda **_kwargs: None,
)
diff --git a/tests/cli/test_interactive_retry_wait.py b/tests/cli/test_interactive_retry_wait.py
index 9d2d8977d..0d76e209b 100644
--- a/tests/cli/test_interactive_retry_wait.py
+++ b/tests/cli/test_interactive_retry_wait.py
@@ -4,7 +4,7 @@ from unittest.mock import patch
import pytest
from nanobot.bus.outbound_events import ProgressEvent, RetryWaitEvent
-from nanobot.cli import commands
+from nanobot.cli import terminal
@pytest.mark.asyncio
@@ -22,8 +22,8 @@ async def test_interactive_retry_wait_is_rendered_as_progress_even_when_progress
async def fake_print(text: str, active_thinking: object | None, renderer=None) -> None:
calls.append((text, active_thinking))
- with patch("nanobot.cli.commands._print_interactive_progress_line", side_effect=fake_print):
- handled = await commands._maybe_print_interactive_progress(
+ with patch("nanobot.cli.terminal._print_interactive_progress_line", side_effect=fake_print):
+ handled = await terminal._maybe_print_interactive_progress(
msg,
thinking,
channels_config,
@@ -46,8 +46,8 @@ async def test_reasoning_displayed_when_show_reasoning_enabled():
metadata={},
)
- with patch("nanobot.cli.commands._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)):
- handled = await commands._maybe_print_interactive_progress(msg, None, channels_config)
+ with patch("nanobot.cli.terminal._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)):
+ handled = await terminal._maybe_print_interactive_progress(msg, None, channels_config)
assert handled is True
assert calls == ["Let me think about this..."]
@@ -66,8 +66,8 @@ async def test_reasoning_delta_displayed_when_show_reasoning_enabled():
metadata={},
)
- with patch("nanobot.cli.commands._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)):
- handled = await commands._maybe_print_interactive_progress(msg, None, channels_config)
+ with patch("nanobot.cli.terminal._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)):
+ handled = await terminal._maybe_print_interactive_progress(msg, None, channels_config)
assert handled is True
assert calls == ["I should search first."]
@@ -79,10 +79,10 @@ async def test_reasoning_delta_buffers_until_sentence_boundary():
channels_config = SimpleNamespace(
send_progress=True, send_tool_hints=False, show_reasoning=True,
)
- reasoning_buffer = commands._ReasoningBuffer()
+ reasoning_buffer = terminal._ReasoningBuffer()
- with patch("nanobot.cli.commands._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)):
- first = await commands._maybe_print_interactive_progress(
+ with patch("nanobot.cli.terminal._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)):
+ first = await terminal._maybe_print_interactive_progress(
SimpleNamespace(
content="The",
event=ProgressEvent(content="The", reasoning_delta=True),
@@ -92,7 +92,7 @@ async def test_reasoning_delta_buffers_until_sentence_boundary():
channels_config,
reasoning_buffer=reasoning_buffer,
)
- second = await commands._maybe_print_interactive_progress(
+ second = await terminal._maybe_print_interactive_progress(
SimpleNamespace(
content=" user asked.",
event=ProgressEvent(content=" user asked.", reasoning_delta=True),
@@ -114,10 +114,10 @@ async def test_reasoning_end_flushes_buffered_delta():
channels_config = SimpleNamespace(
send_progress=True, send_tool_hints=False, show_reasoning=True,
)
- reasoning_buffer = commands._ReasoningBuffer()
+ reasoning_buffer = terminal._ReasoningBuffer()
- with patch("nanobot.cli.commands._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)):
- delta = await commands._maybe_print_interactive_progress(
+ with patch("nanobot.cli.terminal._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)):
+ delta = await terminal._maybe_print_interactive_progress(
SimpleNamespace(
content="The user asked",
event=ProgressEvent(content="The user asked", reasoning_delta=True),
@@ -127,7 +127,7 @@ async def test_reasoning_end_flushes_buffered_delta():
channels_config,
reasoning_buffer=reasoning_buffer,
)
- end = await commands._maybe_print_interactive_progress(
+ end = await terminal._maybe_print_interactive_progress(
SimpleNamespace(
content="",
event=ProgressEvent(reasoning_end=True),
@@ -155,8 +155,8 @@ async def test_reasoning_hidden_when_show_reasoning_disabled():
metadata={},
)
- with patch("nanobot.cli.commands._print_cli_reasoning") as mock_reasoning:
- handled = await commands._maybe_print_interactive_progress(msg, None, channels_config)
+ with patch("nanobot.cli.terminal._print_cli_reasoning") as mock_reasoning:
+ handled = await terminal._maybe_print_interactive_progress(msg, None, channels_config)
assert handled is True
mock_reasoning.assert_not_called()
@@ -178,8 +178,8 @@ async def test_non_reasoning_progress_not_affected_by_show_reasoning():
async def fake_print(text: str, thinking=None, renderer=None):
calls.append(text)
- with patch("nanobot.cli.commands._print_interactive_progress_line", side_effect=fake_print):
- handled = await commands._maybe_print_interactive_progress(msg, None, channels_config)
+ with patch("nanobot.cli.terminal._print_interactive_progress_line", side_effect=fake_print):
+ handled = await terminal._maybe_print_interactive_progress(msg, None, channels_config)
assert handled is True
assert calls == ["working on it..."]
@@ -200,10 +200,10 @@ async def test_reasoning_shown_when_send_progress_disabled():
)
with patch(
- "nanobot.cli.commands._print_cli_reasoning",
+ "nanobot.cli.terminal._print_cli_reasoning",
side_effect=lambda t, th, r=None: calls.append(t),
):
- handled = await commands._maybe_print_interactive_progress(msg, None, channels_config)
+ handled = await terminal._maybe_print_interactive_progress(msg, None, channels_config)
assert handled is True
assert calls == ["Let me think about this..."]
diff --git a/tests/cli/test_safe_file_history.py b/tests/cli/test_safe_file_history.py
index 5d4efe89e..7c0118732 100644
--- a/tests/cli/test_safe_file_history.py
+++ b/tests/cli/test_safe_file_history.py
@@ -3,7 +3,12 @@
Surrogate characters in CLI input must not crash history file writes.
"""
-from nanobot.cli.commands import SafeFileHistory, _sanitize_surrogates
+from nanobot.cli.commands import SafeFileHistory as LegacySafeFileHistory
+from nanobot.cli.terminal import SafeFileHistory, _sanitize_surrogates
+
+
+def test_commands_keeps_safe_file_history_import_compatible() -> None:
+ assert LegacySafeFileHistory is SafeFileHistory
class TestSanitizeSurrogates:
diff --git a/tests/command/test_router_dispatchable.py b/tests/command/test_router_dispatchable.py
index 59ecec602..837b55e23 100644
--- a/tests/command/test_router_dispatchable.py
+++ b/tests/command/test_router_dispatchable.py
@@ -108,7 +108,7 @@ class TestMidTurnCommandDispatchedDirectly:
))
loop.sessions.save = MagicMock()
loop.sessions.invalidate = MagicMock()
- loop._schedule_background = MagicMock()
+ loop.schedule_background = MagicMock()
loop._cancel_active_tasks = AsyncMock(return_value=0)
return loop