fix(cli): run headless when no terminal is attached instead of crashing the TUI

Without a tty on stdin and stdout (CI, nohup, pipes, cron, TERM=dumb) the Go
TUI handshakes fine and then exits 1 the moment Bubble Tea tries to take over
the screen. Because the backend was already activated, that exit escaped the
pre-activation mapping and surfaced as a raw traceback:
RuntimeError: Bubble Tea TUI exited with status 1.

Now main() checks for a terminal right after argument parsing. With a target
it switches to the headless path (same as -n) and prints one dim notice.
Without a target the start screen is the only way to enter one, so it prints
a panel telling the user to pass -t <target> -n and exits 1. A bare --resume
is left to the picker, which already lists runs when there is no terminal.

If the sidecar still dies after startup, check_return_code raises
TuiProcessExitedError, run_tui maps it to InteractiveInterfaceExitedError,
and main() prints an INTERACTIVE INTERFACE STOPPED panel with the -n hint
under its own telemetry name instead of re-raising.
This commit is contained in:
Ahmed Allam 2026-10-04 21:11:11 +00:00 • committed by Ahmed Allam
parent e3401c4fc7
commit 1ee5c2bcc6
5 changed files with 223 additions and 10 deletions

View file

@ -57,7 +57,9 @@ strix (--target <target> | --target-list <path>) [options]
</ParamField>
<ParamField path="--non-interactive, -n" type="boolean">
Run in headless mode without TUI. Ideal for CI/CD.
Run in headless mode without TUI. Ideal for CI/CD. When no terminal is
attached (CI, `nohup`, pipes) and a target is given, Strix runs headless
automatically and says so on one line.
</ParamField>
<ParamField path="--fail-on" type="string">

View file

@ -17,12 +17,17 @@ class InteractiveSetupUnavailableError(RuntimeError):
"""Raised when the interactive TUI cannot be launched."""
class InteractiveInterfaceExitedError(RuntimeError):
"""Raised when the TUI process dies after it was up (no terminal, killed)."""
async def run_tui(args: argparse.Namespace) -> None:
"""Run the Bubble Tea TUI."""
from strix.interface.tui.runtime import (
GoTuiPreActivationError,
run_go_tui,
)
from strix.interface.tui.sidecar import TuiProcessExitedError
try:
await run_go_tui(args)
@ -30,9 +35,12 @@ async def run_tui(args: argparse.Namespace) -> None:
raise InteractiveSetupUnavailableError(
f"The interactive interface could not start: {exc}"
) from exc
except TuiProcessExitedError as exc:
raise InteractiveInterfaceExitedError(str(exc)) from exc
__all__ = [
"InteractiveInterfaceExitedError",
"InteractiveSetupUnavailableError",
"run_tui",
]

View file

@ -7,6 +7,7 @@ import argparse
import asyncio
import contextlib
import logging
import os
import sys
from pathlib import Path
from typing import Any
@ -30,6 +31,7 @@ from strix.interface.environment import (
validate_environment,
)
from strix.interface.interactive import (
InteractiveInterfaceExitedError,
InteractiveSetupUnavailableError,
run_tui,
)
@ -345,6 +347,39 @@ def _print_cli_error(message: str) -> None:
)
HEADLESS_HINT = "If Strix runs without a terminal (CI, nohup, pipes), pass -n to run headless."
def _terminal_attached() -> bool:
"""Whether the TUI can take over the terminal: a tty on both ends, not dumb."""
if os.environ.get("TERM", "").strip().lower() == "dumb":
return False
return all(hasattr(stream, "isatty") and stream.isatty() for stream in (sys.stdin, sys.stdout))
def _fall_back_to_headless(args: argparse.Namespace) -> None:
"""Run headless when there is no terminal for the TUI to attach to.
CI jobs, ``nohup``, pipes and cron have no tty; the Go TUI exits as soon
as it tries to take over the screen. With a target the scan can still run
as if ``-n`` was given. Without one the start screen is the only way to
enter a target, so stop with the fix instead. A bare ``--resume`` is left
to the picker, which already explains itself without a terminal.
"""
if args.non_interactive or args.resume_picker or _terminal_attached():
return
if args.needs_setup:
report_error("no_terminal_for_setup")
_print_error_panel(
"NO TERMINAL ATTACHED",
"The interactive interface needs a terminal and no target was given.\n"
"Pass -t <target> -n to run headless.",
)
sys.exit(1)
args.non_interactive = True
Console().print("No terminal attached, running headless (same as -n).", style="dim")
def _pick_run_to_resume(args: argparse.Namespace) -> None:
"""A bare --resume: let the user pick a run, then load it like --resume <name>."""
from strix.interface.resume_picker import PickerUnavailableError, pick_run
@ -443,6 +478,7 @@ def main() -> None:
start_import_warmup()
args = parse_arguments()
_fall_back_to_headless(args)
start_background_check()
if not args.non_interactive and prompt_update_if_available(Console()):
@ -481,6 +517,11 @@ def main() -> None:
report_error("interactive_setup_unavailable", exc)
_print_error_panel("INTERACTIVE SETUP UNAVAILABLE", str(exc))
sys.exit(1)
except InteractiveInterfaceExitedError as exc:
exit_reason = "error"
report_error("interactive_interface_exited", exc)
_print_error_panel("INTERACTIVE INTERFACE STOPPED", f"{exc}.\n{HEADLESS_HINT}")
sys.exit(1)
except KeyboardInterrupt:
exit_reason = "interrupted"
except Exception as exc:

View file

@ -177,9 +177,17 @@ async def _launch_windows_tui_process(
return windows_process, connection
class TuiProcessExitedError(RuntimeError):
"""The Go TUI process ended with a non-zero status."""
def __init__(self, return_code: int) -> None:
super().__init__(f"Bubble Tea TUI exited with status {return_code}")
self.return_code = return_code
def check_return_code(return_code: int) -> None:
if return_code != 0:
raise RuntimeError(f"Bubble Tea TUI exited with status {return_code}")
raise TuiProcessExitedError(return_code)
def package_version() -> str:

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import argparse
import asyncio
import importlib
import sys
from types import SimpleNamespace
@ -11,24 +12,41 @@ import pytest
cli_main: Any = importlib.import_module("strix.interface.main")
report_state_module: Any = importlib.import_module("strix.report.state")
interactive: Any = importlib.import_module("strix.interface.interactive")
tui_runtime: Any = importlib.import_module("strix.interface.tui.runtime")
tui_sidecar: Any = importlib.import_module("strix.interface.tui.sidecar")
def _launch(
monkeypatch: pytest.MonkeyPatch, *, needs_setup: bool, resume_picker: bool = False
monkeypatch: pytest.MonkeyPatch,
*,
needs_setup: bool,
resume_picker: bool = False,
terminal: bool = True,
args: argparse.Namespace | None = None,
) -> list[str]:
calls: list[str] = []
args = argparse.Namespace(
non_interactive=False,
needs_setup=needs_setup,
resume_picker=resume_picker,
run_name=None,
fail_on=None,
)
if args is None:
args = argparse.Namespace(
non_interactive=False,
needs_setup=needs_setup,
resume_picker=resume_picker,
run_name=None,
fail_on=None,
)
async def run_tui(_args: argparse.Namespace) -> None:
calls.append("tui")
async def run_cli(_args: argparse.Namespace) -> None:
calls.append("cli")
monkeypatch.setattr(sys, "argv", ["strix", "--target", "https://example.com"])
monkeypatch.setattr(cli_main, "_terminal_attached", lambda: terminal)
monkeypatch.setattr("strix.interface.cli.run_cli", run_cli)
monkeypatch.setattr(cli_main, "report_error", lambda *_args, **_kwargs: None)
monkeypatch.setattr(cli_main, "_print_error_panel", lambda title, _msg: calls.append(title))
monkeypatch.setattr(cli_main, "display_completion_message", lambda *_args: None)
monkeypatch.setattr(cli_main, "setup_console_logging", lambda: None)
monkeypatch.setattr(cli_main, "start_import_warmup", lambda: None)
monkeypatch.setattr(cli_main, "parse_arguments", lambda: args)
@ -103,6 +121,7 @@ def test_tui_startup_failure_marks_the_prepared_run_failed(monkeypatch: pytest.M
raise cli_main.InteractiveSetupUnavailableError("no sidecar")
monkeypatch.setattr(sys, "argv", ["strix", "--target", "https://example.com"])
monkeypatch.setattr(cli_main, "_terminal_attached", lambda: True)
monkeypatch.setattr(cli_main, "setup_console_logging", lambda: None)
monkeypatch.setattr(cli_main, "start_import_warmup", lambda: None)
monkeypatch.setattr(cli_main, "parse_arguments", lambda: args)
@ -125,3 +144,138 @@ def test_tui_startup_failure_marks_the_prepared_run_failed(monkeypatch: pytest.M
assert exit_info.value.code == 1
assert calls == ["bootstrap", "panel", "cleanup:failed"]
def test_no_terminal_with_a_target_runs_headless_with_a_notice(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
args = argparse.Namespace(
non_interactive=False, needs_setup=False, resume_picker=False, run_name=None, fail_on=None
)
calls = _launch(monkeypatch, needs_setup=False, terminal=False, args=args)
assert calls == ["bootstrap", "cli"]
assert args.non_interactive is True
assert "No terminal attached, running headless (same as -n)." in capsys.readouterr().out
def test_no_terminal_without_a_target_stops_with_the_headless_hint(
monkeypatch: pytest.MonkeyPatch,
) -> None:
panels: list[tuple[str, str]] = []
args = argparse.Namespace(
non_interactive=False, needs_setup=True, resume_picker=False, run_name=None, fail_on=None
)
monkeypatch.setattr(cli_main, "_terminal_attached", lambda: False)
monkeypatch.setattr(cli_main, "report_error", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
cli_main, "_print_error_panel", lambda title, msg: panels.append((title, msg))
)
with pytest.raises(SystemExit) as exit_info:
cli_main._fall_back_to_headless(args)
assert exit_info.value.code == 1
assert panels == [
(
"NO TERMINAL ATTACHED",
"The interactive interface needs a terminal and no target was given.\n"
"Pass -t <target> -n to run headless.",
)
]
assert args.non_interactive is False
def test_no_terminal_leaves_a_bare_resume_to_the_picker(monkeypatch: pytest.MonkeyPatch) -> None:
args = argparse.Namespace(
non_interactive=False, needs_setup=False, resume_picker=True, run_name=None, fail_on=None
)
monkeypatch.setattr(cli_main, "_terminal_attached", lambda: False)
cli_main._fall_back_to_headless(args)
assert args.non_interactive is False
def test_terminal_attached_needs_a_tty_on_both_ends_and_a_real_term(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def stream(*, tty: bool) -> SimpleNamespace:
return SimpleNamespace(isatty=lambda: tty)
monkeypatch.delenv("TERM", raising=False)
monkeypatch.setattr(sys, "stdin", stream(tty=True))
monkeypatch.setattr(sys, "stdout", stream(tty=True))
assert cli_main._terminal_attached() is True
monkeypatch.setattr(sys, "stdout", stream(tty=False))
assert cli_main._terminal_attached() is False
monkeypatch.setattr(sys, "stdout", stream(tty=True))
monkeypatch.setattr(sys, "stdin", stream(tty=False))
assert cli_main._terminal_attached() is False
monkeypatch.setattr(sys, "stdin", stream(tty=True))
monkeypatch.setenv("TERM", "dumb")
assert cli_main._terminal_attached() is False
def test_tui_process_dying_after_startup_prints_a_panel_instead_of_a_traceback(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[str] = []
panels: list[tuple[str, str]] = []
report_state = SimpleNamespace(cleanup=lambda status: calls.append(f"cleanup:{status}"))
args = argparse.Namespace(
non_interactive=False, needs_setup=False, resume_picker=False, run_name="run", fail_on=None
)
async def run_tui(_args: argparse.Namespace) -> None:
raise cli_main.InteractiveInterfaceExitedError("Bubble Tea TUI exited with status 1")
monkeypatch.setattr(sys, "argv", ["strix", "--target", "https://example.com"])
monkeypatch.setattr(cli_main, "_terminal_attached", lambda: True)
monkeypatch.setattr(cli_main, "setup_console_logging", lambda: None)
monkeypatch.setattr(cli_main, "start_import_warmup", lambda: None)
monkeypatch.setattr(cli_main, "parse_arguments", lambda: args)
monkeypatch.setattr(cli_main, "start_background_check", lambda: None)
monkeypatch.setattr(cli_main, "prompt_update_if_available", lambda _console: False)
monkeypatch.setattr(cli_main, "check_docker_installed", lambda: None)
monkeypatch.setattr(cli_main, "pull_docker_image", lambda: None)
monkeypatch.setattr(cli_main, "validate_environment", lambda: None)
monkeypatch.setattr(cli_main, "wait_for_import_warmup", lambda: None)
monkeypatch.setattr(cli_main, "_bootstrap_scan", lambda _args: calls.append("bootstrap"))
monkeypatch.setattr(cli_main, "run_tui", run_tui)
monkeypatch.setattr(cli_main, "report_error", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
cli_main, "_print_error_panel", lambda title, msg: panels.append((title, msg))
)
monkeypatch.setattr(cli_main.posthog, "end", lambda *_args, **_kwargs: None)
monkeypatch.setattr(cli_main.scarf, "end", lambda *_args, **_kwargs: None)
monkeypatch.setattr(report_state_module, "get_global_report_state", lambda: report_state)
with pytest.raises(SystemExit) as exit_info:
cli_main.main()
assert exit_info.value.code == 1
assert calls == ["bootstrap", "cleanup:failed"]
assert panels == [
(
"INTERACTIVE INTERFACE STOPPED",
"Bubble Tea TUI exited with status 1.\n"
"If Strix runs without a terminal (CI, nohup, pipes), pass -n to run headless.",
)
]
def test_run_tui_maps_a_dead_sidecar_to_the_interface_exited_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def run_go_tui(_args: argparse.Namespace) -> None:
tui_sidecar.check_return_code(1)
monkeypatch.setattr(tui_runtime, "run_go_tui", run_go_tui)
with pytest.raises(interactive.InteractiveInterfaceExitedError, match="status 1"):
asyncio.run(interactive.run_tui(argparse.Namespace()))