opentui(phase3): launcher integration — HERMES_TUI_ENGINE dual-engine
hermes --tui launches the native OpenTUI engine (Bun) when HERMES_TUI_ENGINE=opentui (env) or display.tui_engine=opentui (config); Ink stays the default and the shipping path is untouched. - _resolve_tui_engine() (env > config > ink); refuses opentui on Windows/Termux (no Bun) -> falls back to ink with a notice. - _make_opentui_argv() -> [bun, src/entry.real.tsx] (no build step). - _bun_bin() with HERMES_BUN override. - Branch at top of _make_tui_argv BEFORE _ensure_tui_node (Bun-only host must not bootstrap Node). - Gate _launch_tui NODE_OPTIONS/--max-old-space-size on engine==ink (Bun is JSC; the V8 flag errors/ignores). Verified end-to-end via tmux: real hermes --tui -> Bun -> OpenTUI -> real Python gateway streamed a real reply. No-flag default still ink.
This commit is contained in:
+14
-195
@@ -90,7 +90,7 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Coroutine, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -268,38 +268,6 @@ _SAFE_ENV_KEYS = frozenset({
|
||||
"PATH", "HOME", "USER", "LANG", "LC_ALL", "TERM", "SHELL", "TMPDIR",
|
||||
})
|
||||
|
||||
_SAFE_ENV_KEYS_CASE_INSENSITIVE = frozenset({
|
||||
# Windows process/location vars. These are needed by launcher-style tools
|
||||
# such as Docker Desktop's MCP plugin discovery, and do not carry secrets.
|
||||
"ALLUSERSPROFILE",
|
||||
"APPDATA",
|
||||
"COMMONPROGRAMFILES",
|
||||
"COMMONPROGRAMFILES(X86)",
|
||||
"COMMONPROGRAMW6432",
|
||||
"COMPUTERNAME",
|
||||
"COMSPEC",
|
||||
"HOMEDRIVE",
|
||||
"HOMEPATH",
|
||||
"LOCALAPPDATA",
|
||||
"NUMBER_OF_PROCESSORS",
|
||||
"OS",
|
||||
"PATHEXT",
|
||||
"PROCESSOR_ARCHITECTURE",
|
||||
"PROGRAMDATA",
|
||||
"PROGRAMFILES",
|
||||
"PROGRAMFILES(X86)",
|
||||
"PROGRAMW6432",
|
||||
"PUBLIC",
|
||||
"SYSTEMDRIVE",
|
||||
"SYSTEMROOT",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"USERDOMAIN",
|
||||
"USERNAME",
|
||||
"USERPROFILE",
|
||||
"WINDIR",
|
||||
})
|
||||
|
||||
# Regex for credential patterns to strip from error messages
|
||||
_CREDENTIAL_PATTERN = re.compile(
|
||||
r"(?:"
|
||||
@@ -337,11 +305,7 @@ def _build_safe_env(user_env: Optional[dict]) -> dict:
|
||||
"""
|
||||
env = {}
|
||||
for key, value in os.environ.items():
|
||||
if (
|
||||
key in _SAFE_ENV_KEYS
|
||||
or key.upper() in _SAFE_ENV_KEYS_CASE_INSENSITIVE
|
||||
or key.startswith("XDG_")
|
||||
):
|
||||
if key in _SAFE_ENV_KEYS or key.startswith("XDG_"):
|
||||
env[key] = value
|
||||
if user_env:
|
||||
env.update(user_env)
|
||||
@@ -1202,26 +1166,6 @@ class MCPServerTask:
|
||||
"""Check if this server uses HTTP transport."""
|
||||
return "url" in self._config
|
||||
|
||||
def _advertises_tools(self) -> bool:
|
||||
"""Whether the server advertises the ``tools`` capability.
|
||||
|
||||
Per the MCP spec, ``InitializeResult.capabilities.tools`` is non-None
|
||||
iff the server implements the ``tools/*`` request family. Prompt-only
|
||||
or resource-only servers omit it, and calling ``tools/list`` against
|
||||
them raises ``McpError(-32601 Method not found)`` — which previously
|
||||
killed the connection during discovery and made every keepalive fail.
|
||||
(Ported from anomalyco/opencode#31271.)
|
||||
|
||||
Returns True when no capability info was captured (legacy fallback:
|
||||
preserve the old always-call-list_tools behavior rather than regress
|
||||
any server that was working before this gate).
|
||||
"""
|
||||
init_result = self.initialize_result
|
||||
caps = getattr(init_result, "capabilities", None) if init_result is not None else None
|
||||
if caps is None:
|
||||
return True
|
||||
return getattr(caps, "tools", None) is not None
|
||||
|
||||
# ----- Dynamic tool discovery (notifications/tools/list_changed) -----
|
||||
|
||||
async def _refresh_tools_task(self):
|
||||
@@ -1293,12 +1237,6 @@ class MCPServerTask:
|
||||
"""
|
||||
from tools.registry import registry
|
||||
|
||||
if not self._advertises_tools():
|
||||
# A server that doesn't implement tools/* should never send
|
||||
# tools/list_changed, but guard anyway — calling tools/list
|
||||
# would raise McpError(-32601).
|
||||
return
|
||||
|
||||
async with self._refresh_lock:
|
||||
# Capture old tool names for change diff
|
||||
old_tool_names = set(self._registered_tool_names)
|
||||
@@ -1386,22 +1324,12 @@ class MCPServerTask:
|
||||
|
||||
# Timeout — no lifecycle event fired. Send a keepalive
|
||||
# to exercise the connection and detect stale sockets.
|
||||
# Prompt-only / resource-only servers don't implement
|
||||
# ``tools/list`` (McpError -32601), so use the universal
|
||||
# ``ping`` request for them instead — otherwise every
|
||||
# keepalive cycle would trigger a spurious reconnect.
|
||||
if self.session:
|
||||
try:
|
||||
if self._advertises_tools():
|
||||
await asyncio.wait_for(
|
||||
self.session.list_tools(),
|
||||
timeout=30.0,
|
||||
)
|
||||
else:
|
||||
await asyncio.wait_for(
|
||||
self.session.send_ping(),
|
||||
timeout=30.0,
|
||||
)
|
||||
await asyncio.wait_for(
|
||||
self.session.list_tools(),
|
||||
timeout=30.0,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"MCP server '%s' keepalive failed, "
|
||||
@@ -1814,25 +1742,9 @@ class MCPServerTask:
|
||||
)
|
||||
|
||||
async def _discover_tools(self):
|
||||
"""Discover tools from the connected session.
|
||||
|
||||
Capability-gated: prompt-only / resource-only MCP servers don't
|
||||
implement ``tools/list``, and calling it raises ``McpError(-32601)``,
|
||||
which previously aborted the connection — those servers could never
|
||||
stay connected for their prompts/resources. Skip the call when the
|
||||
server doesn't advertise the ``tools`` capability.
|
||||
(Ported from anomalyco/opencode#31271.)
|
||||
"""
|
||||
"""Discover tools from the connected session."""
|
||||
if self.session is None:
|
||||
return
|
||||
if not self._advertises_tools():
|
||||
logger.info(
|
||||
"MCP server '%s': does not advertise 'tools' capability — "
|
||||
"skipping tools/list (prompts/resources remain available)",
|
||||
self.name,
|
||||
)
|
||||
self._tools = []
|
||||
return
|
||||
async with self._rpc_lock:
|
||||
tools_result = await self.session.list_tools()
|
||||
self._tools = (
|
||||
@@ -2074,8 +1986,6 @@ class MCPServerTask:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_servers: Dict[str, MCPServerTask] = {}
|
||||
_server_connecting: set[str] = set()
|
||||
_server_connect_errors: Dict[str, str] = {}
|
||||
|
||||
# Circuit breaker: consecutive error counts per server. After
|
||||
# _CIRCUIT_BREAKER_THRESHOLD consecutive failures, the handler returns
|
||||
@@ -2462,8 +2372,8 @@ _mcp_tool_server_names: Dict[str, str] = {}
|
||||
_mcp_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
_mcp_thread: Optional[threading.Thread] = None
|
||||
|
||||
# Protects _mcp_loop, _mcp_thread, _servers, MCP connection status maps,
|
||||
# _parallel_safe_servers, _mcp_tool_server_names, and _stdio_pids.
|
||||
# Protects _mcp_loop, _mcp_thread, _servers, _parallel_safe_servers,
|
||||
# _mcp_tool_server_names, and _stdio_pids.
|
||||
_lock = threading.Lock()
|
||||
|
||||
# PIDs of stdio MCP server subprocesses. Tracked so we can force-kill
|
||||
@@ -2550,37 +2460,6 @@ def _ensure_mcp_loop():
|
||||
_mcp_thread.start()
|
||||
|
||||
|
||||
def _wrap_with_home_override(coro: "Coroutine") -> "Coroutine":
|
||||
"""Carry the caller's context-local HERMES_HOME override into ``coro``.
|
||||
|
||||
Returns ``coro`` unchanged when no override is active. Otherwise wraps
|
||||
it so the override is set inside the coroutine's own (task-local)
|
||||
context on the MCP loop and reset when it completes — concurrent calls
|
||||
carrying different scopes don't interfere.
|
||||
"""
|
||||
try:
|
||||
from hermes_constants import (
|
||||
get_hermes_home_override,
|
||||
reset_hermes_home_override,
|
||||
set_hermes_home_override,
|
||||
)
|
||||
|
||||
home_override = get_hermes_home_override()
|
||||
except Exception:
|
||||
return coro
|
||||
if not home_override:
|
||||
return coro
|
||||
|
||||
async def _scoped():
|
||||
token = set_hermes_home_override(home_override)
|
||||
try:
|
||||
return await coro
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
return _scoped()
|
||||
|
||||
|
||||
def _run_on_mcp_loop(coro_or_factory, timeout: float = 30):
|
||||
"""Schedule a coroutine on the MCP event loop and block until done.
|
||||
|
||||
@@ -2603,19 +2482,6 @@ def _run_on_mcp_loop(coro_or_factory, timeout: float = 30):
|
||||
raise RuntimeError("MCP event loop is not running")
|
||||
|
||||
coro = coro_or_factory() if callable(coro_or_factory) else coro_or_factory
|
||||
|
||||
# Propagate the context-local HERMES_HOME override onto the MCP loop.
|
||||
# Tasks scheduled via run_coroutine_threadsafe are created INSIDE the
|
||||
# loop thread, so they copy the loop thread's context — not the
|
||||
# scheduling thread's. A per-request profile scope (the dashboard's
|
||||
# ?profile= endpoints, e.g. the MCP "Test server" probe) would silently
|
||||
# vanish here: OAuth token stores and any other get_hermes_home()
|
||||
# resolution inside the coroutine would read the process home instead
|
||||
# of the selected profile's. Re-establish the override inside the
|
||||
# task's own context (task-local — concurrent calls carrying different
|
||||
# scopes don't interfere). No-op when no override is active.
|
||||
coro = _wrap_with_home_override(coro)
|
||||
|
||||
future = safe_schedule_threadsafe(
|
||||
coro, loop,
|
||||
logger=logger,
|
||||
@@ -3607,8 +3473,6 @@ async def _discover_and_register_server(name: str, config: dict) -> List[str]:
|
||||
timeout=connect_timeout,
|
||||
)
|
||||
with _lock:
|
||||
_server_connecting.discard(name)
|
||||
_server_connect_errors.pop(name, None)
|
||||
_servers[name] = server
|
||||
|
||||
registered_names = _register_server_tools(name, server, config)
|
||||
@@ -3655,9 +3519,6 @@ def register_mcp_servers(servers: Dict[str, dict]) -> List[str]:
|
||||
for k, v in servers.items()
|
||||
if k not in _servers and _parse_boolish(v.get("enabled", True), default=True)
|
||||
}
|
||||
_server_connecting.update(new_servers)
|
||||
for srv_name in new_servers:
|
||||
_server_connect_errors.pop(srv_name, None)
|
||||
# Track which servers opt-in to parallel tool calls (idempotent).
|
||||
for srv_name, srv_cfg in servers.items():
|
||||
if _parse_boolish(srv_cfg.get("supports_parallel_tool_calls", False), default=False):
|
||||
@@ -3685,20 +3546,12 @@ def register_mcp_servers(servers: Dict[str, dict]) -> List[str]:
|
||||
for name, result in zip(server_names, results):
|
||||
if isinstance(result, BaseException):
|
||||
command = new_servers.get(name, {}).get("command")
|
||||
message = _format_connect_error(result)
|
||||
with _lock:
|
||||
_server_connecting.discard(name)
|
||||
_server_connect_errors[name] = message
|
||||
logger.warning(
|
||||
"Failed to connect to MCP server '%s'%s: %s",
|
||||
name,
|
||||
f" (command={command})" if command else "",
|
||||
message,
|
||||
_format_connect_error(result),
|
||||
)
|
||||
else:
|
||||
with _lock:
|
||||
_server_connecting.discard(name)
|
||||
_server_connect_errors.pop(name, None)
|
||||
|
||||
# Per-server timeouts are handled inside _discover_and_register_server.
|
||||
# The outer timeout is generous: 120s total for parallel discovery.
|
||||
@@ -3803,10 +3656,8 @@ def is_mcp_tool_parallel_safe(tool_name: str) -> bool:
|
||||
def get_mcp_status() -> List[dict]:
|
||||
"""Return status of all configured MCP servers for banner display.
|
||||
|
||||
Returns a list of dicts with keys: name, transport, tools, connected,
|
||||
disabled, and status. Includes connected servers, disabled servers,
|
||||
in-flight connection attempts, recorded failures, and servers that are
|
||||
configured but have not been started in this process yet.
|
||||
Returns a list of dicts with keys: name, transport, tools, connected.
|
||||
Includes both successfully connected servers and configured-but-failed ones.
|
||||
"""
|
||||
result: List[dict] = []
|
||||
|
||||
@@ -3817,8 +3668,6 @@ def get_mcp_status() -> List[dict]:
|
||||
|
||||
with _lock:
|
||||
active_servers = dict(_servers)
|
||||
connecting = set(_server_connecting)
|
||||
connect_errors = dict(_server_connect_errors)
|
||||
|
||||
for name, cfg in configured.items():
|
||||
transport = cfg.get("transport", "http") if "url" in cfg else "stdio"
|
||||
@@ -3831,12 +3680,11 @@ def get_mcp_status() -> List[dict]:
|
||||
"tools": len(server._registered_tool_names) if hasattr(server, "_registered_tool_names") else len(server._tools),
|
||||
"connected": True,
|
||||
"disabled": False,
|
||||
"status": "connected",
|
||||
}
|
||||
if server._sampling:
|
||||
entry["sampling"] = dict(server._sampling.metrics)
|
||||
result.append(entry)
|
||||
elif not enabled:
|
||||
else:
|
||||
# A server with enabled: false is intentionally not connected — it is
|
||||
# disabled, not failed. Surface that distinction so consumers (banner,
|
||||
# TUI) can render "disabled" rather than an alarming "failed".
|
||||
@@ -3845,36 +3693,7 @@ def get_mcp_status() -> List[dict]:
|
||||
"transport": transport,
|
||||
"tools": 0,
|
||||
"connected": False,
|
||||
"disabled": True,
|
||||
"status": "disabled",
|
||||
})
|
||||
elif name in connecting:
|
||||
result.append({
|
||||
"name": name,
|
||||
"transport": transport,
|
||||
"tools": 0,
|
||||
"connected": False,
|
||||
"disabled": False,
|
||||
"status": "connecting",
|
||||
})
|
||||
elif name in connect_errors:
|
||||
result.append({
|
||||
"name": name,
|
||||
"transport": transport,
|
||||
"tools": 0,
|
||||
"connected": False,
|
||||
"disabled": False,
|
||||
"status": "failed",
|
||||
"error": connect_errors[name],
|
||||
})
|
||||
else:
|
||||
result.append({
|
||||
"name": name,
|
||||
"transport": transport,
|
||||
"tools": 0,
|
||||
"connected": False,
|
||||
"disabled": False,
|
||||
"status": "configured",
|
||||
"disabled": not enabled,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user