diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 2c5b0361662..ddaa3ee3198 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -148,7 +148,17 @@ def _wants_tui_early(argv: "list[str] | None" = None) -> bool: """Earliest TUI decision, usable before argparse/config imports. Precedence: explicit ``--cli`` wins (forces classic REPL), then - ``--tui``/``HERMES_TUI=1``, then ``display.interface`` in config. + explicit ``--tui``/``HERMES_TUI=1``, then a real-TTY gate (a + non-interactive stdio can't host the Ink UI, so ambient config never + boots it there), then ``display.interface`` in config. + + The TTY gate is load-bearing for headless spawners — kanban workers, + cron jobs, pipes run ``hermes … chat -q`` with stdio on a pipe. This + is the earliest launch decision (it runs before ``cmd_chat`` / + ``_resolve_use_tui``), so a ``display.interface: tui`` default used to + boot the TUI here — whose no-TTY bail-out exits 0 without doing the + task → "protocol violation" on every attempt. An explicit ``--tui`` + still reaches the informative bail-out. """ if argv is None: argv = sys.argv[1:] @@ -156,6 +166,11 @@ def _wants_tui_early(argv: "list[str] | None" = None) -> bool: return False if os.environ.get("HERMES_TUI") == "1" or "--tui" in argv: return True + try: + if not (sys.stdin.isatty() and sys.stdout.isatty()): + return False + except Exception: + return False return _config_default_interface_early() == "tui" diff --git a/tests/hermes_cli/test_default_interface_resolution.py b/tests/hermes_cli/test_default_interface_resolution.py index ce7c5d8b55c..85208638ec6 100644 --- a/tests/hermes_cli/test_default_interface_resolution.py +++ b/tests/hermes_cli/test_default_interface_resolution.py @@ -153,10 +153,24 @@ class TestWantsTuiEarly: return _make - def test_config_tui_bare_argv(self, home_with_interface): + def test_config_tui_bare_argv(self, home_with_interface, monkeypatch): home_with_interface("tui") + _fake_tty(monkeypatch, True) # config-tui only applies on a real TTY assert m._wants_tui_early([]) is True + def test_no_tty_blocks_config_tui(self, home_with_interface, monkeypatch): + # Headless (worker/cron/pipe): ambient config-tui must not boot the + # Ink UI in the earliest launch decision — that's the crash the + # kanban worker hit before the gate existed. + home_with_interface("tui") + _fake_tty(monkeypatch, False) + assert m._wants_tui_early([]) is False + + def test_explicit_tui_flag_survives_no_tty(self, home_with_interface, monkeypatch): + home_with_interface("cli") + _fake_tty(monkeypatch, False) + assert m._wants_tui_early(["--tui"]) is True + def test_cli_flag_overrides_config_tui(self, home_with_interface): home_with_interface("tui") assert m._wants_tui_early(["--cli"]) is False