Skip to content

CLI / TUI / Web

源码版本v2026.7.20

Responsibility

The interface layer is the other group of entry points (besides the gateway's messaging platforms) through which users reach the agent: CLI (hermes_cli/, a command-line subcommand system), TUI (ui-tui/, a TypeScript terminal UI + the tui_gateway/ Python bridge), Web (web/, a browser interface). All three ultimately feed user input into the agent's run_conversation and render the output back. They sit alongside the gateway layer — the gateway connects to external platforms; the interface layer connects to local users.

Key Files

CLI

TUI

Web

CLI Subcommand Surface

hermes_cli/_parser.py factors out the top-level argparse so that modules like relaunch.py can introspect available flags without running main. The parser only owns the chat subcommand and global options; other subcommands are constructed in place in main.py (tightly coupled to the cmd_* functions). The examples in _EPILOGUE are the full subcommand landscape the user sees:

python
hermes                        Start interactive chat
hermes --tui                  Launch the modern TUI
hermes auth add <provider>    Add a pooled credential
hermes gateway                Run messaging gateway
hermes dashboard              Start web UI dashboard (port 9119)

CLI is both a "conversation entry" and an "ops panel" — auth/model/config/gateway/dashboard/backup all hang off the same subcommand tree. Only chat and its derivatives (-c/--resume) actually enter run_conversation; the rest go straight to their own modules.

TUI Gateway's stdin/JSON-RPC Main Loop

The TUI puts terminal rendering on the TypeScript side (ui-tui/); the Python side runs only a thin tui_gateway bridge. The two communicate over stdin/stdout via JSON-RPC. The main() in tui_gateway/entry.py is a readline + dispatch loop at its core (tui_gateway/entry.py):

python
while True:
    raw = sys.stdin.readline()
    if not raw:
        # Stdin fell through — spurious EOF (child flipped O_NONBLOCK)
        # or genuine close.  handle_spurious_eof decides whether to continue.
        if not handle_spurious_eof(_recovery_times, _log_exit):
            break
        continue
    try:
        req = json.loads(raw.strip())
    except json.JSONDecodeError:
        write_json({"jsonrpc": "2.0", "error": {"code": -32700, ...}, "id": None})
        continue
    resp = dispatch(req)

A few engineering details matter: handle_spurious_eof deals with "fake EOF" caused by a child process flipping O_NONBLOCK; _log_exit leaves a crash log behind when writes to stdout fail; SIGPIPE is explicitly ignored (signal.signal(signal.SIGPIPE, signal.SIG_IGN)), so that background TTS/beep threads writing to a closed pipe don't drag the whole process down.

All Three Converge on run_conversation

No matter which entry you come in from, conversation-type requests end up calling AIAgent.run_conversation — which itself is just a forwarder; the real main loop lives in agent/conversation_loop.py (run_agent.py:6350):

python
def run_conversation(self, user_message, system_message=None,
                     conversation_history=None, ..., moa_config=None) -> Dict[str, Any]:
    """Forwarder — see ``agent.conversation_loop.run_conversation``."""
    from agent.conversation_loop import run_conversation
    from agent.portal_tags import set_conversation_context
    token = set_conversation_context(self._conversation_root_id())
    with scoped_runtime_main({}):
        return run_conversation(self, user_message, ...)

The conversation ID is pushed down via a ContextVar — every LLM call inside the main loop (compression, vision, MoA slot, background-review fork) picks up the same tag automatically, no need to pass it explicitly at each call site. The agent instance is assembled by init_agent (agent/agent_init.py:276); the long string of callback parameters in its signature (step_callback/stream_delta_callback/event_callback/...) are the hooks for the three shells — same agent, different shells attach different callbacks, and the render form is completely different.

Data Flow

  1. CLI: hermes <subcommand> → parsed by hermes_cli/_parser.py → executed by the subcommand module (auth/backup/blueprint/...); conversation-type commands ultimately call AIAgent.run_conversation:6350.
  2. TUI: ui-tui/ (TS frontend) connects to tui_gateway/ws.py via WebSocket; tui_gateway bridges terminal events to the agent (tui_gateway/server.py holds the agent); output is pushed back to the frontend via tui_gateway/event_publisher.py.
  3. Web: web/ (Vite frontend) similarly connects to the agent through a backend, rendering the conversation and tool results.
  4. All three assemble their agent instance via init_agent:276 and share the same capability layer.

Design Motivation

Why do three shells share one run_conversation instead of each running its own loop? The core is "rendering differs vs business is consistent". Streaming tokens, tool progress, event format — those are the shell's business; conversation state, tool calls, budget decrement, memory injection — those are the agent's business. Keeping shells isolated at the callback layer means swapping a shell doesn't require touching business, and adding business doesn't require changing all three places. tui_gateway exists on its own because the TS side can't directly hold a Python agent — it's a language bridge, not a business layer.

Boundaries and Failure

  • The TUI's stdin/stdout is a JSON-RPC pipe, not a log. Stray print-debugging into stdout will break the TUI's parser; Python-side logs go to stderr, which the TUI surfaces as gateway.stderr events in the Activity panel.
  • A crashed tui_gateway subprocess doesn't auto-reconnect. _CRASH_LOG is for post-mortem forensics, not recovery — the user has to manually restart hermes --tui.
  • Web is not a replacement for TUI. web/ runs through hermes dashboard (port 9119); its scope is a local console + long-conversation viewer, not a remote multi-user web product.

Summary

The interface layer = three shells (CLI/TUI/Web) + one tui_gateway bridge. They hold no business logic, only the input/output form. They sit alongside the gateway layer: the gateway connects to external messaging platforms; the interface layer connects to local terminals/browsers. ACP (see here) connects to the IDE, completing the full "reach surface".

Unofficial community learning site. Content based on the MIT-licensed NousResearch/hermes-agent source.