diff --git a/cli.py b/cli.py index b5900da71542..cd41920d4ef0 100644 --- a/cli.py +++ b/cli.py @@ -751,14 +751,6 @@ def load_cli_config() -> Dict[str, Any]: except Exception: pass -# Initialize friendly tool labels from config (default on) -try: - from agent.display import set_friendly_tool_labels - _ftl = CLI_CONFIG.get("display", {}).get("friendly_tool_labels", True) - set_friendly_tool_labels(bool(_ftl)) -except Exception: - pass - # Neuter AsyncHttpxClientWrapper.__del__ before any AsyncOpenAI clients are # created. The SDK's __del__ schedules aclose() on asyncio.get_running_loop() # which, during CLI idle time, finds prompt_toolkit's event loop and tries to @@ -1181,7 +1173,9 @@ def _reset_terminal_input_modes_on_exit() -> None: except Exception: pass try: - with open("/dev/tty", "w", encoding="ascii") as tty: + # Windows doesn't have /dev/tty — use CON instead + tty_path = "CON" if sys.platform == "win32" else "/dev/tty" + with open(tty_path, "w", encoding="ascii") as tty: tty.write(_TERMINAL_INPUT_MODE_RESET_SEQ) tty.flush() except Exception: @@ -1529,89 +1523,6 @@ def _worktree_has_unpushed_commits(worktree_path: str, timeout: int = 10) -> boo return True -def _worktree_is_dirty(worktree_path: str, timeout: int = 10) -> bool: - """Return whether a worktree has uncommitted changes (staged, unstaged, or - untracked). - - Fails SAFE: on any error returns True so callers do not delete a worktree - whose state they cannot determine. - """ - import subprocess - - try: - result = subprocess.run( - ["git", "status", "--porcelain"], - capture_output=True, text=True, timeout=timeout, cwd=worktree_path, - ) - if result.returncode != 0: - return True - return bool(result.stdout.strip()) - except Exception: - return True - - -def _worktree_lock_is_live(repo_root: str, worktree_path: str, timeout: int = 10): - """Classify a worktree's git lock as live, dead, or absent. - - ``hermes -w`` locks each worktree with reason ``hermes pid=`` so a - concurrent hermes process' startup prune leaves an in-use worktree alone. - But a *crashed* session leaves the lock behind forever, and - ``git worktree remove --force`` (single ``-f``) refuses to remove a locked - worktree — so dead-locked worktrees accumulate indefinitely. This lets the - pruner tell the two apart: - - - ``"live"`` — locked and the owning pid is still running (skip it). - - ``"dead"`` — locked but the owning pid is gone, or the reason isn't a - parseable hermes lock (safe to unlock + reap). - - ``None`` — not locked at all. - - Fails SAFE toward ``"live"``: if git can't be queried at all we cannot - prove the worktree is safe to touch, so we report it as live. - """ - import re - import subprocess - - try: - result = subprocess.run( - ["git", "worktree", "list", "--porcelain"], - capture_output=True, text=True, timeout=timeout, cwd=repo_root, - ) - if result.returncode != 0: - return "live" - except Exception: - return "live" - - target = Path(worktree_path).resolve() - current: Optional[Path] = None - for line in result.stdout.splitlines(): - if line.startswith("worktree "): - try: - current = Path(line[len("worktree "):].strip()).resolve() - except Exception: - current = None - elif line == "locked" or line.startswith("locked "): - if current != target: - continue - reason = line[len("locked"):].strip() - m = re.search(r"hermes pid=(\d+)", reason) - if not m: - # Locked by something we don't recognize as a hermes session - # (or lock reason unavailable). Treat as dead — a foreign lock - # on a hermes -w worktree is almost certainly a leftover, and - # the age/dirty/unpushed gates already ran before we got here. - return "dead" - pid = int(m.group(1)) - if pid == os.getpid(): - return "live" - try: - from gateway.status import _pid_exists - return "live" if _pid_exists(pid) else "dead" - except Exception: - # Can't determine liveness — fail safe toward keeping it. - return "live" - return None - - def _cleanup_worktree(info: Dict[str, str] = None) -> None: """Remove a worktree and its branch on exit. @@ -1756,23 +1667,11 @@ def _run_checkpoint_auto_maintenance() -> None: def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None: """Remove stale worktrees and orphaned branches on startup. - Age-based tiers (aggressive cleanup keeps ``.worktrees/`` from growing - unbounded): + Age-based tiers: - Under max_age_hours (24h): skip — session may still be active. - 24h–72h: remove if no unpushed commits. - Over 72h: force remove regardless (nothing should sit this long). - Lock handling (orthogonal to age): ``hermes -w`` locks each worktree with - reason ``hermes pid=`` so a concurrent hermes process leaves an in-use - worktree alone. A *live*-locked worktree is skipped at any age; a - *dead*-locked one (owning pid gone — a crashed session) is unlocked first - so ``git worktree remove --force`` can actually reap it, otherwise those - leftovers accumulate forever (``remove --force`` refuses a locked tree). - - Branch deletion is gated on ``git worktree remove`` succeeding, so a failed - removal never orphans the branch (which would drop easy reachability of any - commits still in the worktree). - Also prunes orphaned ``hermes/*`` and ``pr-*`` local branches that have no corresponding worktree. """ @@ -1800,37 +1699,12 @@ def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None: except Exception: continue - force = mtime <= hard_cutoff # Over 72h — reap aggressively + force = mtime <= hard_cutoff # Over 72h — force remove - # Never delete real work, regardless of age. Unpushed commits and - # uncommitted changes may be a crashed session's in-flight work; the - # >72h tier reaps only abandoned *clean, fully-pushed* worktrees (the - # scratch trees that actually cause .worktrees/ bloat). - if _worktree_has_unpushed_commits(str(entry), timeout=5): - continue # Has unpushed commits or can't check — skip if not force: - # 24h–72h tier is conservative: unpushed check above is enough. - pass - elif _worktree_is_dirty(str(entry), timeout=5): - continue # >72h but dirty — preserve uncommitted work - - # Respect git-native session locks. A lock owned by a still-running - # hermes process means the worktree is actively in use — never touch - # it. A lock whose owning pid is gone is a crashed session's leftover: - # unlock it so `git worktree remove --force` (single -f) can reap it, - # otherwise dead-locked worktrees pile up indefinitely. - lock_state = _worktree_lock_is_live(repo_root, str(entry), timeout=5) - if lock_state == "live": - logger.debug("Skipping live-locked worktree: %s", entry.name) - continue - if lock_state == "dead": - try: - subprocess.run( - ["git", "worktree", "unlock", str(entry)], - capture_output=True, text=True, timeout=10, cwd=repo_root, - ) - except Exception as e: - logger.debug("Failed to unlock dead worktree %s: %s", entry.name, e) + # 24h–72h tier: only remove if no unpushed commits + if _worktree_has_unpushed_commits(str(entry), timeout=5): + continue # Has unpushed commits or can't check — skip # Safe to remove try: @@ -1840,18 +1714,10 @@ def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None: ) branch = branch_result.stdout.strip() - remove_result = subprocess.run( + subprocess.run( ["git", "worktree", "remove", str(entry), "--force"], capture_output=True, text=True, timeout=15, cwd=repo_root, ) - if remove_result.returncode != 0: - # Removal failed — keep the branch so any commits stay - # reachable rather than orphaning it. - logger.debug( - "Failed to remove worktree %s: %s", - entry.name, remove_result.stderr.strip(), - ) - continue if branch: subprocess.run( ["git", "branch", "-D", branch], @@ -2638,26 +2504,6 @@ def _prepend_note_to_message(message, note: str): return message -def _cli_visible_print(text: str = "") -> None: - """Print normally unless prompt_toolkit owns the live terminal. - - Bare ``print()`` output is swallowed by ``patch_stdout`` while an - interactive ``Application`` is running, so ``/sessions`` and ``/history`` - would render nothing. Route through ``_cprint`` (prompt_toolkit-native) - in that case, and fall back to ``print`` otherwise. - """ - try: - from prompt_toolkit.application import get_app_or_none - app = get_app_or_none() - except Exception: - app = None - - if app is not None and getattr(app, "_is_running", False): - _cprint(text) - else: - print(text) - - # --------------------------------------------------------------------------- # File-drop / local attachment detection — extracted as pure helpers for tests. # --------------------------------------------------------------------------- @@ -5571,14 +5417,10 @@ def _stream_delta(self, text) -> None: self._stream_last_was_newline = True # start of stream = boundary if not getattr(self, "_in_reasoning_block", False): - # Case-insensitive matching against a lowercased view so - # mixed-case tag variants (, , …) are caught. - prefilt_lower = self._stream_prefilt.lower() for tag in _OPEN_TAGS: - tag_lower = tag.lower() search_start = 0 while True: - idx = prefilt_lower.find(tag_lower, search_start) + idx = self._stream_prefilt.find(tag, search_start) if idx == -1: break # Check if this is a block boundary position @@ -5618,12 +5460,11 @@ def _stream_delta(self, text) -> None: # Could also be a partial open tag at the end — hold it back if not getattr(self, "_in_reasoning_block", False): - # Check for partial tag match at the end (case-insensitive) + # Check for partial tag match at the end safe = self._stream_prefilt for tag in _OPEN_TAGS: - tag_lower = tag.lower() for i in range(1, len(tag)): - if prefilt_lower.endswith(tag_lower[:i]): + if self._stream_prefilt.endswith(tag[:i]): safe = self._stream_prefilt[:-i] break if safe: @@ -5636,9 +5477,8 @@ def _stream_delta(self, text) -> None: # Keep accumulating _stream_prefilt because close tags can arrive # split across multiple tokens (e.g. "..."). if getattr(self, "_in_reasoning_block", False): - prefilt_lower = self._stream_prefilt.lower() for tag in _CLOSE_TAGS: - idx = prefilt_lower.find(tag.lower()) + idx = self._stream_prefilt.find(tag) if idx != -1: self._in_reasoning_block = False # When show_reasoning is on, route inner content to @@ -6703,30 +6543,30 @@ def _show_recent_sessions(self, *, reason: str = "history", limit: int = 10) -> from hermes_cli.main import _relative_time - _cli_visible_print() + print() if reason == "history": - _cli_visible_print("(._.) No messages in the current chat yet — here are recent sessions you can resume:") + print("(._.) No messages in the current chat yet — here are recent sessions you can resume:") else: - _cli_visible_print(" Recent sessions:") - _cli_visible_print() - _cli_visible_print(f" {'#':<3} {'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}") - _cli_visible_print(f" {'─' * 3} {'─' * 32} {'─' * 40} {'─' * 13} {'─' * 24}") + print(" Recent sessions:") + print() + print(f" {'#':<3} {'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}") + print(f" {'─' * 3} {'─' * 32} {'─' * 40} {'─' * 13} {'─' * 24}") for idx, session in enumerate(sessions, start=1): title = session.get("title") or "—" preview = (session.get("preview") or "")[:38] last_active = _relative_time(session.get("last_active")) - _cli_visible_print(f" {idx:<3} {title:<32} {preview:<40} {last_active:<13} {session['id']}") - _cli_visible_print() - _cli_visible_print(" Use /resume , /resume , or /resume to continue.") - _cli_visible_print(" Example: /resume 2") - _cli_visible_print() + print(f" {idx:<3} {title:<32} {preview:<40} {last_active:<13} {session['id']}") + print() + print(" Use /resume , /resume , or /resume to continue.") + print(" Example: /resume 2") + print() return True def show_history(self): """Display conversation history.""" if not self.conversation_history: if not self._show_recent_sessions(reason="history"): - _cli_visible_print("(._.) No conversation history yet.") + print("(._.) No conversation history yet.") return preview_limit = 400 @@ -6755,14 +6595,14 @@ def flush_tool_summary(): return noun = "message" if hidden_tool_messages == 1 else "messages" - _cli_visible_print("\n [Tools]") - _cli_visible_print(f" ({hidden_tool_messages} tool {noun} hidden)") + print("\n [Tools]") + print(f" ({hidden_tool_messages} tool {noun} hidden)") hidden_tool_messages = 0 - _cli_visible_print() - _cli_visible_print("+" + "-" * 50 + "+") - _cli_visible_print("|" + " " * 12 + "(^_^) Conversation History" + " " * 11 + "|") - _cli_visible_print("+" + "-" * 50 + "+") + print() + print("+" + "-" * 50 + "+") + print("|" + " " * 12 + "(^_^) Conversation History" + " " * 11 + "|") + print("+" + "-" * 50 + "+") for msg in self.conversation_history: role = msg.get("role", "unknown") @@ -6781,13 +6621,13 @@ def flush_tool_summary(): content_text = "" if content is None else str(content) if role == "user": - _cli_visible_print(f"\n [You #{visible_index}]{_ts_suffix(msg)}") - _cli_visible_print( + print(f"\n [You #{visible_index}]{_ts_suffix(msg)}") + print( f" {content_text[:preview_limit]}{'...' if len(content_text) > preview_limit else ''}" ) continue - _cli_visible_print(f"\n [Hermes #{visible_index}]{_ts_suffix(msg)}") + print(f"\n [Hermes #{visible_index}]{_ts_suffix(msg)}") tool_calls = msg.get("tool_calls") or [] if content_text: preview = content_text[:preview_limit] @@ -6800,10 +6640,10 @@ def flush_tool_summary(): else: preview = "(no text response)" suffix = "" - _cli_visible_print(f" {preview}{suffix}") + print(f" {preview}{suffix}") flush_tool_summary() - _cli_visible_print() + print() def _notify_session_boundary(self, event_type: str) -> None: """Fire a session-boundary plugin hook (on_session_finalize or on_session_reset). @@ -8539,7 +8379,7 @@ def process_command(self, command: str) -> bool: elif canonical == "copy": self._handle_copy_command(cmd_original) elif canonical == "debug": - self._handle_debug_command(cmd_original) + self._handle_debug_command() elif canonical == "update": if self._handle_update_command(): return False @@ -8639,8 +8479,6 @@ def process_command(self, command: str) -> bool: self._handle_stop_command() elif canonical == "agents": self._handle_agents_command() - elif canonical == "journey": - self._handle_journey_command(cmd_original) elif canonical == "background": self._handle_background_command(cmd_original) elif canonical == "queue": @@ -8740,19 +8578,12 @@ def process_command(self, command: str) -> bool: try: # shell=True is intentional: quick_commands are user-defined # shell snippets from config.yaml — not agent/LLM controlled. - # Sanitize env to prevent credential leakage — - # quick commands run in the CLI process which - # has all API keys in os.environ. - from tools.environments.local import _sanitize_subprocess_env - sanitized_env = _sanitize_subprocess_env(os.environ.copy()) result = subprocess.run( exec_cmd, shell=True, capture_output=True, - text=True, timeout=30, env=sanitized_env + text=True, timeout=30 ) output = result.stdout.strip() or result.stderr.strip() if output: - from agent.redact import redact_sensitive_text - output = redact_sensitive_text(output) self._console_print(_rich_text_from_ansi(output)) else: self._console_print("[dim]Command returned no output[/]") @@ -8922,31 +8753,6 @@ def _get_goal_manager(self): - def _drain_interrupt_queue_to_pending_input(self) -> None: - """Move stray messages from ``_interrupt_queue`` into ``_pending_input``. - - While the agent is running, user input is routed into - ``_interrupt_queue`` (see the architecture comment near - ``_route_user_input_when_busy``). The explicit-interrupt path at the - top of ``process_loop`` only drains that queue when - ``busy_input_mode == "interrupt"`` AND a ``pending_message`` was - acknowledged. If the agent's turn finishes naturally (no interrupt), - any messages typed during the turn stay stuck in ``_interrupt_queue`` - forever. Subsequent ``Enter`` presses re-route to the same blocked - queue and the CLI appears to hang. - - Called once at the end of every turn from ``process_loop``'s ``finally`` - block. Catches and swallows ``Exception`` because the drain must never - break the main loop. (#20271) - """ - try: - while not self._interrupt_queue.empty(): - stray = self._interrupt_queue.get_nowait() - if stray: - self._pending_input.put(stray) - except Exception: - pass # Non-fatal — never break the main loop - def _maybe_continue_goal_after_turn(self) -> None: """Hook run after every CLI turn. Judges + maybe re-queues. @@ -9401,7 +9207,7 @@ def _show_usage(self): total = agent.session_total_tokens compressor = agent.context_compressor - last_prompt = compressor.last_prompt_tokens if compressor.last_prompt_tokens > 0 else 0 + last_prompt = compressor.last_prompt_tokens ctx_len = compressor.context_length pct = min(100, (last_prompt / ctx_len * 100)) if ctx_len else 0 compressions = compressor.compression_count @@ -10279,11 +10085,9 @@ def _check_config_mcp_changes(self) -> None: target=self._reload_mcp, daemon=True ) _reload_thread.start() - # Do NOT join here — process_loop calls this from its idle branch, so a - # blocking join would freeze input consumption for up to 30s (and a hung - # MCP server could block far longer). The reload runs purely in the - # background daemon thread, which reports its own progress/completion - # status via print() inside _reload_mcp(). + _reload_thread.join(timeout=30) + if _reload_thread.is_alive(): + print(" ⚠️ MCP reload timed out (30s). Some servers may not have reconnected.") # Inline-skip tokens that bypass the destructive-slash confirmation modal. # A general escape hatch for non-interactive use (scripting/automation) and @@ -10911,16 +10715,8 @@ def _voice_start_recording(self): except Exception: pass - # Recorder creation can fail (no input device, PortAudio init error). - # Reset the flag on failure or _voice_recording stays True forever and - # every future voice start is silently skipped by the guard above. if self._voice_recorder is None: - try: - self._voice_recorder = create_audio_recorder() - except Exception: - with self._voice_lock: - self._voice_recording = False - raise + self._voice_recorder = create_audio_recorder() # Apply config-driven silence params (numeric-guarded so YAML # scalar corruption doesn't break recording start-up). @@ -11748,51 +11544,6 @@ def _restore_modal_input_snapshot(self) -> None: except Exception: pass - def _clear_active_overlays_for_interrupt(self) -> None: - """Drain and clear every input-blocking overlay left by an interrupted agent. - - approval/clarify/sudo/secret prompts each block a worker thread on a - ``response_queue.get()``. When the agent is interrupted the worker - thread is torn down, but the overlay's state dict stays set — leaving - the CLI input gated (``read_only`` condition + keypress filter) with no - thread servicing the prompt. The result is a frozen terminal until the - prompt's own timeout expires. Push a terminal value onto each queue so - any still-blocked thread unblocks cleanly, then nil the state out and - restore the user's pre-modal draft (#14026). - - Safe default per prompt: approval -> "deny", clarify/sudo/secret -> - cancel (None / empty). Each step is wrapped so a dead queue can't - prevent clearing the others. - """ - if self._approval_state: - try: - self._approval_state["response_queue"].put("deny") - except Exception: - pass - self._approval_state = None - if self._clarify_state: - try: - self._clarify_state["response_queue"].put( - "The user cancelled. Use your best judgement to proceed." - ) - except Exception: - pass - self._clarify_state = None - self._clarify_freetext = False - if self._sudo_state: - try: - self._sudo_state["response_queue"].put("") - except Exception: - pass - self._sudo_state = None - self._sudo_deadline = 0 - self._restore_modal_input_snapshot() - if self._secret_state: - try: - self._cancel_secret_capture() - except Exception: - self._secret_state = None - def _submit_secret_response(self, value: str) -> None: if not self._secret_state: return @@ -12169,12 +11920,6 @@ def run_agent(): if stop_event is not None: stop_event.set() self.agent.interrupt(interrupt_msg) - # Clear any active overlay states the interrupted agent - # left behind. approval/clarify/sudo/secret prompts gate - # input (read_only condition + keypress filter) until - # explicitly reset — without this the CLI freezes after - # an interrupt until the prompt's own timeout expires (#14026). - self._clear_active_overlays_for_interrupt() # Debug: log to file (stdout may be devnull from redirect_stdout) try: _dbg = _hermes_home / "interrupt_debug.log" @@ -13502,42 +13247,50 @@ def handle_ctrl_c(event): event.app.invalidate() return - # Cancel slash confirmation prompt (foreground UI, not an - # agent-blocking overlay — cancel and stop here). + # Cancel sudo prompt + if self._sudo_state: + self._sudo_state["response_queue"].put("") + self._sudo_state = None + event.app.invalidate() + return + + # Cancel secret prompt + if self._secret_state: + self._cancel_secret_capture() + event.app.current_buffer.reset() + event.app.invalidate() + return + + # Cancel approval prompt (deny) + if self._approval_state: + self._approval_state["response_queue"].put("deny") + self._approval_state = None + event.app.invalidate() + return + + # Cancel slash confirmation prompt if self._slash_confirm_state: self._submit_slash_confirm_response("cancel") event.app.current_buffer.reset() event.app.invalidate() return - # Cancel /model picker (foreground UI — cancel and stop here). + # Cancel /model picker if self._model_picker_state: self._close_model_picker() event.app.current_buffer.reset() event.app.invalidate() return - # Clear all agent-blocking overlays (approval/clarify/sudo/secret) - # in one shot. We do NOT return after clearing — we fall through so - # that if the agent is also running we fire the interrupt on the same - # Ctrl+C press. This fixes the case where a stale/orphaned overlay - # (left behind by a previous interrupt) consumes the press without - # ever reaching the agent-interrupt branch, leaving the chat frozen - # (#14026). - _overlay_cleared = bool( - self._sudo_state - or self._secret_state - or self._approval_state - or self._clarify_state - ) - if _overlay_cleared: - self._clear_active_overlays_for_interrupt() + # Cancel clarify prompt + if self._clarify_state: + self._clarify_state["response_queue"].put( + "The user cancelled. Use your best judgement to proceed." + ) + self._clarify_state = None + self._clarify_freetext = False event.app.current_buffer.reset() event.app.invalidate() - - # If we only cleared overlays and the agent is NOT running, stop here - # (don't fall through to the interrupt/exit path). - if _overlay_cleared and not (self._agent_running and self.agent): return if self._agent_running and self.agent: @@ -13594,35 +13347,50 @@ def handle_ctrl_q(event): event.app.invalidate() return - # Cancel slash confirmation prompt (foreground UI — cancel and stop). + # Cancel sudo prompt + if self._sudo_state: + self._sudo_state["response_queue"].put("") + self._sudo_state = None + event.app.invalidate() + return + + # Cancel secret prompt + if self._secret_state: + self._cancel_secret_capture() + event.app.current_buffer.reset() + event.app.invalidate() + return + + # Cancel approval prompt (deny) + if self._approval_state: + self._approval_state["response_queue"].put("deny") + self._approval_state = None + event.app.invalidate() + return + + # Cancel slash confirmation prompt if self._slash_confirm_state: self._submit_slash_confirm_response("cancel") event.app.current_buffer.reset() event.app.invalidate() return - # Cancel /model picker (foreground UI — cancel and stop). + # Cancel /model picker if self._model_picker_state: self._close_model_picker() event.app.current_buffer.reset() event.app.invalidate() return - # Clear all agent-blocking overlays in one shot, then fall through to - # the agent-interrupt branch so a single Ctrl+Q both clears a stale - # overlay and interrupts a still-running agent (#14026). - _overlay_cleared = bool( - self._sudo_state - or self._secret_state - or self._approval_state - or self._clarify_state - ) - if _overlay_cleared: - self._clear_active_overlays_for_interrupt() + # Cancel clarify prompt + if self._clarify_state: + self._clarify_state["response_queue"].put( + "The user cancelled. Use your best judgement to proceed." + ) + self._clarify_state = None + self._clarify_freetext = False event.app.current_buffer.reset() event.app.invalidate() - - if _overlay_cleared and not (self._agent_running and self.agent): return if self._agent_running and self.agent: @@ -14973,15 +14741,6 @@ def process_loop(): if self._last_turn_interrupted: self._recover_terminal_after_interrupt() - # Re-queue any messages that arrived in _interrupt_queue - # while the agent was running and were never claimed by - # the explicit interrupt path. See - # _drain_interrupt_queue_to_pending_input for the full - # rationale. Regression of #17666 / #18760 — the drain - # block from the original PR #17939 was deferred as - # "worth its own review" and never re-landed (#20271). - self._drain_interrupt_queue_to_pending_input() - # Goal continuation: if a standing goal is active, ask # the judge whether the turn satisfied it. If not, and # there's no real user message already queued, push the @@ -15594,21 +15353,7 @@ def main( ) if missing_skills: missing_display = ", ".join(missing_skills) - # If at least one skill loaded, degrade gracefully: skip the - # unknown ones and continue. A typo'd skill name should not crash - # the worker (which auto-blocks the Kanban task after retries). - # Only when EVERY requested skill is missing do we hard-fail, so a - # fully-misconfigured worker fails loudly instead of running blind. - if loaded_skills: - logger.warning( - "Unknown skill(s) requested, skipping: %s. " - "Continuing with: %s. " - "List available skills with `hermes skills list`.", - missing_display, - ", ".join(loaded_skills), - ) - else: - raise ValueError(f"Unknown skill(s): {missing_display}") + raise ValueError(f"Unknown skill(s): {missing_display}") if skills_prompt: cli.system_prompt = "\n\n".join( part for part in (cli.system_prompt, skills_prompt) if part