From b06e2f846cf6631e9f679d81d66be3c5421ea355 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 9 Jul 2026 16:13:28 -0500 Subject: [PATCH] =?UTF-8?q?fix(kanban):=20no-TTY=20gate=20in=20=5Fwants=5F?= =?UTF-8?q?tui=5Fearly=20=E2=80=94=20the=20actual=20worker-crash=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier fix gated _resolve_use_tui, but the EARLY launcher (_wants_tui_early) decides TUI from display.interface before cmd_chat runs — so a `display.interface: tui` default still booted the Ink UI for headless spawns (kanban workers), whose no-TTY bail-out exits 0 → "protocol violation". Gate the early resolver on a real TTY: headless stdio never boots the TUI regardless of config; explicit --tui still does. --- hermes_cli/main.py | 17 ++++++++++++++++- .../test_default_interface_resolution.py | 16 +++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) 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