Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -899,6 +899,38 @@ delegation:
# Hermes-specific overrides (optional — most config comes from ~/.honcho/config.json):
# honcho: {}

# =============================================================================
# TUI Remote Bridge (backend-only live WebSocket attach)
# =============================================================================
# Starts an opt-in WebSocket listener inside a running `hermes --tui` gateway.
# Remote clients speak the same JSON-RPC protocol as ui-tui's stdio transport:
# connect, wait for gateway.ready, call session.active_list, then
# session.activate to mirror/control an in-memory live TUI session.
#
# Defaults are loopback-only and disabled. To reach it from a phone over
# Tailscale/LAN, bind to 0.0.0.0 (or a specific host) AND set a token; Hermes
# refuses non-loopback binds without one. Native clients may pass the token as
# `?token=...`, `Authorization: Bearer YOUR_TOKEN`, or `X-Hermes-TUI-Remote-Token`.
# Browser clients should also set trusted_origins for their app origin.
#
# Equivalent environment overrides:
# HERMES_TUI_REMOTE_BRIDGE=1
# HERMES_TUI_REMOTE_BRIDGE_HOST=0.0.0.0
# HERMES_TUI_REMOTE_BRIDGE_PORT=8769
# HERMES_TUI_REMOTE_BRIDGE_TOKEN=...
# HERMES_TUI_REMOTE_BRIDGE_ORIGINS=http://localhost:5174,https://app.example
#
# One-shot loopback-only convenience:
# hermes --tui --remote-control # alias: --rc
#
# tui_remote_bridge:
# enabled: false
# host: "127.0.0.1"
# port: 8769
# path: "/api/tui/ws"
# token: ""
# trusted_origins: []

# =============================================================================
# Display
# =============================================================================
Expand Down
22 changes: 22 additions & 0 deletions hermes_cli/_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,17 @@ def build_top_level_parser():
default=False,
help="Launch the modern TUI instead of the classic REPL",
)
_inherited_flag(
parser,
"--remote-control",
"--rc",
action="store_true",
default=False,
help=(
"With --tui: start the opt-in loopback Remote Control bridge "
"for live clients"
),
)
_inherited_flag(
parser,
"--dev",
Expand Down Expand Up @@ -369,6 +380,17 @@ def build_top_level_parser():
default=False,
help="Launch the modern TUI instead of the classic REPL",
)
_inherited_flag(
chat_parser,
"--remote-control",
"--rc",
action="store_true",
default=argparse.SUPPRESS,
help=(
"With --tui: start the opt-in loopback Remote Control bridge "
"for live clients"
),
)
_inherited_flag(
chat_parser,
"--dev",
Expand Down
13 changes: 13 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1264,6 +1264,19 @@ def _ensure_hermes_home_managed(home: Path):
"extra_body": {},
},
},

# Backend-only WebSocket listener that lets a mobile/web/native client attach
# to the live in-memory sessions owned by a running `hermes --tui` process.
# Off by default. Non-loopback binds (0.0.0.0, LAN/Tailscale hostnames, etc.)
# require a bearer token and still enforce Host/Origin guardrails.
"tui_remote_bridge": {
"enabled": False,
"host": "127.0.0.1",
"port": 8769,
"path": "/api/tui/ws",
"token": "",
"trusted_origins": [],
},

"display": {
"compact": False,
Expand Down
8 changes: 8 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1552,6 +1552,7 @@ def _launch_tui(
pass_session_id: bool = False,
max_turns: Optional[int] = None,
accept_hooks: bool = False,
remote_control: bool = False,
):
"""Replace current process with the TUI."""
tui_dir = PROJECT_ROOT / "ui-tui"
Expand Down Expand Up @@ -1631,6 +1632,12 @@ def _launch_tui(
env["HERMES_TUI_TOOL_PROGRESS"] = "off"
if accept_hooks:
env["HERMES_ACCEPT_HOOKS"] = "1"
if remote_control:
# Friendly CLI switch for the backend TUI Remote Control bridge. The
# bridge itself keeps the secure defaults: loopback bind, default port,
# and a token requirement for any non-loopback host configured by env or
# config.
env["HERMES_TUI_REMOTE_BRIDGE"] = "1"
# Guarantee an 8GB V8 heap for the TUI. Default node cap is ~1.5–4GB
# depending on version and can fatal-OOM on long sessions with large
# transcripts / reasoning blobs. Token-level merge: respect any
Expand Down Expand Up @@ -1881,6 +1888,7 @@ def cmd_chat(args):
pass_session_id=getattr(args, "pass_session_id", False),
max_turns=getattr(args, "max_turns", None),
accept_hooks=getattr(args, "accept_hooks", False),
remote_control=getattr(args, "remote_control", False),
)

# Import and run the CLI
Expand Down
29 changes: 29 additions & 0 deletions tests/hermes_cli/test_argparse_flag_propagation.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,35 @@ def fake_main(**kwargs):
assert "verbose" not in captured


class TestRemoteControlArg:
"""Verify --remote-control/--rc parse on both top-level and chat forms."""

@pytest.mark.parametrize(
"argv",
[
["--tui", "--remote-control"],
["--tui", "--rc"],
["chat", "--tui", "--remote-control"],
["chat", "--tui", "--rc"],
],
)
def test_remote_control_flag_sets_attribute(self, argv):
from hermes_cli._parser import build_top_level_parser

parser, _subparsers, _chat_parser = build_top_level_parser()
args = parser.parse_args(argv)

assert args.remote_control is True

def test_chat_without_remote_control_preserves_parent_default(self):
from hermes_cli._parser import build_top_level_parser

parser, _subparsers, _chat_parser = build_top_level_parser()
args = parser.parse_args(["--tui", "chat"])

assert args.remote_control is False


class TestYoloEnvVar:
"""Verify --yolo sets HERMES_YOLO_MODE regardless of flag position.

Expand Down
65 changes: 65 additions & 0 deletions tests/hermes_cli/test_tui_resume_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ def _args(**overrides):
"toolsets": None,
"tui": True,
"tui_dev": False,
"remote_control": False,
}
base.update(overrides)
return Namespace(**base)
Expand Down Expand Up @@ -201,6 +202,7 @@ def fake_launch(resume_session_id=None, **kwargs):
pass_session_id=True,
max_turns=7,
accept_hooks=True,
remote_control=True,
)
)

Expand All @@ -214,6 +216,7 @@ def fake_launch(resume_session_id=None, **kwargs):
assert captured["pass_session_id"] is True
assert captured["max_turns"] == 7
assert captured["accept_hooks"] is True
assert captured["remote_control"] is True


def test_main_top_level_tui_accepts_toolsets(monkeypatch, main_mod):
Expand Down Expand Up @@ -896,6 +899,68 @@ def fake_call(argv, cwd=None, env=None):
assert env["NODE_ENV"] == "production"


def test_launch_tui_remote_control_sets_bridge_env(monkeypatch, main_mod):
captured = {}

monkeypatch.setattr(
main_mod,
"_make_tui_argv",
lambda tui_dir, tui_dev: (["node", "dist/entry.js"], Path(".")),
)
monkeypatch.setattr(
main_mod.subprocess,
"call",
lambda argv, cwd=None, env=None: captured.update({"env": env}) or 1,
)

with pytest.raises(SystemExit):
main_mod._launch_tui(remote_control=True)

assert captured["env"]["HERMES_TUI_REMOTE_BRIDGE"] == "1"


def test_launch_tui_without_remote_control_leaves_bridge_env_unset(monkeypatch, main_mod):
captured = {}

monkeypatch.delenv("HERMES_TUI_REMOTE_BRIDGE", raising=False)
monkeypatch.setattr(
main_mod,
"_make_tui_argv",
lambda tui_dir, tui_dev: (["node", "dist/entry.js"], Path(".")),
)
monkeypatch.setattr(
main_mod.subprocess,
"call",
lambda argv, cwd=None, env=None: captured.update({"env": env}) or 1,
)

with pytest.raises(SystemExit):
main_mod._launch_tui()

assert "HERMES_TUI_REMOTE_BRIDGE" not in captured["env"]


def test_launch_tui_remote_control_overrides_disabled_bridge_env(monkeypatch, main_mod):
captured = {}

monkeypatch.setenv("HERMES_TUI_REMOTE_BRIDGE", "0")
monkeypatch.setattr(
main_mod,
"_make_tui_argv",
lambda tui_dir, tui_dev: (["node", "dist/entry.js"], Path(".")),
)
monkeypatch.setattr(
main_mod.subprocess,
"call",
lambda argv, cwd=None, env=None: captured.update({"env": env}) or 1,
)

with pytest.raises(SystemExit):
main_mod._launch_tui(remote_control=True)

assert captured["env"]["HERMES_TUI_REMOTE_BRIDGE"] == "1"


def test_launch_tui_exit_code_42_relaunches_update(monkeypatch, main_mod):
from unittest.mock import patch

Expand Down
3 changes: 3 additions & 0 deletions tests/test_tui_gateway_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4206,7 +4206,10 @@ def run_conversation(self, prompt, conversation_history=None, stream_callback=No
monkeypatch.setattr(server, "_get_db", lambda: None)
monkeypatch.setattr(server, "_session_info", lambda agent: {"model": agent.model})

original_emit = server._emit

def _emit(event, sid, payload=None):
original_emit(event, sid, payload)
if event == "message.complete":
done.set()

Expand Down
Loading