From 794f48766c7e984236ec993e26b0da1c2586448b Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Tue, 5 May 2026 13:42:39 -0700 Subject: [PATCH] fix(tui): close slash parity gaps with CLI (#20339) * fix(tui): close slash parity gaps with CLI Route unsupported /skills subcommands through slash.exec, support /new titles, and handle /redraw natively so TUI behavior matches classic CLI. Also filter gateway-only commands out of the TUI catalog while keeping /status discoverable. * fix(tui): run remaining CLI parity paths natively Forward chat launch flags into the TUI runtime and handle live-session status and skill reloads in the gateway process so TUI state no longer depends on the slash worker's stale CLI instance. * fix(tui): block stale snapshot restores Prevent snapshot restore from running through the isolated slash worker because it mutates disk state without refreshing the live TUI agent. * chore: uptick * fix(tui): guard async session title updates Handle failures from the fire-and-forget session.title RPC so title-setting errors do not surface as unhandled promise rejections while preserving session-scoped messaging. --- hermes_cli/main.py | 532 +++++++++++++----- tests/hermes_cli/test_tui_resume_flow.py | 147 ++++- tests/test_tui_gateway_server.py | 222 ++++++-- tests/tui_gateway/test_make_agent_provider.py | 43 ++ tui_gateway/server.py | 332 +++++++++-- .../src/__tests__/createSlashHandler.test.ts | 79 ++- ui-tui/src/app/createGatewayEventHandler.ts | 39 +- ui-tui/src/app/interfaces.ts | 7 +- ui-tui/src/app/slash/commands/core.ts | 34 +- ui-tui/src/app/slash/commands/ops.ts | 59 +- ui-tui/src/app/useMainApp.ts | 20 +- ui-tui/src/app/useSessionLifecycle.ts | 30 +- ui-tui/src/config/env.ts | 2 + ui-tui/src/gatewayTypes.ts | 4 + 14 files changed, 1266 insertions(+), 284 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 89dd166776..9601f31ab5 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -52,6 +52,7 @@ import sys from pathlib import Path from typing import Optional + def _add_accept_hooks_flag(parser) -> None: """Attach the ``--accept-hooks`` flag. Shared across every agent subparser so the flag works regardless of CLI position.""" @@ -120,6 +121,7 @@ def _apply_profile_override() -> None: # resolve_profile_env() with a value it must reject + sys.exit on. if profile_name is not None and consume == 2: import re as _re + if not _re.match(r"^[a-z0-9][a-z0-9_-]{0,63}$", profile_name): profile_name = None consume = 0 @@ -191,6 +193,7 @@ load_hermes_dotenv(project_env=PROJECT_ROOT / ".env") try: if "HERMES_REDACT_SECRETS" not in os.environ: import yaml as _yaml_early + _cfg_path = get_hermes_home() / "config.yaml" if _cfg_path.exists(): with open(_cfg_path, encoding="utf-8") as _f: @@ -793,9 +796,15 @@ def _read_tui_active_session_file(path: Optional[str]) -> Optional[str]: return None -def _print_tui_exit_summary(session_id: Optional[str], active_session_file: Optional[str] = None) -> None: +def _print_tui_exit_summary( + session_id: Optional[str], active_session_file: Optional[str] = None +) -> None: """Print a shell-visible epilogue after TUI exits.""" - target = _read_tui_active_session_file(active_session_file) or session_id or _resolve_last_session(source="tui") + target = ( + _read_tui_active_session_file(active_session_file) + or session_id + or _resolve_last_session(source="tui") + ) if not target: return @@ -914,7 +923,9 @@ def _tui_need_npm_install(root: Path) -> bool: continue return True - if isinstance(installed[name], dict) and comparable(pkg) != comparable(installed[name]): + if isinstance(installed[name], dict) and comparable(pkg) != comparable( + installed[name] + ): return True return False @@ -1156,6 +1167,16 @@ def _launch_tui( model: Optional[str] = None, provider: Optional[str] = None, toolsets: object = None, + skills: object = None, + verbose: bool = False, + quiet: bool = False, + query: Optional[str] = None, + image: Optional[str] = None, + worktree: bool = False, + checkpoints: bool = False, + pass_session_id: bool = False, + max_turns: Optional[int] = None, + accept_hooks: bool = False, ): """Replace current process with the TUI.""" tui_dir = PROJECT_ROOT / "ui-tui" @@ -1174,6 +1195,29 @@ def _launch_tui( env.setdefault("HERMES_PYTHON", sys.executable) env.setdefault("HERMES_CWD", os.getcwd()) env.setdefault("NODE_ENV", "development" if tui_dev else "production") + + wt_info = None + if worktree: + try: + from cli import ( + _cleanup_worktree, + _git_repo_root, + _prune_stale_worktrees, + _setup_worktree, + ) + + repo = _git_repo_root() + if repo: + _prune_stale_worktrees(repo) + wt_info = _setup_worktree() + except Exception as exc: + print(f"✗ Failed to create TUI worktree: {exc}", file=sys.stderr) + wt_info = None + if not wt_info: + sys.exit(1) + env["HERMES_CWD"] = wt_info["path"] + env["TERMINAL_CWD"] = wt_info["path"] + if model: env["HERMES_MODEL"] = model env["HERMES_INFERENCE_MODEL"] = model @@ -1183,6 +1227,35 @@ def _launch_tui( tui_toolsets = _normalize_tui_toolsets(toolsets) if tui_toolsets: env["HERMES_TUI_TOOLSETS"] = ",".join(tui_toolsets) + if skills: + if isinstance(skills, (list, tuple)): + flattened = [] + for item in skills: + flattened.extend( + part.strip() for part in str(item).split(",") if part.strip() + ) + if flattened: + env["HERMES_TUI_SKILLS"] = ",".join(flattened) + else: + value = str(skills).strip() + if value: + env["HERMES_TUI_SKILLS"] = value + if query: + env["HERMES_TUI_QUERY"] = query + if image: + env["HERMES_TUI_IMAGE"] = image + if checkpoints: + env["HERMES_TUI_CHECKPOINTS"] = "1" + if pass_session_id: + env["HERMES_TUI_PASS_SESSION_ID"] = "1" + if max_turns is not None: + env["HERMES_TUI_MAX_TURNS"] = str(max_turns) + if verbose: + env["HERMES_TUI_TOOL_PROGRESS"] = "verbose" + elif quiet: + env["HERMES_TUI_TOOL_PROGRESS"] = "off" + if accept_hooks: + env["HERMES_ACCEPT_HOOKS"] = "1" # Guarantee an 8GB V8 heap + exposed GC for the TUI. Default node cap is # ~1.5–4GB depending on version and can fatal-OOM on long sessions with # large transcripts / reasoning blobs. Token-level merge: respect any @@ -1212,6 +1285,11 @@ def _launch_tui( os.unlink(active_session_file) except OSError: pass + if wt_info: + try: + _cleanup_worktree(wt_info) + except Exception: + pass sys.exit(code) @@ -1231,6 +1309,7 @@ def _pin_kanban_board_env() -> None: return try: from hermes_cli.kanban_db import get_current_board + os.environ["HERMES_KANBAN_BOARD"] = get_current_board() except Exception: pass @@ -1353,6 +1432,16 @@ def cmd_chat(args): model=getattr(args, "model", None), provider=getattr(args, "provider", None), toolsets=getattr(args, "toolsets", None), + skills=getattr(args, "skills", None), + verbose=getattr(args, "verbose", False), + quiet=getattr(args, "quiet", False), + query=getattr(args, "query", None), + image=getattr(args, "image", None), + worktree=getattr(args, "worktree", False), + checkpoints=getattr(args, "checkpoints", False), + pass_session_id=getattr(args, "pass_session_id", False), + max_turns=getattr(args, "max_turns", None), + accept_hooks=getattr(args, "accept_hooks", False), ) # Import and run the CLI @@ -1504,7 +1593,9 @@ def cmd_whatsapp(args): return if not (bridge_dir / "node_modules").exists(): - print("\n→ Installing WhatsApp bridge dependencies (this can take a few minutes)...") + print( + "\n→ Installing WhatsApp bridge dependencies (this can take a few minutes)..." + ) npm = shutil.which("npm") if not npm: print(" ✗ npm not found on PATH — install Node.js first") @@ -1740,9 +1831,7 @@ def select_provider_and_model(args=None): raw_api_key_refs.setdefault((name.lower(), model), template) if provider_key: raw_api_key_refs.setdefault((provider_key.lower(),), template) - raw_api_key_refs.setdefault( - (provider_key.lower(), model), template - ) + raw_api_key_refs.setdefault((provider_key.lower(), model), template) raw_list = raw_cfg.get("custom_providers") if isinstance(raw_list, list): @@ -1752,8 +1841,7 @@ def select_provider_and_model(args=None): _record_raw( raw_entry.get("name", ""), "", - raw_entry.get("model", "") - or raw_entry.get("default_model", ""), + raw_entry.get("model", "") or raw_entry.get("default_model", ""), raw_entry.get("api_key", ""), ) raw_providers = raw_cfg.get("providers") @@ -1764,8 +1852,7 @@ def select_provider_and_model(args=None): _record_raw( raw_entry.get("name", "") or raw_key, raw_key, - raw_entry.get("model", "") - or raw_entry.get("default_model", ""), + raw_entry.get("model", "") or raw_entry.get("default_model", ""), raw_entry.get("api_key", ""), ) @@ -1806,9 +1893,7 @@ def select_provider_and_model(args=None): "model": entry.get("model", ""), "api_mode": entry.get("api_mode", ""), "provider_key": provider_key, - "api_key_ref": _lookup_ref( - name, provider_key, entry.get("model", "") - ), + "api_key_ref": _lookup_ref(name, provider_key, entry.get("model", "")), } return custom_provider_map @@ -1982,15 +2067,15 @@ def _clear_stale_openai_base_url(): # (task_key, display_name, short_description) _AUX_TASKS: list[tuple[str, str, str]] = [ - ("vision", "Vision", "image/screenshot analysis"), - ("compression", "Compression", "context summarization"), - ("web_extract", "Web extract", "web page summarization"), - ("session_search", "Session search", "past-conversation recall"), - ("approval", "Approval", "smart command approval"), - ("mcp", "MCP", "MCP tool reasoning"), + ("vision", "Vision", "image/screenshot analysis"), + ("compression", "Compression", "context summarization"), + ("web_extract", "Web extract", "web page summarization"), + ("session_search", "Session search", "past-conversation recall"), + ("approval", "Approval", "smart command approval"), + ("mcp", "MCP", "MCP tool reasoning"), ("title_generation", "Title generation", "session titles"), - ("skills_hub", "Skills hub", "skills search/install"), - ("curator", "Curator", "skill-usage review pass"), + ("skills_hub", "Skills hub", "skills search/install"), + ("curator", "Curator", "skill-usage review pass"), ] @@ -2089,7 +2174,7 @@ def _aux_config_menu() -> None: print(" Auxiliary models — side-task routing") print() print(" Side tasks (vision, compression, web extraction, etc.) default") - print(" to your main chat model. \"auto\" means \"use my main model\" —") + print(' to your main chat model. "auto" means "use my main model" —') print(" Hermes only falls back to a lightweight backend (OpenRouter,") print(" Nous Portal) if the main model is unavailable. Override a") print(" task below if you want it pinned to a specific provider/model.") @@ -2100,15 +2185,20 @@ def _aux_config_menu() -> None: desc_col = max(len(desc) for _, _, desc in _AUX_TASKS) + 4 entries: list[tuple[str, str]] = [] for task_key, name, desc in _AUX_TASKS: - task_cfg = aux.get(task_key, {}) if isinstance(aux.get(task_key), dict) else {} + task_cfg = ( + aux.get(task_key, {}) if isinstance(aux.get(task_key), dict) else {} + ) current = _format_aux_current(task_cfg) - label = f"{name.ljust(name_col)}{('(' + desc + ')').ljust(desc_col)}{current}" + label = ( + f"{name.ljust(name_col)}{('(' + desc + ')').ljust(desc_col)}{current}" + ) entries.append((task_key, label)) entries.append(("__reset__", "Reset all to auto")) - entries.append(("__back__", "Back")) + entries.append(("__back__", "Back")) idx = _prompt_provider_choice( - [label for _, label in entries], default=0, + [label for _, label in entries], + default=0, ) if idx is None: return @@ -2160,7 +2250,9 @@ def _aux_select_for_task(task: str) -> None: entries: list[tuple[str, str, list[str]]] = [] # (slug, label, models) # "auto" always first - auto_marker = " ← current" if current_provider == "auto" and not current_base_url else "" + auto_marker = ( + " ← current" if current_provider == "auto" and not current_base_url else "" + ) entries.append(("__auto__", f"auto (recommended){auto_marker}", [])) for p in providers: @@ -2169,7 +2261,9 @@ def _aux_select_for_task(task: str) -> None: total = p.get("total_models", 0) models = p.get("models") or [] model_hint = f" — {total} models" if total else "" - marker = " ← current" if slug == current_provider and not current_base_url else "" + marker = ( + " ← current" if slug == current_provider and not current_base_url else "" + ) entries.append((slug, f"{name}{model_hint}{marker}", list(models))) # Custom endpoint (raw base_url) @@ -2237,14 +2331,17 @@ def _aux_flow_provider_model( selected = val or "" else: selected = _prompt_model_selection( - model_list, current_model=current_model, pricing=pricing, + model_list, + current_model=current_model, + pricing=pricing, ) if selected is None: print("No change.") return - _save_aux_choice(task, provider=provider_slug, model=selected or "", - base_url="", api_key="") + _save_aux_choice( + task, provider=provider_slug, model=selected or "", base_url="", api_key="" + ) if selected: print(f"{display_name}: {provider_slug} · {selected}") else: @@ -2264,7 +2361,9 @@ def _aux_flow_custom_endpoint(task: str, task_cfg: dict) -> None: print(" Provide an OpenAI-compatible base URL (e.g. http://localhost:11434/v1)") print() try: - url_prompt = f"Base URL [{current_base_url}]: " if current_base_url else "Base URL: " + url_prompt = ( + f"Base URL [{current_base_url}]: " if current_base_url else "Base URL: " + ) url = input(url_prompt).strip() except (KeyboardInterrupt, EOFError): print() @@ -2274,20 +2373,30 @@ def _aux_flow_custom_endpoint(task: str, task_cfg: dict) -> None: print("No URL provided. No change.") return try: - model_prompt = f"Model slug (optional) [{current_model}]: " if current_model else "Model slug (optional): " + model_prompt = ( + f"Model slug (optional) [{current_model}]: " + if current_model + else "Model slug (optional): " + ) model = input(model_prompt).strip() except (KeyboardInterrupt, EOFError): print() return model = model or current_model try: - api_key = getpass.getpass("API key (optional, blank = use OPENAI_API_KEY): ").strip() + api_key = getpass.getpass( + "API key (optional, blank = use OPENAI_API_KEY): " + ).strip() except (KeyboardInterrupt, EOFError): print() return _save_aux_choice( - task, provider="custom", model=model, base_url=url, api_key=api_key, + task, + provider="custom", + model=model, + base_url=url, + api_key=api_key, ) short_url = url.replace("https://", "").replace("http://", "").rstrip("/") print(f"{display_name}: custom ({short_url})" + (f" · {model}" if model else "")) @@ -2403,7 +2512,9 @@ def _model_flow_ai_gateway(config, current_model=""): api_key = get_env_value("AI_GATEWAY_API_KEY") if not api_key: print("No Vercel AI Gateway API key configured.") - print("Create API key here: https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway&title=AI+Gateway") + print( + "Create API key here: https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway&title=AI+Gateway" + ) print("Add a payment method to get $5 in free credits.") print() try: @@ -2772,6 +2883,7 @@ def _model_flow_minimax_oauth(config, current_model="", args=None): _login_minimax_oauth, PROVIDER_REGISTRY, ) + state = get_provider_auth_state("minimax-oauth") if not state or not state.get("access_token"): print("Not logged into MiniMax. Starting OAuth login...") @@ -2797,6 +2909,7 @@ def _model_flow_minimax_oauth(config, current_model="", args=None): return from hermes_cli.models import _PROVIDER_MODELS + model_ids = _PROVIDER_MODELS.get("minimax-oauth", []) selected = _prompt_model_selection(model_ids, current_model) if not selected: @@ -3186,7 +3299,12 @@ def _model_flow_azure_foundry(config, current_model=""): (models.dev, provider metadata, hardcoded family fallbacks). """ from hermes_cli.auth import _save_model_choice, deactivate_provider # noqa: F401 - from hermes_cli.config import get_env_value, save_env_value, load_config, save_config + from hermes_cli.config import ( + get_env_value, + save_env_value, + load_config, + save_config, + ) from hermes_cli import azure_detect import getpass @@ -3214,7 +3332,11 @@ def _model_flow_azure_foundry(config, current_model=""): if current_base_url: print(f" Current endpoint: {current_base_url}") if current_api_mode: - _lbl = "OpenAI-style" if current_api_mode == "chat_completions" else "Anthropic-style" + _lbl = ( + "OpenAI-style" + if current_api_mode == "chat_completions" + else "Anthropic-style" + ) print(f" Current API mode: {_lbl}") if current_api_key: print(f" Current API key: {current_api_key[:8]}...") @@ -3261,12 +3383,16 @@ def _model_flow_azure_foundry(config, current_model=""): api_mode: str = detection.api_mode or "" if api_mode: - mode_label = "OpenAI-style" if api_mode == "chat_completions" else "Anthropic-style" + mode_label = ( + "OpenAI-style" if api_mode == "chat_completions" else "Anthropic-style" + ) print(f"✓ Detected API transport: {mode_label}") if detection.reason: print(f" ({detection.reason})") if discovered_models: - print(f"✓ Found {len(discovered_models)} deployed model(s) on this endpoint") + print( + f"✓ Found {len(discovered_models)} deployed model(s) on this endpoint" + ) else: print(f"⚠ Auto-detection incomplete: {detection.reason}") print() @@ -3277,7 +3403,10 @@ def _model_flow_azure_foundry(config, current_model=""): print(" For: Claude models deployed via Anthropic API format") try: default_choice = "2" if current_api_mode == "anthropic_messages" else "1" - mode_choice = input(f"API format [1/2] ({default_choice}): ").strip() or default_choice + mode_choice = ( + input(f"API format [1/2] ({default_choice}): ").strip() + or default_choice + ) except (KeyboardInterrupt, EOFError): print("\nCancelled.") return @@ -3291,7 +3420,9 @@ def _model_flow_azure_foundry(config, current_model=""): for i, mid in enumerate(discovered_models[:30], start=1): print(f" {i:>2}. {mid}") if len(discovered_models) > 30: - print(f" ... and {len(discovered_models) - 30} more (type name manually if not shown)") + print( + f" ... and {len(discovered_models) - 30} more (type name manually if not shown)" + ) print() try: pick = input( @@ -3322,7 +3453,9 @@ def _model_flow_azure_foundry(config, current_model=""): # ── Step 5: context-length lookup ──────────────────────────────── ctx_len = azure_detect.lookup_context_length( - effective_model, effective_url, effective_key, + effective_model, + effective_url, + effective_key, ) # ── Step 6: persist ────────────────────────────────────────────── @@ -3578,9 +3711,7 @@ def _model_flow_named_custom(config, provider_info): original_api_key_ref = str( provider_info.get("api_key_ref", "") or "" ).strip() - original_api_key = str( - provider_info.get("api_key", "") or "" - ).strip() + original_api_key = str(provider_info.get("api_key", "") or "").strip() had_inline_api_key = bool(original_api_key_ref or original_api_key) if ( had_inline_api_key @@ -4082,7 +4213,9 @@ def _prompt_api_key(pconfig, existing_key: str, provider_id: str = "") -> tuple: if choice.startswith("c"): save_env_value(key_env, "") - print(f" API key cleared. Re-run `hermes setup` to configure {pconfig.name} again.") + print( + f" API key cleared. Re-run `hermes setup` to configure {pconfig.name} again." + ) return "", True # Keep (default, or any other input) @@ -4124,7 +4257,9 @@ def _model_flow_kimi(config, current_model=""): if existing_key: break - existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id) + existing_key, abort = _prompt_api_key( + pconfig, existing_key, provider_id=provider_id + ) if abort: return @@ -4213,7 +4348,12 @@ def _model_flow_stepfun(config, current_model=""): _save_model_choice, deactivate_provider, ) - from hermes_cli.config import get_env_value, save_env_value, load_config, save_config + from hermes_cli.config import ( + get_env_value, + save_env_value, + load_config, + save_config, + ) from hermes_cli.models import fetch_api_models provider_id = "stepfun" @@ -4227,7 +4367,9 @@ def _model_flow_stepfun(config, current_model=""): if existing_key: break - existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id) + existing_key, abort = _prompt_api_key( + pconfig, existing_key, provider_id=provider_id + ) if abort: return @@ -4241,7 +4383,10 @@ def _model_flow_stepfun(config, current_model=""): current_region = _infer_stepfun_region(current_base or pconfig.inference_base_url) region_choices = [ - ("international", f"International ({_stepfun_base_url_for_region('international')})"), + ( + "international", + f"International ({_stepfun_base_url_for_region('international')})", + ), ("china", f"China ({_stepfun_base_url_for_region('china')})"), ] ordered_regions = [] @@ -4605,7 +4750,9 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): if existing_key: break - existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id) + existing_key, abort = _prompt_api_key( + pconfig, existing_key, provider_id=provider_id + ) if abort: return @@ -4711,7 +4858,9 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): api_key_for_probe = existing_key or (get_env_value(key_env) if key_env else "") try: - model_list = fetch_lmstudio_models(api_key=api_key_for_probe, base_url=effective_base) + model_list = fetch_lmstudio_models( + api_key=api_key_for_probe, base_url=effective_base + ) except AuthError as exc: print(f" LM Studio rejected the request: {exc}") print(" Set LM_API_KEY (or update it) to match the server's bearer token.") @@ -5136,6 +5285,7 @@ def cmd_kanban(args): def cmd_hooks(args): """Shell-hook inspection and management.""" from hermes_cli.hooks import hooks_command + hooks_command(args) @@ -5463,10 +5613,12 @@ def _find_stale_dashboard_pids() -> list[int]: # UnicodeDecodeError from leaving result.stdout=None and turning # the later .split() into an AttributeError (#17049). result = subprocess.run( - ["wmic", "process", "get", "ProcessId,CommandLine", - "/FORMAT:LIST"], - capture_output=True, text=True, timeout=10, - encoding="utf-8", errors="ignore", + ["wmic", "process", "get", "ProcessId,CommandLine", "/FORMAT:LIST"], + capture_output=True, + text=True, + timeout=10, + encoding="utf-8", + errors="ignore", ) if result.returncode != 0 or result.stdout is None: return [] @@ -5474,11 +5626,13 @@ def _find_stale_dashboard_pids() -> list[int]: for line in result.stdout.split("\n"): line = line.strip() if line.startswith("CommandLine="): - current_cmd = line[len("CommandLine="):] + current_cmd = line[len("CommandLine=") :] elif line.startswith("ProcessId="): - pid_str = line[len("ProcessId="):] - if (any(p in current_cmd for p in patterns) - and int(pid_str) != self_pid): + pid_str = line[len("ProcessId=") :] + if ( + any(p in current_cmd for p in patterns) + and int(pid_str) != self_pid + ): try: dashboard_pids.append(int(pid_str)) except ValueError: @@ -5492,7 +5646,9 @@ def _find_stale_dashboard_pids() -> list[int]: # both words (e.g. a chat session discussing "dashboard"). result = subprocess.run( ["ps", "-A", "-o", "pid=,command="], - capture_output=True, text=True, timeout=10, + capture_output=True, + text=True, + timeout=10, ) if result.returncode == 0: for line in getattr(result, "stdout", "").split("\n"): @@ -5507,8 +5663,7 @@ def _find_stale_dashboard_pids() -> list[int]: except ValueError: continue command = parts[1] - if (any(p in command for p in patterns) - and pid != self_pid): + if any(p in command for p in patterns) and pid != self_pid: dashboard_pids.append(pid) except (FileNotFoundError, subprocess.TimeoutExpired, OSError): return [] @@ -5552,7 +5707,9 @@ def _print_curator_first_run_notice() -> None: ) print(" Preview now: hermes curator run --dry-run") print(" Pause it: hermes curator pause") - print(" Docs: https://hermes-agent.nousresearch.com/docs/user-guide/features/curator") + print( + " Docs: https://hermes-agent.nousresearch.com/docs/user-guide/features/curator" + ) def _kill_stale_dashboard_processes( @@ -5591,7 +5748,9 @@ def _kill_stale_dashboard_processes( try: result = subprocess.run( ["taskkill", "/PID", str(pid), "/F"], - capture_output=True, text=True, timeout=10, + capture_output=True, + text=True, + timeout=10, ) if result.returncode == 0: killed.append(pid) @@ -5616,8 +5775,9 @@ def _kill_stale_dashboard_processes( # Poll for exit up to ~3s total. deadline = _time.monotonic() + 3.0 - pending = [p for p in pids if p not in killed - and p not in {f[0] for f in failed}] + pending = [ + p for p in pids if p not in killed and p not in {f[0] for f in failed} + ] while pending and _time.monotonic() < deadline: _time.sleep(0.1) still_pending = [] @@ -6604,6 +6764,7 @@ def _cmd_update_check(): commits_word = "commit" if behind == 1 else "commits" print(f"⚕ Update available: {behind} {commits_word} behind {compare_branch}.") from hermes_cli.config import recommended_update_command + print(f" Run '{recommended_update_command()}' to install.") @@ -6642,11 +6803,19 @@ def _ensure_fhs_path_guard() -> None: home = os.environ.get("HOME") or "/root" try: probe = subprocess.run( - ["env", "-i", - f"HOME={home}", - f"TERM={os.environ.get('TERM', 'dumb')}", - "bash", "-i", "-c", "command -v hermes"], - capture_output=True, text=True, timeout=10, + [ + "env", + "-i", + f"HOME={home}", + f"TERM={os.environ.get('TERM', 'dumb')}", + "bash", + "-i", + "-c", + "command -v hermes", + ], + capture_output=True, + text=True, + timeout=10, ) except (FileNotFoundError, subprocess.TimeoutExpired): return # no bash or probe hung — don't block update on this @@ -6655,8 +6824,7 @@ def _ensure_fhs_path_guard() -> None: path_line = 'export PATH="/usr/local/bin:$PATH"' path_comment = ( - "# Hermes Agent — ensure /usr/local/bin is on PATH " - "(RHEL non-login shells)" + "# Hermes Agent — ensure /usr/local/bin is on PATH " "(RHEL non-login shells)" ) wrote_any = False for candidate in (".bashrc", ".bash_profile"): @@ -6709,9 +6877,12 @@ def _run_pre_update_backup(args) -> None: try: from hermes_cli.config import load_config + cfg = load_config() except Exception as exc: - logging.getLogger(__name__).debug("Could not load config for pre-update backup: %s", exc) + logging.getLogger(__name__).debug( + "Could not load config for pre-update backup: %s", exc + ) cfg = {} updates_cfg = cfg.get("updates", {}) if isinstance(cfg, dict) else {} @@ -6727,7 +6898,9 @@ def _run_pre_update_backup(args) -> None: try: from hermes_cli.backup import create_pre_update_backup except Exception as exc: - print(f"⚠ Pre-update backup: could not load backup module ({exc}); continuing update.") + print( + f"⚠ Pre-update backup: could not load backup module ({exc}); continuing update." + ) print() return @@ -6764,6 +6937,7 @@ def _run_pre_update_backup(args) -> None: # Render path using display_hermes_home so the user sees ~/.hermes/... try: from hermes_constants import get_hermes_home, display_hermes_home + home = get_hermes_home() try: display_path = f"{display_hermes_home()}/{out_path.relative_to(home)}" @@ -7204,7 +7378,9 @@ def _cmd_update_impl(args, gateway_mode: bool): print() if assume_yes: - print(" ℹ --yes: auto-applying config migration (skipping API-key prompts).") + print( + " ℹ --yes: auto-applying config migration (skipping API-key prompts)." + ) response = "y" elif gateway_mode: response = ( @@ -7309,7 +7485,9 @@ def _cmd_update_impl(args, gateway_mode: bool): import signal as _signal def _wait_for_service_active( - scope_cmd_: list, svc_name_: str, timeout: float = 10.0, + scope_cmd_: list, + svc_name_: str, + timeout: float = 10.0, ) -> bool: """Poll ``systemctl is-active`` until the unit reports active. @@ -7323,7 +7501,9 @@ def _cmd_update_impl(args, gateway_mode: bool): try: _verify = subprocess.run( scope_cmd_ + ["is-active", svc_name_], - capture_output=True, text=True, timeout=5, + capture_output=True, + text=True, + timeout=5, ) if _verify.stdout.strip() == "active": return True @@ -7334,7 +7514,9 @@ def _cmd_update_impl(args, gateway_mode: bool): _time.sleep(0.5) def _service_restart_sec( - scope_cmd_: list, svc_name_: str, default: float = 0.0, + scope_cmd_: list, + svc_name_: str, + default: float = 0.0, ) -> float: """Read the unit's ``RestartUSec`` (RestartSec) in seconds. @@ -7346,11 +7528,16 @@ def _cmd_update_impl(args, gateway_mode: bool): """ try: _show = subprocess.run( - scope_cmd_ + [ - "show", svc_name_, - "--property=RestartUSec", "--value", + scope_cmd_ + + [ + "show", + svc_name_, + "--property=RestartUSec", + "--value", ], - capture_output=True, text=True, timeout=5, + capture_output=True, + text=True, + timeout=5, ) except (FileNotFoundError, subprocess.TimeoutExpired): return default @@ -7392,12 +7579,17 @@ def _cmd_update_impl(args, gateway_mode: bool): _cfg_drain = None try: from hermes_cli.config import load_config - _cfg_agent = (load_config().get("agent") or {}) + + _cfg_agent = load_config().get("agent") or {} _cfg_drain = _cfg_agent.get("restart_drain_timeout") except Exception: pass try: - _drain_budget = float(_cfg_drain) if _cfg_drain is not None else float(_DEFAULT_DRAIN) + _drain_budget = ( + float(_cfg_drain) + if _cfg_drain is not None + else float(_DEFAULT_DRAIN) + ) except (TypeError, ValueError): _drain_budget = float(_DEFAULT_DRAIN) # Add a 15s margin so the drain loop + final exit finish before @@ -7463,14 +7655,23 @@ def _cmd_update_impl(args, gateway_mode: bool): _main_pid = 0 try: _show = subprocess.run( - scope_cmd + [ - "show", svc_name, - "--property=MainPID", "--value", + scope_cmd + + [ + "show", + svc_name, + "--property=MainPID", + "--value", ], - capture_output=True, text=True, timeout=5, + capture_output=True, + text=True, + timeout=5, ) _main_pid = int((_show.stdout or "").strip() or 0) - except (ValueError, subprocess.TimeoutExpired, FileNotFoundError): + except ( + ValueError, + subprocess.TimeoutExpired, + FileNotFoundError, + ): _main_pid = 0 _graceful_ok = False @@ -7479,7 +7680,8 @@ def _cmd_update_impl(args, gateway_mode: bool): f" → {svc_name}: draining (up to {int(_drain_budget)}s)..." ) _graceful_ok = _graceful_restart_via_sigusr1( - _main_pid, drain_timeout=_drain_budget, + _main_pid, + drain_timeout=_drain_budget, ) if _graceful_ok: @@ -7492,13 +7694,17 @@ def _cmd_update_impl(args, gateway_mode: bool): # units without RestartSec set we fall back # to the original 10s budget. _restart_sec = _service_restart_sec( - scope_cmd, svc_name, default=0.0, + scope_cmd, + svc_name, + default=0.0, ) _post_drain_timeout = max( - 10.0, _restart_sec + 10.0, + 10.0, + _restart_sec + 10.0, ) if _wait_for_service_active( - scope_cmd, svc_name, + scope_cmd, + svc_name, timeout=_post_drain_timeout, ): restarted_services.append(svc_name) @@ -7527,7 +7733,9 @@ def _cmd_update_impl(args, gateway_mode: bool): # restart. systemctl restart returns 0 even # if the new process crashes immediately. if _wait_for_service_active( - scope_cmd, svc_name, timeout=10.0, + scope_cmd, + svc_name, + timeout=10.0, ): restarted_services.append(svc_name) else: @@ -7544,7 +7752,9 @@ def _cmd_update_impl(args, gateway_mode: bool): timeout=15, ) if _wait_for_service_active( - scope_cmd, svc_name, timeout=10.0, + scope_cmd, + svc_name, + timeout=10.0, ): restarted_services.append(svc_name) print(f" ✓ {svc_name} recovered on retry") @@ -7610,7 +7820,8 @@ def _cmd_update_impl(args, gateway_mode: bool): # the drain budget, fall back to SIGTERM — the watcher # still sees the exit and relaunches either way. drained = _graceful_restart_via_sigusr1( - pid, drain_timeout=_drain_budget, + pid, + drain_timeout=_drain_budget, ) if not drained: try: @@ -7662,7 +7873,8 @@ def _cmd_update_impl(args, gateway_mode: bool): _time.sleep(3.0) _service_pids_after = _get_service_pids() _surviving = find_gateway_pids( - exclude_pids=_service_pids_after, all_profiles=True, + exclude_pids=_service_pids_after, + all_profiles=True, ) # Scope to PIDs we already tried to kill during this # update (killed_pids). Anything new is a gateway that @@ -7921,7 +8133,9 @@ def cmd_profile(args): if clone_all: print(f"Full copy from {source_label}.") else: - print(f"Cloned config, .env, SOUL.md, and skills from {source_label}.") + print( + f"Cloned config, .env, SOUL.md, and skills from {source_label}." + ) # Auto-clone Honcho config for the new profile (only with --clone/--clone-all) if clone or clone_all: @@ -8135,8 +8349,12 @@ def _report_dashboard_status() -> int: cmdline_path = f"/proc/{pid}/cmdline" if os.path.exists(cmdline_path): with open(cmdline_path, "rb") as f: - cmdline = f.read().replace(b"\x00", b" ").decode( - "utf-8", errors="replace").strip() + cmdline = ( + f.read() + .replace(b"\x00", b" ") + .decode("utf-8", errors="replace") + .strip() + ) except (OSError, ValueError): pass if cmdline: @@ -8508,14 +8726,14 @@ def main(): "--reconfigure", action="store_true", help="(Default on existing installs.) Re-run the full wizard, " - "showing current values as defaults. Kept for backwards " - "compatibility — a bare 'hermes setup' now does this.", + "showing current values as defaults. Kept for backwards " + "compatibility — a bare 'hermes setup' now does this.", ) setup_parser.add_argument( "--quick", action="store_true", help="On existing installs: only prompt for items that are missing " - "or unset, instead of running the full reconfigure wizard.", + "or unset, instead of running the full reconfigure wizard.", ) setup_parser.set_defaults(func=cmd_setup) @@ -8541,7 +8759,7 @@ def main(): slack_manifest = slack_sub.add_parser( "manifest", help="Print or write a Slack app manifest with every gateway command " - "registered as a native slash (/btw, /stop, /model, ...)", + "registered as a native slash (/btw, /stop, /model, ...)", description=( "Generate a Slack app manifest that registers every gateway " "command in COMMAND_REGISTRY as a first-class Slack slash " @@ -8557,7 +8775,7 @@ def main(): default=None, metavar="PATH", help="Write manifest to a file instead of stdout. With no PATH " - "writes to $HERMES_HOME/slack-manifest.json.", + "writes to $HERMES_HOME/slack-manifest.json.", ) slack_manifest.add_argument( "--name", @@ -8573,7 +8791,7 @@ def main(): "--slashes-only", action="store_true", help="Emit only the features.slash_commands array (for merging " - "into an existing manifest manually).", + "into an existing manifest manually).", ) slack_parser.set_defaults(func=cmd_slack) @@ -8690,17 +8908,39 @@ def main(): "reset", help="Clear exhaustion status for all credentials for a provider" ) auth_reset.add_argument("provider", help="Provider id") - auth_status = auth_subparsers.add_parser("status", help="Show auth status for a provider") + auth_status = auth_subparsers.add_parser( + "status", help="Show auth status for a provider" + ) auth_status.add_argument("provider", help="Provider id") - auth_logout = auth_subparsers.add_parser("logout", help="Log out a provider and clear stored auth state") + auth_logout = auth_subparsers.add_parser( + "logout", help="Log out a provider and clear stored auth state" + ) auth_logout.add_argument("provider", help="Provider id") - auth_spotify = auth_subparsers.add_parser("spotify", help="Authenticate Hermes with Spotify via PKCE") - auth_spotify.add_argument("spotify_action", nargs="?", choices=["login", "status", "logout"], default="login") - auth_spotify.add_argument("--client-id", help="Spotify app client_id (or set HERMES_SPOTIFY_CLIENT_ID)") - auth_spotify.add_argument("--redirect-uri", help="Allow-listed localhost redirect URI for your Spotify app") + auth_spotify = auth_subparsers.add_parser( + "spotify", help="Authenticate Hermes with Spotify via PKCE" + ) + auth_spotify.add_argument( + "spotify_action", + nargs="?", + choices=["login", "status", "logout"], + default="login", + ) + auth_spotify.add_argument( + "--client-id", help="Spotify app client_id (or set HERMES_SPOTIFY_CLIENT_ID)" + ) + auth_spotify.add_argument( + "--redirect-uri", + help="Allow-listed localhost redirect URI for your Spotify app", + ) auth_spotify.add_argument("--scope", help="Override requested Spotify scopes") - auth_spotify.add_argument("--no-browser", action="store_true", help="Do not attempt to open the browser automatically") - auth_spotify.add_argument("--timeout", type=float, help="Callback/token exchange timeout in seconds") + auth_spotify.add_argument( + "--no-browser", + action="store_true", + help="Do not attempt to open the browser automatically", + ) + auth_spotify.add_argument( + "--timeout", type=float, help="Callback/token exchange timeout in seconds" + ) auth_parser.set_defaults(func=cmd_auth) # ========================================================================= @@ -8938,6 +9178,7 @@ def main(): # kanban command — multi-profile collaboration board # ========================================================================= from hermes_cli.kanban import build_parser as _build_kanban_parser + kanban_parser = _build_kanban_parser(subparsers) kanban_parser.set_defaults(func=cmd_kanban) @@ -8956,7 +9197,8 @@ def main(): hooks_subparsers = hooks_parser.add_subparsers(dest="hooks_action") hooks_subparsers.add_parser( - "list", aliases=["ls"], + "list", + aliases=["ls"], help="List configured hooks with matcher, timeout, and consent status", ) @@ -8969,14 +9211,18 @@ def main(): help="Hook event name (e.g. pre_tool_call, pre_llm_call, subagent_stop)", ) _hk_test.add_argument( - "--for-tool", dest="for_tool", default=None, + "--for-tool", + dest="for_tool", + default=None, help=( "Only fire hooks whose matcher matches this tool name " "(used for pre_tool_call / post_tool_call)" ), ) _hk_test.add_argument( - "--payload-file", dest="payload_file", default=None, + "--payload-file", + dest="payload_file", + default=None, help=( "Path to a JSON file whose contents are merged into the " "synthetic payload before execution" @@ -8984,7 +9230,8 @@ def main(): ) _hk_revoke = hooks_subparsers.add_parser( - "revoke", aliases=["remove", "rm"], + "revoke", + aliases=["remove", "rm"], help="Remove a command's allowlist entries (takes effect on next restart)", ) _hk_revoke.add_argument( @@ -9299,7 +9546,7 @@ Examples: "--enabled-only", action="store_true", help="Hide disabled skills. Use with -p to see exactly " - "which skills will load for that profile.", + "which skills will load for that profile.", ) skills_check = skills_subparsers.add_parser( @@ -9508,6 +9755,7 @@ Examples: ) try: from hermes_cli.curator import register_cli as _register_curator_cli + _register_curator_cli(curator_parser) except Exception as _exc: logging.getLogger(__name__).debug("curator CLI wiring failed: %s", _exc) @@ -9940,8 +10188,9 @@ Examples: print("Cancelled.") return sessions_dir = get_hermes_home() / "sessions" - count = db.prune_sessions(older_than_days=days, source=args.source, - sessions_dir=sessions_dir) + count = db.prune_sessions( + older_than_days=days, source=args.source, sessions_dir=sessions_dir + ) print(f"Pruned {count} session(s).") elif action == "rename": @@ -9978,6 +10227,7 @@ Examples: # Launch hermes --resume by replacing the current process print(f"Resuming session: {selected_id}") from hermes_cli.relaunch import relaunch + relaunch(["--resume", selected_id]) return # won't reach here after execvp @@ -10501,22 +10751,23 @@ Examples: # the nested subcommand (dest varies by parser). _AGENT_COMMANDS = {None, "chat", "acp", "rl"} _AGENT_SUBCOMMANDS = { - "cron": ("cron_command", {"run", "tick"}), + "cron": ("cron_command", {"run", "tick"}), "gateway": ("gateway_command", {"run"}), - "mcp": ("mcp_action", {"serve"}), + "mcp": ("mcp_action", {"serve"}), } _sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None)) - if ( - args.command in _AGENT_COMMANDS - or (_sub_attr and getattr(args, _sub_attr, None) in _sub_set) + if args.command in _AGENT_COMMANDS or ( + _sub_attr and getattr(args, _sub_attr, None) in _sub_set ): _accept_hooks = bool(getattr(args, "accept_hooks", False)) try: from hermes_cli.plugins import discover_plugins + discover_plugins() except Exception: logger.debug( - "plugin discovery failed at CLI startup", exc_info=True, + "plugin discovery failed at CLI startup", + exc_info=True, ) try: # MCP tool discovery — no event loop running in CLI/TUI startup, @@ -10524,14 +10775,17 @@ Examples: # to avoid freezing the gateway's event loop on its first message # via the same lazy import path (#16856). from tools.mcp_tool import discover_mcp_tools + discover_mcp_tools() except Exception: logger.debug( - "MCP tool discovery failed at CLI startup", exc_info=True, + "MCP tool discovery failed at CLI startup", + exc_info=True, ) try: from hermes_cli.config import load_config from agent.shell_hooks import register_from_config + register_from_config(load_config(), accept_hooks=_accept_hooks) except Exception: logger.debug( @@ -10544,12 +10798,14 @@ Examples: if getattr(args, "oneshot", None): from hermes_cli.oneshot import run_oneshot - sys.exit(run_oneshot( - args.oneshot, - model=getattr(args, "model", None), - provider=getattr(args, "provider", None), - toolsets=getattr(args, "toolsets", None), - )) + sys.exit( + run_oneshot( + args.oneshot, + model=getattr(args, "model", None), + provider=getattr(args, "provider", None), + toolsets=getattr(args, "toolsets", None), + ) + ) # Handle top-level --resume / --continue as shortcut to chat if (args.resume or args.continue_last) and args.command is None: diff --git a/tests/hermes_cli/test_tui_resume_flow.py b/tests/hermes_cli/test_tui_resume_flow.py index 8086ee87e3..76533a3451 100644 --- a/tests/hermes_cli/test_tui_resume_flow.py +++ b/tests/hermes_cli/test_tui_resume_flow.py @@ -36,7 +36,14 @@ def test_cmd_chat_tui_continue_uses_latest_tui_session(monkeypatch, main_mod): calls.append(source) return "20260408_235959_a1b2c3" if source == "tui" else None - def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None): + def fake_launch( + resume_session_id=None, + tui_dev=False, + model=None, + provider=None, + toolsets=None, + **kwargs, + ): captured["resume"] = resume_session_id raise SystemExit(0) @@ -63,7 +70,14 @@ def test_cmd_chat_tui_continue_falls_back_to_latest_cli_session(monkeypatch, mai return "20260408_235959_d4e5f6" return None - def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None): + def fake_launch( + resume_session_id=None, + tui_dev=False, + model=None, + provider=None, + toolsets=None, + **kwargs, + ): captured["resume"] = resume_session_id raise SystemExit(0) @@ -81,7 +95,14 @@ def test_cmd_chat_tui_continue_falls_back_to_latest_cli_session(monkeypatch, mai def test_cmd_chat_tui_resume_resolves_title_before_launch(monkeypatch, main_mod): captured = {} - def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None): + def fake_launch( + resume_session_id=None, + tui_dev=False, + model=None, + provider=None, + toolsets=None, + **kwargs, + ): captured["resume"] = resume_session_id raise SystemExit(0) @@ -99,7 +120,14 @@ def test_cmd_chat_tui_resume_resolves_title_before_launch(monkeypatch, main_mod) def test_cmd_chat_tui_passes_model_and_provider(monkeypatch, main_mod): captured = {} - def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None): + def fake_launch( + resume_session_id=None, + tui_dev=False, + model=None, + provider=None, + toolsets=None, + **kwargs, + ): captured.update( { "model": model, @@ -130,7 +158,14 @@ def test_cmd_chat_tui_passes_model_and_provider(monkeypatch, main_mod): def test_cmd_chat_tui_passes_toolsets(monkeypatch, main_mod): captured = {} - def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None): + def fake_launch( + resume_session_id=None, + tui_dev=False, + model=None, + provider=None, + toolsets=None, + **kwargs, + ): captured["toolsets"] = toolsets raise SystemExit(0) @@ -142,22 +177,74 @@ def test_cmd_chat_tui_passes_toolsets(monkeypatch, main_mod): assert captured["toolsets"] == "web,terminal" +def test_cmd_chat_tui_forwards_chat_flags(monkeypatch, main_mod): + captured = {} + + def fake_launch(resume_session_id=None, **kwargs): + captured["resume_session_id"] = resume_session_id + captured.update(kwargs) + raise SystemExit(0) + + monkeypatch.setattr(main_mod, "_launch_tui", fake_launch) + + with pytest.raises(SystemExit): + main_mod.cmd_chat( + _args( + skills=["foo,bar"], + verbose=True, + quiet=True, + query="hello", + image="/tmp/cat.png", + worktree=True, + checkpoints=True, + pass_session_id=True, + max_turns=7, + accept_hooks=True, + ) + ) + + assert captured["skills"] == ["foo,bar"] + assert captured["verbose"] is True + assert captured["quiet"] is True + assert captured["query"] == "hello" + assert captured["image"] == "/tmp/cat.png" + assert captured["worktree"] is True + assert captured["checkpoints"] is True + assert captured["pass_session_id"] is True + assert captured["max_turns"] == 7 + assert captured["accept_hooks"] is True + + def test_main_top_level_tui_accepts_toolsets(monkeypatch, main_mod): captured = {} import hermes_cli.config as config_mod monkeypatch.setattr(sys, "argv", ["hermes", "--tui", "--toolsets", "web,terminal"]) - monkeypatch.setitem(sys.modules, "hermes_cli.plugins", types.SimpleNamespace(discover_plugins=lambda: None)) - monkeypatch.setitem(sys.modules, "tools.mcp_tool", types.SimpleNamespace(discover_mcp_tools=lambda: None)) + monkeypatch.setitem( + sys.modules, + "hermes_cli.plugins", + types.SimpleNamespace(discover_plugins=lambda: None), + ) + monkeypatch.setitem( + sys.modules, + "tools.mcp_tool", + types.SimpleNamespace(discover_mcp_tools=lambda: None), + ) monkeypatch.setattr(config_mod, "load_config", lambda: {}) monkeypatch.setattr(config_mod, "get_container_exec_info", lambda: None) monkeypatch.setitem( sys.modules, "agent.shell_hooks", - types.SimpleNamespace(register_from_config=lambda _cfg, accept_hooks=False: None), + types.SimpleNamespace( + register_from_config=lambda _cfg, accept_hooks=False: None + ), + ) + monkeypatch.setattr( + main_mod, + "cmd_chat", + lambda args: captured.update({"toolsets": args.toolsets, "tui": args.tui}), ) - monkeypatch.setattr(main_mod, "cmd_chat", lambda args: captured.update({"toolsets": args.toolsets, "tui": args.tui})) main_mod.main() @@ -169,27 +256,49 @@ def test_main_top_level_oneshot_accepts_toolsets(monkeypatch, main_mod): import hermes_cli.config as config_mod - monkeypatch.setattr(sys, "argv", ["hermes", "-z", "hello", "--toolsets", "web,terminal"]) - monkeypatch.setitem(sys.modules, "hermes_cli.plugins", types.SimpleNamespace(discover_plugins=lambda: None)) - monkeypatch.setitem(sys.modules, "tools.mcp_tool", types.SimpleNamespace(discover_mcp_tools=lambda: None)) + monkeypatch.setattr( + sys, "argv", ["hermes", "-z", "hello", "--toolsets", "web,terminal"] + ) + monkeypatch.setitem( + sys.modules, + "hermes_cli.plugins", + types.SimpleNamespace(discover_plugins=lambda: None), + ) + monkeypatch.setitem( + sys.modules, + "tools.mcp_tool", + types.SimpleNamespace(discover_mcp_tools=lambda: None), + ) monkeypatch.setattr(config_mod, "load_config", lambda: {}) monkeypatch.setattr(config_mod, "get_container_exec_info", lambda: None) monkeypatch.setitem( sys.modules, "agent.shell_hooks", - types.SimpleNamespace(register_from_config=lambda _cfg, accept_hooks=False: None), + types.SimpleNamespace( + register_from_config=lambda _cfg, accept_hooks=False: None + ), ) monkeypatch.setitem( sys.modules, "hermes_cli.oneshot", - types.SimpleNamespace(run_oneshot=lambda prompt, **kwargs: captured.update({"prompt": prompt, **kwargs}) or 0), + types.SimpleNamespace( + run_oneshot=lambda prompt, **kwargs: captured.update( + {"prompt": prompt, **kwargs} + ) + or 0 + ), ) with pytest.raises(SystemExit) as exc: main_mod.main() assert exc.value.code == 0 - assert captured == {"prompt": "hello", "model": None, "provider": None, "toolsets": "web,terminal"} + assert captured == { + "prompt": "hello", + "model": None, + "provider": None, + "toolsets": "web,terminal", + } def _stub_plugin_discovery(monkeypatch): @@ -256,7 +365,9 @@ def test_oneshot_accepts_plugin_toolset_after_discovery(monkeypatch): monkeypatch.setitem( sys.modules, "hermes_cli.plugins", - types.SimpleNamespace(discover_plugins=lambda: discovered.update({"ready": True})), + types.SimpleNamespace( + discover_plugins=lambda: discovered.update({"ready": True}) + ), ) valid, error = _validate_explicit_toolsets("plugin_demo") @@ -328,7 +439,9 @@ def test_launch_tui_exports_model_provider_and_toolsets(monkeypatch, main_mod): monkeypatch.setattr(main_mod.subprocess, "call", fake_call) with pytest.raises(SystemExit): - main_mod._launch_tui(model="nous/hermes-test", provider="nous", toolsets="web, terminal") + main_mod._launch_tui( + model="nous/hermes-test", provider="nous", toolsets="web, terminal" + ) env = captured["env"] assert env["HERMES_MODEL"] == "nous/hermes-test" diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 03647f55f0..5a25a306ba 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -70,9 +70,7 @@ def test_dispatch_rejects_non_object_request(): def test_dispatch_rejects_non_object_params(): - resp = server.dispatch( - {"id": "1", "method": "session.create", "params": []} - ) + resp = server.dispatch({"id": "1", "method": "session.create", "params": []}) assert resp == { "jsonrpc": "2.0", @@ -133,12 +131,16 @@ def test_voice_toggle_handles_non_dict_voice_cfg(monkeypatch): monkeypatch.setattr(server, "_load_cfg", lambda b=bad: {"voice": b}) status_resp = server.dispatch( - {"id": "voice-status", "method": "voice.toggle", "params": {"action": "status"}} + { + "id": "voice-status", + "method": "voice.toggle", + "params": {"action": "status"}, + } ) - assert status_resp["result"]["record_key"] == "ctrl+b", ( - f"voice.record_key fell back to default for voice={bad!r}" - ) + assert ( + status_resp["result"]["record_key"] == "ctrl+b" + ), f"voice.record_key fell back to default for voice={bad!r}" # Round-4 follow-up: the YAML root itself may be a non-dict. A # hand-edit that collapses config.yaml to a scalar / list would @@ -148,12 +150,16 @@ def test_voice_toggle_handles_non_dict_voice_cfg(monkeypatch): monkeypatch.setattr(server, "_load_cfg", lambda r=bad_root: r) status_resp = server.dispatch( - {"id": "voice-status-root", "method": "voice.toggle", "params": {"action": "status"}} + { + "id": "voice-status-root", + "method": "voice.toggle", + "params": {"action": "status"}, + } ) - assert status_resp["result"]["record_key"] == "ctrl+b", ( - f"voice.record_key fell back to default for root={bad_root!r}" - ) + assert ( + status_resp["result"]["record_key"] == "ctrl+b" + ), f"voice.record_key fell back to default for root={bad_root!r}" def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch): @@ -174,7 +180,9 @@ def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch): monkeypatch.setitem( sys.modules, "hermes_cli.voice", - types.SimpleNamespace(start_continuous=fake_start_continuous, stop_continuous=lambda: None), + types.SimpleNamespace( + start_continuous=fake_start_continuous, stop_continuous=lambda: None + ), ) monkeypatch.setenv("HERMES_VOICE", "1") @@ -183,10 +191,16 @@ def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch): monkeypatch.setattr(server, "_load_cfg", lambda b=bad: {"voice": b}) resp = server.dispatch( - {"id": "voice-record", "method": "voice.record", "params": {"action": "start"}} + { + "id": "voice-record", + "method": "voice.record", + "params": {"action": "start"}, + } ) - assert "result" in resp, f"voice.record raised for voice={bad!r}: {resp.get('error')}" + assert ( + "result" in resp + ), f"voice.record raised for voice={bad!r}: {resp.get('error')}" assert resp["result"]["status"] == "recording" assert captured["silence_threshold"] == 200 assert captured["silence_duration"] == 3.0 @@ -204,16 +218,20 @@ def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch): monkeypatch.setattr(server, "_load_cfg", lambda c=bad_bool_cfg: {"voice": c}) resp = server.dispatch( - {"id": "voice-record-bool", "method": "voice.record", "params": {"action": "start"}} + { + "id": "voice-record-bool", + "method": "voice.record", + "params": {"action": "start"}, + } ) assert "result" in resp, f"voice.record raised for bool cfg={bad_bool_cfg!r}" - assert captured["silence_threshold"] == 200, ( - f"bool silence_threshold leaked through for {bad_bool_cfg!r}" - ) - assert captured["silence_duration"] == 3.0, ( - f"bool silence_duration leaked through for {bad_bool_cfg!r}" - ) + assert ( + captured["silence_threshold"] == 200 + ), f"bool silence_threshold leaked through for {bad_bool_cfg!r}" + assert ( + captured["silence_duration"] == 3.0 + ), f"bool silence_duration leaked through for {bad_bool_cfg!r}" def test_voice_toggle_tts_branch_also_carries_record_key(monkeypatch): @@ -281,7 +299,9 @@ def test_load_enabled_toolsets_accepts_plugin_env_after_discovery(monkeypatch): monkeypatch.setitem( sys.modules, "hermes_cli.plugins", - types.SimpleNamespace(discover_plugins=lambda: discovered.update({"ready": True})), + types.SimpleNamespace( + discover_plugins=lambda: discovered.update({"ready": True}) + ), ) assert server._load_enabled_toolsets() == ["plugin_demo"] @@ -302,7 +322,9 @@ def test_load_enabled_toolsets_rejects_disabled_mcp_env(monkeypatch, capsys): "read_raw_config", lambda: {"mcp_servers": {"mcp-off": {"enabled": False}}}, ) - monkeypatch.setattr(config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}}) + monkeypatch.setattr( + config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}} + ) # Sorted: ["kanban", "memory"]. `kanban` is auto-recovered by # _get_platform_tools because it's a non-configurable platform toolset @@ -324,7 +346,9 @@ def test_load_enabled_toolsets_falls_back_when_tui_env_invalid(monkeypatch, caps import hermes_cli.config as config_mod - monkeypatch.setattr(config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}}) + monkeypatch.setattr( + config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}} + ) assert server._load_enabled_toolsets() == ["kanban", "memory"] assert "using configured CLI toolsets" in capsys.readouterr().err @@ -340,7 +364,9 @@ def test_load_enabled_toolsets_warns_when_config_fallback_fails(monkeypatch, cap import hermes_cli.config as config_mod - monkeypatch.setattr(config_mod, "load_config", lambda: (_ for _ in ()).throw(RuntimeError("boom"))) + monkeypatch.setattr( + config_mod, "load_config", lambda: (_ for _ in ()).throw(RuntimeError("boom")) + ) assert server._load_enabled_toolsets() is None assert "could not be loaded" in capsys.readouterr().err @@ -351,7 +377,9 @@ def test_load_enabled_toolsets_honors_builtin_env_if_config_fails(monkeypatch): import hermes_cli.config as config_mod - monkeypatch.setattr(config_mod, "load_config", lambda: (_ for _ in ()).throw(RuntimeError("boom"))) + monkeypatch.setattr( + config_mod, "load_config", lambda: (_ for _ in ()).throw(RuntimeError("boom")) + ) assert server._load_enabled_toolsets() == ["web"] @@ -362,7 +390,9 @@ def test_load_enabled_toolsets_all_env_means_all(monkeypatch): assert server._load_enabled_toolsets() is None -def test_load_enabled_toolsets_all_env_warns_about_ignored_extra_entries(monkeypatch, capsys): +def test_load_enabled_toolsets_all_env_warns_about_ignored_extra_entries( + monkeypatch, capsys +): monkeypatch.setenv("HERMES_TUI_TOOLSETS", "all,nope") assert server._load_enabled_toolsets() is None @@ -1801,9 +1831,7 @@ def test_session_compress_uses_compress_helper(monkeypatch): emit.assert_any_call("session.info", "sid", {"model": "x"}) # Final status.update clears the pinned "compressing" indicator so the # status bar can revert to the neutral state when compaction finishes. - emit.assert_any_call( - "status.update", "sid", {"kind": "status", "text": "ready"} - ) + emit.assert_any_call("status.update", "sid", {"kind": "status", "text": "ready"}) def test_session_compress_syncs_session_key_after_rotation(monkeypatch): @@ -2050,6 +2078,120 @@ def test_commands_catalog_includes_tui_mouse_command(): assert "/mouse" in tui_pairs +def test_commands_catalog_filters_gateway_only_commands_and_keeps_status_visible(): + resp = server.handle_request( + {"id": "1", "method": "commands.catalog", "params": {}} + ) + + pairs = dict(resp["result"]["pairs"]) + canon = resp["result"]["canon"] + + assert "/status" in pairs + assert canon["/status"] == "/status" + + assert "/topic" not in pairs + assert "/approve" not in pairs + assert "/deny" not in pairs + assert "/sethome" not in pairs + + assert "/topic" not in canon + assert "/approve" not in canon + assert "/deny" not in canon + assert "/set-home" not in canon + + +def test_session_status_reads_live_gateway_agent(monkeypatch): + agent = types.SimpleNamespace( + model="live-model", + provider="live-provider", + session_total_tokens=1234, + ) + server._sessions["sid"] = _session(agent=agent, running=True) + + class _DB: + def get_session(self, key): + assert key == "session-key" + return { + "title": "Live TUI", + "started_at": 1_700_000_000, + "updated_at": 1_700_000_060, + } + + monkeypatch.setattr(server, "_get_db", lambda: _DB()) + try: + resp = server.handle_request( + {"id": "1", "method": "session.status", "params": {"session_id": "sid"}} + ) + finally: + server._sessions.pop("sid", None) + + out = resp["result"]["output"] + assert "Hermes TUI Status" in out + assert "Session ID: session-key" in out + assert "Title: Live TUI" in out + assert "Model: live-model (live-provider)" in out + assert "Tokens: 1,234" in out + assert "Agent Running: Yes" in out + + +def test_skills_reload_runs_in_gateway_process(monkeypatch): + import agent.skill_commands as skill_commands + + called = {} + monkeypatch.setattr( + skill_commands, + "reload_skills", + lambda: called.setdefault( + "result", + { + "added": [{"name": "new-skill", "description": "demo"}], + "removed": [], + "total": 42, + }, + ), + ) + + resp = server.handle_request({"id": "1", "method": "skills.reload", "params": {}}) + + assert called["result"]["total"] == 42 + assert "new-skill" in resp["result"]["output"] + assert "42 skill(s) available" in resp["result"]["output"] + + +def test_snapshot_restore_is_blocked_from_tui_worker(): + server._sessions["sid"] = _session() + try: + worker_resp = server.handle_request( + { + "id": "1", + "method": "slash.exec", + "params": {"command": "snapshot restore latest", "session_id": "sid"}, + } + ) + dispatch_resp = server.handle_request( + { + "id": "2", + "method": "command.dispatch", + "params": { + "arg": "restore latest", + "name": "snapshot", + "session_id": "sid", + }, + } + ) + finally: + server._sessions.pop("sid", None) + + assert worker_resp["error"]["code"] == 4018 + assert ( + "snapshot restore mutates live config/state" in worker_resp["error"]["message"] + ) + assert dispatch_resp["result"]["type"] == "exec" + assert ( + "/snapshot restore is blocked in the TUI" in dispatch_resp["result"]["output"] + ) + + def test_command_dispatch_exec_nonzero_surfaces_error(monkeypatch): monkeypatch.setattr( server, @@ -4161,9 +4303,7 @@ def test_reload_env_rpc_calls_hermes_cli_reload_env(monkeypatch): fake = types.SimpleNamespace(reload_env=_fake_reload) with patch.dict(sys.modules, {"hermes_cli.config": fake}): - resp = server.handle_request( - {"id": "1", "method": "reload.env", "params": {}} - ) + resp = server.handle_request({"id": "1", "method": "reload.env", "params": {}}) assert resp["result"] == {"updated": 7} assert calls["n"] == 1 @@ -4175,9 +4315,7 @@ def test_reload_env_rpc_surfaces_errors(monkeypatch): fake = types.SimpleNamespace(reload_env=_broken) with patch.dict(sys.modules, {"hermes_cli.config": fake}): - resp = server.handle_request( - {"id": "1", "method": "reload.env", "params": {}} - ) + resp = server.handle_request({"id": "1", "method": "reload.env", "params": {}}) assert "error" in resp assert "env path locked" in resp["error"]["message"] @@ -4188,7 +4326,9 @@ def test_reload_env_rpc_surfaces_errors(monkeypatch): def _setup_make_agent_mocks(monkeypatch, cfg): monkeypatch.setattr(server, "_load_cfg", lambda: cfg) - monkeypatch.setattr(server, "_resolve_startup_runtime", lambda: ("test-model", None)) + monkeypatch.setattr( + server, "_resolve_startup_runtime", lambda: ("test-model", None) + ) monkeypatch.setattr( "hermes_cli.runtime_provider.resolve_runtime_provider", lambda requested=None, target_model=None: { @@ -4219,7 +4359,9 @@ def test_make_agent_reads_nested_max_turns(monkeypatch): def test_make_agent_nested_max_turns_takes_priority(monkeypatch): - _setup_make_agent_mocks(monkeypatch, {"agent": {"max_turns": 500}, "max_turns": 100}) + _setup_make_agent_mocks( + monkeypatch, {"agent": {"max_turns": 500}, "max_turns": 100} + ) with patch("run_agent.AIAgent") as mock_agent: server._make_agent("sid1", "key1") @@ -4309,6 +4451,8 @@ def test_config_show_displays_nested_max_turns(monkeypatch): resp = server.handle_request({"id": "1", "method": "config.show", "params": {}}) sections = resp["result"]["sections"] - agent_rows = next(section["rows"] for section in sections if section["title"] == "Agent") + agent_rows = next( + section["rows"] for section in sections if section["title"] == "Agent" + ) assert ["Max Turns", "120"] in agent_rows diff --git a/tests/tui_gateway/test_make_agent_provider.py b/tests/tui_gateway/test_make_agent_provider.py index 44d7ff7902..896f68a382 100644 --- a/tests/tui_gateway/test_make_agent_provider.py +++ b/tests/tui_gateway/test_make_agent_provider.py @@ -5,6 +5,7 @@ Without resolve_runtime_provider(), bare-slug models in config provider/base_url/api_key empty in AIAgent, causing HTTP 404. """ +import os from unittest.mock import MagicMock, patch @@ -97,6 +98,48 @@ def test_make_agent_ignores_display_personality_without_system_prompt(): assert mock_agent.call_args.kwargs["ephemeral_system_prompt"] is None +def test_make_agent_honors_tui_launch_env_flags(): + fake_runtime = { + "provider": "openrouter", + "base_url": "https://api.synthetic.new/v1", + "api_key": "sk-test", + "api_mode": "chat_completions", + "command": None, + "args": None, + "credential_pool": None, + } + fake_cfg = {"agent": {"system_prompt": ""}, "model": {"default": "glm-5"}} + + with ( + patch.dict( + os.environ, + { + "HERMES_TUI_MAX_TURNS": "7", + "HERMES_TUI_CHECKPOINTS": "1", + "HERMES_TUI_PASS_SESSION_ID": "1", + "HERMES_IGNORE_RULES": "1", + }, + ), + patch("tui_gateway.server._load_cfg", return_value=fake_cfg), + patch("tui_gateway.server._get_db", return_value=MagicMock()), + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value=fake_runtime, + ), + patch("run_agent.AIAgent") as mock_agent, + ): + from tui_gateway.server import _make_agent + + _make_agent("sid-env", "key-env") + + kwargs = mock_agent.call_args.kwargs + assert kwargs["max_iterations"] == 7 + assert kwargs["checkpoints_enabled"] is True + assert kwargs["pass_session_id"] is True + assert kwargs["skip_context_files"] is True + assert kwargs["skip_memory"] is True + + def test_probe_config_health_flags_null_sections(): """Bare YAML keys (`agent:` with no value) parse as None and silently drop nested settings; probe must surface them so users can fix.""" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 68b03f091a..1e1bb2af34 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -157,7 +157,9 @@ _LONG_HANDLERS = frozenset( ) try: - _rpc_pool_workers = max(2, int(os.environ.get("HERMES_TUI_RPC_POOL_WORKERS") or "4")) + _rpc_pool_workers = max( + 2, int(os.environ.get("HERMES_TUI_RPC_POOL_WORKERS") or "4") + ) except (ValueError, TypeError): _rpc_pool_workers = 4 _pool = concurrent.futures.ThreadPoolExecutor( @@ -567,7 +569,10 @@ def _start_agent_build(sid: str, session: dict) -> None: register_gateway_notify, load_permanent_allowlist, ) - register_gateway_notify(key, lambda data: _emit("approval.request", sid, data)) + + register_gateway_notify( + key, lambda data: _emit("approval.request", sid, data) + ) notify_registered = True load_permanent_allowlist() except Exception: @@ -598,6 +603,7 @@ def _start_agent_build(sid: str, session: dict) -> None: if notify_registered: try: from tools.approval import unregister_gateway_notify + unregister_gateway_notify(key) except Exception: pass @@ -877,6 +883,9 @@ def _load_show_reasoning() -> bool: def _load_tool_progress_mode() -> str: + env = os.environ.get("HERMES_TUI_TOOL_PROGRESS", "").strip().lower() + if env in {"off", "new", "all", "verbose"}: + return env raw = (_load_cfg().get("display") or {}).get("tool_progress", "all") if raw is False: return "off" @@ -938,7 +947,11 @@ def _load_enabled_toolsets() -> list[str] | None: from hermes_cli.tools_config import _parse_enabled_flag raw_cfg = read_raw_config() - mcp_servers = raw_cfg.get("mcp_servers") if isinstance(raw_cfg.get("mcp_servers"), dict) else {} + mcp_servers = ( + raw_cfg.get("mcp_servers") + if isinstance(raw_cfg.get("mcp_servers"), dict) + else {} + ) for name, server_cfg in mcp_servers.items(): if not isinstance(server_cfg, dict): continue @@ -952,7 +965,11 @@ def _load_enabled_toolsets() -> list[str] | None: mcp_valid = [name for name in unresolved if name in mcp_names] disabled = [name for name in unresolved if name in mcp_disabled] - unknown = [name for name in unresolved if name not in mcp_names and name not in mcp_disabled] + unknown = [ + name + for name in unresolved + if name not in mcp_names and name not in mcp_disabled + ] valid = built_in + mcp_valid if unknown: @@ -973,7 +990,9 @@ def _load_enabled_toolsets() -> list[str] | None: if valid: return valid - fallback_notice = "[tui] no valid HERMES_TUI_TOOLSETS entries; using configured CLI toolsets" + fallback_notice = ( + "[tui] no valid HERMES_TUI_TOOLSETS entries; using configured CLI toolsets" + ) try: from hermes_cli.config import load_config @@ -1715,10 +1734,28 @@ def _apply_personality_to_session( def _cfg_max_turns(cfg: dict, default: int) -> int: + try: + env_max = int(os.environ.get("HERMES_TUI_MAX_TURNS", "") or 0) + if env_max > 0: + return env_max + except (TypeError, ValueError): + pass agent_cfg = cfg.get("agent") or {} return int(agent_cfg.get("max_turns") or cfg.get("max_turns") or default) +def _parse_tui_skills_env() -> list[str]: + raw = os.environ.get("HERMES_TUI_SKILLS", "") + skills: list[str] = [] + seen: set[str] = set() + for part in raw.replace("\n", ",").split(","): + item = part.strip() + if item and item not in seen: + seen.add(item) + skills.append(item) + return skills + + def _background_agent_kwargs(agent, task_id: str) -> dict: cfg = _load_cfg() @@ -1788,6 +1825,20 @@ def _make_agent(sid: str, key: str, session_id: str | None = None): cfg = _load_cfg() agent_cfg = cfg.get("agent") or {} system_prompt = (agent_cfg.get("system_prompt", "") or "").strip() + startup_skills = _parse_tui_skills_env() + if startup_skills: + from agent.skill_commands import build_preloaded_skills_prompt + + skills_prompt, _loaded_skills, missing_skills = build_preloaded_skills_prompt( + startup_skills, + task_id=session_id or key, + ) + if missing_skills: + raise ValueError(f"Unknown skill(s): {', '.join(missing_skills)}") + if skills_prompt: + system_prompt = "\n\n".join( + part for part in (system_prompt, skills_prompt) if part + ).strip() model, requested_provider = _resolve_startup_runtime() runtime = resolve_runtime_provider( requested=requested_provider, @@ -1812,6 +1863,10 @@ def _make_agent(sid: str, key: str, session_id: str | None = None): session_id=session_id or key, session_db=_get_db(), ephemeral_system_prompt=system_prompt or None, + checkpoints_enabled=is_truthy_value(os.environ.get("HERMES_TUI_CHECKPOINTS")), + pass_session_id=is_truthy_value(os.environ.get("HERMES_TUI_PASS_SESSION_ID")), + skip_context_files=is_truthy_value(os.environ.get("HERMES_IGNORE_RULES")), + skip_memory=is_truthy_value(os.environ.get("HERMES_IGNORE_RULES")), **_agent_cbs(sid), ) @@ -1856,10 +1911,8 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80): # prompt_toolkit; the TUI has no equivalent print surface, so without # this callback the review would write the skill/memory change silently. try: - agent.background_review_callback = ( - lambda message, _sid=sid: _emit( - "review.summary", _sid, {"text": str(message)} - ) + agent.background_review_callback = lambda message, _sid=sid: _emit( + "review.summary", _sid, {"text": str(message)} ) except Exception: # Bare AIAgents that don't expose the attribute (unlikely, but keep @@ -2269,7 +2322,71 @@ def _(rid, params: dict) -> dict: if err: return err agent = session.get("agent") - return _ok(rid, _get_usage(agent) if agent is not None else {"calls": 0, "input": 0, "output": 0, "total": 0}) + return _ok( + rid, + ( + _get_usage(agent) + if agent is not None + else {"calls": 0, "input": 0, "output": 0, "total": 0} + ), + ) + + +@method("session.status") +def _(rid, params: dict) -> dict: + session, err = _sess_nowait(params, rid) + if err: + return err + + from hermes_constants import display_hermes_home + + key = session.get("session_key") or params.get("session_id") or "" + agent = session.get("agent") + meta = {} + db = _get_db() + if db and key: + try: + meta = db.get_session(key) or {} + except Exception: + meta = {} + + def _dt(value, fallback: datetime | None = None) -> datetime: + if value: + try: + return datetime.fromtimestamp(float(value)) + except Exception: + pass + return fallback or datetime.now() + + created = _dt(meta.get("started_at")) + updated = created + for field in ("updated_at", "last_updated_at", "last_activity_at"): + if meta.get(field): + updated = _dt(meta.get(field), created) + break + + usage = _get_usage(agent) if agent is not None else {} + provider = getattr(agent, "provider", None) or "unknown" + model = getattr(agent, "model", None) or "(unknown)" + lines = [ + "Hermes TUI Status", + "", + f"Session ID: {key}", + f"Path: {display_hermes_home()}", + ] + title = (meta.get("title") or "").strip() + if title: + lines.append(f"Title: {title}") + lines.extend( + [ + f"Model: {model} ({provider})", + f"Created: {created.strftime('%Y-%m-%d %H:%M')}", + f"Last Activity: {updated.strftime('%Y-%m-%d %H:%M')}", + f"Tokens: {int(usage.get('total') or 0):,}", + f"Agent Running: {'Yes' if session.get('running') else 'No'}", + ] + ) + return _ok(rid, {"output": "\n".join(lines)}) @method("session.history") @@ -2375,7 +2492,9 @@ def _(rid, params: dict) -> dict: after_count = len(messages) # Re-read system prompt + tools after compression — _compress_context # may have rebuilt the system prompt (_cached_system_prompt=None). - _sys_prompt_after = getattr(_agent, "_cached_system_prompt", "") or _sys_prompt + _sys_prompt_after = ( + getattr(_agent, "_cached_system_prompt", "") or _sys_prompt + ) _tools_after = getattr(_agent, "tools", None) or _tools after_tokens = ( estimate_request_tokens_rough( @@ -2823,7 +2942,15 @@ def _(rid, params: dict) -> dict: def run_after_agent_ready() -> None: err = _wait_agent(session, rid) if err: - _emit("error", sid, {"message": err.get("error", {}).get("message", "agent initialization failed")}) + _emit( + "error", + sid, + { + "message": err.get("error", {}).get( + "message", "agent initialization failed" + ) + }, + ) with session["history_lock"]: session["running"] = False return @@ -2867,7 +2994,9 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: base_url=getattr(agent, "base_url", "") or "", api_key=getattr(agent, "api_key", "") or "", provider=getattr(agent, "provider", "") or "", - config_context_length=getattr(agent, "_config_context_length", None), + config_context_length=getattr( + agent, "_config_context_length", None + ), ) ctx = preprocess_context_references( prompt, @@ -3024,18 +3153,14 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: # ("✓ Goal achieved" / "⏸ budget exhausted") is surfaced as # a system line so the user sees progress regardless of # outcome. Mirrors gateway/run._post_turn_goal_continuation. - if ( - status == "complete" - and isinstance(raw, str) - and raw.strip() - ): + if status == "complete" and isinstance(raw, str) and raw.strip(): try: from hermes_cli.goals import GoalManager sid_key = session.get("session_key") or "" if sid_key: try: - goals_cfg = (_load_cfg().get("goals") or {}) + goals_cfg = _load_cfg().get("goals") or {} goal_max_turns = int(goals_cfg.get("max_turns", 20) or 20) except Exception: goal_max_turns = 20 @@ -3045,7 +3170,8 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: ) if goal_mgr.is_active(): decision = goal_mgr.evaluate_after_turn( - raw, user_initiated=True, + raw, + user_initiated=True, ) verdict_msg = decision.get("message") or "" if verdict_msg: @@ -3578,7 +3704,9 @@ def _(rid, params: dict) -> dict: arg = str(value or "").strip().lower() if arg in ("show", "on"): cfg = _load_cfg() - display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {} + display = ( + cfg.get("display") if isinstance(cfg.get("display"), dict) else {} + ) sections = ( display.get("sections") if isinstance(display.get("sections"), dict) @@ -3594,7 +3722,9 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"key": key, "value": "show"}) if arg in ("hide", "off"): cfg = _load_cfg() - display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {} + display = ( + cfg.get("display") if isinstance(cfg.get("display"), dict) else {} + ) sections = ( display.get("sections") if isinstance(display.get("sections"), dict) @@ -3625,7 +3755,9 @@ def _(rid, params: dict) -> dict: return _err(rid, 4002, f"unknown details_mode: {value}") cfg = _load_cfg() display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {} - sections = display.get("sections") if isinstance(display.get("sections"), dict) else {} + sections = ( + display.get("sections") if isinstance(display.get("sections"), dict) else {} + ) display["details_mode"] = nv for section in _DETAIL_SECTION_NAMES: sections[section] = nv @@ -3952,6 +4084,7 @@ def _(rid, params: dict) -> dict: if not user_confirm: try: from hermes_cli.config import load_config as _load_config + _cfg = _load_config() _approvals = _cfg.get("approvals") if isinstance(_cfg, dict) else None _confirm_required = True @@ -3965,15 +4098,18 @@ def _(rid, params: dict) -> dict: # Ink's ops.ts reads ``status`` and prints ``message`` to # the transcript; a follow-up invocation with confirm=true # (or an `always` choice that flips the config) proceeds. - return _ok(rid, { - "status": "confirm_required", - "message": ( - "⚠️ /reload-mcp invalidates the prompt cache (next " - "message re-sends full input tokens). Reply `/reload-mcp " - "now` to proceed, or `/reload-mcp always` to proceed and " - "silence this prompt permanently." - ), - }) + return _ok( + rid, + { + "status": "confirm_required", + "message": ( + "⚠️ /reload-mcp invalidates the prompt cache (next " + "message re-sends full input tokens). Reply `/reload-mcp " + "now` to proceed, or `/reload-mcp always` to proceed and " + "silence this prompt permanently." + ), + }, + ) from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools @@ -3989,6 +4125,7 @@ def _(rid, params: dict) -> dict: if bool(params.get("always", False)): try: from cli import save_config_value as _save_cfg + _save_cfg("approvals.mcp_reload_confirm", False) except Exception as _exc: logger.warning("Failed to persist mcp_reload_confirm=false: %s", _exc) @@ -4025,7 +4162,6 @@ _TUI_HIDDEN: frozenset[str] = frozenset( "set-home", "update", "commands", - "status", "approve", "deny", } @@ -4051,6 +4187,8 @@ _PENDING_INPUT_COMMANDS: frozenset[str] = frozenset( } ) +_WORKER_BLOCKED_COMMANDS: frozenset[str] = frozenset({"snapshot", "snap"}) + @method("commands.catalog") def _(rid, params: dict) -> dict: @@ -4069,14 +4207,14 @@ def _(rid, params: dict) -> dict: cat_order: list[str] = [] for cmd in COMMAND_REGISTRY: + if cmd.name in _TUI_HIDDEN or cmd.gateway_only: + continue + c = f"/{cmd.name}" canon[c.lower()] = c for a in cmd.aliases: canon[f"/{a}".lower()] = c - if cmd.name in _TUI_HIDDEN: - continue - desc = _build_description(cmd) all_pairs.append([c, desc]) @@ -4373,7 +4511,7 @@ def _(rid, params: dict) -> dict: return _err(rid, 4001, "no session key") try: - goals_cfg = (_load_cfg().get("goals") or {}) + goals_cfg = _load_cfg().get("goals") or {} max_turns = int(goals_cfg.get("max_turns", 20) or 20) except Exception: max_turns = 20 @@ -4431,6 +4569,21 @@ def _(rid, params: dict) -> dict: {"type": "send", "notice": notice, "message": state.goal}, ) + if name in ("snapshot", "snap"): + subcommand = arg.split(maxsplit=1)[0].lower() if arg else "" + if subcommand in {"restore", "rewind"}: + return _ok( + rid, + { + "type": "exec", + "output": ( + "/snapshot restore is blocked in the TUI because it changes " + "config/state on disk while the live agent has cached settings. " + "Run it in the classic CLI, then restart the TUI." + ), + }, + ) + return _err(rid, 4018, f"not a quick/plugin/skill command: {name}") @@ -4967,6 +5120,7 @@ def _(rid, params: dict) -> dict: # Build final list in CANONICAL_PROVIDERS order, merging auth data from hermes_cli.auth import PROVIDER_REGISTRY as _auth_reg + ordered: list = [] for entry in CANONICAL_PROVIDERS: if entry.slug in authed_map: @@ -4974,24 +5128,30 @@ def _(rid, params: dict) -> dict: else: pconfig = _auth_reg.get(entry.slug) auth_type = pconfig.auth_type if pconfig else "api_key" - key_env = pconfig.api_key_env_vars[0] if (pconfig and pconfig.api_key_env_vars) else "" + key_env = ( + pconfig.api_key_env_vars[0] + if (pconfig and pconfig.api_key_env_vars) + else "" + ) if auth_type == "api_key" and key_env: warning = f"paste {key_env} to activate" else: warning = f"run `hermes model` to configure ({auth_type})" - ordered.append({ - "slug": entry.slug, - "name": _PROVIDER_LABELS.get(entry.slug, entry.label), - "is_current": entry.slug == current_provider, - "is_user_defined": False, - "models": [], - "total_models": 0, - "source": "built-in", - "authenticated": False, - "auth_type": auth_type, - "key_env": key_env, - "warning": warning, - }) + ordered.append( + { + "slug": entry.slug, + "name": _PROVIDER_LABELS.get(entry.slug, entry.label), + "is_current": entry.slug == current_provider, + "is_user_defined": False, + "models": [], + "total_models": 0, + "source": "built-in", + "authenticated": False, + "auth_type": auth_type, + "key_env": key_env, + "warning": warning, + } + ) # Append user-defined/custom providers not in canonical list ordered.extend(authed_extra) @@ -5037,9 +5197,10 @@ def _(rid, params: dict) -> dict: return _err(rid, 4002, f"unknown provider: {slug}") if pconfig.auth_type != "api_key": return _err( - rid, 4003, + rid, + 4003, f"{pconfig.name} uses {pconfig.auth_type} auth — " - f"run `hermes model` to configure" + f"run `hermes model` to configure", ) if not pconfig.api_key_env_vars: return _err(rid, 4004, f"no env var defined for {pconfig.name}") @@ -5049,6 +5210,7 @@ def _(rid, params: dict) -> dict: save_env_value(env_var, api_key) # Also set in current process so list_authenticated_providers sees it import os + os.environ[env_var] = api_key # Refresh provider data @@ -5132,11 +5294,14 @@ def _(rid, params: dict) -> dict: return _err(rid, 4005, f"no credentials found for {slug}") provider_name = pconfig.name if pconfig else slug - return _ok(rid, { - "slug": slug, - "name": provider_name, - "disconnected": True, - }) + return _ok( + rid, + { + "slug": slug, + "name": provider_name, + "disconnected": True, + }, + ) except Exception as e: return _err(rid, 5035, str(e)) @@ -5222,6 +5387,15 @@ def _(rid, params: dict) -> dict: rid, 4018, f"pending-input command: use command.dispatch for /{_cmd_base}" ) + if _cmd_base in _WORKER_BLOCKED_COMMANDS: + subcommand = _cmd_arg.split(maxsplit=1)[0].lower() if _cmd_arg else "" + if subcommand in {"restore", "rewind"}: + return _err( + rid, + 4018, + "snapshot restore mutates live config/state; use command.dispatch for /snapshot restore", + ) + try: from agent.skill_commands import get_skill_commands @@ -5471,8 +5645,17 @@ def _(rid, params: dict) -> dict: voice_cfg = _voice_cfg_dict() threshold = voice_cfg.get("silence_threshold") duration = voice_cfg.get("silence_duration") - safe_threshold = threshold if isinstance(threshold, (int, float)) and not isinstance(threshold, bool) else 200 - safe_duration = duration if isinstance(duration, (int, float)) and not isinstance(duration, bool) else 3.0 + safe_threshold = ( + threshold + if isinstance(threshold, (int, float)) + and not isinstance(threshold, bool) + else 200 + ) + safe_duration = ( + duration + if isinstance(duration, (int, float)) and not isinstance(duration, bool) + else 3.0 + ) start_continuous( on_transcript=lambda t: _voice_emit("voice.transcript", {"text": t}), on_status=lambda s: _voice_emit("voice.status", {"state": s}), @@ -5772,7 +5955,9 @@ def _browser_connect(rid, params: dict) -> dict: raw_url = params.get("url") if raw_url is not None and not isinstance(raw_url, str): - return _err(rid, 4015, f"browser url must be a string, got {type(raw_url).__name__}") + return _err( + rid, 4015, f"browser url must be a string, got {type(raw_url).__name__}" + ) url = (raw_url or "").strip() or DEFAULT_BROWSER_CDP_URL sid = params.get("session_id") or "" @@ -6225,6 +6410,31 @@ def _(rid, params: dict) -> dict: return _err(rid, 5024, str(e)) +@method("skills.reload") +def _(rid, params: dict) -> dict: + try: + from agent.skill_commands import reload_skills + + result = reload_skills() + added = result.get("added") or [] + removed = result.get("removed") or [] + total = int(result.get("total") or 0) + + lines = ["Reloading skills..."] + if not added and not removed: + lines.append("No new skills detected.") + if added: + lines.append("Added skills:") + lines.extend(f" - {item.get('name', '')}" for item in added) + if removed: + lines.append("Removed skills:") + lines.extend(f" - {item.get('name', '')}" for item in removed) + lines.append(f"{total} skill(s) available") + return _ok(rid, {"output": "\n".join(lines), "result": result}) + except Exception as e: + return _err(rid, 5025, str(e)) + + # ── Methods: shell ─────────────────────────────────────────────────── diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 53ca44a8fe..64aa83274a 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -18,6 +18,27 @@ describe('createSlashHandler', () => { expect(getOverlayState().picker).toBe(true) }) + it('handles /redraw locally without slash worker fallback', () => { + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/redraw')).toBe(true) + expect(ctx.gateway.gw.request).not.toHaveBeenCalled() + expect(ctx.transcript.sys).toHaveBeenCalledWith('ui redrawn') + }) + + it('routes /status to live session.status instead of slash worker', async () => { + patchUiState({ sid: 'sid-abc' }) + const rpc = vi.fn(() => Promise.resolve({ output: 'Hermes TUI Status' })) + const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } }) + + expect(createSlashHandler(ctx)('/status')).toBe(true) + expect(rpc).toHaveBeenCalledWith('session.status', { session_id: 'sid-abc' }) + expect(ctx.gateway.gw.request).not.toHaveBeenCalled() + await vi.waitFor(() => { + expect(ctx.transcript.page).toHaveBeenCalledWith('Hermes TUI Status', 'Status') + }) + }) + it('keeps typed /model switches session-scoped by default', async () => { patchUiState({ sid: 'sid-abc' }) @@ -157,12 +178,49 @@ describe('createSlashHandler', () => { }) }) - it('shows usage for an unknown /skills subcommand', () => { + it('delegates non-native /skills subcommands to slash.exec', () => { const ctx = buildCtx() - createSlashHandler(ctx)('/skills zzz') + createSlashHandler(ctx)('/skills check') expect(ctx.gateway.rpc).not.toHaveBeenCalled() - expect(ctx.transcript.sys).toHaveBeenCalledWith(expect.stringContaining('usage: /skills')) + expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', { + command: 'skills check', + session_id: null + }) + }) + + it('passes /new through to the session lifecycle', () => { + const ctx = buildCtx() + + createSlashHandler(ctx)('/new sprint planning') + getOverlayState().confirm?.onConfirm() + + expect(ctx.session.newSession).toHaveBeenCalledWith('new session started', 'sprint planning') + expect(ctx.gateway.rpc).not.toHaveBeenCalled() + }) + + it('reloads skills in the live gateway and refreshes the catalog', async () => { + const rpc = vi.fn((method: string) => { + if (method === 'skills.reload') { + return Promise.resolve({ output: '42 skill(s) available' }) + } + if (method === 'commands.catalog') { + return Promise.resolve({ canon: { '/new-skill': '/new-skill' }, pairs: [['/new-skill', 'demo']] }) + } + return Promise.resolve({}) + }) + const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } }) + + createSlashHandler(ctx)('/reload-skills') + + expect(rpc).toHaveBeenCalledWith('skills.reload', {}) + await vi.waitFor(() => { + expect(ctx.transcript.page).toHaveBeenCalledWith('42 skill(s) available', 'Reload Skills') + expect(ctx.local.setCatalog).toHaveBeenCalledWith( + expect.objectContaining({ canon: { '/new-skill': '/new-skill' }, pairs: [['/new-skill', 'demo']] }) + ) + }) + expect(ctx.gateway.gw.request).not.toHaveBeenCalled() }) // Regressions from Copilot review on #19835: /voice output + frontend @@ -192,9 +250,7 @@ describe('createSlashHandler', () => { expect(ctx.transcript.sys).toHaveBeenCalledWith('Voice mode enabled') expect(ctx.transcript.sys).toHaveBeenCalledWith(' Alt+R to start/stop recording') }) - expect(ctx.voice.setVoiceRecordKey).toHaveBeenCalledWith( - expect.objectContaining({ ch: 'r', mod: 'alt' }) - ) + expect(ctx.voice.setVoiceRecordKey).toHaveBeenCalledWith(expect.objectContaining({ ch: 'r', mod: 'alt' })) }) it('/voice falls back to Ctrl+B when the gateway response omits record_key', async () => { @@ -447,17 +503,17 @@ describe('createSlashHandler', () => { local: { catalog: { canon: { - '/status': '/status', - '/statusbar': '/statusbar' + '/profile': '/profile', + '/plugins': '/plugins' } } } }) - expect(createSlashHandler(ctx)('/status')).toBe(true) + expect(createSlashHandler(ctx)('/profile')).toBe(true) await vi.waitFor(() => { expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', { - command: 'status', + command: 'profile', session_id: null }) }) @@ -675,7 +731,8 @@ const buildLocal = () => ({ catalog: null, getHistoryItems: vi.fn(() => []), getLastUserMsg: vi.fn(() => ''), - maybeWarn: vi.fn() + maybeWarn: vi.fn(), + setCatalog: vi.fn() }) const buildSession = () => ({ diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 270024a8ef..555a35e8af 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -1,5 +1,6 @@ +import { STARTUP_IMAGE, STARTUP_QUERY } from '../config/env.js' import { STREAM_BATCH_MS } from '../config/timing.js' -import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js' +import { SETUP_REQUIRED_TITLE, buildSetupRequiredSections } from '../content/setup.js' import type { CommandsCatalogResponse, ConfigFullResponse, @@ -64,6 +65,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: let pendingThinkingStatus = '' let thinkingStatusTimer: null | ReturnType<typeof setTimeout> = null + let startupPromptSubmitted = false // Inject the disk-save callback into turnController so recordMessageComplete // can fire-and-forget a persist without having to plumb a gateway ref around. @@ -146,6 +148,36 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: }, ms) } + const scheduleStartupPrompt = () => { + if (startupPromptSubmitted || (!STARTUP_QUERY && !STARTUP_IMAGE)) { + return + } + + startupPromptSubmitted = true + setTimeout(async () => { + let sid = getUiState().sid + + for (let i = 0; !sid && i < 40; i += 1) { + await new Promise(resolve => setTimeout(resolve, 100)) + sid = getUiState().sid + } + + if (!sid) { + return sys('startup query skipped: no active session') + } + + if (STARTUP_IMAGE) { + try { + await rpc('image.attach', { path: STARTUP_IMAGE, session_id: sid }) + } catch (e) { + sys(`startup image attach failed: ${rpcErrorMessage(e)}`) + } + } + + submitRef.current(STARTUP_QUERY || 'What do you see in this image?') + }, 0) + } + // Terminal statuses are never overwritten by late-arriving live events — // otherwise a stale `subagent.start` / `spawn_requested` can clobber a // `failed` or `interrupted` terminal state (Copilot review #14045). @@ -181,6 +213,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: if (STARTUP_RESUME_ID) { patchUiState({ status: 'resuming…' }) resumeById(STARTUP_RESUME_ID) + scheduleStartupPrompt() return } @@ -196,6 +229,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: if (!cfg?.config?.display?.tui_auto_resume_recent) { patchUiState({ status: 'forging session…' }) newSession() + scheduleStartupPrompt() return } @@ -206,17 +240,20 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: if (target) { patchUiState({ status: 'resuming most recent…' }) resumeById(target) + scheduleStartupPrompt() return } patchUiState({ status: 'forging session…' }) newSession() + scheduleStartupPrompt() }) }) .catch(() => { patchUiState({ status: 'forging session…' }) newSession() + scheduleStartupPrompt() }) } diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index dfe88fc040..9b9ceb6830 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -190,7 +190,7 @@ export interface InputHandlerActions { die: () => void dispatchSubmission: (full: string) => void guardBusySessionSwitch: (what?: string) => boolean - newSession: (msg?: string) => void + newSession: (msg?: string, title?: string) => void sys: (text: string) => void } @@ -232,7 +232,7 @@ export interface GatewayEventHandlerContext { session: { STARTUP_RESUME_ID: string colsRef: MutableRefObject<number> - newSession: (msg?: string) => void + newSession: (msg?: string, title?: string) => void resetSession: () => void resumeById: (id: string) => void setCatalog: StateSetter<null | SlashCatalog> @@ -272,12 +272,13 @@ export interface SlashHandlerContext { getHistoryItems: () => Msg[] getLastUserMsg: () => string maybeWarn: (value: unknown) => void + setCatalog: StateSetter<null | SlashCatalog> } session: { closeSession: (targetSid?: null | string) => Promise<unknown> die: () => void guardBusySessionSwitch: (what?: string) => boolean - newSession: (msg?: string) => void + newSession: (msg?: string, title?: string) => void resetVisibleHistory: (info?: null | SessionInfo) => void resumeById: (id: string) => void setSessionStartedAt: StateSetter<number> diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index dcbafb3a82..c40307dc46 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -1,11 +1,14 @@ +import { forceRedraw } from '@hermes/ink' + import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js' import { dailyFortune, randomFortune } from '../../../content/fortunes.js' import { HOTKEYS } from '../../../content/hotkeys.js' -import { isSectionName, nextDetailsMode, parseDetailsMode, SECTION_NAMES } from '../../../domain/details.js' +import { SECTION_NAMES, isSectionName, nextDetailsMode, parseDetailsMode } from '../../../domain/details.js' import type { ConfigGetValueResponse, ConfigSetResponse, SessionSaveResponse, + SessionStatusResponse, SessionSteerResponse, SessionTitleResponse, SessionUndoResponse @@ -112,16 +115,17 @@ export const coreCommands: SlashCommand[] = [ aliases: ['new'], help: 'start a new session', name: 'clear', - run: (_arg, ctx, cmd) => { + run: (arg, ctx, cmd) => { if (ctx.session.guardBusySessionSwitch('switch sessions')) { return } const isNew = cmd.startsWith('/new') + const requestedTitle = isNew ? arg.trim() : '' const commit = () => { patchUiState({ status: 'forging session…' }) - ctx.session.newSession(isNew ? 'new session started' : undefined) + ctx.session.newSession(isNew ? 'new session started' : undefined, requestedTitle || undefined) } if (NO_CONFIRM_DESTRUCTIVE) { @@ -141,6 +145,30 @@ export const coreCommands: SlashCommand[] = [ } }, + { + help: 'force a full UI repaint', + name: 'redraw', + run: (_arg, ctx) => { + forceRedraw(process.stdout) + ctx.transcript.sys('ui redrawn') + } + }, + + { + help: 'show live session info', + name: 'status', + run: (_arg, ctx) => { + if (!ctx.sid) { + return ctx.transcript.sys('no active session') + } + + ctx.gateway + .rpc<SessionStatusResponse>('session.status', { session_id: ctx.sid }) + .then(ctx.guarded<SessionStatusResponse>(r => ctx.transcript.page(r.output || '(no status)', 'Status'))) + .catch(ctx.guardedErr) + } + }, + { help: 'resume a prior session', name: 'resume', diff --git a/ui-tui/src/app/slash/commands/ops.ts b/ui-tui/src/app/slash/commands/ops.ts index ad9f3e94d1..d8f6522dc0 100644 --- a/ui-tui/src/app/slash/commands/ops.ts +++ b/ui-tui/src/app/slash/commands/ops.ts @@ -1,5 +1,6 @@ import type { BrowserManageResponse, + CommandsCatalogResponse, DelegationPauseResponse, ProcessStopResponse, ReloadEnvResponse, @@ -56,6 +57,10 @@ interface SkillsBrowseResponse { total_pages?: number } +interface SkillsReloadResponse { + output?: string +} + export const opsCommands: SlashCommand[] = [ { help: 'stop background processes', @@ -435,10 +440,44 @@ export const opsCommands: SlashCommand[] = [ } }, + { + aliases: ['reload_skills'], + help: 're-scan installed skills in the live TUI gateway', + name: 'reload-skills', + run: (_arg, ctx) => { + ctx.gateway + .rpc<SkillsReloadResponse>('skills.reload', {}) + .then( + ctx.guarded<SkillsReloadResponse>(r => { + ctx.transcript.page(r.output || 'skills reloaded', 'Reload Skills') + ctx.gateway + .rpc<CommandsCatalogResponse>('commands.catalog', {}) + .then( + ctx.guarded<CommandsCatalogResponse>(catalog => { + if (!catalog?.pairs) { + return + } + + ctx.local.setCatalog({ + canon: (catalog.canon ?? {}) as Record<string, string>, + categories: catalog.categories ?? [], + pairs: catalog.pairs as [string, string][], + skillCount: (catalog.skill_count ?? 0) as number, + sub: (catalog.sub ?? {}) as Record<string, string[]> + }) + }) + ) + .catch(() => {}) + }) + ) + .catch(ctx.guardedErr) + } + }, + { help: 'browse, inspect, install skills', name: 'skills', - run: (arg, ctx) => { + run: (arg, ctx, cmd) => { const text = arg.trim() if (!text) { @@ -449,6 +488,22 @@ export const opsCommands: SlashCommand[] = [ const query = rest.join(' ').trim() const { rpc } = ctx.gateway const { panel, sys } = ctx.transcript + const runViaSlashWorker = () => { + ctx.gateway.gw + .request<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: ctx.sid }) + .then(r => { + if (ctx.stale()) { + return + } + + const body = r?.output || '/skills: no output' + const formatted = r?.warning ? `warning: ${r.warning}\n${body}` : body + const long = formatted.length > 180 || formatted.split('\n').filter(Boolean).length > 2 + + long ? ctx.transcript.page(formatted, 'Skills') : ctx.transcript.sys(formatted) + }) + .catch(ctx.guardedErr) + } if (sub === 'list') { rpc<SkillsListResponse>('skills.manage', { action: 'list' }) @@ -593,7 +648,7 @@ export const opsCommands: SlashCommand[] = [ return } - sys('usage: /skills [list | inspect <n> | install <n> | search <q> | browse [page]]') + runViaSlashWorker() } }, diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 282f8da208..874eca50a2 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -1,4 +1,4 @@ -import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@hermes/ink' +import { useApp, useHasSelection, useSelection, useStdout, useTerminalTitle, type ScrollBoxHandle } from '@hermes/ink' import { useStore } from '@nanostores/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -16,8 +16,8 @@ import type { } from '../gatewayTypes.js' import { useGitBranch } from '../hooks/useGitBranch.js' import { useVirtualHistory } from '../hooks/useVirtualHistory.js' -import { appendTranscriptMessage } from '../lib/messages.js' import { composerPromptWidth } from '../lib/inputMetrics.js' +import { appendTranscriptMessage } from '../lib/messages.js' import { DEFAULT_VOICE_RECORD_KEY, isMac, type ParsedVoiceRecordKey } from '../lib/platform.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import { terminalParityHints } from '../lib/terminalParity.js' @@ -631,7 +631,8 @@ export function useMainApp(gw: GatewayClient) { catalog, getHistoryItems: () => historyItemsRef.current, getLastUserMsg: () => lastUserMsgRef.current, - maybeWarn + maybeWarn, + setCatalog }, session: { closeSession: session.closeSession, @@ -723,9 +724,12 @@ export function useMainApp(gw: GatewayClient) { const anyPanelVisible = SECTION_NAMES.some( s => sectionMode(s, ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden' ) - const thinkingPanelVisible = sectionMode('thinking', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden' - const toolsPanelVisible = sectionMode('tools', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden' - const activityPanelVisible = sectionMode('activity', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden' + const thinkingPanelVisible = + sectionMode('thinking', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden' + const toolsPanelVisible = + sectionMode('tools', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden' + const activityPanelVisible = + sectionMode('activity', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden' const showProgressArea = useTurnSelector(state => anyPanelVisible @@ -738,7 +742,9 @@ export function useMainApp(gw: GatewayClient) { const hasTrailTools = Boolean(segment.tools?.length) if (segment.kind === 'trail' && !segment.text) { - return (thinkingPanelVisible && hasThinking) || ((toolsPanelVisible || activityPanelVisible) && hasTrailTools) + return ( + (thinkingPanelVisible && hasThinking) || ((toolsPanelVisible || activityPanelVisible) && hasTrailTools) + ) } return ( diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index ccec822004..e73158b27b 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -2,7 +2,7 @@ import { writeFileSync } from 'node:fs' import type { ScrollBoxHandle } from '@hermes/ink' import { evictInkCaches } from '@hermes/ink' -import { type RefObject, useCallback } from 'react' +import { useCallback, type RefObject } from 'react' import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js' import { introMsg, toTranscriptMessages } from '../domain/messages.js' @@ -12,6 +12,7 @@ import type { SessionCloseResponse, SessionCreateResponse, SessionResumeResponse, + SessionTitleResponse, SetupStatusResponse } from '../gatewayTypes.js' import { asRpcResult } from '../lib/rpc.js' @@ -122,7 +123,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { ) const newSession = useCallback( - async (msg?: string) => { + async (msg?: string, title?: string) => { const setup = await rpc<SetupStatusResponse>('setup.status', {}) if (setup?.provider_configured === false) { @@ -141,6 +142,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { } const info = r.info ?? null + const requestedTitle = title?.trim() ?? '' resetSession() setSessionStartedAt(Date.now()) @@ -168,6 +170,30 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { if (msg) { sys(msg) } + + if (requestedTitle) { + rpc<SessionTitleResponse>('session.title', { + session_id: r.session_id, + title: requestedTitle + }) + .then(result => { + if (!result || getUiState().sid !== r.session_id) { + return + } + + const nextTitle = (result.title ?? requestedTitle).trim() + const suffix = result.pending ? ' (queued while session initializes)' : '' + sys(`session title set: ${nextTitle}${suffix}`) + }) + .catch((err: unknown) => { + if (getUiState().sid !== r.session_id) { + return + } + + const message = err instanceof Error ? err.message : String(err) + sys(`warning: failed to set session title: ${message}`) + }) + } }, [closeSession, colsRef, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys] ) diff --git a/ui-tui/src/config/env.ts b/ui-tui/src/config/env.ts index 8fb9cf69a6..8e9dde92fd 100644 --- a/ui-tui/src/config/env.ts +++ b/ui-tui/src/config/env.ts @@ -1,6 +1,8 @@ const truthy = (v?: string) => /^(?:1|true|yes|on)$/i.test((v ?? '').trim()) export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim() +export const STARTUP_QUERY = (process.env.HERMES_TUI_QUERY ?? '').trim() +export const STARTUP_IMAGE = (process.env.HERMES_TUI_IMAGE ?? '').trim() export const MOUSE_TRACKING = !truthy(process.env.HERMES_TUI_DISABLE_MOUSE) export const NO_CONFIRM_DESTRUCTIVE = truthy(process.env.HERMES_TUI_NO_CONFIRM) diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 7fca2837fa..0dacd790f0 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -176,6 +176,10 @@ export interface SessionUsageResponse { total?: number } +export interface SessionStatusResponse { + output?: string +} + export interface SessionCompressResponse { after_messages?: number after_tokens?: number