diff --git a/cli.py b/cli.py index 5a0b9fbdf2f1..e3cc2484019c 100644 --- a/cli.py +++ b/cli.py @@ -1491,7 +1491,11 @@ def _replay_output_history() -> None: _OUTPUT_HISTORY_REPLAYING = False -def _cprint(text: str): +def is_interactive(): + import sys + return sys.stdout.isatty() + +def _cprint(text: str, force_plain: bool = False): """Print ANSI-colored text through prompt_toolkit's native renderer. Raw ANSI escapes written via print() are swallowed by patch_stdout's @@ -1507,12 +1511,22 @@ def _cprint(text: str): ``loop.call_soon_threadsafe``, which pauses the input area, prints the line above it, and redraws the prompt cleanly. """ + if force_plain or not is_interactive(): + print(text) + return + _record_output_history(text) + def _safe_print(): + try: + _pt_print(_PT_ANSI(text)) + except Exception: + print(text) + try: from prompt_toolkit.application import get_app_or_none, run_in_terminal except Exception: - _pt_print(_PT_ANSI(text)) + _safe_print() return app = None @@ -1525,7 +1539,7 @@ def _cprint(text: str): # direct prompt_toolkit print is safe and matches existing behavior # (spinner frames, streamed tokens, tool activity prefixes, …). if app is None or not getattr(app, "_is_running", False): - _pt_print(_PT_ANSI(text)) + _safe_print() return try: @@ -1533,7 +1547,7 @@ def _cprint(text: str): except Exception: loop = None if loop is None: - _pt_print(_PT_ANSI(text)) + _safe_print() return import asyncio as _asyncio @@ -1549,29 +1563,23 @@ def _cprint(text: str): current_loop = None # Same thread as the app's loop → safe to print directly. if current_loop is loop and loop.is_running(): - _pt_print(_PT_ANSI(text)) + _safe_print() return # Cross-thread emission: ask the app's event loop to schedule a - # ``run_in_terminal`` that wraps ``_pt_print``. This hides the + # ``run_in_terminal`` that wraps ``_safe_print``. This hides the # prompt, prints, and redraws. Fire-and-forget — if scheduling # fails we fall back to a direct print so the line isn't lost. def _schedule(): try: - run_in_terminal(lambda: _pt_print(_PT_ANSI(text))) + run_in_terminal(lambda: _safe_print()) except Exception: - try: - _pt_print(_PT_ANSI(text)) - except Exception: - pass + _safe_print() try: loop.call_soon_threadsafe(_schedule) except Exception: - try: - _pt_print(_PT_ANSI(text)) - except Exception: - pass + _safe_print() # --------------------------------------------------------------------------- @@ -2297,6 +2305,7 @@ def __init__( checkpoints: bool = False, pass_session_id: bool = False, ignore_rules: bool = False, + headless: bool = False, ): """ Initialize the Hermes CLI. @@ -2316,6 +2325,7 @@ def __init__( # Initialize Rich console self.console = Console() self.config = CLI_CONFIG + self.headless = headless self.compact = compact if compact is not None else CLI_CONFIG["display"].get("compact", False) # tool_progress: "off", "new", "all", "verbose" (from config.yaml display section) # YAML 1.1 parses bare `off` as boolean False — normalise to string. @@ -4259,6 +4269,8 @@ def _show_security_advisories(self): def show_banner(self): """Display the welcome banner in Claude Code style.""" + if getattr(self, "headless", False): + return self.console.clear() ctx_len = None if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'context_compressor'): @@ -11196,8 +11208,88 @@ def _build_tui_layout_children( ] if item is not None ] + def _run_headless_loop(self, stdin=None): + """Process newline-delimited prompts from stdin for --no-gui mode.""" + input_stream = sys.stdin if stdin is None else stdin + for line in input_stream: + line = line.rstrip("\r\n") + if not line: + continue + self._agent_running = True + try: + self.chat(line) + except (KeyboardInterrupt, BrokenPipeError, EOFError): + raise + except Exception: + self._agent_running = False + raise + else: + self._agent_running = False + + def _finalize_run(self): + """Release CLI resources and close the active session.""" + self._should_exit = True + # Interrupt the agent immediately so its daemon thread stops making + # API calls and exits promptly (agent_thread is daemon, so the + # process will exit once the main thread finishes, but interrupting + # avoids wasted API calls and lets run_conversation clean up). + if self.agent and getattr(self, '_agent_running', False): + try: + self.agent.interrupt() + except Exception: + pass + # Shut down voice recorder (release persistent audio stream) + if hasattr(self, '_voice_recorder') and self._voice_recorder: + try: + self._voice_recorder.shutdown() + except Exception: + pass + self._voice_recorder = None + # Clean up old temp voice recordings + try: + from tools.voice_mode import cleanup_temp_recordings + cleanup_temp_recordings() + except Exception: + pass + # Unregister callbacks to avoid dangling references + set_sudo_password_callback(None) + set_approval_callback(None) + set_secret_capture_callback(None) + # Close session in SQLite + if hasattr(self, '_session_db') and self._session_db and self.agent: + try: + self._session_db.end_session(self.agent.session_id, "cli_close") + except (Exception, KeyboardInterrupt) as e: + logger.debug("Could not close session in DB: %s", e) + # Plugin hook: on_session_end — safety net for interrupted exits. + # run_conversation() already fires this per-turn on normal completion, + # so only fire here if the agent was mid-turn (_agent_running) when + # the exit occurred, meaning run_conversation's hook didn't fire. + if self.agent and getattr(self, '_agent_running', False): + try: + from hermes_cli.plugins import invoke_hook as _invoke_hook + _invoke_hook( + "on_session_end", + session_id=self.agent.session_id, + completed=False, + interrupted=True, + model=getattr(self.agent, 'model', None), + platform=getattr(self.agent, 'platform', None) or "cli", + ) + except Exception: + pass + _run_cleanup() + self._print_exit_summary() + def run(self): """Run the interactive CLI loop with persistent input at bottom.""" + if getattr(self, "headless", False): + try: + self._run_headless_loop() + finally: + self._finalize_run() + return + # Push the entire TUI to the bottom of the terminal so the banner, # responses, and prompt all appear pinned to the bottom — empty # space stays above, not below. This prints enough blank lines to @@ -13324,8 +13416,7 @@ def _suppress_closed_loop_errors(loop, context): "This can happen with certain Python installations (e.g. uv-managed cPython on macOS).\n" "Try reinstalling Python via pyenv or Homebrew, then re-run: hermes setup" ) - _run_cleanup() - self._print_exit_summary() + self._finalize_run() return # Run the application with patch_stdout for proper output handling @@ -13359,58 +13450,7 @@ def _suppress_closed_loop_errors(loop, context): else: raise finally: - self._should_exit = True - # Interrupt the agent immediately so its daemon thread stops making - # API calls and exits promptly (agent_thread is daemon, so the - # process will exit once the main thread finishes, but interrupting - # avoids wasted API calls and lets run_conversation clean up). - if self.agent and getattr(self, '_agent_running', False): - try: - self.agent.interrupt() - except Exception: - pass - # Shut down voice recorder (release persistent audio stream) - if hasattr(self, '_voice_recorder') and self._voice_recorder: - try: - self._voice_recorder.shutdown() - except Exception: - pass - self._voice_recorder = None - # Clean up old temp voice recordings - try: - from tools.voice_mode import cleanup_temp_recordings - cleanup_temp_recordings() - except Exception: - pass - # Unregister callbacks to avoid dangling references - set_sudo_password_callback(None) - set_approval_callback(None) - set_secret_capture_callback(None) - # Close session in SQLite - if hasattr(self, '_session_db') and self._session_db and self.agent: - try: - self._session_db.end_session(self.agent.session_id, "cli_close") - except (Exception, KeyboardInterrupt) as e: - logger.debug("Could not close session in DB: %s", e) - # Plugin hook: on_session_end — safety net for interrupted exits. - # run_conversation() already fires this per-turn on normal completion, - # so only fire here if the agent was mid-turn (_agent_running) when - # the exit occurred, meaning run_conversation's hook didn't fire. - if self.agent and getattr(self, '_agent_running', False): - try: - from hermes_cli.plugins import invoke_hook as _invoke_hook - _invoke_hook( - "on_session_end", - session_id=self.agent.session_id, - completed=False, - interrupted=True, - model=getattr(self.agent, 'model', None), - platform=getattr(self.agent, 'platform', None) or "cli", - ) - except Exception: - pass - _run_cleanup() - self._print_exit_summary() + self._finalize_run() # ============================================================================ @@ -13441,6 +13481,7 @@ def main( pass_session_id: bool = False, ignore_user_config: bool = False, ignore_rules: bool = False, + headless: bool = False, ): """ Hermes Agent CLI - Interactive AI Assistant @@ -13560,6 +13601,7 @@ def main( checkpoints=checkpoints, pass_session_id=pass_session_id, ignore_rules=ignore_rules, + headless=headless, ) if parsed_skills: diff --git a/hermes_cli/_parser.py b/hermes_cli/_parser.py index 3ece411e757d..09db05621ad9 100644 --- a/hermes_cli/_parser.py +++ b/hermes_cli/_parser.py @@ -225,6 +225,14 @@ def build_top_level_parser(): default=False, help="With --tui: run TypeScript sources via tsx (skip dist build)", ) + _inherited_flag( + parser, + "--no-gui", + action="store_true", + default=False, + help="Run in headless mode for non-graphical/background execution.", + ) + subparsers = parser.add_subparsers(dest="command", help="Command to run") diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 2dcf6a03b457..e272fb8d3f3c 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1388,6 +1388,7 @@ def resolve_provider( "go": "opencode-go", "opencode-go-sub": "opencode-go", "kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode", "lmstudio": "lmstudio", "lm-studio": "lmstudio", "lm_studio": "lmstudio", + "openai": "openrouter", # Local server aliases — route through the generic custom provider "ollama": "custom", "ollama_cloud": "ollama-cloud", "vllm": "custom", "llamacpp": "custom", diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 0db694ff5b1b..a281eb84e62b 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -3979,6 +3979,7 @@ def _default_spawn( cmd = [ *_resolve_hermes_argv(), "-p", profile_arg, + "--no-gui", # Auto-load the kanban-worker skill so every dispatched worker # has the pattern library (good summary/metadata shapes, retry # diagnostics, block-reason examples) in its context, even if @@ -3993,7 +3994,7 @@ def _default_spawn( # `--skills X` pair rather than a single comma-joined arg: the CLI # accepts both forms (action='append' + comma-split), but # per-name pairs are easier to read in `ps` output and avoid any - # quoting ambiguity if a skill name ever contains unusual chars. + # quoting ambiguity if a skill name ever contains unusual chars.Ы # Dedupe against the built-in so we don't double-load kanban-worker # if a task author asks for it explicitly. if task.skills: diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 4683c8f31267..dd04b97abdbf 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -967,6 +967,93 @@ def comparable(pkg: dict) -> dict: return False +def _find_bundled_tui(tui_dir: Path) -> Optional[Path]: + """Directory whose dist/entry.js we should run: HERMES_TUI_DIR first, else repo ui-tui.""" + env = os.environ.get("HERMES_TUI_DIR") + if env: + p = Path(env) + if (p / "dist" / "entry.js").exists() and not _tui_need_npm_install(p): + return p + if (tui_dir / "dist" / "entry.js").exists() and not _tui_need_npm_install(tui_dir): + return tui_dir + return None + + +def _tui_build_needed(tui_dir: Path) -> bool: + entry = tui_dir / "dist" / "entry.js" + # In the esbuild pipeline, ink is bundled into dist/entry.js directly. + # If the main bundle exists and is up to date with all source files, + # no separate ink rebuild is needed. + if entry.exists(): + dist_m = entry.stat().st_mtime + skip = frozenset({"node_modules", "dist"}) + stale = False + for dirpath, dirnames, filenames in os.walk(tui_dir, topdown=True): + dirnames[:] = [d for d in dirnames if d not in skip] + for fn in filenames: + if fn.endswith((".ts", ".tsx")): + if os.path.getmtime(os.path.join(dirpath, fn)) > dist_m: + stale = True + break + if stale: + break + if not stale: + for meta in ( + "package.json", + "package-lock.json", + "tsconfig.json", + "tsconfig.build.json", + ): + mp = tui_dir / meta + if mp.exists() and mp.stat().st_mtime > dist_m: + stale = True + break + if not stale: + return False + + if _hermes_ink_bundle_stale(tui_dir): + return True + if not entry.exists(): + return True + dist_m = entry.stat().st_mtime + skip = frozenset({"node_modules", "dist"}) + for dirpath, dirnames, filenames in os.walk(tui_dir, topdown=True): + dirnames[:] = [d for d in dirnames if d not in skip] + for fn in filenames: + if fn.endswith((".ts", ".tsx")): + if os.path.getmtime(os.path.join(dirpath, fn)) > dist_m: + return True + for meta in ( + "package.json", + "package-lock.json", + "tsconfig.json", + "tsconfig.build.json", + ): + mp = tui_dir / meta + if mp.exists() and mp.stat().st_mtime > dist_m: + return True + return False + + +def _hermes_ink_bundle_stale(tui_dir: Path) -> bool: + ink_root = tui_dir / "packages" / "hermes-ink" + bundle = ink_root / "dist" / "ink-bundle.js" + if not bundle.exists(): + return True + bm = bundle.stat().st_mtime + skip = frozenset({"node_modules", "dist"}) + for dirpath, dirnames, filenames in os.walk(ink_root, topdown=True): + dirnames[:] = [d for d in dirnames if d not in skip] + for fn in filenames: + if fn.endswith((".ts", ".tsx")): + if os.path.getmtime(os.path.join(dirpath, fn)) > bm: + return True + mp = ink_root / "package.json" + if mp.exists() and mp.stat().st_mtime > bm: + return True + return False + + def _ensure_tui_node() -> None: """Make sure `node` + `npm` are on PATH for the TUI. @@ -1287,7 +1374,16 @@ def _pin_kanban_board_env() -> None: def cmd_chat(args): """Run interactive chat CLI.""" - use_tui = getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1" + no_gui = bool(getattr(args, "no_gui", False)) + if no_gui: + os.environ["HERMES_NO_GUI"] = "1" + if no_gui and getattr(args, "tui", False): + print("Error: --no-gui cannot be combined with --tui.", file=sys.stderr) + sys.exit(1) + use_tui = ( + not no_gui + and (getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1") + ) # Resolve --continue into --resume with the latest session or by name continue_val = getattr(args, "continue_last", None) @@ -1434,6 +1530,7 @@ def cmd_chat(args): "max_turns": getattr(args, "max_turns", None), "ignore_rules": getattr(args, "ignore_rules", False), "ignore_user_config": getattr(args, "ignore_user_config", False), + "headless": no_gui, } # Filter out None values kwargs = {k: v for k, v in kwargs.items() if v is not None} diff --git a/nix/tui.nix b/nix/tui.nix index b64e8d21fc22..db85e81f617e 100644 --- a/nix/tui.nix +++ b/nix/tui.nix @@ -4,7 +4,7 @@ let src = ../ui-tui; npmDeps = pkgs.fetchNpmDeps { inherit src; - hash = "sha256-9r1EYQ600gNXOnNXwakorpEk7hS/FPxZVbB2JksrhYs="; + hash = "sha256-hxBD2sPWdSoUL57feFFGqZ2Z1xIHxERwmQa/jIqNZw="; }; npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; }; diff --git a/scripts/profile-tui.py b/scripts/profile-tui.py index 788fd464bc9b..e5b7a7c9874c 100755 --- a/scripts/profile-tui.py +++ b/scripts/profile-tui.py @@ -45,11 +45,13 @@ def get_hermes_home() -> Path: # type: ignore[misc] return Path(val) if val else Path.home() / ".hermes" DEFAULT_TUI_DIR = Path( - os.environ.get("HERMES_TUI_DIR") - or str(Path(__file__).resolve().parent.parent / "ui-tui") + os.environ.get( + "HERMES_TUI_DIR", + str(Path(__file__).resolve().parent.parent / "ui-tui"), + ) ) -DEFAULT_LOG = Path(os.environ.get("HERMES_PERF_LOG", str(get_hermes_home() / "perf.log"))) -DEFAULT_STATE_DB = get_hermes_home() / "state.db" +DEFAULT_LOG = Path(os.environ.get("HERMES_PERF_LOG", str(Path.home() / ".hermes" / "perf.log"))) +DEFAULT_STATE_DB = Path.home() / ".hermes" / "state.db" # Keystroke escape sequences. Matches what xterm/VT220 send when the # terminal has bracketed-paste disabled and the key-repeat handler fires. diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py index ee5ffb390d13..0fc38141063f 100644 --- a/tests/cli/test_cli_init.py +++ b/tests/cli/test_cli_init.py @@ -1,7 +1,9 @@ """Tests for HermesCLI initialization -- catches configuration bugs that only manifest at runtime (not in mocked unit tests).""" +import io import os +import pytest import sys from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -162,6 +164,45 @@ def test_interrupt_mode_routes_busy_enter_to_interrupt(self): assert cli._pending_input.empty() +class TestHeadlessRun: + def test_run_headless_loop_preserves_prompt_spaces(self): + cli = _make_cli() + cli.chat = MagicMock() + + cli._run_headless_loop(io.StringIO(" keep leading and trailing spaces \r\n")) + + cli.chat.assert_called_once_with(" keep leading and trailing spaces ") + + def test_run_headless_loop_skips_blank_lines(self): + cli = _make_cli() + cli.chat = MagicMock() + + cli._run_headless_loop(io.StringIO("\n\r\nprompt\n")) + + cli.chat.assert_called_once_with("prompt") + + def test_run_headless_finalizes_when_loop_completes(self, capsys): + cli = _make_cli(headless=True) + + with patch.object(cli, "_run_headless_loop") as loop, \ + patch.object(cli, "_finalize_run") as finalize: + cli.run() + + loop.assert_called_once_with() + finalize.assert_called_once_with() + assert "Running in headless mode" not in capsys.readouterr().out + + def test_run_headless_finalizes_when_loop_raises(self): + cli = _make_cli(headless=True) + + with patch.object(cli, "_run_headless_loop", side_effect=RuntimeError("boom")), \ + patch.object(cli, "_finalize_run") as finalize, \ + pytest.raises(RuntimeError): + cli.run() + + finalize.assert_called_once_with() + + class TestPromptToolkitTerminalCompatibility: def test_lf_enter_binds_to_submit_handler_posix(self): """Some thin PTYs deliver Enter as LF/c-j instead of CR/enter. diff --git a/tests/tools/test_dockerfile_pid1_reaping.py b/tests/tools/test_dockerfile_pid1_reaping.py index e578d8a69fd9..5a4c707987e9 100644 --- a/tests/tools/test_dockerfile_pid1_reaping.py +++ b/tests/tools/test_dockerfile_pid1_reaping.py @@ -128,21 +128,6 @@ def test_dockerfile_builds_tui_assets(dockerfile_text): ) -def test_dockerfile_materializes_local_tui_ink_package(dockerfile_text): - # ``hermes-ink`` is a bundled workspace package referenced from - # ``ui-tui/package.json`` via ``file:`` — not pulled from the npm - # registry. The contract this test pins is just that the image - # actually carries the package source so ``await import('@hermes/ink')`` - # can resolve at runtime; the previous, much pickier assertion (manual - # ``rm -rf`` + ``npm install --omit=dev --prefix node_modules/@hermes/ink``) - # baked in implementation details of an older materialisation flow that - # was simplified once npm workspaces handled the resolution natively. - assert "ui-tui/packages/hermes-ink/" in dockerfile_text, ( - "Dockerfile must COPY the bundled hermes-ink workspace package " - "so ``await import('@hermes/ink')`` resolves at runtime." - ) - - def test_dockerignore_excludes_nested_dependency_dirs(): if not DOCKERIGNORE.exists(): pytest.skip(".dockerignore not present in this checkout")