diff --git a/cli.py b/cli.py index eb19f43f196f..097c62d525ad 100644 --- a/cli.py +++ b/cli.py @@ -63,14 +63,14 @@ format_duration_compact, format_token_count_compact, ) -from hermes_cli.banner import _format_context_length, format_banner_version_label +from hermes_cli.banner import _format_context_length _COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") # Load .env from ~/.hermes/.env first, then project root as dev fallback. # User-managed env files should override stale shell exports on restart. -from hermes_constants import get_hermes_home, display_hermes_home +from hermes_constants import get_hermes_home, display_hermes_home, OPENROUTER_BASE_URL from hermes_cli.env_loader import load_hermes_dotenv _hermes_home = get_hermes_home() @@ -276,7 +276,7 @@ def load_cli_config() -> Dict[str, Any]: "show_reasoning": False, "streaming": True, "busy_input_mode": "interrupt", - + "terminal_title": True, # Set tab/window title via OSC sequences (disable for tmux/screen or if job name is appended by your terminal profile) "skin": "default", }, "clarify": { @@ -523,21 +523,6 @@ def load_cli_config() -> Dict[str, Any]: # Load configuration at module startup CLI_CONFIG = load_cli_config() -# Initialize centralized logging early — agent.log + errors.log in ~/.hermes/logs/. -# This ensures CLI sessions produce a log trail even before AIAgent is instantiated. -try: - from hermes_logging import setup_logging - setup_logging(mode="cli") -except Exception: - pass # Logging setup is best-effort — don't crash the CLI - -# Validate config structure early — print warnings before user hits cryptic errors -try: - from hermes_cli.config import print_config_warnings - print_config_warnings() -except Exception: - pass - # Initialize the skin engine from config try: from hermes_cli.skin_engine import init_skin_from_config @@ -625,11 +610,6 @@ def _run_cleanup(): pass # Shut down memory provider (on_session_end + shutdown_all) at actual # session boundary — NOT per-turn inside run_conversation(). - try: - from hermes_cli.plugins import invoke_hook as _invoke_hook - _invoke_hook("on_session_finalize", session_id=_active_agent_ref.session_id if _active_agent_ref else None, platform="cli") - except Exception: - pass try: if _active_agent_ref and hasattr(_active_agent_ref, 'shutdown_memory_provider'): _active_agent_ref.shutdown_memory_provider( @@ -773,10 +753,7 @@ def _setup_worktree(repo_root: str = None) -> Optional[Dict[str, str]]: def _cleanup_worktree(info: Dict[str, str] = None) -> None: """Remove a worktree and its branch on exit. - Preserves the worktree only if it has unpushed commits (real work - that hasn't been pushed to any remote). Uncommitted changes alone - (untracked files, test artifacts) are not enough to keep it — agent - work lives in commits/PRs, not the working tree. + If the worktree has uncommitted changes, warn and keep it. """ global _active_worktree info = info or _active_worktree @@ -792,27 +769,23 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None: if not Path(wt_path).exists(): return - # Check for unpushed commits — commits reachable from HEAD but not - # from any remote branch. These represent real work the agent did - # but didn't push. - has_unpushed = False + # Check for uncommitted changes try: - result = subprocess.run( - ["git", "log", "--oneline", "HEAD", "--not", "--remotes"], + status = subprocess.run( + ["git", "status", "--porcelain"], capture_output=True, text=True, timeout=10, cwd=wt_path, ) - has_unpushed = bool(result.stdout.strip()) + has_changes = bool(status.stdout.strip()) except Exception: - has_unpushed = True # Assume unpushed on error — don't delete + has_changes = True # Assume dirty on error — don't delete - if has_unpushed: - print(f"\n\033[33m⚠ Worktree has unpushed commits, keeping: {wt_path}\033[0m") - print(f" To clean up manually: git worktree remove --force {wt_path}") + if has_changes: + print(f"\n\033[33m⚠ Worktree has uncommitted changes, keeping: {wt_path}\033[0m") + print(f" To clean up manually: git worktree remove {wt_path}") _active_worktree = None return - # Remove worktree (even if working tree is dirty — uncommitted - # changes without unpushed commits are just artifacts) + # Remove worktree try: subprocess.run( ["git", "worktree", "remove", wt_path, "--force"], @@ -821,7 +794,7 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None: except Exception as e: logger.debug("Failed to remove worktree: %s", e) - # Delete the branch + # Delete the branch (only if it was never pushed / has no upstream) try: subprocess.run( ["git", "branch", "-D", branch], @@ -835,27 +808,19 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None: def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None: - """Remove stale worktrees and orphaned branches on startup. - - Age-based tiers: - - Under max_age_hours (24h): skip — session may still be active. - - 24h–72h: remove if no unpushed commits. - - Over 72h: force remove regardless (nothing should sit this long). + """Remove worktrees older than max_age_hours that have no uncommitted changes. - Also prunes orphaned ``hermes/*`` and ``pr-*`` local branches that - have no corresponding worktree. + Runs silently on startup to clean up after crashed/killed sessions. """ import subprocess import time worktrees_dir = Path(repo_root) / ".worktrees" if not worktrees_dir.exists(): - _prune_orphaned_branches(repo_root) return now = time.time() - soft_cutoff = now - (max_age_hours * 3600) # 24h default - hard_cutoff = now - (max_age_hours * 3 * 3600) # 72h default + cutoff = now - (max_age_hours * 3600) for entry in worktrees_dir.iterdir(): if not entry.is_dir() or not entry.name.startswith("hermes-"): @@ -864,24 +829,21 @@ def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None: # Check age try: mtime = entry.stat().st_mtime - if mtime > soft_cutoff: + if mtime > cutoff: continue # Too recent — skip except Exception: continue - force = mtime <= hard_cutoff # Over 72h — force remove - - if not force: - # 24h–72h tier: only remove if no unpushed commits - try: - result = subprocess.run( - ["git", "log", "--oneline", "HEAD", "--not", "--remotes"], - capture_output=True, text=True, timeout=5, cwd=str(entry), - ) - if result.stdout.strip(): - continue # Has unpushed commits — skip - except Exception: - continue # Can't check — skip + # Check for uncommitted changes + try: + status = subprocess.run( + ["git", "status", "--porcelain"], + capture_output=True, text=True, timeout=5, cwd=str(entry), + ) + if status.stdout.strip(): + continue # Has changes — skip + except Exception: + continue # Can't check — skip # Safe to remove try: @@ -900,81 +862,10 @@ def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None: ["git", "branch", "-D", branch], capture_output=True, text=True, timeout=10, cwd=repo_root, ) - logger.debug("Pruned stale worktree: %s (force=%s)", entry.name, force) + logger.debug("Pruned stale worktree: %s", entry.name) except Exception as e: logger.debug("Failed to prune worktree %s: %s", entry.name, e) - _prune_orphaned_branches(repo_root) - - -def _prune_orphaned_branches(repo_root: str) -> None: - """Delete local ``hermes/hermes-*`` and ``pr-*`` branches with no worktree. - - These are auto-generated by ``hermes -w`` sessions and PR review - workflows respectively. Once their worktree is gone they serve no - purpose and just accumulate. - """ - import subprocess - - try: - result = subprocess.run( - ["git", "branch", "--format=%(refname:short)"], - capture_output=True, text=True, timeout=10, cwd=repo_root, - ) - if result.returncode != 0: - return - all_branches = [b.strip() for b in result.stdout.strip().split("\n") if b.strip()] - except Exception: - return - - # Collect branches that are actively checked out in a worktree - active_branches: set = set() - try: - wt_result = subprocess.run( - ["git", "worktree", "list", "--porcelain"], - capture_output=True, text=True, timeout=10, cwd=repo_root, - ) - for line in wt_result.stdout.split("\n"): - if line.startswith("branch refs/heads/"): - active_branches.add(line.split("branch refs/heads/", 1)[-1].strip()) - except Exception: - return # Can't determine active branches — bail - - # Also protect the currently checked-out branch and main - try: - head_result = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, text=True, timeout=5, cwd=repo_root, - ) - current = head_result.stdout.strip() - if current: - active_branches.add(current) - except Exception: - pass - active_branches.add("main") - - orphaned = [ - b for b in all_branches - if b not in active_branches - and (b.startswith("hermes/hermes-") or b.startswith("pr-")) - ] - - if not orphaned: - return - - # Delete in batches - for i in range(0, len(orphaned), 50): - batch = orphaned[i:i + 50] - try: - subprocess.run( - ["git", "branch", "-D"] + batch, - capture_output=True, text=True, timeout=30, cwd=repo_root, - ) - except Exception as e: - logger.debug("Failed to prune orphaned branches: %s", e) - - logger.debug("Pruned %d orphaned branches", len(orphaned)) - # ============================================================================ # ASCII Art & Branding # ============================================================================ @@ -1397,44 +1288,21 @@ def status(self, *_args, **_kwargs): def _build_compact_banner() -> str: """Build a compact banner that fits the current terminal width.""" - try: - from hermes_cli.skin_engine import get_active_skin - _skin = get_active_skin() - except Exception: - _skin = None - - skin_name = getattr(_skin, "name", "default") if _skin else "default" - border_color = _skin.get_color("banner_border", "#FFD700") if _skin else "#FFD700" - title_color = _skin.get_color("banner_title", "#FFBF00") if _skin else "#FFBF00" - dim_color = _skin.get_color("banner_dim", "#B8860B") if _skin else "#B8860B" - - if skin_name == "default": - line1 = "⚕ NOUS HERMES - AI Agent Framework" - tiny_line = "⚕ NOUS HERMES" - else: - agent_name = _skin.get_branding("agent_name", "Hermes Agent") if _skin else "Hermes Agent" - line1 = f"{agent_name} - AI Agent Framework" - tiny_line = agent_name - - version_line = format_banner_version_label() - - w = min(shutil.get_terminal_size().columns - 2, 88) + w = min(shutil.get_terminal_size().columns - 2, 64) if w < 30: - return f"\n[{title_color}]{tiny_line}[/] [dim {dim_color}]- Nous Research[/]\n" - + return "\n[#FFBF00]⚕ NOUS HERMES[/] [dim #B8860B]- Nous Research[/]\n" inner = w - 2 # inside the box border bar = "═" * w - content_width = inner - 2 - + line1 = "⚕ NOUS HERMES - AI Agent Framework" + line2 = "Messenger of the Digital Gods · Nous Research" # Truncate and pad to fit - line1 = line1[:content_width].ljust(content_width) - line2 = version_line[:content_width].ljust(content_width) - + line1 = line1[:inner - 2].ljust(inner - 2) + line2 = line2[:inner - 2].ljust(inner - 2) return ( - f"\n[bold {border_color}]╔{bar}╗[/]\n" - f"[bold {border_color}]║[/] [{title_color}]{line1}[/] [bold {border_color}]║[/]\n" - f"[bold {border_color}]║[/] [dim {dim_color}]{line2}[/] [bold {border_color}]║[/]\n" - f"[bold {border_color}]╚{bar}╝[/]\n" + f"\n[bold #FFD700]╔{bar}╗[/]\n" + f"[bold #FFD700]║[/] [#FFBF00]{line1}[/] [bold #FFD700]║[/]\n" + f"[bold #FFD700]║[/] [dim #B8860B]{line2}[/] [bold #FFD700]║[/]\n" + f"[bold #FFD700]╚{bar}╝[/]\n" ) @@ -1710,11 +1578,8 @@ def __init__( # Parse and validate toolsets self.enabled_toolsets = toolsets if toolsets and "all" not in toolsets and "*" not in toolsets: - # Validate each toolset — MCP server names are added by - # _get_platform_tools() but aren't registered in TOOLSETS yet - # (that happens later in _sync_mcp_toolsets), so exclude them. - mcp_names = set((CLI_CONFIG.get("mcp_servers") or {}).keys()) - invalid = [t for t in toolsets if not validate_toolset(t) and t not in mcp_names] + # Validate each toolset + invalid = [t for t in toolsets if not validate_toolset(t)] if invalid: self.console.print(f"[bold red]Warning: Unknown toolsets: {', '.join(invalid)}[/]") @@ -2383,12 +2248,6 @@ def _close_reasoning_box(self) -> None: _cprint(f"{_DIM}└{'─' * (w - 2)}┘{_RST}") self._reasoning_box_opened = False - # Flush any content that was deferred while reasoning was rendering. - deferred = getattr(self, "_deferred_content", "") - if deferred: - self._deferred_content = "" - self._emit_stream_text(deferred) - def _stream_delta(self, text) -> None: """Line-buffered streaming callback for real-time token rendering. @@ -2534,13 +2393,6 @@ def _emit_stream_text(self, text: str) -> None: if not text: return - # When show_reasoning is on and reasoning is still rendering, - # defer content until the reasoning box closes. This ensures the - # reasoning block always appears BEFORE the response in the terminal. - if self.show_reasoning and getattr(self, "_reasoning_box_opened", False): - self._deferred_content = getattr(self, "_deferred_content", "") + text - return - # Close the live reasoning box before opening the response box self._close_reasoning_box() @@ -2615,7 +2467,6 @@ def _reset_stream_state(self) -> None: self._reasoning_box_opened = False self._reasoning_buf = "" self._reasoning_preview_buf = "" - self._deferred_content = "" def _slow_command_status(self, command: str) -> str: """Return a user-facing status message for slower slash commands.""" @@ -2677,7 +2528,7 @@ def _ensure_runtime_credentials(self) -> bool: ) except Exception as exc: message = format_runtime_provider_error(exc) - ChatConsole().print(f"[bold red]{message}[/]") + self.console.print(f"[bold red]{message}[/]") return False api_key = runtime.get("api_key") @@ -2932,7 +2783,7 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No self._pending_title = None return True except Exception as e: - ChatConsole().print(f"[bold red]Failed to initialize agent: {e}[/]") + self.console.print(f"[bold red]Failed to initialize agent: {e}[/]") return False def show_banner(self): @@ -2998,22 +2849,6 @@ def show_banner(self): "[dim] Fix: Set model.context_length in config.yaml, or increase your server's context setting[/]" ) - # Warn if the configured model is a Nous Hermes LLM (not agentic) - model_name = getattr(self, "model", "") or "" - if "hermes" in model_name.lower(): - self.console.print() - self.console.print( - "[bold yellow]⚠ Nous Research Hermes 3 & 4 models are NOT agentic and are not " - "designed for use with Hermes Agent.[/]" - ) - self.console.print( - "[dim] They lack tool-calling capabilities required for agent workflows. " - "Consider using an agentic model (Claude, GPT, Gemini, DeepSeek, etc.).[/]" - ) - self.console.print( - "[dim] Switch with: /model sonnet or /model gpt5[/]" - ) - self.console.print() def _preload_resumed_session(self) -> bool: @@ -3963,22 +3798,6 @@ def flush_tool_summary(): flush_tool_summary() print() - def _notify_session_boundary(self, event_type: str) -> None: - """Fire a session-boundary plugin hook (on_session_finalize or on_session_reset). - - Non-blocking — errors are caught and logged. Safe to call from any - lifecycle point (shutdown, /new, /reset). - """ - try: - from hermes_cli.plugins import invoke_hook as _invoke_hook - _invoke_hook( - event_type, - session_id=self.agent.session_id if self.agent else None, - platform=getattr(self, "platform", None) or "cli", - ) - except Exception: - pass - def new_session(self, silent=False): """Start a fresh session with a new session ID and cleared agent state.""" if self.agent and self.conversation_history: @@ -3986,10 +3805,6 @@ def new_session(self, silent=False): self.agent.flush_memories(self.conversation_history) except (Exception, KeyboardInterrupt): pass - self._notify_session_boundary("on_session_finalize") - elif self.agent: - # First session or empty history — still finalize the old session - self._notify_session_boundary("on_session_finalize") old_session_id = self.session_id if self._session_db and old_session_id: @@ -4034,7 +3849,6 @@ def new_session(self, silent=False): ) except Exception: pass - self._notify_session_boundary("on_session_reset") if not silent: print("(^_^)v New session started!") @@ -4229,6 +4043,13 @@ def _handle_branch_command(self, cmd_original: str) -> None: _cprint(f" Original session: {parent_session_id}") _cprint(f" Branch session: {new_session_id}") + def reset_conversation(self): + """Reset the conversation by starting a new session.""" + # Shut down memory provider before resetting — actual session boundary + if hasattr(self, 'agent') and self.agent: + self.agent.shutdown_memory_provider(self.conversation_history) + self.new_session() + def save_conversation(self): """Save the current conversation to a file.""" if not self.conversation_history: @@ -4759,7 +4580,6 @@ def _show_model_and_providers(self): from hermes_cli.models import ( curated_models_for_provider, list_available_providers, normalize_provider, _PROVIDER_LABELS, - get_pricing_for_provider, format_model_pricing_table, ) from hermes_cli.auth import resolve_provider as _resolve_provider @@ -4793,13 +4613,7 @@ def _show_model_and_providers(self): marker = " ← active" if is_active else "" print(f" [{p['id']}]{marker}") curated = curated_models_for_provider(p["id"]) - # Fetch pricing for providers that support it (openrouter, nous) - pricing_map = get_pricing_for_provider(p["id"]) if p["id"] in ("openrouter", "nous") else {} - if curated and pricing_map: - cur_model = self.model if is_active else "" - for line in format_model_pricing_table(curated, pricing_map, current_model=cur_model): - print(line) - elif curated: + if curated: for mid, desc in curated: current_marker = " ← current" if (is_active and mid == self.model) else "" print(f" {mid}{current_marker}") @@ -5145,6 +4959,7 @@ def _show_gateway_status(self): try: config = load_gateway_config() + connected = config.get_connected_platforms() print(" Messaging Platform Configuration:") print(" " + "-" * 55) @@ -5343,8 +5158,6 @@ def process_command(self, command: str) -> bool: self.new_session() elif canonical == "resume": self._handle_resume_command(cmd_original) - elif canonical == "model": - self._handle_model_switch(cmd_original) elif canonical == "provider": self._show_model_and_providers() @@ -5506,7 +5319,7 @@ def process_command(self, command: str) -> bool: if hasattr(self, '_pending_input'): self._pending_input.put(msg) else: - ChatConsole().print(f"[bold red]Failed to load skill for {base_cmd}[/]") + self.console.print(f"[bold red]Failed to load skill for {base_cmd}[/]") else: # Prefix matching: if input uniquely identifies one command, execute it. # Matches against both built-in COMMANDS and installed skill commands so @@ -5567,14 +5380,14 @@ def _handle_plan_command(self, cmd: str): ) if not msg: - ChatConsole().print("[bold red]Failed to load the bundled /plan skill[/]") + self.console.print("[bold red]Failed to load the bundled /plan skill[/]") return _cprint(f" 📝 Plan mode queued via skill. Markdown plan target: {plan_path}") if hasattr(self, '_pending_input'): self._pending_input.put(msg) else: - ChatConsole().print("[bold red]Plan mode unavailable: input queue not initialized[/]") + self.console.print("[bold red]Plan mode unavailable: input queue not initialized[/]") def _handle_background_command(self, cmd: str): """Handle /background — run a prompt in a separate background session. @@ -5995,13 +5808,13 @@ def _handle_browser_command(self, cmd: str): pass print() print("🌐 Browser disconnected from live Chrome") - print(" Browser tools reverted to default mode (local headless or cloud provider)") + print(" Browser tools reverted to default mode (local headless or Browserbase)") print() if hasattr(self, '_pending_input'): self._pending_input.put( "[System note: The user has disconnected the browser tools from their live Chrome. " - "Browser tools are back to default mode (headless local browser or cloud provider).]" + "Browser tools are back to default mode (headless local browser or Browserbase cloud).]" ) else: print() @@ -6028,17 +5841,10 @@ def _handle_browser_command(self, cmd: str): print(" Status: ✓ reachable") except (OSError, Exception): print(" Status: ⚠ not reachable (Chrome may not be running)") + elif os.environ.get("BROWSERBASE_API_KEY"): + print("🌐 Browser: Browserbase (cloud)") else: - try: - from tools.browser_tool import _get_cloud_provider - provider = _get_cloud_provider() - except Exception: - provider = None - - if provider is not None: - print(f"🌐 Browser: {provider.provider_name()} (cloud)") - else: - print("🌐 Browser: local headless Chromium (agent-browser)") + print("🌐 Browser: local headless Chromium (agent-browser)") print() print(" /browser connect — connect to your live Chrome") print(" /browser disconnect — revert to default") @@ -6592,8 +6398,8 @@ def _on_tool_gen_start(self, tool_name: str) -> None: # Tool progress callback (audio cues for voice mode) # ==================================================================== - def _on_tool_progress(self, event_type: str, function_name: str = None, preview: str = None, function_args: dict = None, **kwargs): - """Called on tool lifecycle events (tool.started, tool.completed, reasoning.available, etc.). + def _on_tool_progress(self, function_name: str, preview: str, function_args: dict): + """Called when a tool starts executing. Updates the TUI spinner widget so the user can see what the agent is doing during tool execution (fills the gap between thinking @@ -6655,7 +6461,7 @@ def _on_tool_progress(self, event_type: str, function_name: str = None, preview: if not self._voice_mode: return - if not function_name or function_name.startswith("_"): + if function_name.startswith("_"): return try: from tools.voice_mode import play_beep @@ -7112,7 +6918,7 @@ def _clarify_callback(self, question, choices): timeout = CLI_CONFIG.get("clarify", {}).get("timeout", 120) response_queue = queue.Queue() - is_open_ended = not choices + is_open_ended = not choices or len(choices) == 0 self._clarify_state = { "question": question, @@ -7425,6 +7231,14 @@ def _clear_secret_input_buffer(self) -> None: except Exception: pass + def _clear_current_input(self) -> None: + if getattr(self, "_app", None): + try: + self._app.current_buffer.text = "" + except Exception: + pass + + def chat(self, message, images: list = None) -> Optional[str]: """ Send a message to the agent and get a response. @@ -7592,11 +7406,6 @@ def stream_callback(delta: str): def run_agent(): nonlocal result agent_message = _voice_prefix + message if _voice_prefix else message - # Prepend pending model switch note so the model knows about the switch - _msn = getattr(self, '_pending_model_switch_note', None) - if _msn: - agent_message = _msn + "\n\n" + agent_message - self._pending_model_switch_note = None try: result = self.agent.run_conversation( user_message=agent_message, @@ -8726,26 +8535,18 @@ def get_prompt(): # wrapping of long lines so the input area always fits its content. def _input_height(): try: - from prompt_toolkit.application import get_app - from prompt_toolkit.utils import get_cwidth - doc = input_area.buffer.document - prompt_width = max(2, get_cwidth(self._get_tui_prompt_text())) - try: - available_width = get_app().output.get_size().columns - prompt_width - except Exception: - available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width + prompt_width = max(2, len(self._get_tui_prompt_text())) + available_width = shutil.get_terminal_size().columns - prompt_width if available_width < 10: available_width = 40 visual_lines = 0 for line in doc.lines: - # Each logical line takes at least 1 visual row; long lines wrap. - # Use prompt_toolkit's cell width so CJK wide characters count as 2. - line_width = get_cwidth(line) - if line_width <= 0: + # Each logical line takes at least 1 visual row; long lines wrap + if len(line) == 0: visual_lines += 1 else: - visual_lines += max(1, -(-line_width // available_width)) # ceil division + visual_lines += max(1, -(-len(line) // available_width)) # ceil division return min(max(visual_lines, 1), 8) except Exception: return 1 @@ -9047,6 +8848,7 @@ def _get_sudo_display(): title = '🔐 Sudo Password Required' body = 'Enter password below (hidden), or press Enter to skip' box_width = _panel_box_width(title, [body]) + inner = max(0, box_width - 2) lines = [] lines.append(('class:sudo-border', '╭─ ')) lines.append(('class:sudo-title', title))