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:
alt-glitch
2026-06-08 11:11:54 +00:00
parent 24f74eb888
commit 2bd9c9b881
741 changed files with 17733 additions and 79889 deletions
+3 -181
View File
@@ -167,23 +167,12 @@ COMMAND_REGISTRY: list[CommandDef] = [
cli_only=True),
CommandDef("skills", "Search, install, inspect, or manage skills",
"Tools & Skills", cli_only=True,
gateway_config_gate="skills.write_approval",
subcommands=("search", "browse", "inspect", "install", "audit",
"pending", "approve", "reject", "diff", "approval")),
CommandDef("memory", "Review pending memory writes / toggle the approval gate",
"Tools & Skills",
args_hint="[pending|approve|reject|approval] [id|on|off]",
subcommands=("pending", "approve", "reject", "approval")),
subcommands=("search", "browse", "inspect", "install", "audit")),
CommandDef("bundles", "List skill bundles (aliases /<name> for multiple skills)",
"Tools & Skills"),
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
cli_only=True, args_hint="[subcommand]",
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
CommandDef("suggestions", "Review suggested automations (accept/dismiss)",
"Tools & Skills", aliases=("suggest",), args_hint="[accept|dismiss N | catalog]",
subcommands=("accept", "dismiss", "catalog", "clear")),
CommandDef("blueprint", "Set up an automation from a blueprint template",
"Tools & Skills", aliases=("bp",), args_hint="[name] [slot=value ...]"),
CommandDef("curator", "Background skill maintenance (status, run, pin, archive, list-archived)",
"Tools & Skills", args_hint="[subcommand]",
subcommands=("status", "run", "pause", "resume", "pin", "unpin", "restore", "list-archived")),
@@ -1030,19 +1019,6 @@ _SLACK_RESERVED_COMMANDS = frozenset({
"topic", "mute", "pro", "shortcuts",
})
# High-value aliases that must survive Slack's 50-slash cap even when the
# registry fills up. Without this, adding a new canonical command silently
# clamps off low-priority aliases (they're added in the second pass), so a
# long-standing native slash like /btw could disappear just because an
# unrelated command landed. These claim their slots right after /hermes,
# ahead of both canonical names and the rest of the aliases. Anything not
# listed here still degrades gracefully (reachable via /hermes <command>).
# Keep this list TIGHT: every pinned alias takes a slot a canonical command
# would otherwise get, and the Telegram-parity test fails when a canonical
# gets clamped ("reset" was unpinned for exactly that — /new keeps its
# native slot, the alias spelling stays reachable via /hermes reset).
_SLACK_PRIORITY_ALIASES = ("btw", "bg")
def _sanitize_slack_name(raw: str) -> str:
"""Convert a command name to a valid Slack slash command name.
@@ -1097,21 +1073,6 @@ def slack_native_slashes() -> list[tuple[str, str, str]]:
entries.append((slack_name, desc[:140], hint[:100]))
seen.add(slack_name)
# Priority pass: pin high-value aliases (e.g. /btw, /bg, /reset) ahead of
# everything except /hermes, so a new canonical command can never silently
# clamp them off the 50-slash cap. Each alias borrows its parent command's
# description and hint.
_alias_to_cmd = {
alias: cmd
for cmd in COMMAND_REGISTRY
if _is_gateway_available(cmd, overrides)
for alias in cmd.aliases
}
for alias in _SLACK_PRIORITY_ALIASES:
cmd = _alias_to_cmd.get(alias)
if cmd is not None:
_add(alias, f"Alias for /{cmd.name}{cmd.description}", cmd.args_hint or "")
# First pass: canonical names (so they win slots if we hit the cap).
for cmd in COMMAND_REGISTRY:
if not _is_gateway_available(cmd, overrides):
@@ -1577,140 +1538,12 @@ class SlashCommandCompleter(Completer):
except Exception:
pass
@staticmethod
def _tools_completions(sub_text: str, sub_lower: str):
"""Yield completions for /tools — subcommand + toolset/MCP-server name.
Handles both ``/tools <tab>`` (suggesting ``list|disable|enable``) and
``/tools enable <tab>`` / ``/tools disable <tab>`` (suggesting toolset
keys and MCP server prefixes, filtered by current enable state so the
user only sees actionable options).
"""
SUBS = ("list", "disable", "enable")
parts = sub_text.split()
trailing_space = sub_text.endswith(" ")
# Subcommand stage: zero words typed, or completing the first word.
if len(parts) == 0 or (len(parts) == 1 and not trailing_space):
partial = sub_text if not trailing_space else ""
for sub in SUBS:
if sub.startswith(partial.lower()) and sub != partial.lower():
yield Completion(sub, start_position=-len(partial), display=sub)
return
subcommand = parts[0].lower()
if subcommand not in ("enable", "disable"):
return
partial = "" if trailing_space else parts[-1]
partial_lower = partial.lower()
already = set(parts[1:] if trailing_space else parts[1:-1])
try:
from hermes_cli.config import load_config
from hermes_cli.tools_config import (
CONFIGURABLE_TOOLSETS,
_get_platform_tools,
_get_plugin_toolset_keys,
)
config = load_config()
enabled = _get_platform_tools(config, "cli", include_default_mcp_servers=False)
for ts_key, label, _desc in CONFIGURABLE_TOOLSETS:
if ts_key in already or not ts_key.startswith(partial_lower):
continue
is_on = ts_key in enabled
if subcommand == "enable" and is_on:
continue
if subcommand == "disable" and not is_on:
continue
yield Completion(
ts_key,
start_position=-len(partial),
display=ts_key,
display_meta=label,
)
for ts_key in sorted(_get_plugin_toolset_keys()):
if ts_key in already or not ts_key.startswith(partial_lower):
continue
is_on = ts_key in enabled
if subcommand == "enable" and is_on:
continue
if subcommand == "disable" and not is_on:
continue
yield Completion(
ts_key,
start_position=-len(partial),
display=ts_key,
display_meta="plugin toolset",
)
mcp_servers = config.get("mcp_servers") or {}
if isinstance(mcp_servers, dict):
for server in sorted(mcp_servers):
prefix = f"{server}:"
if prefix in already or not prefix.startswith(partial_lower):
continue
yield Completion(
prefix,
start_position=-len(partial),
display=prefix,
display_meta=f"MCP server '{server}'",
)
except Exception:
return
@staticmethod
def _handoff_completions(sub_text: str, sub_lower: str):
"""Yield platform completions for /handoff.
Offers connected (enabled + configured) gateway platforms. A recorded
home channel is NOT required to list a platform — it's often learned at
runtime — so the meta hints whether one is set yet. Completes only the
first arg (the platform); once one is chosen, stop.
"""
parts = sub_text.split()
trailing_space = sub_text.endswith(" ")
if len(parts) > 1 or (len(parts) == 1 and trailing_space):
return
partial = "" if (not parts or trailing_space) else parts[-1]
partial_lower = partial.lower()
try:
from gateway.config import load_gateway_config
gw = load_gateway_config()
platforms = gw.get_connected_platforms()
except Exception:
return
for platform in platforms:
name = platform.value
if not name.startswith(partial_lower):
continue
try:
home = gw.get_home_channel(platform)
except Exception:
home = None
meta = f"{home.name}" if home and getattr(home, "name", None) else "send this session here"
yield Completion(
name,
start_position=-len(partial),
display=name,
display_meta=meta,
)
@staticmethod
def _personality_completions(sub_text: str, sub_lower: str):
"""Yield completions for /personality from configured personalities."""
try:
# Resolve from the same source the runtime applies personalities —
# agent.personalities via the CLI config (which ships the built-ins).
# load_config()'s schema has no agent.personalities, so the completer
# used to come back empty even with personalities available.
from cli import load_cli_config
personalities = (load_cli_config().get("agent") or {}).get("personalities", {}) or {}
from hermes_cli.config import load_config
personalities = load_config().get("agent", {}).get("personalities", {})
if "none".startswith(sub_lower) and "none" != sub_lower:
yield Completion(
"none",
@@ -1763,17 +1596,6 @@ class SlashCommandCompleter(Completer):
yield from self._personality_completions(sub_text, sub_lower)
return
# /tools needs multi-word completion (subcommand + toolset name)
# so it handles both stages itself, bypassing the single-word
# SUBCOMMANDS branch below.
if base_cmd == "/tools":
yield from self._tools_completions(sub_text, sub_lower)
return
if base_cmd == "/handoff":
yield from self._handoff_completions(sub_text, sub_lower)
return
# Static subcommand completions
if " " not in sub_text and base_cmd in SUBCOMMANDS and self._command_allowed(base_cmd):
for sub in SUBCOMMANDS[base_cmd]: