From 0ed22f614c1a2b6b19eab1b4dbb2a5281b5f57b2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:14:57 -0700 Subject: [PATCH 01/13] refactor(hermes_cli): collapse EIO/loop-dispatch/terminal-write duplication in cli_terminal_mixin, compact docs --- hermes_cli/cli_terminal_mixin.py | 439 ++++++++++--------------------- 1 file changed, 146 insertions(+), 293 deletions(-) diff --git a/hermes_cli/cli_terminal_mixin.py b/hermes_cli/cli_terminal_mixin.py index 489cf77b9e..300b278540 100644 --- a/hermes_cli/cli_terminal_mixin.py +++ b/hermes_cli/cli_terminal_mixin.py @@ -1,8 +1,7 @@ """Terminal repaint/resize recovery, input-mode healing, and clipboard helpers for the interactive CLI -Mixin split out of ``cli.py``; bound onto ``HermesCLI`` via the MRO. cli.py-internal -symbols are imported LAZILY inside each method (``from cli import ...``) — the mixin -never imports ``cli`` at module load time (import cycle). +Mixin bound onto ``HermesCLI`` via the MRO. cli.py-internal symbols are imported LAZILY +inside each method (``from cli import ...``) — never at module load (import cycle). """ from __future__ import annotations @@ -18,6 +17,39 @@ import time from hermes_constants import get_hermes_home +def _is_eio(exc: BaseException) -> bool: + return getattr(exc, "errno", None) == errno.EIO + + +def _run_on_app_loop(app, fn) -> None: + """Run *fn* on the app's asyncio loop when one exists, else inline (fail-open).""" + try: + loop = getattr(app, "loop", None) + except Exception: + loop = None + if loop is not None: + try: + loop.call_soon_threadsafe(fn) + return + except Exception: + pass + fn() + + +def _write_terminal_sequence(app, seq: str) -> None: + """Write a raw escape *seq* via the app output (write_raw > write) or stdout.""" + output = getattr(app, "output", None) if app else None + if output and hasattr(output, "write_raw"): + output.write_raw(seq) + output.flush() + elif output and hasattr(output, "write"): + output.write(seq) + output.flush() + else: + sys.stdout.write(seq) + sys.stdout.flush() + + class CLITerminalMixin: """Terminal repaint/resize recovery, input-mode healing, and clipboard helpers for the interactive CLI""" @@ -36,22 +68,32 @@ class CLITerminalMixin: f" ({reason})" if reason else "", ) + def _app_invalidate(self, app, where: str, *, swallow: bool) -> None: + """``app.invalidate()``: EIO freezes paints, other OSErrors re-raise, and any + other exception re-raises unless *swallow*.""" + try: + app.invalidate() + except OSError as exc: + if _is_eio(exc): + self._mark_terminal_io_broken(where) + return + raise + except Exception: + if not swallow: + raise + def _invalidate(self, min_interval: float = 0.25) -> None: """Throttled UI repaint for high-frequency background updates. - Use this for spinner frames, streaming token flushes, and other - repaints that can fire many times per second — the throttle prevents - terminal blinking on slow/SSH connections, and the resize-recovery - guard avoids stamping footer/status-bar chrome into scrollback while a - SIGWINCH reflow is in flight. + For spinner frames, streaming token flushes and other repaints that fire many + times per second: the throttle prevents blinking on slow/SSH links, and the + resize-recovery guard avoids stamping footer/status-bar chrome into scrollback + while a SIGWINCH reflow is in flight. - Do NOT use this for user-blocking modal prompts (approval / clarify / - sudo). Those are rare, one-shot, user-blocking events that must paint - immediately; route them through ``self._app.invalidate()`` directly, the - same way the modal key-binding handlers already do. Sending a modal's - entry paint through this throttle lets an unrelated background repaint - within the 250ms window — or an in-flight resize — silently drop it, so - the prompt never renders and times out unseen (#41098). + Do NOT use for user-blocking modal prompts (approval / clarify / sudo): those + must paint immediately via ``_paint_now``. Sent through this throttle, an + unrelated repaint within the 250ms window — or an in-flight resize — silently + drops the modal's entry paint, so it never renders and times out unseen (#41098). """ if getattr(self, "_terminal_io_broken", False): return @@ -60,53 +102,28 @@ class CLITerminalMixin: now = time.monotonic() if hasattr(self, "_app") and self._app and (now - getattr(self, "_last_invalidate", 0.0)) >= min_interval: self._last_invalidate = now - try: - self._app.invalidate() - except OSError as exc: - if getattr(exc, "errno", None) == errno.EIO: - self._mark_terminal_io_broken("invalidate") - return - raise + self._app_invalidate(self._app, "invalidate", swallow=False) def _paint_now(self) -> None: """Immediate, unthrottled repaint for user-blocking modal prompts. - Background-thread callbacks (approval / clarify / sudo) set their modal - state then call this to make the panel visible at once. It deliberately - bypasses the ``_invalidate`` throttle and resize-recovery guard — a - modal the user is actively waiting on must never be dropped — mirroring - the direct ``event.app.invalidate()`` the modal key-binding handlers - already use. See ``_invalidate`` for why the throttle must not gate - these paints (#41098). + Deliberately bypasses the ``_invalidate`` throttle and resize-recovery guard — + a modal the user is waiting on must never be dropped (#41098) — mirroring the + direct ``event.app.invalidate()`` the modal key-binding handlers use. """ if getattr(self, "_terminal_io_broken", False): return app = getattr(self, "_app", None) if app is not None: - try: - app.invalidate() - except OSError as exc: - if getattr(exc, "errno", None) == errno.EIO: - self._mark_terminal_io_broken("paint_now") - return - raise - except Exception: - pass + self._app_invalidate(app, "paint_now", swallow=True) def _force_full_redraw(self) -> None: - """Force a clean full-screen repaint of the prompt_toolkit UI. + """Force a clean full-screen repaint of the prompt_toolkit UI (Ctrl+L, ``/redraw``). - Used to recover from terminal buffer drift caused by external - redraws we can't detect — e.g. macOS cmux / tmux tab switches, - ``clear`` issued from a subshell, or SSH window restores. These - wipe or repaint the terminal without firing SIGWINCH, so - prompt_toolkit's tracked ``_cursor_pos`` no longer matches reality - and the next incremental redraw stacks on top of stale content - (ghost status bars, duplicated prompts). - - Bound to Ctrl+L and exposed as the ``/redraw`` slash command, - matching the standard terminal-UX convention (bash, zsh, fish, - vim, htop). + Recovers from terminal buffer drift caused by external redraws we can't detect + (cmux/tmux tab switches, ``clear`` from a subshell, SSH window restores): they + repaint without SIGWINCH, so prompt_toolkit's tracked ``_cursor_pos`` is stale + and the next incremental redraw stacks on old content (ghost status bars). """ from cli import _replay_output_history if getattr(self, "_terminal_io_broken", False): @@ -114,59 +131,36 @@ class CLITerminalMixin: app = getattr(self, "_app", None) if not app: return - self._clear_prompt_toolkit_screen( - app, - rebuild_scrollback=self._redraw_rebuilds_scrollback(), - ) + self._clear_prompt_toolkit_screen(app, rebuild_scrollback=self._redraw_rebuilds_scrollback()) if getattr(self, "_terminal_io_broken", False): return _replay_output_history() self._pet_queue_kitty_frame() - try: - app.invalidate() - except OSError as exc: - if getattr(exc, "errno", None) == errno.EIO: - self._mark_terminal_io_broken("force_full_redraw") - return - raise - except Exception: - pass + self._app_invalidate(app, "force_full_redraw", swallow=True) def _schedule_focus_regain_redraw(self, min_interval: float = 1.0) -> None: """Repaint after a terminal focus-in report (``CSI I``), rate-limited. - Terminals with focus tracking active (Ghostty, iTerm2, xterm builds, - multiplexers that toggle DECSET 1004 upstream) emit ``\\x1b[I`` when - the Hermes tab/window becomes visible again. Emulators can coalesce - or drop hidden-tab output and repaint the surface while we're - invisible, so on regain prompt_toolkit's incremental diff stacks on - stale content — a second copy of the composer/prompt chrome next to - the ghost of the old one (#60920 focus-regain variant, #25337). - - The stock handling maps ``CSI I``/``CSI O`` to ``Keys.Ignore`` so the - bytes never pollute the input buffer; this hook additionally routes - focus-in through the same recovery as Ctrl+L / ``/redraw``. It is - self-gating: terminals that never enable focus tracking never emit - the sequence, so nothing changes for them. Rate-limited so a burst of - focus reports (rapid Alt+Tab, mux pane hops) repaints at most once - per ``min_interval`` seconds. + Terminals with focus tracking (Ghostty, iTerm2, xterm, muxes toggling DECSET + 1004) emit ``\\x1b[I`` when the Hermes tab becomes visible again; emulators may + coalesce hidden-tab output, so on regain the incremental diff stacks on stale + content (#60920 focus-regain variant, #25337). Self-gating — terminals without + focus tracking never emit it. Rate-limited so a burst (rapid Alt+Tab, pane hops) + repaints at most once per ``min_interval`` seconds. """ now = time.monotonic() - last = getattr(self, "_last_focus_regain_redraw", 0.0) - if now - last < min_interval: + if now - getattr(self, "_last_focus_regain_redraw", 0.0) < min_interval: return self._last_focus_regain_redraw = now self._force_full_redraw() @staticmethod def _redraw_rebuilds_scrollback() -> bool: - """Return whether CLI redraw/resize recovery should clear scrollback. + """Whether redraw/resize recovery should also clear scrollback (CSI 3J). - Some terminal/tmux stacks move prompt_toolkit's non-fullscreen bottom - chrome into scrollback when the window is maximized/restored. A normal - CSI 2J viewport clear cannot remove those stale prompt/input-rule rows, - so users who hit that class of bug need CSI 3J as well, followed by the - existing bounded output-history replay. + Some terminal/tmux stacks move prompt_toolkit's bottom chrome into scrollback on + maximize/restore; CSI 2J cannot remove those rows, so affected users opt in to 3J + followed by the bounded output-history replay. """ from cli import CLI_CONFIG display_config = CLI_CONFIG.get("display") if isinstance(CLI_CONFIG, dict) else {} @@ -180,22 +174,16 @@ class CLITerminalMixin: def _recover_terminal_after_interrupt(self) -> None: """Recover the terminal after an interrupted agent turn (#33271). - When the user interrupts a running turn by typing a new message, - prompt_toolkit may have an in-flight ``CSI 6n`` cursor-position query - whose reply (``ESC[;R``) arrives on stdin after the input - parser has torn down. The reply then leaks as literal text - (``^[[19;1R``) and the VT100 parser can stall in a partial-escape - state, accepting no further keystrokes — the terminal appears frozen. + An in-flight ``CSI 6n`` cursor query may answer (``ESC[;R``) after the + input parser tore down: the reply leaks as literal text and the VT100 parser can + stall mid-escape, so the terminal looks frozen. ``flush_stdin()`` drains stray + bytes (no-op on non-TTY), then ``_force_full_redraw()`` repaints cleanly; each + step self-guards so one failing never blocks the other. A dead PTY (EIO) skips + the redraw — painting a broken fd is the #81521 redraw storm. - Two steps recover a sane state: - 1. ``flush_stdin()`` drains stray escape bytes from the OS input - buffer (``termios.tcflush(TCIFLUSH)``; no-op on non-TTY). - 2. ``_force_full_redraw()`` drops prompt_toolkit's cached - screen/cursor state and forces a clean repaint. - - Both steps are independently safe and self-guard, so a failure of one - never prevents the other. If the PTY is already dead (EIO), skip the - redraw entirely — painting a broken fd is the #81521 redraw storm. + Do NOT clear output history here: the interruption marker is printed under + ``_suspend_output_history`` in chat(), so the replay reproduces the response + without duplicating the marker (#60920). """ if getattr(self, "_terminal_io_broken", False): return @@ -204,13 +192,6 @@ class CLITerminalMixin: flush_stdin() except Exception: pass - # #60920: The interruption marker is now printed with - # _suspend_output_history in chat(), so _OUTPUT_HISTORY only - # contains the normal response text (no marker text). Do NOT - # clear history here — _force_full_redraw → _replay_output_history - # replays the response correctly without duplicating the marker. - # The /redraw + Ctrl+L paths also preserve replay for scrollback - # recovery as intended. self._force_full_redraw() def _clear_prompt_toolkit_screen(self, app, *, rebuild_scrollback: bool = False) -> None: @@ -229,113 +210,55 @@ class CLITerminalMixin: pass out.cursor_goto(0, 0) out.flush() - # Drop prompt_toolkit's cached screen + cursor state so the - # next _redraw() starts from a known (0, 0) origin and - # re-renders every cell rather than diffing against stale. + # Drop cached screen + cursor state so the next _redraw() starts from a + # known (0, 0) origin and re-renders every cell instead of diffing stale. renderer.reset(leave_alternate_screen=False) except OSError as exc: - if getattr(exc, "errno", None) == errno.EIO: + if _is_eio(exc): self._mark_terminal_io_broken("clear_screen") - return - pass except Exception: pass def _recover_after_resize(self, app, original_on_resize) -> None: """Recover a resized classic CLI without desynchronizing cursor state. - Unlike _force_full_redraw, we do NOT clear the physical screen or - scrollback here. The startup banner and tool summary are printed - before prompt_toolkit owns the live chrome, so they live in normal - terminal scrollback. Erasing the screen on SIGWINCH removes that - startup UI and ``_replay_output_history`` cannot reconstruct it - (the banner was never added to ``_OUTPUT_HISTORY``). + Unlike ``_force_full_redraw`` this never clears scrollback: the startup banner + lives there and ``_replay_output_history`` cannot reconstruct it. prompt_toolkit's + own resize path runs with its renderer cursor cache intact — its + ``Application._on_resize()`` erases via the cached cursor position, and resetting + the renderer first loses the origin and strands stale prompt glyphs. - Let prompt_toolkit's own resize path run with its renderer cursor - cache intact. Its Application._on_resize() starts with - renderer.erase(leave_alternate_screen=False), which needs the cached - cursor position to move back to the live prompt origin before - erase_down(). Resetting the renderer before that erase loses the - origin and can leave stale prompt glyphs after a narrow resize. + ``_status_bar_suppressed_after_resize`` hides the dynamic status bar / input rules + while the reflow settles: on column shrink the terminal reflows already-painted + rows into scrollback before prompt_toolkit erases them, so a fresh bar looks + duplicated (#19280, #22976). Suppression alone cannot erase an already-reflowed + OLD bar (``renderer.erase()`` does ``cursor_up(_cursor_pos.y)`` with the y cached + at the OLD width, undershooting the reflowed rows), so on an OBSERVED width + change we wipe the viewport (CSI 2J — banner-safe; 3J only when + ``display.cli_rebuild_scrollback_on_redraw``) and replay the transcript before + delegating. Same-width SIGWINCH (tmux attach, GNOME tab bar, focus signals) and + the first signal without a baseline are left untouched: a 2J+replay against + preserved scrollback duplicates everything in ``_OUTPUT_HISTORY`` (#65293); + ``_install_resize_recovery`` seeds the baseline so an initial maximize still + counts. The stale-previous_screen crash on tmux attach is handled by + ``_hermes_call_output_screen_diff``'s retry (#83874). - We also flag ``_status_bar_suppressed_after_resize`` so the dynamic - status bar and input separator rules stay hidden while the terminal - reflow settles. On column shrink the terminal reflows already-rendered - status bar rows into scrollback before prompt_toolkit can erase them; - drawing a fresh full-width bar immediately makes the old and new - versions look duplicated (#19280, #22976). - - Suppression alone is not enough on a WIDTH change. prompt_toolkit's - ``renderer.erase()`` does ``cursor_up(_cursor_pos.y)`` + ``erase_down()`` - using the ``_cursor_pos.y`` cached from the LAST render at the OLD - width (renderer.py). When the column count shrinks, the terminal - reflows each already-painted full-width chrome row into 2+ physical - rows, so the cached ``y`` undershoots: ``cursor_up`` does not climb - past the reflowed rows and ``erase_down`` leaves the stale bar stranded - ABOVE the live origin. The next paint then stacks a fresh bar below it - — the duplicated-status-bar report (two bars, two elapsed readings). - Suppression hides the *new* bar but never erases the already-reflowed - *old* one, so the ghost survives the whole suppression window. - - Fix: on a width change, wipe the visible viewport with ``erase_screen`` - (CSI 2J) BEFORE delegating to prompt_toolkit's resize, then let its - repaint redraw from a clean origin. This is banner-safe: 2J clears - only the visible screen, NOT scrollback history (that is CSI 3J, which - we do not send here — ``rebuild_scrollback=False``), so the startup - banner that scrolled into history is preserved and - ``_replay_output_history`` is not needed. Row-count-only changes skip - the clear (no reflow, so no ghost) to avoid an unnecessary repaint. - - The suppression is transient: a short follow-up timer clears it and - repaints once the reflow has settled, so the bar returns on its own - during idle. Previously the flag was only cleared on the next - *submitted* user input, so a resize/reflow (tmux pane change, SSH - window restore, font zoom) followed by idle left the status bar hidden - indefinitely even while the refresh clock kept ticking (the dynamic - chrome rendered at height 0 on every repaint). The next-submit clear - at the input loop remains as a fast path. + Suppression is transient: a debounced timer clears it and repaints once the + reflow settles, so the bar returns during idle (previously it stayed hidden + until the next submitted input). The next-submit clear remains as a fast path. """ from cli import _replay_output_history self._status_bar_suppressed_after_resize = True - # On a WIDTH change the terminal has already reflowed the old full-width - # chrome into extra physical rows that prompt_toolkit's stale-cursor - # erase (cursor_up(_cursor_pos.y) cached at the OLD width) will not - # reach, leaving a duplicated status bar stranded above the live origin. - # Ctrl+L / /redraw clears it cleanly, so route the resize path through - # the SAME recovery: wipe the visible viewport (banner-safe — CSI 2J - # by default; CSI 3J only when display.cli_rebuild_scrollback_on_redraw - # is enabled) and replay the transcript so nothing is lost. - # Same-width SIGWINCH (tmux attach, benign focus/tab signals) is left - # untouched — no clear, no replay — because a 2J without replay erases - # the visible transcript and a replay against preserved scrollback - # duplicates it (#65293). The stale-previous_screen crash tmux attach - # used to trigger is handled by _hermes_call_output_screen_diff's - # retry-with-first-paint instead (#83874). try: new_width = self._get_tui_terminal_width() except Exception: new_width = None prev_width = getattr(self, "_last_resize_width", None) - # Replay only on an OBSERVED width change. The first signal of a - # session must not count as one (#65293): GNOME Terminal and friends - # deliver benign SIGWINCHes (tab bar appearing, monitor-scale change, - # focus events), and a 2J+replay against preserved scrollback - # duplicates everything ``_OUTPUT_HISTORY`` holds — after a resume - # that is the entire "Previous Conversation" recap plus the first - # live exchange. ``_install_resize_recovery`` seeds the baseline at - # startup, so an initial maximize/restore still differs from it and - # is still recovered; with no baseline (width probe failed) this - # signal just records one for the next comparison. - width_changed = ( - new_width is not None - and prev_width is not None - and new_width != prev_width - ) + width_changed = new_width is not None and prev_width is not None and new_width != prev_width if width_changed: try: self._clear_prompt_toolkit_screen( - app, - rebuild_scrollback=self._redraw_rebuilds_scrollback(), + app, rebuild_scrollback=self._redraw_rebuilds_scrollback() ) _replay_output_history() except Exception: @@ -350,8 +273,8 @@ class CLITerminalMixin: def _schedule_status_bar_unsuppress(self, app, delay: float = 0.35) -> None: """Clear the post-resize status-bar suppression after the reflow settles. - Debounced: a fresh resize cancels the pending unsuppress and restarts - the timer, so a resize storm only repaints the bar once it stops. + Debounced: a fresh resize cancels the pending timer, so a resize storm repaints + the bar only once it stops. """ try: old_timer = getattr(self, "_status_bar_unsuppress_timer", None) @@ -368,20 +291,7 @@ class CLITerminalMixin: except Exception: pass - def _fire(): - try: - loop = getattr(app, "loop", None) - except Exception: - loop = None - if loop is not None: - try: - loop.call_soon_threadsafe(_clear) - return - except Exception: - pass - _clear() - - timer = threading.Timer(delay, _fire) + timer = threading.Timer(delay, lambda: _run_on_app_loop(app, _clear)) timer.daemon = True self._status_bar_unsuppress_timer = timer timer.start() @@ -407,17 +317,7 @@ class CLITerminalMixin: self._resize_recovery_pending = False self._recover_after_resize(app, original_on_resize) - try: - loop = app.loop # type: ignore[attr-defined] - except Exception: - loop = None - if loop is not None: - try: - loop.call_soon_threadsafe(_run_recovery) - return - except Exception: - pass - _run_recovery() + _run_on_app_loop(app, _run_recovery) with lock: if old_timer is not None: @@ -435,25 +335,15 @@ class CLITerminalMixin: self._recover_after_resize(app, original_on_resize) def _install_resize_recovery(self, app) -> None: - """Route prompt_toolkit's ``_on_resize`` through the debounced - ghost-clearing recovery (#5474/#49120) and record the current terminal - width as the baseline for width-change detection. + """Route ``app._on_resize`` through the debounced ghost-clearing recovery + (#5474/#49120) and seed the width baseline for width-change detection. - Seeding the baseline here is what keeps the session's FIRST SIGWINCH - honest (#65293): ``_recover_after_resize`` replays the transcript only - on an observed width change, and without a startup baseline it could - not tell a benign signal (GNOME Terminal tab bar, monitor-scale - change) from a real one. An initial maximize/restore still differs - from the seeded width, so it is still recovered. - - The probe reads ``app.output`` directly — NOT - ``_get_tui_terminal_width`` — because this runs before ``app.run()``, - when ``get_app()`` still returns prompt_toolkit's DummyApplication - whose DummyOutput reports a hardcoded 80 columns; seeding that fake - width would make the first real signal look like a width change and - resurrect the duplicate-replay bug this exists to fix. - ``app.output`` is the same object the running app's resize handler - measures, so install-time and signal-time widths are comparable. + The seed keeps the session's FIRST SIGWINCH honest (#65293): without it a benign + signal is indistinguishable from a real resize. It reads ``app.output`` directly, + NOT ``_get_tui_terminal_width``: before ``app.run()`` ``get_app()`` is the + DummyApplication whose DummyOutput reports a hardcoded 80 columns, and seeding + that fake width would make the first real signal look like a change. + ``app.output`` is what the running resize handler measures, so both are comparable. """ width = None try: @@ -474,19 +364,13 @@ class CLITerminalMixin: app._on_resize = _resize_clear_ghosts def _try_attach_clipboard_image(self) -> bool: - """Check clipboard for an image and attach it if found. - - Saves the image to ~/.hermes/images/ and appends the path to - ``_attached_images``. Returns True if an image was attached. - """ + """Save a clipboard image to ~/.hermes/images/ and attach it; True if attached.""" from cli import datetime from hermes_cli.clipboard import save_clipboard_image - img_dir = get_hermes_home() / "images" self._image_counter += 1 ts = datetime.now().strftime("%Y%m%d_%H%M%S") - img_path = img_dir / f"clip_{ts}_{self._image_counter}.png" - + img_path = get_hermes_home() / "images" / f"clip_{ts}_{self._image_counter}.png" if save_clipboard_image(img_path): self._attached_images.append(img_path) return True @@ -494,12 +378,10 @@ class CLITerminalMixin: return False def _write_osc52_clipboard(self, text: str) -> None: - """Copy *text* to terminal clipboard via OSC 52. + """Copy *text* to the terminal clipboard via OSC 52. - Wrapped for tmux/screen passthrough (mirrors the TUI's - wrapForMultiplexer in ui-tui/src/lib/osc52.ts) — without the DCS - wrapper the multiplexer consumes the sequence and the copy is - silently lost. + Wrapped for tmux/screen passthrough (mirrors ui-tui/src/lib/osc52.ts) — without + the DCS wrapper the multiplexer consumes the sequence and the copy is lost. """ payload = base64.b64encode(text.encode("utf-8")).decode("ascii") seq = f"\x1b]52;c;{payload}\x07" @@ -507,18 +389,7 @@ class CLITerminalMixin: seq = "\x1bPtmux;" + seq.replace("\x1b", "\x1b\x1b") + "\x1b\\" elif os.environ.get("STY"): seq = "\x1bP" + seq + "\x1b\\" - out = getattr(self, "_app", None) - output = getattr(out, "output", None) if out else None - if output and hasattr(output, "write_raw"): - output.write_raw(seq) - output.flush() - return - if output and hasattr(output, "write"): - output.write(seq) - output.flush() - return - sys.stdout.write(seq) - sys.stdout.flush() + _write_terminal_sequence(getattr(self, "_app", None), seq) def _recover_terminal_input_modes(self, *, reason: str) -> None: """Best-effort reset when leaked mouse reports indicate mode drift.""" @@ -538,25 +409,15 @@ class CLITerminalMixin: return self._last_input_mode_recovery = now - out = getattr(self, "_app", None) - output = getattr(out, "output", None) if out else None + app = getattr(self, "_app", None) + output = getattr(app, "output", None) if app else None try: - if output and hasattr(output, "write_raw"): - output.write_raw(_TERMINAL_INPUT_MODE_RESET_SEQ) - output.flush() - elif output and hasattr(output, "write"): - output.write(_TERMINAL_INPUT_MODE_RESET_SEQ) - output.flush() - else: - sys.stdout.write(_TERMINAL_INPUT_MODE_RESET_SEQ) - sys.stdout.flush() + _write_terminal_sequence(app, _TERMINAL_INPUT_MODE_RESET_SEQ) except Exception: return - # The reset sequence above pops kitty keyboard mode and resets - # modifyOtherKeys too — re-request extended keys so Shift+Enter / - # modified-key reporting isn't silently dead for the rest of the - # session after a recovery (sibling of the startup push). + # The reset pops kitty keyboard mode and resets modifyOtherKeys too — re-request + # extended keys so Shift+Enter isn't silently dead for the rest of the session. try: if _cli_multiline_shortcuts_enabled(self.config or CLI_CONFIG): _enable_extended_enter_keys(output) @@ -574,18 +435,12 @@ class CLITerminalMixin: def _check_termios_drift(self) -> None: """Watchdog: heal the tty if it drifted back to cooked mode. - See ``_heal_cooked_mode_drift`` for the failure class (a lost - ``run_in_terminal`` cooked→raw restore leaves the terminal - line-buffering keystrokes while the prompt_toolkit app believes it - owns raw mode — the CLI looks dead but the process is healthy). - - Called from ``process_loop``'s idle branch, so a drifted terminal - self-heals within ~a second of the agent going idle instead of - requiring an external ``stty`` rescue. Skipped while a - ``run_in_terminal`` window is legitimately holding cooked mode - (``app._running_in_terminal``), while the agent is running (approval - prompts and sudo prompts legitimately manipulate the tty), and on - Windows (no termios). + See ``_heal_cooked_mode_drift``: a lost ``run_in_terminal`` cooked→raw restore + leaves the tty line-buffering while prompt_toolkit believes it owns raw mode — + the CLI looks dead but the process is healthy. Called from ``process_loop``'s + idle branch so it self-heals within ~1s of idling. Skipped while a + ``run_in_terminal`` window legitimately holds cooked mode, while the agent is + running (approval/sudo prompts manipulate the tty), and on Windows (no termios). """ from cli import _DIM, _RST, _cprint, _heal_cooked_mode_drift, logger if os.name == "nt": @@ -593,7 +448,6 @@ class CLITerminalMixin: app = getattr(self, "_app", None) if app is None or not getattr(app, "_is_running", False): return - # A run_in_terminal window is *supposed* to be cooked — don't fight it. if getattr(app, "_running_in_terminal", False): return now = time.monotonic() @@ -611,9 +465,8 @@ class CLITerminalMixin: "Healed cooked-mode termios drift on stdin — a " "run_in_terminal cooked→raw restore was lost." ) - # Redraw so the prompt is visibly alive again. try: - self._invalidate() + self._invalidate() # so the prompt is visibly alive again except Exception: pass if not self._termios_drift_notice_shown: From d60e8ef6e8e6301e4f7c9dae1508fd479480bb6b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:22:23 -0700 Subject: [PATCH 02/13] refactor(hermes_cli): unify session-bound manager getters, flatten /title and /clear branches in cli_loops_mixin --- hermes_cli/cli_loops_mixin.py | 510 ++++++++++++++-------------------- 1 file changed, 202 insertions(+), 308 deletions(-) diff --git a/hermes_cli/cli_loops_mixin.py b/hermes_cli/cli_loops_mixin.py index 6b974adeec..37a39d268d 100644 --- a/hermes_cli/cli_loops_mixin.py +++ b/hermes_cli/cli_loops_mixin.py @@ -1,8 +1,7 @@ """Simple slash-command wrappers plus goal/heartbeat/loop manager hooks for the interactive CLI -Mixin split out of ``cli.py``; bound onto ``HermesCLI`` via the MRO. cli.py-internal -symbols are imported LAZILY inside each method (``from cli import ...``) — the mixin -never imports ``cli`` at module load time (import cycle). +Mixin bound onto ``HermesCLI`` via the MRO. cli.py-internal symbols are imported LAZILY +inside each method (``from cli import ...``) — never at module load (import cycle). """ from __future__ import annotations @@ -15,6 +14,12 @@ import time from rich.markup import escape as _escape +_FRESH_START = " ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n" + + +def _preview(payload: str) -> str: + return f"{payload[:80]}{'...' if len(payload) > 80 else ''}" + class CLILoopsMixin: """Simple slash-command wrappers plus goal/heartbeat/loop manager hooks for the interactive CLI""" @@ -35,9 +40,8 @@ class CLILoopsMixin: self.show_help(_slash_args(cmd_original)) def _cmd_redraw(self, cmd_original: str): - # Manual recovery for terminal buffer drift from multiplexer - # tab switches, subshell ``clear``, SSH window restores, etc. - # See issue #8688 (cmux). Ctrl+L is bound to the same helper. + # Manual recovery for terminal buffer drift (mux tab switches, subshell ``clear``, + # SSH restores; #8688). Ctrl+L is bound to the same helper. from cli import _DIM, _RST, _cprint self._force_full_redraw() _cprint(f" {_DIM}✓ UI redrawn{_RST}") @@ -60,95 +64,49 @@ class CLILoopsMixin: return True # confirmation cancelled — command handled, keep REPL alive self.new_session(silent=True) _clear_output_history() - # Clear terminal screen. Inside the TUI, Rich's console.clear() - # goes through patch_stdout's StdoutProxy which swallows the - # screen-clear escape sequences. Use prompt_toolkit's output - # object directly to actually clear the terminal. - if self._app: - out = self._app.output - out.erase_screen() - out.cursor_goto(0, 0) - out.flush() - else: + if not self._app: self.console.clear() - # Show fresh banner. Inside the TUI we must route Rich output - # through ChatConsole (which uses prompt_toolkit's native ANSI - # renderer) instead of self.console (which writes raw to stdout - # and gets mangled by patch_stdout). - if self._app: - cc = ChatConsole() - term_w = shutil.get_terminal_size().columns - if self.compact or term_w < 80: - cc.print(_build_compact_banner()) - else: - tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True) - cwd = os.getenv("TERMINAL_CWD", os.getcwd()) - ctx_len = None - if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'context_compressor'): - ctx_len = self.agent.context_compressor.context_length - build_welcome_banner( - console=cc, - model=self.model, - cwd=cwd, - tools=tools, - enabled_toolsets=self.enabled_toolsets, - session_id=self.session_id, - context_length=ctx_len, - provider=self.provider, - ) - _cprint(" ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n") - self._print_random_tip() - else: self.show_banner() - print(" ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n") + print(_FRESH_START) self._print_random_tip() + return + # Inside the TUI, Rich's console.clear() and self.console both go through + # patch_stdout's StdoutProxy, which swallows the clear escapes / mangles raw + # output: clear via prompt_toolkit's output and print through ChatConsole. + out = self._app.output + out.erase_screen() + out.cursor_goto(0, 0) + out.flush() + cc = ChatConsole() + if self.compact or shutil.get_terminal_size().columns < 80: + cc.print(_build_compact_banner()) + else: + tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True) + ctx_len = None + if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'context_compressor'): + ctx_len = self.agent.context_compressor.context_length + build_welcome_banner( + console=cc, + model=self.model, + cwd=os.getenv("TERMINAL_CWD", os.getcwd()), + tools=tools, + enabled_toolsets=self.enabled_toolsets, + session_id=self.session_id, + context_length=ctx_len, + provider=self.provider, + ) + _cprint(_FRESH_START) + self._print_random_tip() def _cmd_title(self, cmd_original: str): from cli import _cprint + from hermes_state import format_session_db_unavailable parts = cmd_original.split(maxsplit=1) - if len(parts) > 1: - raw_title = parts[1].strip() - if raw_title: - if self._session_db: - # Sanitize the title early so feedback matches what gets stored - try: - from hermes_state import SessionDB - new_title = SessionDB.sanitize_title(raw_title) - except ValueError as e: - # sanitize_title rejected the input (e.g. too long). - # Print that one reason and stop — don't fall - # through to the "empty after cleanup" branch and - # print a second, contradictory error (SC-05). - _cprint(f" {e}") - return True - if not new_title: - _cprint(" Title is empty after cleanup. Please use printable characters.") - elif self._session_db.get_session(self.session_id): - # Session exists in DB — set title directly - try: - if self._session_db.set_session_title(self.session_id, new_title): - self._status_bar_title_checked_at = 0.0 - _cprint(f" Session title set: {new_title}") - else: - _cprint(" Session not found in database.") - except ValueError as e: - _cprint(f" {e}") - else: - # Session not created yet — defer the title - # Check uniqueness proactively with the sanitized title - existing = self._session_db.get_session_by_title(new_title) - if existing: - _cprint(f" Title '{new_title}' is already in use by session {existing['id']}") - else: - self._pending_title = new_title - _cprint(f" Session title queued: {new_title} (will be saved on first message)") - else: - from hermes_state import format_session_db_unavailable - _cprint(f" {format_session_db_unavailable()}") - else: - _cprint(" Usage: /title ") - # Show current title and session ID if no argument given - elif self._session_db: + if len(parts) == 1: + # No argument: show current title and session ID. + if not self._session_db: + _cprint(f" {format_session_db_unavailable()}") + return _cprint(f" Session ID: {self.session_id}") session = self._session_db.get_session(self.session_id) if session and session.get("title"): @@ -157,14 +115,46 @@ class CLILoopsMixin: _cprint(f" Title (pending): {self._pending_title}") else: _cprint(" No title set. Usage: /title ") - else: - from hermes_state import format_session_db_unavailable + return + raw_title = parts[1].strip() + if not raw_title: + _cprint(" Usage: /title ") + return + if not self._session_db: _cprint(f" {format_session_db_unavailable()}") + return + # Sanitize early so feedback matches what gets stored. A rejection (e.g. too + # long) prints that one reason and stops — never a second, contradictory + # "empty after cleanup" error (SC-05). + try: + from hermes_state import SessionDB + new_title = SessionDB.sanitize_title(raw_title) + except ValueError as e: + _cprint(f" {e}") + return True + if not new_title: + _cprint(" Title is empty after cleanup. Please use printable characters.") + elif self._session_db.get_session(self.session_id): + try: + if self._session_db.set_session_title(self.session_id, new_title): + self._status_bar_title_checked_at = 0.0 + _cprint(f" Session title set: {new_title}") + else: + _cprint(" Session not found in database.") + except ValueError as e: + _cprint(f" {e}") + else: + # Session not created yet — check uniqueness now, defer the title. + existing = self._session_db.get_session_by_title(new_title) + if existing: + _cprint(f" Title '{new_title}' is already in use by session {existing['id']}") + else: + self._pending_title = new_title + _cprint(f" Session title queued: {new_title} (will be saved on first message)") def _cmd_new(self, cmd_original: str): - # Strip inline-skip tokens (now/--yes/-y) before deriving the title - # so "/new now My Session" yields title="My Session" instead of - # title="now My Session". See _split_destructive_skip. + # Strip inline-skip tokens (now/--yes/-y) before deriving the title so + # "/new now My Session" yields title="My Session". See _split_destructive_skip. _new_args, _ = self._split_destructive_skip(cmd_original) title = _new_args.strip() or None if self._confirm_destructive_slash( @@ -179,23 +169,19 @@ class CLILoopsMixin: def _cmd_retry(self, cmd_original: str): retry_msg = self.retry_last() if retry_msg and hasattr(self, '_pending_input'): - # Re-queue the message so process_loop sends it to the agent - self._pending_input.put(retry_msg) + self._pending_input.put(retry_msg) # process_loop sends it to the agent def _cmd_undo(self, cmd_original: str): - # Parse optional turn count: "/undo" → 1, "/undo 3" → 3. + # "/undo" → 1, "/undo 3" → 3. _undo_n = 1 _undo_parts = cmd_original.split() if len(_undo_parts) > 1: try: - _undo_n = int(_undo_parts[1]) + _undo_n = max(1, int(_undo_parts[1])) except ValueError: print(f"(._.) Invalid count {_undo_parts[1]!r} — use /undo or /undo N.") return True # bad arg — command handled, keep the REPL alive - if _undo_n < 1: - _undo_n = 1 - # Nothing to undo → say so immediately; don't pop a destructive - # confirmation dialog for a guaranteed no-op (SC-06). + # Nothing to undo → say so; no destructive confirmation for a no-op (SC-06). if not self.conversation_history: print("(._.) No messages to undo.") return True @@ -204,11 +190,7 @@ class CLILoopsMixin: if _undo_n == 1 else f"This removes the last {_undo_n} user turns from history." ) - if self._confirm_destructive_slash( - "undo", - _undo_desc, - cmd_original=cmd_original, - ) is None: + if self._confirm_destructive_slash("undo", _undo_desc, cmd_original=cmd_original) is None: return True # confirmation cancelled — command handled, keep REPL alive self.undo_last(_undo_n) @@ -250,11 +232,9 @@ class CLILoopsMixin: def _cmd_plugins(self, cmd_original: str): from cli import display_hermes_home try: - # Discover from disk (bundled + user), matching `hermes plugins - # list` — so installed-but-not-enabled plugins are visible here - # too. The plugin manager only knows about *loaded* plugins, so - # using it alone made freshly-installed, not-yet-enabled plugins - # look like "nothing installed". + # Discover from disk (bundled + user) like `hermes plugins list`, so + # installed-but-not-enabled plugins show up; the plugin manager only knows + # *loaded* plugins and made fresh installs look like "nothing installed". from hermes_cli.plugins_cmd import ( _discover_all_plugins, _get_disabled_set, @@ -266,10 +246,8 @@ class CLILoopsMixin: enabled = _get_enabled_set() disabled = _get_disabled_set() - # `/plugins` is a quick glance — default to user-installed - # plugins (what the user actually added). Bundled provider/ - # platform plugins are summarized on one line; the full - # catalog lives behind `hermes plugins list`. + # `/plugins` is a quick glance: user plugins only, bundled ones summarized + # on one line (full catalog behind `hermes plugins list`). user_entries = [e for e in entries if e[3] != "bundled"] bundled_count = len(entries) - len(user_entries) @@ -279,37 +257,30 @@ class CLILoopsMixin: print(f" Or drop a plugin directory into {display_hermes_home()}/plugins/") if bundled_count: print(f" ({bundled_count} bundled plugins available — see: hermes plugins list)") - else: - # Loaded-plugin details (tools/hooks/commands counts, errors) - # keyed by name, when available. - loaded: dict = {} - try: - from hermes_cli.plugins import get_plugin_manager - for p in get_plugin_manager().list_plugins(): - loaded[p["name"]] = p - except Exception: - loaded = {} + return + # Loaded-plugin details (tools/hooks/commands counts, errors) by name. + loaded: dict = {} + try: + from hermes_cli.plugins import get_plugin_manager + for p in get_plugin_manager().list_plugins(): + loaded[p["name"]] = p + except Exception: + loaded = {} - print(f"User plugins ({len(user_entries)}):") - for name, version, _desc, source, _dir, key in sorted(user_entries): - state = _plugin_status(name, enabled, disabled, key=key) - glyph = {"enabled": "✓", "disabled": "✗"}.get(state, "○") - ver = f" v{version}" if version else "" - info = loaded.get(name) or {} - bits = [] - if info.get("tools"): - bits.append(f"{info['tools']} tools") - if info.get("hooks"): - bits.append(f"{info['hooks']} hooks") - if info.get("commands"): - bits.append(f"{info['commands']} commands") - detail = f" ({', '.join(bits)})" if bits else "" - label = "" if state == "enabled" else f" [{state}]" - error = f" — {info['error']}" if info.get("error") else "" - print(f" {glyph} {name}{ver}{label}{detail}{error}") - if bundled_count: - print(f" (+{bundled_count} bundled — see: hermes plugins list)") - print(" Enable/disable: hermes plugins enable/disable ") + print(f"User plugins ({len(user_entries)}):") + for name, version, _desc, source, _dir, key in sorted(user_entries): + state = _plugin_status(name, enabled, disabled, key=key) + glyph = {"enabled": "✓", "disabled": "✗"}.get(state, "○") + ver = f" v{version}" if version else "" + info = loaded.get(name) or {} + bits = [f"{info[k]} {k}" for k in ("tools", "hooks", "commands") if info.get(k)] + detail = f" ({', '.join(bits)})" if bits else "" + label = "" if state == "enabled" else f" [{state}]" + error = f" — {info['error']}" if info.get("error") else "" + print(f" {glyph} {name}{ver}{label}{detail}{error}") + if bundled_count: + print(f" (+{bundled_count} bundled — see: hermes plugins list)") + print(" Enable/disable: hermes plugins enable/disable ") except Exception as e: print(f"Plugin system error: {e}") @@ -321,16 +292,14 @@ class CLILoopsMixin: else: self._pending_input.put(payload) if self._agent_running: - _cprint(f" Queued for the next turn: {payload[:80]}{'...' if len(payload) > 80 else ''}") + _cprint(f" Queued for the next turn: {_preview(payload)}") else: - _cprint(f" Queued: {payload[:80]}{'...' if len(payload) > 80 else ''}") + _cprint(f" Queued: {_preview(payload)}") def _cmd_steer(self, cmd_original: str): - # Inject a message after the next tool call without interrupting. - # If the agent is actively running, push the text into the agent's - # pending_steer slot — the drain hook in _execute_tool_calls_* - # will append it to the next tool result's content. If no agent - # is running, fall back to queue semantics (same as /queue). + # Inject a message after the next tool call without interrupting: while the + # agent runs, push into its pending_steer slot (drained by _execute_tool_calls_* + # into the next tool result); otherwise fall back to /queue semantics. from cli import _cprint, _slash_args payload = _slash_args(cmd_original) if not payload: @@ -342,83 +311,76 @@ class CLILoopsMixin: _cprint(f" Steer failed: {exc}") else: if accepted: - _cprint(f" ⏩ Steer queued — arrives after the next tool call: {payload[:80]}{'...' if len(payload) > 80 else ''}") + _cprint(f" ⏩ Steer queued — arrives after the next tool call: {_preview(payload)}") else: _cprint(" Steer rejected (empty payload).") else: - # No active run — treat as a normal next-turn message. self._pending_input.put(payload) - _cprint(f" No agent running; queued as next turn: {payload[:80]}{'...' if len(payload) > 80 else ''}") + _cprint(f" No agent running; queued as next turn: {_preview(payload)}") # ──────────────────────────────────────────────────────────────── - # /goal — persistent cross-turn goals (Ralph-style loop) + # Session-bound managers: /goal (Ralph-style loop), /heartbeat, /loop # ──────────────────────────────────────────────────────────────── - def _get_goal_manager(self): - """Return the GoalManager bound to the current session_id. + def _session_bound_manager(self, attr: str, label: str, load): + """Return the manager cached on ``self.``, rebuilt when ``session_id`` changed + (after /new or a compression-driven session split). - Cached on ``self._goal_manager`` and rebound lazily when - ``session_id`` changes (e.g. after /new or a compression-driven - session split). + ``load()`` does the imports and returns a ``sid -> manager`` factory; an import + failure is logged and yields None, as does an empty session_id. """ try: + make = load() + except Exception as exc: + logging.debug("%s unavailable: %s", label, exc) + return None + sid = getattr(self, "session_id", None) or "" + if not sid: + return None + existing = getattr(self, attr, None) + if existing is not None and getattr(existing, "session_id", None) == sid: + return existing + mgr = make(sid) + setattr(self, attr, mgr) + return mgr + + def _get_goal_manager(self): + """GoalManager bound to the current session_id (see ``_session_bound_manager``).""" + def load(): from hermes_cli.goals import GoalManager from hermes_cli.config import load_config - except Exception as exc: - logging.debug("goal manager unavailable: %s", exc) - return None - sid = getattr(self, "session_id", None) or "" - if not sid: - return None - - existing = getattr(self, "_goal_manager", None) - if existing is not None and getattr(existing, "session_id", None) == sid: - return existing - - try: - cfg = load_config() or {} - goals_cfg = cfg.get("goals") or {} - max_turns = int(goals_cfg.get("max_turns", 20) or 20) - except Exception: - max_turns = 20 - - mgr = GoalManager(session_id=sid, default_max_turns=max_turns) - self._goal_manager = mgr - return mgr + def make(sid): + try: + goals_cfg = (load_config() or {}).get("goals") or {} + max_turns = int(goals_cfg.get("max_turns", 20) or 20) + except Exception: + max_turns = 20 + return GoalManager(session_id=sid, default_max_turns=max_turns) + return make + return self._session_bound_manager("_goal_manager", "goal manager", load) def _get_heartbeat_manager(self): - """Return the HeartbeatManager bound to the current session_id. - - Cached on ``self._heartbeat_manager`` and rebound lazily when - ``session_id`` changes (mirrors ``_get_goal_manager``). - """ - try: + """HeartbeatManager bound to the current session_id (see ``_session_bound_manager``).""" + def load(): from hermes_cli.heartbeat import HeartbeatManager - except Exception as exc: - logging.debug("heartbeat manager unavailable: %s", exc) - return None + return lambda sid: HeartbeatManager(session_id=sid) + return self._session_bound_manager("_heartbeat_manager", "heartbeat manager", load) - sid = getattr(self, "session_id", None) or "" - if not sid: - return None - - existing = getattr(self, "_heartbeat_manager", None) - if existing is not None and getattr(existing, "session_id", None) == sid: - return existing - - mgr = HeartbeatManager(session_id=sid) - self._heartbeat_manager = mgr - return mgr + def _get_loop_manager(self): + """LoopManager bound to the current session_id (see ``_session_bound_manager``).""" + def load(): + from hermes_cli.loops import LoopManager + return lambda sid: LoopManager(session_id=sid) + return self._session_bound_manager("_loop_manager", "loop manager", load) def _start_heartbeat_watchdog(self): """Start the idle-poll thread that fires due heartbeats. - Same pattern as the wake-word watchdog: a daemon thread polls a few - times a minute; when the session is idle (no agent running, empty - input queue) and the heartbeat is due, its prompt is injected into - ``_pending_input`` as a normal user turn. Missed ticks coalesce — - the anchor resets on fire, so a busy hour yields ONE heartbeat turn, - not a backlog. Idempotent; safe to call on every /heartbeat set. + Same pattern as the wake-word watchdog: a daemon thread polls a few times a + minute; when the session is idle (no agent running, empty input queue) and the + heartbeat is due, its prompt is injected into ``_pending_input`` as a normal user + turn. Missed ticks coalesce — the anchor resets on fire, so a busy hour yields ONE + heartbeat turn. Idempotent; safe to call on every /heartbeat set. """ if getattr(self, "_heartbeat_watchdog_started", False): return @@ -452,54 +414,23 @@ class CLILoopsMixin: threading.Thread(target=_loop, daemon=True, name="heartbeat-watchdog").start() - # ──────────────────────────────────────────────────────────────── - # /loop — recurring in-session wakeups (Claude Code /loop parity) - # ──────────────────────────────────────────────────────────────── - def _get_loop_manager(self): - """Return the LoopManager bound to the current session_id. - - Cached on ``self._loop_manager`` and rebound lazily when - ``session_id`` changes (mirrors ``_get_goal_manager``). - """ - try: - from hermes_cli.loops import LoopManager - except Exception as exc: - logging.debug("loop manager unavailable: %s", exc) - return None - - sid = getattr(self, "session_id", None) or "" - if not sid: - return None - - existing = getattr(self, "_loop_manager", None) - if existing is not None and getattr(existing, "session_id", None) == sid: - return existing - - mgr = LoopManager(session_id=sid) - self._loop_manager = mgr - return mgr - def _maybe_fire_loop_tick(self) -> None: """Idle hook run from process_loop: fire a due /loop wakeup. - Only runs while the agent is idle and nothing is queued — a real - user message always wins the idle boundary. An active (non-parked) - /goal also wins: its judge-driven continuations own the idle - boundary, so the loop defers to the next poll. + Only while the agent is idle and nothing is queued — a real user message always + wins the idle boundary, and so does an active (non-parked) /goal, whose + judge-driven continuations own it; the loop defers to the next poll. """ from cli import _DIM, _RST, _cprint mgr = self._get_loop_manager() if mgr is None or not mgr.is_due(): return - # The idle poll runs at ~10 Hz; once a tick is due but deferred - # (queued input / active goal), every poll would otherwise hit the - # DB via goal_blocks_loop_tick. Throttle the deferred re-check. + # The idle poll runs at ~10 Hz; a due-but-deferred tick would otherwise hit the + # DB (goal_blocks_loop_tick) on every poll. Throttle the re-check. now = time.time() if now - getattr(self, "_last_loop_tick_check", 0.0) < 2.0: return self._last_loop_tick_check = now - # Real user input (or anything else queued) takes priority; the - # loop stays due and fires at the next idle poll. try: if not self._pending_input.empty(): return @@ -528,11 +459,10 @@ class CLILoopsMixin: except Exception: pass return - # A slash-command loop (e.g. `/loop 10m /recap`) is dispatched via - # process_command, which never reaches the post-turn chat() finally - # block — so the tick would never complete and the loop would wedge - # on awaiting_response. Slash ticks have no model reply to evaluate; - # complete them immediately (caps and scheduling still apply). + # A slash-command loop (`/loop 10m /recap`) is dispatched via process_command and + # never reaches chat()'s post-turn finally, so its tick would never complete and + # the loop would wedge on awaiting_response. Slash ticks have no model reply to + # judge; complete them immediately (caps and scheduling still apply). if wakeup.lstrip().startswith("/"): try: decision = mgr.complete_tick("") @@ -545,8 +475,7 @@ class CLILoopsMixin: def _last_assistant_response_text(self) -> str: """Text of the most recent assistant message ("" when none); multimodal parts are flattened.""" try: - hist = self.conversation_history or [] - for msg in reversed(hist): + for msg in reversed(self.conversation_history or []): if msg.get("role") == "assistant": content = msg.get("content", "") if isinstance(content, list): @@ -564,10 +493,9 @@ class CLILoopsMixin: def _maybe_complete_loop_tick_after_turn(self) -> None: """Post-turn hook: evaluate a finished /loop wakeup turn. - No-op unless the turn that just ended was a loop wakeup - (``awaiting_response`` set by ``fire_tick``). Detects the - LOOP_COMPLETE marker, judges --until, applies caps, and schedules - the next tick. Mirrors _maybe_continue_goal_after_turn's shape. + No-op unless the turn was a loop wakeup (``awaiting_response`` set by + ``fire_tick``). Detects the LOOP_COMPLETE marker, judges --until, applies caps, + and schedules the next tick. Mirrors _maybe_continue_goal_after_turn's shape. """ from cli import _DIM, _RST, _cprint mgr = self._get_loop_manager() @@ -577,8 +505,8 @@ class CLILoopsMixin: if state is None or not state.awaiting_response: return - # A user-interrupted wakeup turn pauses the loop (recoverable via - # /loop resume) — same contract as the goal loop's Ctrl+C handling. + # A user-interrupted wakeup turn pauses the loop (recoverable via /loop resume) + # — same contract as the goal loop's Ctrl+C handling. if getattr(self, "_last_turn_interrupted", False): try: mgr.pause(reason="user-interrupted (Ctrl+C)") @@ -590,9 +518,7 @@ class CLILoopsMixin: ) return - last_response = self._last_assistant_response_text() - - decision = mgr.complete_tick(last_response) + decision = mgr.complete_tick(self._last_assistant_response_text()) msg = decision.get("message") or "" if msg: _cprint(f" {msg}") @@ -600,50 +526,31 @@ class CLILoopsMixin: _cprint(f" {_DIM}↻ Loop: {mgr.state.remaining_label()}.{_RST}") def _maybe_continue_goal_after_turn(self) -> None: - """Hook run after every CLI turn. Judges + maybe re-queues. + """Hook run after every CLI turn: judge the goal and maybe re-queue a continuation. - Safe to call when no goal is set — returns quickly. - - Preemption is automatic: if a real user message is already in - ``_pending_input`` we skip judging (the user's new input takes - priority and we'll re-judge after that turn). If judge says done, - mark it done and tell the user. If judge says continue and we're - under budget, push the continuation prompt onto the queue. - - Interrupt handling: if the turn was user-cancelled (Ctrl+C), we - AUTO-PAUSE the goal instead of judging + re-queuing. Otherwise - Ctrl+C feels like it did nothing — the judge runs on whatever - partial output landed, almost always says "continue", and the - loop keeps going. Auto-pause keeps the goal recoverable via - ``/goal resume`` once the user has sorted out what they want. - The empty-response skip mirrors the gateway guard at - ``_handle_message`` in ``gateway/run.py``. + Safe when no goal is set. Preemption is automatic: a real user message already in + ``_pending_input`` skips judging (the user's turn goes first; we re-judge after). + A user-cancelled turn (Ctrl+C) AUTO-PAUSES the goal instead of judging — the judge + would run on partial output, almost always say "continue", and re-queue exactly + what the user cancelled; pausing is observable and recoverable via ``/goal + resume``. The empty-response skip mirrors ``_handle_message`` in ``gateway/run.py``. """ from cli import _DIM, _RST, _cprint, _looks_like_slash_command mgr = self._get_goal_manager() if mgr is None or not mgr.is_active(): return - # If a real user message is already queued, don't inject a - # continuation prompt on top — let the user's turn go first. - # Slash commands don't count as "real user messages" for this - # check: they're inspection/mutation (e.g. /subgoal added mid- - # run) and the process_loop dispatches them via process_command, - # not via chat(). If we treat a queued /subgoal as preempting, - # the goal loop silently stalls — we'd return here, then the - # slash command consumes its queue slot via process_command() - # which never re-fires the goal hook. Peek at all queued entries - # and only defer when there's a non-slash payload. + # Slash commands don't count as "real user messages": they're dispatched via + # process_command, not chat(), so a queued /subgoal would consume its slot without + # ever re-firing this hook and the goal loop would silently stall. Peek at every + # queued entry (Queue.queue is the deque; FIFO undisturbed) and defer only on a + # non-slash payload. Bundled payloads are (text, images) tuples. try: pending = getattr(self, "_pending_input", None) if pending is not None and not pending.empty(): has_real_message = False try: - # Queue.queue is the underlying deque — direct peek - # without disturbing FIFO order. for entry in list(pending.queue): - # Bundled payloads are (text, images) tuples; - # unpack for inspection. if isinstance(entry, tuple) and entry: entry = entry[0] if isinstance(entry, str) and _looks_like_slash_command(entry): @@ -651,19 +558,12 @@ class CLILoopsMixin: has_real_message = True break except Exception: - # Fallback: if we can't introspect the queue, behave - # like the old check and defer to be safe. - has_real_message = True + has_real_message = True # can't introspect — defer to be safe if has_real_message: return except Exception: pass - # If the turn was user-interrupted (Ctrl+C), auto-pause the goal - # and bail. The judge call would almost always return "continue" - # on the partial output and immediately re-queue another turn, - # which is exactly what the user cancelled. Pausing (rather than - # silently skipping) is the observable, recoverable behavior. if getattr(self, "_last_turn_interrupted", False): try: mgr.pause(reason="user-interrupted (Ctrl+C)") @@ -675,13 +575,9 @@ class CLILoopsMixin: ) return - # Extract the agent's final response for this turn. + # Empty/whitespace responses are almost always transient failures (API error, + # empty stream): judging would say "continue" and trip the parse-failure backstop. last_response = self._last_assistant_response_text() - - # Skip judging on empty/whitespace-only responses. These are almost - # always transient failures (API error, empty stream) where the - # judge would say "continue" and trip the consecutive-parse-failures - # backstop unnecessarily. Mirrors the gateway guard. if not last_response.strip(): return @@ -692,9 +588,7 @@ class CLILoopsMixin: _bg_procs = None decision = mgr.evaluate_after_turn( - last_response, - user_initiated=True, - background_processes=_bg_procs, + last_response, user_initiated=True, background_processes=_bg_procs ) msg = decision.get("message") or "" if msg: From d7e268d57bf13c49f3a1e4f3d524e706839a5b70 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:35:51 -0700 Subject: [PATCH 03/13] refactor(hermes_cli): collapse clipboard platform chains into dispatch tables, unify PowerShell probe/extract --- hermes_cli/clipboard.py | 432 ++++++++++++++-------------------------- 1 file changed, 148 insertions(+), 284 deletions(-) diff --git a/hermes_cli/clipboard.py b/hermes_cli/clipboard.py index 307a5a8fe9..3e7b82b384 100644 --- a/hermes_cli/clipboard.py +++ b/hermes_cli/clipboard.py @@ -1,12 +1,8 @@ -"""Clipboard image extraction for macOS, Windows, Linux, and WSL2. +"""Clipboard image extraction and text write for macOS, Windows, Linux, and WSL2. -Provides a single function `save_clipboard_image(dest)` that checks the system clipboard for image -data, saves it to *dest* as PNG, and returns True on success. No external Python dependencies — uses -only OS-level CLI tools that ship with the platform (or are commonly installed). - -Platform support: macOS — osascript (always available), pngpaste (if installed) Windows — PowerShell -via WinForms, Get-Clipboard, file-drop fallback WSL2 — powershell.exe via WinForms, Get-Clipboard, -file-drop fallback Linux — wl-paste (Wayland), xclip (X11) +No Python deps — only OS-level CLI tools: macOS osascript (always present) / pngpaste (optional); +Windows and WSL2 PowerShell via WinForms, Get-Clipboard, then a file-drop fallback; Linux +wl-paste (Wayland), xclip (X11). """ import base64 @@ -21,6 +17,7 @@ from hermes_constants import is_wsl as _is_wsl logger = logging.getLogger(__name__) _PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" _TEXT = dict(capture_output=True, text=True, encoding='utf-8', errors='replace') +_PS_FLAGS = ("-NoProfile", "-NonInteractive") def _nonempty(path: Path) -> bool: @@ -40,8 +37,16 @@ def _probe(argv: list, timeout: int, ok, *, missing: str | None = None) -> bool: return False +def _pipe_to_file(argv: list, dest: Path) -> bool: + """Run *argv* with stdout redirected into *dest*; True when a non-empty file resulted.""" + with open(dest, "wb") as f: + subprocess.run(argv, stdout=f, stderr=subprocess.DEVNULL, timeout=5, check=True) + return _nonempty(dest) + + def _linux_backends(): - """(enabled, has_image, save) in Linux fallthrough order: WSL → Wayland → X11.""" + """(enabled, has_image, save) in Linux fallthrough order: WSL → Wayland → X11 + (a failed WSL probe falls through — WSLg might have wl-paste or xclip working).""" return ( (_is_wsl(), _wsl_has_image, _wsl_save), (bool(os.environ.get("WAYLAND_DISPLAY")), _wayland_has_image, _wayland_save), @@ -50,16 +55,12 @@ def _linux_backends(): def save_clipboard_image(dest: Path) -> bool: - """Extract an image from the system clipboard and save it as PNG. - - Returns True if an image was found and saved, False otherwise. - """ + """Save the clipboard image to *dest* as PNG; True when an image was found and written.""" dest.parent.mkdir(parents=True, exist_ok=True) if sys.platform == "darwin": - return _macos_save(dest) - if sys.platform == "win32": - return _windows_save(dest) - return _linux_save(dest) + # pngpaste first (fast, handles more formats); osascript is the always-present fallback. + return _macos_pngpaste(dest) or _macos_osascript(dest) + return (_windows_save if sys.platform == "win32" else _linux_save)(dest) def has_clipboard_image() -> bool: @@ -73,66 +74,42 @@ def has_clipboard_image() -> bool: # ── Text write (native tools, mirrors ui-tui/src/lib/clipboard.ts) ────── -def _powershell_write_script(b64: str) -> str: - # PowerShell decodes piped stdin with the system ANSI code page (e.g. - # CP936), not UTF-8, so stdin-based writes mangle CJK/emoji. Base64 the - # UTF-8 bytes and decode inside PowerShell instead — same approach as - # the TUI's writeClipboardText. - return ( - "Set-Clipboard -Value ([System.Text.Encoding]::UTF8.GetString(" - f"[System.Convert]::FromBase64String('{b64}')))" - ) - - -def _write_clipboard_commands() -> list: - """Return (cmd_argv, use_stdin) candidates in platform fallback order.""" - if sys.platform == "darwin": - return [(["pbcopy"], True)] - if sys.platform == "win32": - return [(["powershell", "-NoProfile", "-NonInteractive"], False)] - attempts = [] - if _is_wsl(): - attempts.append((["powershell.exe", "-NoProfile", "-NonInteractive"], False)) - if os.environ.get("WAYLAND_DISPLAY"): - attempts.append((["wl-copy", "--type", "text/plain"], True)) - attempts.append((["xclip", "-selection", "clipboard", "-in"], True)) - attempts.append((["xsel", "--clipboard", "--input"], True)) - return attempts +def _write_clipboard_commands(data: bytes) -> list: + """(argv, run_kwargs) candidates for writing *data*, in platform fallback order.""" + # PowerShell decodes piped stdin with the system ANSI code page (e.g. CP936), not UTF-8, so + # stdin-based writes mangle CJK/emoji. Base64 the UTF-8 bytes and decode inside PowerShell + # instead (same approach as the TUI's writeClipboardText). + b64 = base64.b64encode(data).decode("ascii") + ps_argv = [*_PS_FLAGS, "-Command", "Set-Clipboard -Value ([System.Text.Encoding]::UTF8" + f".GetString([System.Convert]::FromBase64String('{b64}')))"] + ps_kw, pipe = {"stdin": subprocess.DEVNULL}, {"input": data} + linux = sys.platform not in ("darwin", "win32") + return [(argv, kw) for enabled, argv, kw in ( + (sys.platform == "darwin", ["pbcopy"], pipe), + (sys.platform == "win32", ["powershell", *ps_argv], ps_kw), + (linux and _is_wsl(), ["powershell.exe", *ps_argv], ps_kw), + (linux and os.environ.get("WAYLAND_DISPLAY"), ["wl-copy", "--type", "text/plain"], pipe), + (linux, ["xclip", "-selection", "clipboard", "-in"], pipe), + (linux, ["xsel", "--clipboard", "--input"], pipe), + ) if enabled] def is_remote_shell_session(env=None) -> bool: - """True when running inside an SSH session. - - Mirrors ui-tui/src/lib/terminalSetup.ts isRemoteShellSession(). Over SSH, native clipboard tools - write the REMOTE machine's clipboard (or an X-forwarded one), which is almost never what the - user wants — OSC 52 reaches the LOCAL terminal emulator instead. - """ + """True inside an SSH session (mirrors ui-tui/src/lib/terminalSetup.ts). Over SSH, native + clipboard tools write the REMOTE machine's clipboard (or an X-forwarded one), which is almost + never what the user wants — OSC 52 reaches the LOCAL terminal instead.""" e = os.environ if env is None else env - return bool( - e.get("SSH_CONNECTION") or e.get("SSH_TTY") or e.get("SSH_CLIENT") - ) + return bool(e.get("SSH_CONNECTION") or e.get("SSH_TTY") or e.get("SSH_CLIENT")) def write_clipboard_text(text: str) -> bool: - """Write *text* to the system clipboard via native platform tools. - - Fallback order matches the TUI (ui-tui/src/lib/clipboard.ts): macOS pbcopy → Windows/WSL - PowerShell Set-Clipboard → wl-copy → xclip → xsel. Returns True if any backend succeeded; - callers should fall back to OSC 52 on False. - """ - data = text.encode("utf-8") - quiet = dict(stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - for argv, use_stdin in _write_clipboard_commands(): + """Write *text* to the clipboard via native tools; fallback order matches the TUI: pbcopy → + Windows/WSL PowerShell Set-Clipboard → wl-copy → xclip → xsel. Returns True if any backend + succeeded; callers fall back to OSC 52 on False.""" + for argv, kw in _write_clipboard_commands(text.encode("utf-8")): try: - if use_stdin: - proc = subprocess.run(argv, input=data, timeout=10, **quiet) - else: - b64 = base64.b64encode(data).decode("ascii") - proc = subprocess.run( - argv + ["-Command", _powershell_write_script(b64)], - stdin=subprocess.DEVNULL, timeout=10, **quiet, - ) - if proc.returncode == 0: + if subprocess.run(argv, timeout=10, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, **kw).returncode == 0: return True except (OSError, subprocess.SubprocessError): continue @@ -141,23 +118,16 @@ def write_clipboard_text(text: str) -> bool: # ── macOS ──────────────────────────────────────────────────────────────── -def _macos_save(dest: Path) -> bool: - """Try pngpaste first (fast, handles more formats), fall back to osascript.""" - return _macos_pngpaste(dest) or _macos_osascript(dest) - - def _macos_has_image() -> bool: - """Check if macOS clipboard contains image data.""" return _probe(["osascript", "-e", "clipboard info"], 3, lambda r: "«class PNGf»" in r.stdout or "«class TIFF»" in r.stdout) def _macos_pngpaste(dest: Path) -> bool: - """Use pngpaste (brew install pngpaste) — fastest, cleanest.""" + """pngpaste (brew install pngpaste) — fastest, cleanest.""" try: r = subprocess.run(["pngpaste", str(dest)], capture_output=True, timeout=3) - if r.returncode == 0 and _nonempty(dest): - return True + return r.returncode == 0 and _nonempty(dest) except FileNotFoundError: pass # pngpaste not installed except Exception as e: @@ -166,74 +136,27 @@ def _macos_pngpaste(dest: Path) -> bool: def _macos_osascript(dest: Path) -> bool: - """Use osascript to extract PNG data from clipboard (always available).""" + """osascript PNG extraction (always available).""" if not _macos_has_image(): return False - - # Extract as PNG - script = ( - 'try\n' - ' set imgData to the clipboard as «class PNGf»\n' - f' set f to open for access POSIX file "{dest}" with write permission\n' - ' write imgData to f\n' - ' close access f\n' - 'on error\n' - ' return "fail"\n' - 'end try\n' - ) + script = f'''try + set imgData to the clipboard as «class PNGf» + set f to open for access POSIX file "{dest}" with write permission + write imgData to f + close access f +on error + return "fail" +end try +''' try: r = subprocess.run(["osascript", "-e", script], timeout=5, **_TEXT) - if r.returncode == 0 and "fail" not in r.stdout and _nonempty(dest): - return True + return r.returncode == 0 and "fail" not in r.stdout and _nonempty(dest) except Exception as e: logger.debug("osascript clipboard extract failed: %s", e) return False -# ── Shared PowerShell scripts (native Windows + WSL2) ───────────────────── - -# .NET System.Windows.Forms.Clipboard — used by both native Windows (powershell) -# and WSL2 (powershell.exe) paths. -_PS_CHECK_IMAGE = ( - "Add-Type -AssemblyName System.Windows.Forms;" - "[System.Windows.Forms.Clipboard]::ContainsImage()" -) - -_PS_EXTRACT_IMAGE = ( - "Add-Type -AssemblyName System.Windows.Forms;" - "Add-Type -AssemblyName System.Drawing;" - "$img = [System.Windows.Forms.Clipboard]::GetImage();" - "if ($null -eq $img) { exit 1 }" - "$ms = New-Object System.IO.MemoryStream;" - "$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png);" - "[System.Convert]::ToBase64String($ms.ToArray())" -) - -_PS_CHECK_IMAGE_GET_CLIPBOARD = ( - "try { " - "$img = Get-Clipboard -Format Image -ErrorAction Stop;" - "if ($null -ne $img) { 'True' } else { 'False' }" - "} catch { 'False' }" -) - -_PS_EXTRACT_IMAGE_GET_CLIPBOARD = ( - "try { " - "Add-Type -AssemblyName System.Drawing;" - "Add-Type -AssemblyName PresentationCore;" - "Add-Type -AssemblyName WindowsBase;" - "$img = Get-Clipboard -Format Image -ErrorAction Stop;" - "if ($null -eq $img) { exit 1 }" - "$ms = New-Object System.IO.MemoryStream;" - "if ($img -is [System.Drawing.Image]) {" - "$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)" - "} elseif ($img -is [System.Windows.Media.Imaging.BitmapSource]) {" - "$enc = New-Object System.Windows.Media.Imaging.PngBitmapEncoder;" - "$enc.Frames.Add([System.Windows.Media.Imaging.BitmapFrame]::Create($img));" - "$enc.Save($ms)" - "} else { exit 2 }" - "[System.Convert]::ToBase64String($ms.ToArray())" - "} catch { exit 1 }" -) +# ── PowerShell (native Windows powershell/pwsh + WSL2 powershell.exe) ───── _FILEDROP_IMAGE_EXTS = "'.png','.jpg','.jpeg','.gif','.webp','.bmp','.tiff','.tif'" _PS_FILEDROP_HIT = ( @@ -243,186 +166,143 @@ _PS_FILEDROP_HIT = ( "$hit = $files | Where-Object { $exts -contains ([System.IO.Path]::GetExtension($_).ToLowerInvariant()) } | Select-Object -First 1;" ) -_PS_CHECK_FILEDROP_IMAGE = ( - _PS_FILEDROP_HIT - + "if ($null -ne $hit) { 'True' } else { 'False' }" - "} catch { 'False' }" -) - -_PS_EXTRACT_FILEDROP_IMAGE = ( - _PS_FILEDROP_HIT - + "if ($null -eq $hit) { exit 1 }" - "[System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes($hit))" - "} catch { exit 1 }" -) - -_POWERSHELL_HAS_IMAGE_SCRIPTS = ( - _PS_CHECK_IMAGE, - _PS_CHECK_IMAGE_GET_CLIPBOARD, - _PS_CHECK_FILEDROP_IMAGE, -) - -_POWERSHELL_EXTRACT_IMAGE_SCRIPTS = ( - _PS_EXTRACT_IMAGE, - _PS_EXTRACT_IMAGE_GET_CLIPBOARD, - _PS_EXTRACT_FILEDROP_IMAGE, +# (has_image, extract-as-base64-PNG) script pairs, tried in order. +_PS_IMAGE_STRATEGIES = ( + ( # .NET System.Windows.Forms.Clipboard + "Add-Type -AssemblyName System.Windows.Forms;" + "[System.Windows.Forms.Clipboard]::ContainsImage()", + "Add-Type -AssemblyName System.Windows.Forms;" + "Add-Type -AssemblyName System.Drawing;" + "$img = [System.Windows.Forms.Clipboard]::GetImage();" + "if ($null -eq $img) { exit 1 }" + "$ms = New-Object System.IO.MemoryStream;" + "$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png);" + "[System.Convert]::ToBase64String($ms.ToArray())", + ), + ( # Get-Clipboard -Format Image (System.Drawing.Image or WPF BitmapSource) + "try { " + "$img = Get-Clipboard -Format Image -ErrorAction Stop;" + "if ($null -ne $img) { 'True' } else { 'False' }" + "} catch { 'False' }", + "try { " + "Add-Type -AssemblyName System.Drawing;" + "Add-Type -AssemblyName PresentationCore;" + "Add-Type -AssemblyName WindowsBase;" + "$img = Get-Clipboard -Format Image -ErrorAction Stop;" + "if ($null -eq $img) { exit 1 }" + "$ms = New-Object System.IO.MemoryStream;" + "if ($img -is [System.Drawing.Image]) {" + "$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)" + "} elseif ($img -is [System.Windows.Media.Imaging.BitmapSource]) {" + "$enc = New-Object System.Windows.Media.Imaging.PngBitmapEncoder;" + "$enc.Frames.Add([System.Windows.Media.Imaging.BitmapFrame]::Create($img));" + "$enc.Save($ms)" + "} else { exit 2 }" + "[System.Convert]::ToBase64String($ms.ToArray())" + "} catch { exit 1 }", + ), + ( # copied image *file* (Explorer file drop) + _PS_FILEDROP_HIT + + "if ($null -ne $hit) { 'True' } else { 'False' }" + "} catch { 'False' }", + _PS_FILEDROP_HIT + + "if ($null -eq $hit) { exit 1 }" + "[System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes($hit))" + "} catch { exit 1 }", + ), ) -def _run_powershell(exe: str, script: str, timeout: int) -> subprocess.CompletedProcess: - return subprocess.run( - [exe, "-NoProfile", "-NonInteractive", "-Command", script], timeout=timeout, **_TEXT, - ) - - -def _write_base64_image(dest: Path, b64_data: str) -> bool: - dest.write_bytes(base64.b64decode(b64_data, validate=True)) - return _nonempty(dest) - - -def _powershell_has_image(exe: str, *, timeout: int, label: str) -> bool: - for script in _POWERSHELL_HAS_IMAGE_SCRIPTS: +def _ps_clipboard(exe: str, timeout: int, label: str, dest: Path | None = None) -> bool: + """Probe (*dest* None) or extract (base64 PNG → *dest*) the Windows clipboard image via *exe*. + A missing *exe* ends the whole chain (every script needs the same binary); any other failure + logs (and drops a partial *dest*) then tries the next strategy.""" + for check, extract in _PS_IMAGE_STRATEGIES: try: - r = _run_powershell(exe, script, timeout=timeout) - if r.returncode == 0 and "True" in r.stdout: - return True + argv = [exe, *_PS_FLAGS, "-Command", check if dest is None else extract] + r = subprocess.run(argv, timeout=timeout, **_TEXT) + if dest is None: + if r.returncode == 0 and "True" in r.stdout: + return True + elif r.returncode == 0 and r.stdout.strip(): + dest.write_bytes(base64.b64decode(r.stdout.strip(), validate=True)) + if _nonempty(dest): + return True except FileNotFoundError: logger.debug("%s not found — clipboard unavailable", exe) return False except Exception as e: - logger.debug("%s clipboard image check failed: %s", label, e) + logger.debug("%s clipboard image %s failed: %s", label, + "check" if dest is None else "extraction", e) + if dest is not None: + dest.unlink(missing_ok=True) return False -def _powershell_save_image(exe: str, dest: Path, *, timeout: int, label: str) -> bool: - for script in _POWERSHELL_EXTRACT_IMAGE_SCRIPTS: - try: - r = _run_powershell(exe, script, timeout=timeout) - if r.returncode != 0: - continue - b64_data = r.stdout.strip() - if b64_data and _write_base64_image(dest, b64_data): - return True - except FileNotFoundError: - logger.debug("%s not found — clipboard unavailable", exe) - return False - except Exception as e: - logger.debug("%s clipboard image extraction failed: %s", label, e) - dest.unlink(missing_ok=True) - return False - - -# ── Native Windows ──────────────────────────────────────────────────────── - -# Native Windows uses ``powershell`` (Windows PowerShell 5.1, always present) -# or ``pwsh`` (PowerShell 7+, optional). Discovery is cached per-process. - - -def _find_powershell() -> str | None: - """Return the first available PowerShell executable, or None.""" - for name in ("powershell", "pwsh"): - try: - r = _run_powershell(name, "echo ok", timeout=5) - if r.returncode == 0 and "ok" in r.stdout: - return name - except Exception: - continue - return None - - -# Cache the resolved PowerShell executable (checked once per process) -_ps_exe: str | None | bool = False # False = not yet checked +_ps_exe: str | None | bool = False # resolved PowerShell executable; False = not yet checked def _get_ps_exe() -> str | None: + """First working PowerShell — ``powershell`` (5.1, always present) or ``pwsh`` (7+, + optional) — cached per process; None when neither runs.""" global _ps_exe if _ps_exe is False: - _ps_exe = _find_powershell() + _ps_exe = next((name for name in ("powershell", "pwsh") if _probe( + [name, *_PS_FLAGS, "-Command", "echo ok"], 5, + lambda r: r.returncode == 0 and "ok" in r.stdout)), None) return _ps_exe def _windows_has_image() -> bool: - """Check if the Windows clipboard contains an image.""" ps = _get_ps_exe() - if ps is None: - return False - return _powershell_has_image(ps, timeout=5, label="Windows") + return ps is not None and _ps_clipboard(ps, 5, "Windows") def _windows_save(dest: Path) -> bool: - """Extract clipboard image on native Windows via PowerShell → base64 PNG.""" ps = _get_ps_exe() if ps is None: logger.debug("No PowerShell found — Windows clipboard image paste unavailable") return False - return _powershell_save_image(ps, dest, timeout=15, label="Windows") + return _ps_clipboard(ps, 15, "Windows", dest) -# ── Linux ──────────────────────────────────────────────────────────────── +# ── Linux: WSL (powershell.exe) → Wayland (wl-paste) → X11 (xclip) ─────── def _linux_save(dest: Path) -> bool: - """Try clipboard backends in priority order: WSL → Wayland → X11 - (a failed WSL probe falls through — WSLg might have wl-paste or xclip working).""" return any(enabled and save(dest) for enabled, _, save in _linux_backends()) -# ── WSL2 (powershell.exe) ──────────────────────────────────────────────── -# Reuses _PS_CHECK_IMAGE / _PS_EXTRACT_IMAGE defined above. - def _wsl_has_image() -> bool: - """Check if Windows clipboard has an image (via powershell.exe).""" - return _powershell_has_image("powershell.exe", timeout=8, label="WSL") + return _ps_clipboard("powershell.exe", 8, "WSL") def _wsl_save(dest: Path) -> bool: - """Extract clipboard image via powershell.exe → base64 → decode to PNG.""" - return _powershell_save_image("powershell.exe", dest, timeout=15, label="WSL") + return _ps_clipboard("powershell.exe", 15, "WSL", dest) -# ── Wayland (wl-paste) ────────────────────────────────────────────────── - _WAYLAND_MIME_PREFERENCE = ("image/png", "image/jpeg", "image/bmp", "image/gif", "image/webp") _WL_LIST_TYPES = ["wl-paste", "--list-types"] _WL_MISSING = "wl-paste not installed — Wayland clipboard unavailable" def _wayland_has_image() -> bool: - """Check if Wayland clipboard has image content.""" return _probe(_WL_LIST_TYPES, 3, lambda r: r.returncode == 0 and any( t.startswith("image/") for t in r.stdout.splitlines()), missing=_WL_MISSING) -def _pipe_to_file(argv: list, dest: Path) -> bool: - """Run *argv* with stdout redirected into *dest*; True when a non-empty file resulted.""" - with open(dest, "wb") as f: - subprocess.run(argv, stdout=f, stderr=subprocess.DEVNULL, timeout=5, check=True) - return _nonempty(dest) - - def _wayland_save(dest: Path) -> bool: - """Use wl-paste to extract clipboard image (Wayland sessions).""" try: types_r = subprocess.run(_WL_LIST_TYPES, timeout=3, **_TEXT) - if types_r.returncode != 0: - return False - types = types_r.stdout.splitlines() - # Prefer PNG, fall back to other image formats - mime = next((m for m in _WAYLAND_MIME_PREFERENCE if m in types), None) + types = types_r.stdout.splitlines() if types_r.returncode == 0 else () + mime = next((m for m in _WAYLAND_MIME_PREFERENCE if m in types), None) # PNG preferred if not mime: return False - - if not _pipe_to_file(["wl-paste", "--type", mime], dest): - dest.unlink(missing_ok=True) - return False - - # save_clipboard_image() promises a PNG output path. Wayland can offer - # JPEG/GIF/WebP/BMP payloads, so normalize every non-PNG result before - # returning success. - if mime != "image/png" and (not _convert_to_png(dest) or not _is_png_file(dest)): - dest.unlink(missing_ok=True) - return False - return True - + # save_clipboard_image() promises a PNG. Wayland can offer JPEG/GIF/WebP/BMP payloads, + # so every non-PNG result is normalized (and re-verified) before reporting success. + if _pipe_to_file(["wl-paste", "--type", mime], dest) and ( + mime == "image/png" or (_convert_to_png(dest) and _is_png_file(dest))): + return True + dest.unlink(missing_ok=True) except FileNotFoundError: logger.debug(_WL_MISSING) except Exception as e: @@ -432,31 +312,26 @@ def _wayland_save(dest: Path) -> bool: def _convert_to_png(path: Path) -> bool: - """Convert an image file to PNG in-place (requires Pillow or ImageMagick).""" - # Try Pillow first (likely installed in the venv) + """Convert an image file to PNG in-place: Pillow first (likely in the venv), then ImageMagick. + When neither works the file is left as-is — BMP is still usable for most APIs.""" try: from PIL import Image - img = Image.open(path) - img.save(path, "PNG") + Image.open(path).save(path, "PNG") return True except ImportError: pass except Exception as e: logger.debug("Pillow BMP→PNG conversion failed: %s", e) - # Fall back to ImageMagick convert tmp = path.with_suffix(".bmp") try: path.rename(tmp) - r = subprocess.run( - ["convert", str(tmp), "png:" + str(path)], - capture_output=True, timeout=5, - ) + r = subprocess.run(["convert", str(tmp), "png:" + str(path)], capture_output=True, + timeout=5) if r.returncode == 0 and _nonempty(path): tmp.unlink(missing_ok=True) return True - # Convert failed — restore the original file - tmp.rename(path) + tmp.rename(path) # convert failed — restore the original file except Exception as e: if isinstance(e, FileNotFoundError): logger.debug("ImageMagick not installed — cannot convert BMP to PNG") @@ -464,13 +339,10 @@ def _convert_to_png(path: Path) -> bool: logger.debug("ImageMagick BMP→PNG conversion failed: %s", e) if tmp.exists() and not path.exists(): tmp.rename(path) - - # Can't convert — BMP is still usable as-is for most APIs return _nonempty(path) def _is_png_file(path: Path) -> bool: - """Return True when *path* starts with the PNG file signature.""" try: with path.open("rb") as f: return f.read(len(_PNG_SIGNATURE)) == _PNG_SIGNATURE @@ -478,27 +350,19 @@ def _is_png_file(path: Path) -> bool: return False -# ── X11 (xclip) ───────────────────────────────────────────────────────── - _XCLIP_TARGETS = ["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"] def _xclip_has_image() -> bool: - """Check if X11 clipboard has image content.""" return _probe(_XCLIP_TARGETS, 3, lambda r: r.returncode == 0 and "image/png" in r.stdout) def _xclip_save(dest: Path) -> bool: - """Use xclip to extract clipboard image (X11 sessions).""" - # Check if clipboard has image content if not _probe(_XCLIP_TARGETS, 3, lambda r: "image/png" in r.stdout, missing="xclip not installed — X11 clipboard image paste unavailable"): return False - - # Extract PNG data try: - if _pipe_to_file(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], dest): - return True + return _pipe_to_file(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], dest) except Exception as e: logger.debug("xclip image extraction failed: %s", e) dest.unlink(missing_ok=True) From b127bf01baa14ddeee947308c85effa2f0a1397c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:38:58 -0700 Subject: [PATCH 04/13] refactor(hermes_cli): split claw _cmd_migrate into phases, unify confirm/error-block printing --- hermes_cli/claw.py | 678 +++++++++++++++++++-------------------------- 1 file changed, 282 insertions(+), 396 deletions(-) diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py index 22e14a8c9a..5dd311f189 100644 --- a/hermes_cli/claw.py +++ b/hermes_cli/claw.py @@ -1,23 +1,19 @@ """hermes claw — OpenClaw migration commands.""" import importlib.util +import itertools import logging import subprocess import sys from datetime import datetime from pathlib import Path -from typing import Optional +from types import SimpleNamespace +from typing import Callable, Optional from hermes_cli.config import get_hermes_home, get_config_path, load_config, save_config from hermes_constants import get_optional_skills_dir from hermes_cli.setup import ( - Colors, - color, - print_header, - print_info, - print_success, - print_error, - prompt_yes_no, + Colors, color, print_header, print_info, print_success, print_error, prompt_yes_no, ) logger = logging.getLogger(__name__) @@ -32,147 +28,154 @@ _OPENCLAW_SCRIPT_INSTALLED = get_hermes_home() / "skills" / _SCRIPT_REL # Known OpenClaw directory names (current + legacy) _OPENCLAW_DIR_NAMES = (".openclaw", ".clawdbot", ".moltbot") +# `hermes claw migrate` flags and their defaults. Secrets are never included implicitly — they +# must be requested via --migrate-secrets even under --preset full (mirrors OpenClaw's two-phase +# migrate-hermes posture), so a full run cannot silently import API keys. +_MIGRATE_ARG_DEFAULTS = ( + ("source", None), ("dry_run", False), ("preset", "full"), ("overwrite", False), + ("migrate_secrets", False), ("workspace_target", None), ("skill_conflict", "skip"), + ("no_backup", False), ("yes", False), +) + +# (status, heading, color, default reason) — printed in this order after migrated items. +_REPORT_REASON_GROUPS = ( + ("conflict", " ⚠ Conflicts (skipped — use --overwrite to force):", Colors.YELLOW, + "already exists"), + ("skipped", " ─ Skipped:", Colors.DIM, ""), + ("error", " ✗ Errors:", Colors.RED, "unknown error"), +) + +# Workspace marker files listed by `hermes claw cleanup` (name, display label, presence check). +_WORKSPACE_ITEM_LABELS = ( + ("todo.json", "todo.json", Path.exists), ("sessions", "sessions/", Path.is_dir), + ("SOUL.md", "SOUL.md", Path.exists), ("MEMORY.md", "MEMORY.md", Path.exists), +) + def _print_banner(title: str) -> None: """Print the magenta boxed banner shared by the claw subcommands.""" print() - for line in ( - "┌─────────────────────────────────────────────────────────┐", - f"│ ⚕ Hermes — {title:<35s}│", - "└─────────────────────────────────────────────────────────┘", - ): + rule = "─" * 57 + for line in (f"┌{rule}┐", f"│ ⚕ Hermes — {title:<35s}│", f"└{rule}┘"): print(color(line, Colors.MAGENTA)) -def _warn_running(running: list[str], headline: str, *lines: str) -> None: - """Print the 'OpenClaw is running' warning block.""" +def _error_block(headline: str, *lines: str) -> None: + """Print a blank line, an error headline, then each line as info.""" print() print_error(headline) - for detail in running: - print_info(f" * {detail}") for line in lines: print_info(line) + + +def _confirm(auto_yes: bool, question: str, *, default: bool, declined: str, + non_tty: Optional[tuple[str, ...]] = None) -> Optional[bool]: + """Ask to proceed unless --yes; print ``declined`` and return False when the user says no. + + With ``non_tty`` set, a non-interactive stdin prints those lines and returns None instead of + prompting (callers treat None as "don't apply" but never as an explicit refusal). + """ + if auto_yes: + return True + if non_tty is not None and not sys.stdin.isatty(): + for line in non_tty: + print_info(line) + return None + if prompt_yes_no(question, default=default): + return True + print_info(declined) + return False + + +def _warn_token_conflict(auto_yes: bool, headline: str, lines: list[str], declined: str, + non_tty: Optional[tuple[str, ...]] = None) -> None: + """Print a bot-token conflict warning and exit 0 if the user explicitly declines to continue.""" + _error_block(headline, *lines) print() + if _confirm(auto_yes, "Continue anyway?", default=False, declined=declined, + non_tty=non_tty) is False: + sys.exit(0) + def _detect_openclaw_processes() -> list[str]: """Detect running OpenClaw processes and services.""" found: list[str] = [] - - def _posix_probe(cmd: list[str], timeout: int): - try: - return subprocess.run( - cmd, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=timeout, - ) - except (FileNotFoundError, subprocess.TimeoutExpired): - return None - - # -- systemd service (Linux) ------------------------------------------ - if sys.platform != "win32": - result = _posix_probe(["systemctl", "--user", "is-active", "openclaw-gateway.service"], 5) - if result is not None and result.stdout.strip() == "active": - found.append("systemd service: openclaw-gateway.service") - - # -- process scan ------------------------------------------------------ if sys.platform == "win32": - # bounded_probe_run: a plain subprocess.run(timeout=...) can hang - # forever on Windows in post-timeout cleanup when a conhost.exe - # descendant holds duplicated pipe handles (#87134) — and a hang is - # not an exception, so the try/except here can't save the caller. + # bounded_probe_run: a plain subprocess.run(timeout=...) can hang forever on Windows in + # post-timeout cleanup when a conhost.exe descendant holds duplicated pipe handles — and + # a hang is not an exception, so the try/except here can't save the caller. from hermes_cli._subprocess_compat import bounded_probe_run try: for exe in ("openclaw.exe", "clawd.exe"): - result = bounded_probe_run( - ["tasklist", "/FI", f"IMAGENAME eq {exe}"], - timeout=5, - ) + result = bounded_probe_run(["tasklist", "/FI", f"IMAGENAME eq {exe}"], timeout=5) if result is not None and exe in (result.stdout or "").lower(): found.append(f"process: {exe}") - - # Node.js-hosted OpenClaw — tasklist doesn't show command lines, - # so fall back to PowerShell. + # Node.js-hosted OpenClaw — tasklist doesn't show command lines, so use PowerShell. ps_cmd = ( 'Get-CimInstance Win32_Process -Filter "Name = \'node.exe\'" | ' 'Where-Object { $_.CommandLine -match "openclaw|clawd" } | ' 'Select-Object -First 1 ProcessId' ) - result = bounded_probe_run( - ["powershell", "-NoProfile", "-Command", ps_cmd], - timeout=5, - ) + result = bounded_probe_run(["powershell", "-NoProfile", "-Command", ps_cmd], timeout=5) if result is not None and (result.stdout or "").strip(): - found.append(f"node.exe process with openclaw in command line (PID {result.stdout.strip()})") + pid = result.stdout.strip() + found.append(f"node.exe process with openclaw in command line (PID {pid})") except Exception: pass - else: - result = _posix_probe(["pgrep", "-f", "openclaw"], 3) - if result is not None and result.returncode == 0: - found.append(f"openclaw process(es) (PIDs: {', '.join(result.stdout.strip().split())})") + return found + result = _posix_probe(["systemctl", "--user", "is-active", "openclaw-gateway.service"], 5) + if result is not None and result.stdout.strip() == "active": + found.append("systemd service: openclaw-gateway.service") + result = _posix_probe(["pgrep", "-f", "openclaw"], 3) + if result is not None and result.returncode == 0: + found.append(f"openclaw process(es) (PIDs: {', '.join(result.stdout.strip().split())})") return found +def _posix_probe(cmd: list[str], timeout: int): + try: + return subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', + errors='replace', timeout=timeout) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + + def _warn_if_openclaw_running(auto_yes: bool) -> None: - """Warn if OpenClaw is still running before migration. - - Telegram, Discord, and Slack only allow one active connection per bot token. Migrating while - OpenClaw is running causes both to fight for the same token. - """ + """Warn if OpenClaw is still running: Telegram/Discord/Slack allow one session per bot token.""" running = _detect_openclaw_processes() - if not running: - return - - _warn_running( - running, - "OpenClaw appears to be running:", - "Messaging platforms (Telegram, Discord, Slack) only allow one " - "active session per bot token. If you continue, both OpenClaw and " - "Hermes may try to use the same token, causing disconnects.", - "Recommendation: stop OpenClaw before migrating.", - ) - if auto_yes: - return - if not sys.stdin.isatty(): - print_info("Non-interactive session — continuing to preview only.") - return - if not prompt_yes_no("Continue anyway?", default=False): - print_info("Migration cancelled. Stop OpenClaw and try again.") - sys.exit(0) + if running: + _warn_token_conflict( + auto_yes, "OpenClaw appears to be running:", + [f" * {detail}" for detail in running] + [ + "Messaging platforms (Telegram, Discord, Slack) only allow one " + "active session per bot token. If you continue, both OpenClaw and " + "Hermes may try to use the same token, causing disconnects.", + "Recommendation: stop OpenClaw before migrating."], + declined="Migration cancelled. Stop OpenClaw and try again.", + non_tty=("Non-interactive session — continuing to preview only.",), + ) def _warn_if_gateway_running(auto_yes: bool) -> None: - """Check if a Hermes gateway is running with connected platforms. - - Migrating bot tokens while the gateway is polling causes conflicts (e.g. Telegram 409 - "terminated by other getUpdates request"); warn and let the user decide. - """ + """Warn if a Hermes gateway has connected platforms (token conflicts, e.g. Telegram 409).""" from gateway.status import get_running_pid, read_runtime_status if not get_running_pid(): return - - data = read_runtime_status() or {} - platforms = data.get("platforms") or {} + platforms = (read_runtime_status() or {}).get("platforms") or {} connected = [name for name, info in platforms.items() if isinstance(info, dict) and info.get("state") == "connected"] - if not connected: - return - - print() - print_error( - "Hermes gateway is running with active connections: " - + ", ".join(connected) - ) - print_info( - "Migrating bot tokens while the gateway is active will cause " - "conflicts (Telegram, Discord, and Slack only allow one active " - "session per token)." - ) - print_info("Recommendation: stop the gateway first with 'hermes gateway stop'.") - print() - if not auto_yes and not prompt_yes_no("Continue anyway?", default=False): - print_info("Migration cancelled. Stop the gateway and try again.") - sys.exit(0) + if connected: + _warn_token_conflict( + auto_yes, "Hermes gateway is running with active connections: " + ", ".join(connected), + ["Migrating bot tokens while the gateway is active will cause " + "conflicts (Telegram, Discord, and Slack only allow one active " + "session per token).", + "Recommendation: stop the gateway first with 'hermes gateway stop'."], + declined="Migration cancelled. Stop the gateway and try again.", + ) def _find_migration_script() -> Path | None: @@ -186,8 +189,7 @@ def _load_migration_module(script_path: Path): if spec is None or spec.loader is None: return None mod = importlib.util.module_from_spec(spec) - # Register in sys.modules so @dataclass can resolve the module - # (Python 3.11+ requires this for dynamically loaded modules) + # Register in sys.modules so @dataclass can resolve the module (Python 3.11+ requires this). sys.modules[spec.name] = mod try: spec.loader.exec_module(mod) @@ -203,193 +205,146 @@ def _find_openclaw_dirs() -> list[Path]: def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]: - """Scan an OpenClaw directory for workspace state files.""" - findings: list[tuple[Path, str]] = [] - - if not source_dir.exists(): - return findings - - def _add(path: Path, scope: str) -> None: - if path.exists(): - kind = "directory" if path.is_dir() else "file" - findings.append((path, f"{scope} {kind}: {path.relative_to(source_dir).as_posix()}")) - - # Direct state files in the root - for name in ("todo.json", "sessions", "logs"): - _add(source_dir / name, "Root") - - # State files inside workspace-like subdirectories + """List state files in an OpenClaw directory root, then in its workspace-like subdirs.""" + candidates = [(source_dir / name, "Root") for name in ("todo.json", "sessions", "logs")] try: children = sorted(source_dir.iterdir()) except OSError: - return findings - - for child in children: - if child.is_dir() and not child.name.startswith("."): - for state_name in ("todo.json", "sessions", "logs", "memory"): - _add(child / state_name, "Workspace") - - return findings + children = [] + candidates += [ + (child / name, "Workspace") + for child in children if child.is_dir() and not child.name.startswith(".") + for name in ("todo.json", "sessions", "logs", "memory") + ] + return [ + (p, f"{scope} {'directory' if p.is_dir() else 'file'}: " + f"{p.relative_to(source_dir).as_posix()}") + for p, scope in candidates if p.exists() + ] def _archive_directory(source_dir: Path, dry_run: bool = False) -> Path: - """Rename an OpenClaw directory to .pre-migration.""" + """Rename an OpenClaw directory to .pre-migration (date-stamped, then numbered, if taken).""" base = f"{source_dir.name}.pre-migration" - archive_path = source_dir.parent / base - - # If archive already exists, add timestamp; if it still exists (multiple - # runs same day), add a counter. - if archive_path.exists(): - timestamp = datetime.now().strftime("%Y%m%d") - archive_path = source_dir.parent / f"{base}-{timestamp}" - counter = 2 - while archive_path.exists(): - archive_path = source_dir.parent / f"{base}-{timestamp}-{counter}" - counter += 1 - + stamped = f"{base}-{datetime.now().strftime('%Y%m%d')}" + names = itertools.chain((base, stamped), (f"{stamped}-{n}" for n in itertools.count(2))) + archive_path = next(p for p in (source_dir.parent / n for n in names) if not p.exists()) if not dry_run: source_dir.rename(archive_path) - return archive_path def claw_command(args): """Route hermes claw subcommands.""" action = getattr(args, "claw_action", None) - if action == "migrate": _cmd_migrate(args) elif action in {"cleanup", "clean"}: _cmd_cleanup(args) else: - print("Usage: hermes claw [options]") - print() - print("Commands:") - print(" migrate Migrate settings from OpenClaw to Hermes") - print(" cleanup Archive leftover OpenClaw directories after migration") - print() - print("Run 'hermes claw --help' for options.") + print("Usage: hermes claw [options]\n\nCommands:\n" + " migrate Migrate settings from OpenClaw to Hermes\n" + " cleanup Archive leftover OpenClaw directories after migration\n\n" + "Run 'hermes claw --help' for options.") def _cmd_migrate(args): - """Run the OpenClaw → Hermes migration.""" + """Run the OpenClaw → Hermes migration: preflight, preview, confirm, back up, apply.""" + opts = SimpleNamespace(**{k: getattr(args, k, d) for k, d in _MIGRATE_ARG_DEFAULTS}) # Explicit --source, else first existing of current + legacy names; default to ~/.openclaw. - explicit_source = getattr(args, "source", None) - source_dir = Path(explicit_source) if explicit_source else next(iter(_find_openclaw_dirs()), Path.home() / ".openclaw") - dry_run = getattr(args, "dry_run", False) - preset = getattr(args, "preset", "full") - overwrite = getattr(args, "overwrite", False) - migrate_secrets = getattr(args, "migrate_secrets", False) - workspace_target = getattr(args, "workspace_target", None) - skill_conflict = getattr(args, "skill_conflict", "skip") - no_backup = getattr(args, "no_backup", False) - - # Secrets are never included implicitly — they must be explicitly requested - # via --migrate-secrets, even under --preset full. This mirrors OpenClaw's - # migrate-hermes posture (two-phase: run once without secrets, rerun with - # --include-secrets) and prevents a --preset full invocation from silently - # importing API keys that the user may not have intended to copy. - + opts.source_dir = (Path(opts.source) if opts.source + else next(iter(_find_openclaw_dirs()), Path.home() / ".openclaw")) _print_banner("OpenClaw Migration") - # Check source directory - if not source_dir.is_dir(): - print() - print_error(f"OpenClaw directory not found: {source_dir}") - print_info("Make sure your OpenClaw installation is at the expected path.") - print_info("You can specify a custom path: hermes claw migrate --source /path/to/.openclaw") + if not opts.source_dir.is_dir(): + _error_block( + f"OpenClaw directory not found: {opts.source_dir}", + "Make sure your OpenClaw installation is at the expected path.", + "You can specify a custom path: hermes claw migrate --source /path/to/.openclaw", + ) return - - # Find the migration script script_path = _find_migration_script() if not script_path: - print() - print_error("Migration script not found.") - print_info("Expected at one of:") - print_info(f" {_OPENCLAW_SCRIPT}") - print_info(f" {_OPENCLAW_SCRIPT_INSTALLED}") - print_info("Make sure the openclaw-migration skill is installed.") + _error_block("Migration script not found.", "Expected at one of:", f" {_OPENCLAW_SCRIPT}", + f" {_OPENCLAW_SCRIPT_INSTALLED}", + "Make sure the openclaw-migration skill is installed.") return - # Show what we're doing - hermes_home = get_hermes_home() - auto_yes = getattr(args, "yes", False) + opts.hermes_home = get_hermes_home() print() print_header("Migration Settings") - print_info(f"Source: {source_dir}") - print_info(f"Target: {hermes_home}") - print_info(f"Preset: {preset}") - print_info(f"Overwrite: {'yes' if overwrite else 'no (skip conflicts)'}") - print_info(f"Secrets: {'yes (allowlisted only)' if migrate_secrets else 'no'}") - if skill_conflict != "skip": - print_info(f"Skill conflicts: {skill_conflict}") - if workspace_target: - print_info(f"Workspace: {workspace_target}") + print_info(f"Source: {opts.source_dir}") + print_info(f"Target: {opts.hermes_home}") + print_info(f"Preset: {opts.preset}") + print_info(f"Overwrite: {'yes' if opts.overwrite else 'no (skip conflicts)'}") + print_info(f"Secrets: {'yes (allowlisted only)' if opts.migrate_secrets else 'no'}") + if opts.skill_conflict != "skip": + print_info(f"Skill conflicts: {opts.skill_conflict}") + if opts.workspace_target: + print_info(f"Workspace: {opts.workspace_target}") print() - - # Check if OpenClaw is still running — migrating tokens while both are - # active will cause conflicts (e.g. Telegram 409). - _warn_if_openclaw_running(auto_yes) - - # Check if a Hermes gateway is running with connected platforms. - _warn_if_gateway_running(auto_yes) - + # Migrating tokens while OpenClaw or the gateway is active causes conflicts (e.g. Telegram 409). + _warn_if_openclaw_running(opts.yes) + _warn_if_gateway_running(opts.yes) # Ensure config.yaml exists before migration tries to read it - config_path = get_config_path() - if not config_path.exists(): + if not get_config_path().exists(): save_config(load_config()) - # Load the migration module + run_migrator = _load_migrator(script_path, opts) + if run_migrator is None or not _preview_migration(run_migrator, opts): + return + print() + if _confirm(opts.yes, "Proceed with migration?", default=True, declined="Migration cancelled.", + non_tty=("Non-interactive session — preview only.", + "To execute, re-run with: hermes claw migrate --yes")): + _apply_migration(run_migrator, opts) + # Source directory is left untouched — archiving is `hermes claw cleanup`'s job. + + +def _load_migrator(script_path: Path, opts: SimpleNamespace) -> Optional[Callable[[bool], dict]]: + """Load the migration script; return ``run(execute) -> report`` or None (error printed).""" try: mod = _load_migration_module(script_path) except Exception as e: - print() - print_error(f"Could not load migration script: {e}") + _error_block(f"Could not load migration script: {e}") logger.debug("OpenClaw migration error", exc_info=True) - return + return None if mod is None: print_error("Could not load migration script.") - return - - selected = mod.resolve_selected_options(None, None, preset=preset) - ws_target = Path(workspace_target).resolve() if workspace_target else None + return None + selected = mod.resolve_selected_options(None, None, preset=opts.preset) + ws_target = Path(opts.workspace_target).resolve() if opts.workspace_target else None def _run_migrator(execute: bool) -> dict: return mod.Migrator( - source_root=source_dir.resolve(), - target_root=hermes_home.resolve(), - execute=execute, - workspace_target=ws_target, - overwrite=overwrite, - migrate_secrets=migrate_secrets, - output_dir=None, - selected_options=selected, - preset_name=preset, - skill_conflict_mode=skill_conflict, + source_root=opts.source_dir.resolve(), target_root=opts.hermes_home.resolve(), + execute=execute, workspace_target=ws_target, overwrite=opts.overwrite, + migrate_secrets=opts.migrate_secrets, output_dir=None, selected_options=selected, + preset_name=opts.preset, skill_conflict_mode=opts.skill_conflict, ).migrate() - # ── Phase 1: Always preview first ────────────────────────── - try: - preview_report = _run_migrator(execute=False) - except Exception as e: - print() - print_error(f"Migration preview failed: {e}") - logger.debug("OpenClaw migration preview error", exc_info=True) - return + return _run_migrator + +def _preview_migration(run_migrator: Callable[[bool], dict], opts: SimpleNamespace) -> bool: + """Always preview first; return True only if the run should proceed to apply.""" + try: + preview_report = run_migrator(False) + except Exception as e: + _error_block(f"Migration preview failed: {e}") + logger.debug("OpenClaw migration preview error", exc_info=True) + return False preview_summary = preview_report.get("summary", {}) preview_count = preview_summary.get("migrated", 0) preview_conflicts = preview_summary.get("conflict", 0) - # "Nothing to migrate" means nothing migrated AND nothing blocked by - # conflicts. If there are conflicts, we still want to show the plan and - # surface the refusal/--overwrite guidance instead of silently bailing. + # "Nothing to migrate" means nothing migrated AND nothing blocked by conflicts. With + # conflicts, still show the plan and surface the --overwrite guidance instead of bailing. if preview_count == 0 and preview_conflicts == 0: print() print_info("Nothing to migrate from OpenClaw.") _print_migration_report(preview_report, dry_run=True) - return - + return False print() print_header( f"Migration Preview — {preview_count} item(s) would be imported" @@ -398,165 +353,100 @@ def _cmd_migrate(args): ) print_info("No changes have been made yet. Review the list below:") _print_migration_report(preview_report, dry_run=True) + if opts.dry_run: + return False - # If --dry-run, stop here - if dry_run: - return - - # ── Phase 1b: Refuse if the plan has conflicts and --overwrite is not set ─ - # Modelled on OpenClaw's assertConflictFreePlan() — apply is a safe no-op - # on conflicts unless the user explicitly opts in to overwriting. Without - # this guard, the user would answer "yes, proceed" and silently end up - # with a migration that skipped every conflicting item. - if preview_conflicts > 0 and not overwrite: - print() - print_error( - f"Plan has {preview_conflicts} conflict(s). Refusing to apply." - ) - print_info( + # Modelled on OpenClaw's assertConflictFreePlan(): apply is a safe no-op on conflicts unless + # the user opts in to overwriting — otherwise "yes, proceed" would silently skip every + # conflicting item. + if preview_conflicts > 0 and not opts.overwrite: + _error_block( + f"Plan has {preview_conflicts} conflict(s). Refusing to apply.", "Each conflict is an item whose target already exists in ~/.hermes/. " "Re-run with --overwrite to replace conflicting targets (item-level " - "backups are written to the migration report directory)." + "backups are written to the migration report directory).", + "Or re-run with --dry-run to review the full plan.", ) - print_info("Or re-run with --dry-run to review the full plan.") - return + return False + return True - # ── Phase 2: Confirm and execute ─────────────────────────── - print() - if not auto_yes: - if not sys.stdin.isatty(): - print_info("Non-interactive session — preview only.") - print_info("To execute, re-run with: hermes claw migrate --yes") - return - if not prompt_yes_no("Proceed with migration?", default=True): - print_info("Migration cancelled.") - return - # ── Phase 2b: Pre-apply backup of the Hermes home ───────── - # Delegates to hermes_cli.backup.create_pre_migration_backup(), which - # shares implementation with the pre-update backup (same exclusion - # rules, same SQLite safe-copy, zip format) so the archive is - # restorable with `hermes import`. Mirrors OpenClaw's - # createPreMigrationBackup posture — one atomic restore point before - # any mutation, auto-pruned to the last 5 pre-migration zips. +def _apply_migration(run_migrator: Callable[[bool], dict], opts: SimpleNamespace) -> None: + """Take a pre-migration backup (unless --no-backup), execute, and print the report. + + The backup shares implementation with the pre-update backup (same exclusions, SQLite + safe-copy, zip format) so it is restorable with `hermes import` — one atomic restore point + before any mutation, auto-pruned to the last 5 pre-migration zips. + """ backup_archive: Optional[Path] = None - if not no_backup: + if not opts.no_backup: try: from hermes_cli.backup import create_pre_migration_backup, _format_size - backup_archive = create_pre_migration_backup(hermes_home=hermes_home) + backup_archive = create_pre_migration_backup(hermes_home=opts.hermes_home) if backup_archive: size_str = _format_size(backup_archive.stat().st_size) print() print_success(f"Pre-migration backup: {backup_archive} ({size_str})") print_info(f"Restore with: hermes import {backup_archive.name}") except Exception as e: - print() - print_error(f"Could not create pre-migration backup: {e}") - print_info( - "Re-run with --no-backup to skip, or free up disk space under the Hermes home." + _error_block( + f"Could not create pre-migration backup: {e}", + "Re-run with --no-backup to skip, or free up disk space under the Hermes home.", ) logger.debug("Pre-migration backup error", exc_info=True) return - try: - report = _run_migrator(execute=True) + report = run_migrator(True) except Exception as e: - print() - print_error(f"Migration failed: {e}") + _error_block(f"Migration failed: {e}") logger.debug("OpenClaw migration error", exc_info=True) if backup_archive: print_info(f"A pre-migration backup is available at: {backup_archive}") print_info(f"Restore with: hermes import {backup_archive.name}") return - - # Print results _print_migration_report(report, dry_run=False) - # Source directory is left untouched — archiving is not the migration - # tool's responsibility. Users who want to clean up can run - # 'hermes claw cleanup' separately. - def _cmd_cleanup(args): - """Archive leftover OpenClaw directories after migration. - - Scans for OpenClaw directories that still exist after migration and offers to rename them to - .pre-migration to free disk space. - """ + """Offer to rename leftover OpenClaw directories to .pre-migration to free disk space.""" dry_run = getattr(args, "dry_run", False) auto_yes = getattr(args, "yes", False) explicit_source = getattr(args, "source", None) _print_banner("OpenClaw Cleanup") - dirs_to_check = [Path(explicit_source)] if explicit_source else _find_openclaw_dirs() - if not dirs_to_check: print() print_success("No OpenClaw directories found. Nothing to clean up.") return - # Warn if OpenClaw is still running — archiving while the service is - # active causes it to recreate an empty skeleton directory (#8502). + # Archiving while the service is active makes it recreate an empty skeleton directory. running = _detect_openclaw_processes() if running: - _warn_running( - running, + _error_block( "OpenClaw appears to be still running:", + *(f" * {detail}" for detail in running), "Archiving .openclaw/ while the service is active may cause it to " "immediately recreate an empty skeleton directory, destroying your config.", "Stop OpenClaw first: systemctl --user stop openclaw-gateway.service", ) - if not auto_yes: - if not sys.stdin.isatty(): - print_info("Non-interactive session — aborting. Stop OpenClaw and re-run.") - return - if not prompt_yes_no("Proceed anyway?", default=False): - print_info("Aborted. Stop OpenClaw first, then re-run: hermes claw cleanup") - return + print() + if not _confirm( + auto_yes, "Proceed anyway?", default=False, + non_tty=("Non-interactive session — aborting. Stop OpenClaw and re-run.",), + declined="Aborted. Stop OpenClaw first, then re-run: hermes claw cleanup", + ): + return total_archived = 0 - for source_dir in dirs_to_check: - print() - print_header(f"Found: {source_dir}") - - # Scan for state files - state_files = _scan_workspace_state(source_dir) - - # Show directory stats - try: - workspace_dirs = [ - d for d in source_dir.iterdir() - if d.is_dir() and not d.name.startswith(".") - and any((d / name).exists() for name in ("todo.json", "SOUL.md", "MEMORY.md", "USER.md")) - ] - except OSError: - workspace_dirs = [] - - if workspace_dirs: - print_info(f"Workspace directories: {len(workspace_dirs)}") - for ws in workspace_dirs[:5]: - items = [label for name, label, check in _WORKSPACE_ITEM_LABELS if check(ws / name)] - print(f" {ws.name}/ ({', '.join(items) or 'empty'})") - _print_more(workspace_dirs, 5) - - if state_files: - print() - print(color(f" {len(state_files)} state file(s) found:", Colors.YELLOW)) - for _path, desc in state_files[:8]: - print(f" {desc}") - _print_more(state_files, 8) - - print() - + _describe_openclaw_dir(source_dir) if dry_run: archive_path = _archive_directory(source_dir, dry_run=True) print_info(f"Would archive: {source_dir} → {archive_path}") - elif not auto_yes and not sys.stdin.isatty(): - print_info(f"Non-interactive session — would archive: {source_dir}") - print_info("To execute, re-run with: hermes claw cleanup --yes") - elif auto_yes or prompt_yes_no(f"Archive {source_dir}?", default=True): + elif _confirm(auto_yes, f"Archive {source_dir}?", default=True, declined="Skipped.", + non_tty=(f"Non-interactive session — would archive: {source_dir}", + "To execute, re-run with: hermes claw cleanup --yes")): try: archive_path = _archive_directory(source_dir) print_success(f"Archived: {source_dir} → {archive_path}") @@ -564,73 +454,76 @@ def _cmd_cleanup(args): except OSError as e: print_error(f"Could not archive: {e}") print_info(f"Try manually: mv {source_dir} {source_dir}.pre-migration") - else: - print_info("Skipped.") - # Summary print() + n = len(dirs_to_check) if dry_run else total_archived + word = "directory" if n == 1 else "directories" if dry_run: - _n_dirs = len(dirs_to_check) - print_info(f"Dry run complete. {_n_dirs} {_dirs_word(_n_dirs)} would be archived.") + print_info(f"Dry run complete. {n} {word} would be archived.") print_info("Run without --dry-run to archive them.") elif total_archived: - print_success(f"Cleaned up {total_archived} OpenClaw {_dirs_word(total_archived)}.") + print_success(f"Cleaned up {total_archived} OpenClaw {word}.") print_info("Directories were renamed, not deleted. You can undo by renaming them back.") else: print_info("No directories were archived.") -def _dirs_word(n: int) -> str: - return "directory" if n == 1 else "directories" +def _describe_openclaw_dir(source_dir: Path) -> None: + """Print the workspace directories (first 5) and state files (first 8) of one OpenClaw dir.""" + print() + print_header(f"Found: {source_dir}") + state_files = _scan_workspace_state(source_dir) + try: + workspace_dirs = [ + d for d in source_dir.iterdir() + if d.is_dir() and not d.name.startswith(".") + and any((d / n).exists() for n in ("todo.json", "SOUL.md", "MEMORY.md", "USER.md")) + ] + except OSError: + workspace_dirs = [] + + if workspace_dirs: + print_info(f"Workspace directories: {len(workspace_dirs)}") + _print_rows([_workspace_row(ws) for ws in workspace_dirs], 5) + if state_files: + print() + print(color(f" {len(state_files)} state file(s) found:", Colors.YELLOW)) + _print_rows([desc for _path, desc in state_files], 8) + print() -def _print_more(seq, shown: int) -> None: - if len(seq) > shown: - print(f" ... and {len(seq) - shown} more") +def _workspace_row(ws: Path) -> str: + items = [label for name, label, check in _WORKSPACE_ITEM_LABELS if check(ws / name)] + return f"{ws.name}/ ({', '.join(items) or 'empty'})" -# (status, heading, color, default reason) — printed in this order after migrated items. -_REPORT_REASON_GROUPS = ( - ("conflict", " ⚠ Conflicts (skipped — use --overwrite to force):", Colors.YELLOW, "already exists"), - ("skipped", " ─ Skipped:", Colors.DIM, ""), - ("error", " ✗ Errors:", Colors.RED, "unknown error"), -) - -# Workspace marker files listed by `hermes claw cleanup` (name, display label, presence check). -_WORKSPACE_ITEM_LABELS = ( - ("todo.json", "todo.json", Path.exists), - ("sessions", "sessions/", Path.is_dir), - ("SOUL.md", "SOUL.md", Path.exists), - ("MEMORY.md", "MEMORY.md", Path.exists), -) +def _print_rows(rows: list[str], shown: int) -> None: + """Print the first ``shown`` rows indented, then a '... and N more' trailer if truncated.""" + for row in rows[:shown]: + print(f" {row}") + if len(rows) > shown: + print(f" ... and {len(rows) - shown} more") def _print_migration_report(report: dict, dry_run: bool): """Print a formatted migration report.""" summary = report.get("summary", {}) migrated = summary.get("migrated", 0) - print() + print_header("Dry Run Results" if dry_run else "Migration Results") if dry_run: - print_header("Dry Run Results") print_info("No files were modified. This is a preview of what would happen.") - else: - print_header("Migration Results") - print() - # Detailed items items = report.get("items", []) migrated_items = [i for i in items if i.get("status") == "migrated"] if migrated_items: - label = "Would migrate" if dry_run else "Migrated" - print(color(f" ✓ {label}:", Colors.GREEN)) + print(color(f" ✓ {'Would migrate' if dry_run else 'Migrated'}:", Colors.GREEN)) for item in migrated_items: - kind = item.get("kind", "unknown") - dest = item.get("destination", "") - print(f" {kind:<22s} → {str(dest).replace(str(Path.home()), '~')}" if dest else f" {kind}") + kind, dest = item.get("kind", "unknown"), item.get("destination", "") + print(f" {kind:<22s} → {str(dest).replace(str(Path.home()), '~')}" if dest + else f" {kind}") print() - for status, heading, heading_color, default_reason in _REPORT_REASON_GROUPS: group = [i for i in items if i.get("status") == status] if not group: @@ -640,23 +533,13 @@ def _print_migration_report(report: dict, dry_run: bool): print(f" {item.get('kind', 'unknown'):<22s} {item.get('reason', default_reason)}") print() - # Summary line - parts = [ - f"{count} {label}" - for count, label in ( - (migrated, "would migrate" if dry_run else "migrated"), - (summary.get("conflict", 0), "conflict(s)"), - (summary.get("skipped", 0), "skipped"), - (summary.get("error", 0), "error(s)"), - ) - if count - ] + counts = ((migrated, "would migrate" if dry_run else "migrated"), + (summary.get("conflict", 0), "conflict(s)"), (summary.get("skipped", 0), "skipped"), + (summary.get("error", 0), "error(s)")) + parts = [f"{count} {label}" for count, label in counts if count] print_info(f"Summary: {', '.join(parts)}" if parts else "Nothing to migrate.") - - # Output directory - output_dir = report.get("output_dir") - if output_dir: - print_info(f"Full report saved to: {output_dir}") + if report.get("output_dir"): + print_info(f"Full report saved to: {report['output_dir']}") if dry_run: print() @@ -668,8 +551,11 @@ def _print_migration_report(report: dict, dry_run: bool): # Warn if API keys were skipped (migrate_secrets not enabled) if any(i.get("kind") == "provider-keys" and i.get("status") == "skipped" for i in items): print() - print(color(" ⚠ API keys were NOT migrated (secrets migration is disabled by default).", Colors.YELLOW)) - print(color(" Your OPENROUTER_API_KEY and other provider keys must be added manually.", Colors.YELLOW)) + for line in ( + " ⚠ API keys were NOT migrated (secrets migration is disabled by default).", + " Your OPENROUTER_API_KEY and other provider keys must be added manually.", + ): + print(color(line, Colors.YELLOW)) print() print_info("To migrate API keys, re-run with:") print_info(" hermes claw migrate --migrate-secrets") From fa67526f3849681e64caeb823e0888991a7c03ef Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:41:02 -0700 Subject: [PATCH 05/13] refactor(hermes_cli): extract fallback-runtime/compression-chain/recap helpers in cli_agent_setup_mixin --- hermes_cli/cli_agent_setup_mixin.py | 323 +++++++++++++--------------- 1 file changed, 154 insertions(+), 169 deletions(-) diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index 5edf0c89cb..19d91cebb8 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -1,10 +1,8 @@ """Agent-construction and session-resume display methods for ``HermesCLI``. -Holds the agent lifecycle cluster lifted from ``cli.py``: runtime-credential -resolution, per-turn agent config, first-use agent construction, and resumed-session -preload + history recap. ``cli.py``-internal helpers are imported lazily inside each -method (``from cli import ...`` resolves once ``cli`` is fully loaded) so this module -never imports ``cli`` at import time -> no import cycle. +Runtime-credential resolution, per-turn agent config, first-use agent construction, and +resumed-session preload + history recap. ``cli.py`` helpers are imported lazily inside +each method so this module never imports ``cli`` at import time (import cycle). """ from __future__ import annotations @@ -49,13 +47,18 @@ def _current_runtime(cli) -> dict: def _route_signature(model, runtime: dict) -> tuple: """Hashable identity of (model, routing) used to detect when the agent must be rebuilt.""" return ( - model, - runtime.get("provider"), - runtime.get("requested_provider"), - runtime.get("base_url"), - runtime.get("api_mode"), - runtime.get("command"), - tuple(runtime.get("args") or ()), + model, runtime.get("provider"), runtime.get("requested_provider"), runtime.get("base_url"), + runtime.get("api_mode"), runtime.get("command"), tuple(runtime.get("args") or ()), + ) + + +def _keyless_custom_base(base_url) -> bool: + """Custom/local endpoints (llama.cpp, ollama, vLLM) often need no auth; only a + non-OpenRouter base_url qualifies.""" + return bool( + isinstance(base_url, str) + and base_url + and not base_url_host_matches(base_url, "openrouter.ai") ) @@ -69,6 +72,30 @@ def _compression_descendant(session_db, session_id): return resolved_id if resolved_id and resolved_id != session_id else None +def _user_display_text(content) -> str: + """Recap text for a user row; multimodal lists become text parts + ``[image]`` markers.""" + if isinstance(content, list): + return " ".join( + part.get("text", "") if part.get("type") == "text" else "[image]" + for part in content + if isinstance(part, dict) and part.get("type") in ("text", "image_url") + ) + return "" if content is None else str(content) + + +def _tool_calls_summary(tool_calls) -> str: + """``[N tool call(s): name, ...]`` with up to 4 distinct names.""" + names = [] + for tc in tool_calls: + fn = tc.get("function", {}) + name = fn.get("name", "unknown") if isinstance(fn, dict) else "unknown" + if name not in names: + names.append(name) + names_str = ", ".join(names[:4]) + (", ..." if len(names) > 4 else "") + noun = "call" if len(tool_calls) == 1 else "calls" + return f"[{len(tool_calls)} tool {noun}: {names_str}]" + + # display_kind -> recap event line; ``hidden`` rows are skipped before this lookup. _RESUME_EVENT_TEXT = { "model_switch": "model changed", @@ -78,9 +105,7 @@ _RESUME_EVENT_TEXT = { # (skin key, fallback) for recap panel colors: body text, session label, border, assistant label. _RESUME_SKIN_COLORS = ( - ("banner_text", "#FFF8DC"), - ("session_label", "#DAA520"), - ("session_border", "#8B8682"), + ("banner_text", "#FFF8DC"), ("session_label", "#DAA520"), ("session_border", "#8B8682"), ("ui_ok", "#8FBC8F"), ) @@ -91,51 +116,25 @@ class CLIAgentSetupMixin: def _ensure_runtime_credentials(self) -> bool: """Re-resolve provider credentials before agent use so key rotation / token refresh are picked up without restarting the CLI. False on auth failure.""" - from cli import ChatConsole, _cprint, logger + from cli import ChatConsole, logger from hermes_cli.runtime_provider import resolve_runtime_provider, format_runtime_provider_error _primary_exc = None runtime = None try: runtime = resolve_runtime_provider( - requested=self.requested_provider, - explicit_api_key=self._explicit_api_key, + requested=self.requested_provider, explicit_api_key=self._explicit_api_key, explicit_base_url=self._explicit_base_url, ) except Exception as exc: _primary_exc = exc - # Primary provider auth failed — try fallback providers before giving up. if runtime is None and _primary_exc is not None: from hermes_cli.auth import AuthError if isinstance(_primary_exc, AuthError): - _fb_chain = self._fallback_model if isinstance(self._fallback_model, list) else [] - for _fb in _fb_chain: - _fb_provider = (_fb.get("provider") or "").strip().lower() - _fb_model = (_fb.get("model") or "").strip() - if not _fb_provider or not _fb_model: - continue - try: - from hermes_cli.fallback_config import resolve_entry_api_key - - _fb_kwargs = {"requested": _fb_provider} - if _fb.get("base_url"): - _fb_kwargs["explicit_base_url"] = _fb["base_url"] - _fb_api_key = resolve_entry_api_key(_fb) - if _fb_api_key: - _fb_kwargs["explicit_api_key"] = _fb_api_key - runtime = resolve_runtime_provider(**_fb_kwargs) - logger.warning( - "Primary provider auth failed (%s). Falling through to fallback: %s/%s", - _primary_exc, _fb_provider, _fb_model, - ) - _cprint(f"⚠️ Primary auth failed — switching to fallback: {_fb_provider} / {_fb_model}") - self.requested_provider = _fb_provider - self.model = _fb_model - _primary_exc = None - break - except Exception: - continue + runtime = self._resolve_fallback_runtime(_primary_exc) + if runtime is not None: + _primary_exc = None if runtime is None: message = format_runtime_provider_error(_primary_exc) if _primary_exc else "Provider resolution failed." @@ -152,20 +151,13 @@ class CLIAgentSetupMixin: # invokes it per request, so skip string validation / placeholder substitution. _is_callable_provider = callable(api_key) and not isinstance(api_key, str) if not _is_callable_provider and (not isinstance(api_key, str) or not api_key): - # Custom/local endpoints (llama.cpp, ollama, vLLM) often need no auth: with a - # non-OpenRouter base_url use a placeholder key so the SDK doesn't reject it. - _source = runtime.get("source", "") - _has_custom_base = ( - isinstance(base_url, str) - and base_url - and not base_url_host_matches(base_url, "openrouter.ai") - ) - if _has_custom_base: + if _keyless_custom_base(base_url): + # Placeholder key so the SDK doesn't reject the keyless local endpoint. api_key = "no-key-required" logger.debug( "No API key for custom endpoint %s (source=%s), " "using placeholder — local servers typically ignore auth", - base_url, _source, + base_url, runtime.get("source", ""), ) else: _prov = (resolved_provider or self.requested_provider or "").strip() @@ -231,6 +223,39 @@ class CLIAgentSetupMixin: return True + def _resolve_fallback_runtime(self, primary_exc): + """Primary provider auth failed: try each fallback entry in order and switch the + CLI's requested_provider/model to the first that resolves. None if none do.""" + from cli import _cprint, logger + from hermes_cli.runtime_provider import resolve_runtime_provider + _fb_chain = self._fallback_model if isinstance(self._fallback_model, list) else [] + for _fb in _fb_chain: + _fb_provider = (_fb.get("provider") or "").strip().lower() + _fb_model = (_fb.get("model") or "").strip() + if not _fb_provider or not _fb_model: + continue + try: + from hermes_cli.fallback_config import resolve_entry_api_key + + _fb_kwargs = {"requested": _fb_provider} + if _fb.get("base_url"): + _fb_kwargs["explicit_base_url"] = _fb["base_url"] + _fb_api_key = resolve_entry_api_key(_fb) + if _fb_api_key: + _fb_kwargs["explicit_api_key"] = _fb_api_key + runtime = resolve_runtime_provider(**_fb_kwargs) + logger.warning( + "Primary provider auth failed (%s). Falling through to fallback: %s/%s", + primary_exc, _fb_provider, _fb_model, + ) + _cprint(f"⚠️ Primary auth failed — switching to fallback: {_fb_provider} / {_fb_model}") + self.requested_provider = _fb_provider + self.model = _fb_model + return runtime + except Exception: + continue + return None + def _runtime_credentials_ready(self) -> bool: """Silently probe whether any inference provider can be resolved. @@ -240,8 +265,7 @@ class CLIAgentSetupMixin: try: runtime = resolve_runtime_provider( - requested=self.requested_provider, - explicit_api_key=self._explicit_api_key, + requested=self.requested_provider, explicit_api_key=self._explicit_api_key, explicit_base_url=self._explicit_base_url, ) except Exception: @@ -252,12 +276,7 @@ class CLIAgentSetupMixin: base_url = runtime.get("base_url") if (callable(api_key) and not isinstance(api_key, str)) or (isinstance(api_key, str) and api_key): return bool(base_url) - # Keyless custom/local endpoints (ollama, llama.cpp, vLLM…) are fine. - return bool( - isinstance(base_url, str) - and base_url - and not base_url_host_matches(base_url, "openrouter.ai") - ) + return _keyless_custom_base(base_url) def _offer_first_run_setup(self) -> bool: """Offer the provider picker when no provider is configured at all (interactive @@ -322,20 +341,34 @@ class CLIAgentSetupMixin: runtime = _current_runtime(self) route = {"model": self.model, "runtime": runtime, "signature": _route_signature(self.model, runtime)} - - if getattr(self, "service_tier", None) != "priority": - route["request_overrides"] = None - return route - - try: - overrides = resolve_fast_mode_overrides( - route["model"], provider=runtime["provider"], base_url=runtime["base_url"], - ) - except Exception: - overrides = None + overrides = None + if getattr(self, "service_tier", None) == "priority": + try: + overrides = resolve_fast_mode_overrides( + route["model"], provider=runtime["provider"], base_url=runtime["base_url"], + ) + except Exception: + overrides = None route["request_overrides"] = overrides return route + def _follow_compression_chain(self, session_meta, announce): + """If the resumed id is an empty compression-chain head, announce and switch to + the descendant holding the messages; returns the (possibly refreshed) meta.""" + resolved_id = _compression_descendant(self._session_db, self.session_id) + if resolved_id: + announce(resolved_id) + self.session_id = resolved_id + session_meta = self._session_db.get_session(self.session_id) or session_meta + return session_meta + + def _reopen_session(self) -> None: + """Clear ended_at so the resumed session is active again (best effort).""" + try: + self._session_db.reopen_session(self.session_id) + except Exception: + pass + def _load_resumed_history_late(self) -> bool: """Late resume path: validate the session and load its history from the DB when _preload_resumed_session() (called from run()) did not already populate it. @@ -360,15 +393,14 @@ class CLIAgentSetupMixin: _cprint(f"\033[1;31mSession not found: {self.session_id}{_RST}") _cprint(f"{_DIM}Use a session ID from a previous CLI run (hermes sessions list).{_RST}") return False - resolved_id = _compression_descendant(self._session_db, self.session_id) - if resolved_id: - ChatConsole().print( + session_meta = self._follow_compression_chain( + session_meta, + lambda rid: ChatConsole().print( f"[dim]Session {_escape(self.session_id)} was compressed into " - f"{_escape(resolved_id)}; resuming the descendant with your " + f"{_escape(rid)}; resuming the descendant with your " f"transcript.[/dim]" - ) - self.session_id = resolved_id - session_meta = self._session_db.get_session(self.session_id) or session_meta + ), + ) if getattr(self, "_resume_history_error", None): return False # Only the TIP session's rows are loaded here (no ancestors), so use the @@ -401,11 +433,7 @@ class CLIAgentSetupMixin: f"Session {self.session_id} found but has no messages. Starting fresh.", f"[bold {_accent_hex()}]Session {_escape(self.session_id)} found but has no messages. Starting fresh.[/]", ) - # Re-open the session (clear ended_at so it's active again) - try: - self._session_db.reopen_session(self.session_id) - except Exception: - pass + self._reopen_session() return True def _init_agent(self, *, model_override: str = None, runtime_override: dict = None, request_overrides: dict | None = None) -> bool: @@ -429,8 +457,7 @@ class CLIAgentSetupMixin: from hermes_cli.mcp_startup import ensure_mcp_discovery_before_agent_build ensure_mcp_discovery_before_agent_build( - logger=logger, - single_query=getattr(self, "_single_query_mode", False), + logger=logger, single_query=getattr(self, "_single_query_mode", False) ) if self._session_db is None: @@ -449,63 +476,48 @@ class CLIAgentSetupMixin: try: runtime = runtime_override or _current_runtime(self) effective_model = model_override or self.model + # -q never builds the prompt_toolkit app, so the clarify modal can't be + # answered — answer headless instead of polling until clarify_timeout. + clarify_callback = ( + _single_query_clarify_callback + if getattr(self, "_single_query_mode", False) + else self._clarify_callback + ) self.agent = AIAgent( - model=effective_model, - api_key=runtime.get("api_key"), - base_url=runtime.get("base_url"), - provider=runtime.get("provider"), + model=effective_model, api_key=runtime.get("api_key"), + base_url=runtime.get("base_url"), provider=runtime.get("provider"), requested_provider=runtime.get("requested_provider"), - api_mode=runtime.get("api_mode"), - acp_command=runtime.get("command"), - acp_args=runtime.get("args"), - credential_pool=runtime.get("credential_pool"), - max_tokens=self.max_tokens, - max_iterations=self.max_turns, + api_mode=runtime.get("api_mode"), acp_command=runtime.get("command"), + acp_args=runtime.get("args"), credential_pool=runtime.get("credential_pool"), + max_tokens=self.max_tokens, max_iterations=self.max_turns, run_budget_seconds=getattr(self, "run_budget_seconds", None), - enabled_toolsets=self.enabled_toolsets, - disabled_toolsets=self.disabled_toolsets, - verbose_logging=self.verbose, - quiet_mode=not self.verbose, + enabled_toolsets=self.enabled_toolsets, disabled_toolsets=self.disabled_toolsets, + verbose_logging=self.verbose, quiet_mode=not self.verbose, tool_progress_mode=getattr(self, "tool_progress_mode", "all"), ephemeral_system_prompt=self.system_prompt if self.system_prompt else None, prefill_messages=self.prefill_messages or None, - reasoning_config=self.reasoning_config, - service_tier=self.service_tier, - request_overrides=request_overrides, - providers_allowed=self._providers_only, - providers_ignored=self._providers_ignore, - providers_order=self._providers_order, + reasoning_config=self.reasoning_config, service_tier=self.service_tier, + request_overrides=request_overrides, providers_allowed=self._providers_only, + providers_ignored=self._providers_ignore, providers_order=self._providers_order, provider_sort=self._provider_sort, provider_require_parameters=self._provider_require_params, provider_data_collection=self._provider_data_collection, openrouter_min_coding_score=self._openrouter_min_coding_score, - session_id=self.session_id, - platform="cli", - session_db=self._session_db, - # -q never builds the prompt_toolkit app, so the clarify modal can't be - # answered — answer headless instead of polling until clarify_timeout. - clarify_callback=( - _single_query_clarify_callback - if getattr(self, "_single_query_mode", False) - else self._clarify_callback - ), + session_id=self.session_id, platform="cli", session_db=self._session_db, + clarify_callback=clarify_callback, reasoning_callback=self._current_reasoning_callback(), - fallback_model=self._fallback_model, - thinking_callback=self._on_thinking, + fallback_model=self._fallback_model, thinking_callback=self._on_thinking, checkpoints_enabled=self.checkpoints_enabled, checkpoint_max_snapshots=self.checkpoint_max_snapshots, checkpoint_max_total_size_mb=self.checkpoint_max_total_size_mb, checkpoint_max_file_size_mb=self.checkpoint_max_file_size_mb, - pass_session_id=self.pass_session_id, - skip_context_files=self.ignore_rules, - skip_memory=self.ignore_rules, - tool_progress_callback=self._on_tool_progress, + pass_session_id=self.pass_session_id, skip_context_files=self.ignore_rules, + skip_memory=self.ignore_rules, tool_progress_callback=self._on_tool_progress, tool_start_callback=self._on_tool_start if self._inline_diffs_enabled else None, tool_complete_callback=self._on_tool_complete if self._inline_diffs_enabled else None, stream_delta_callback=self._stream_delta if self.streaming_enabled else None, tool_gen_callback=self._on_tool_gen_start if self.streaming_enabled else None, - notice_callback=self._on_notice, - notice_clear_callback=self._on_notice_clear, + notice_callback=self._on_notice, notice_clear_callback=self._on_notice_clear, reaction_callback=self._on_reaction, ) # Reference for atexit memory-provider shutdown: ``_run_cleanup`` in cli.py @@ -535,7 +547,7 @@ class CLIAgentSetupMixin: _cprint(f" Session title applied: {self._pending_title}") self._pending_title = None # else: row creation failed transiently — keep _pending_title for retry - except (ValueError, Exception) as e: + except Exception as e: _cprint(f" Could not apply pending title: {e}") # Keep _pending_title so it can be retried after row creation succeeds return True @@ -574,7 +586,6 @@ class CLIAgentSetupMixin: "Resume safety check failed for %s (proceeding without guard): %s", self.session_id, exc, ) - return None return None def _preload_resumed_session(self) -> bool: @@ -591,23 +602,20 @@ class CLIAgentSetupMixin: self._console_print("[dim]Use a session ID from a previous CLI run (hermes sessions list).[/]") return False - resolved_id = _compression_descendant(self._session_db, self.session_id) - if resolved_id: - self._console_print( + session_meta = self._follow_compression_chain( + session_meta, + lambda rid: self._console_print( f"[dim]Session {self.session_id} was compressed into " - f"{resolved_id}; resuming the descendant with your transcript.[/]" - ) - self.session_id = resolved_id - session_meta = self._session_db.get_session(self.session_id) or session_meta - + f"{rid}; resuming the descendant with your transcript.[/]" + ), + ) resume_limit_error = self._resume_history_limit_error() if resume_limit_error: self._resume_history_error = resume_limit_error self._console_print(f"[bold red]Cannot resume session:[/] {resume_limit_error}") return False - model_history, display_history = self._session_db.get_resume_conversations(self.session_id) - restored = model_history + restored, display_history = self._session_db.get_resume_conversations(self.session_id) accent_color = _accent_hex() if not restored: self._console_print( @@ -635,12 +643,7 @@ class CLIAgentSetupMixin: self._restore_session_yolo(session_meta) self._restore_session_model(session_meta) - # Re-open the session (clear ended_at so it's active again) - try: - self._session_db.reopen_session(self.session_id) - except Exception: - pass - + self._reopen_session() return True def _display_resumed_history(self): @@ -677,17 +680,10 @@ class CLIAgentSetupMixin: if role in ("system", "tool"): continue + # Stored history is untrusted for display: strip escape sequences/control + # chars so replay can't clear the screen, retitle the window or restyle the panel. if role == "user": - text = "" if content is None else str(content) - if isinstance(content, list): # multimodal: text parts + [image] markers - text = " ".join( - part.get("text", "") if part.get("type") == "text" else "[image]" - for part in content - if isinstance(part, dict) and part.get("type") in ("text", "image_url") - ) - # Stored history is untrusted for display: strip escape sequences/control - # chars so replay can't clear the screen, retitle the window or restyle the panel. - text = _sanitize_display_text(text) + text = _sanitize_display_text(_user_display_text(content)) if len(text) > MAX_USER_LEN: text = text[:MAX_USER_LEN] + "..." entries.append(("user", text)) @@ -706,15 +702,7 @@ class CLIAgentSetupMixin: text = text[:MAX_ASST_LEN] + "..." parts.append(text) if tool_calls: - names = [] - for tc in tool_calls: - fn = tc.get("function", {}) - name = fn.get("name", "unknown") if isinstance(fn, dict) else "unknown" - if name not in names: - names.append(name) - names_str = ", ".join(names[:4]) + (", ..." if len(names) > 4 else "") - noun = "call" if len(tool_calls) == 1 else "calls" - tc_summary = f"[{len(tool_calls)} tool {noun}: {names_str}]" + tc_summary = _tool_calls_summary(tool_calls) parts.append(tc_summary) full_parts.append(tc_summary) # Skip pure-reasoning messages with no visible output, and tool-call-only @@ -778,11 +766,8 @@ class CLIAgentSetupMixin: lines.append("") # small gap panel = Panel( - lines, - title=f"[dim {_session_label_c}]Previous Conversation[/]", - border_style=f"dim {_session_border_c}", - padding=(0, 1), - style=_history_text_c, + lines, title=f"[dim {_session_label_c}]Previous Conversation[/]", + border_style=f"dim {_session_border_c}", padding=(0, 1), style=_history_text_c, ) _record_output_history_entry(lambda: self._render_resume_history_panel_lines(panel)) with _suspend_output_history(): From baa22490c567e4238d2f5dd8bc9a040da2bb33b3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:44:42 -0700 Subject: [PATCH 06/13] refactor(hermes_cli): fold hanging import/call spans in cli_loops_mixin and cli_terminal_mixin --- hermes_cli/cli_loops_mixin.py | 24 ++++++------------------ hermes_cli/cli_terminal_mixin.py | 10 ++-------- 2 files changed, 8 insertions(+), 26 deletions(-) diff --git a/hermes_cli/cli_loops_mixin.py b/hermes_cli/cli_loops_mixin.py index 37a39d268d..c3f91761cb 100644 --- a/hermes_cli/cli_loops_mixin.py +++ b/hermes_cli/cli_loops_mixin.py @@ -48,12 +48,8 @@ class CLILoopsMixin: def _cmd_clear(self, cmd_original: str): from cli import ( - ChatConsole, - _build_compact_banner, - _clear_output_history, - _cprint, - build_welcome_banner, - get_tool_definitions, + ChatConsole, _build_compact_banner, _clear_output_history, _cprint, + build_welcome_banner, get_tool_definitions, ) if self._confirm_destructive_slash( "clear", @@ -86,14 +82,9 @@ class CLILoopsMixin: if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'context_compressor'): ctx_len = self.agent.context_compressor.context_length build_welcome_banner( - console=cc, - model=self.model, - cwd=os.getenv("TERMINAL_CWD", os.getcwd()), - tools=tools, - enabled_toolsets=self.enabled_toolsets, - session_id=self.session_id, - context_length=ctx_len, - provider=self.provider, + console=cc, model=self.model, cwd=os.getenv("TERMINAL_CWD", os.getcwd()), + tools=tools, enabled_toolsets=self.enabled_toolsets, session_id=self.session_id, + context_length=ctx_len, provider=self.provider, ) _cprint(_FRESH_START) self._print_random_tip() @@ -236,10 +227,7 @@ class CLILoopsMixin: # installed-but-not-enabled plugins show up; the plugin manager only knows # *loaded* plugins and made fresh installs look like "nothing installed". from hermes_cli.plugins_cmd import ( - _discover_all_plugins, - _get_disabled_set, - _get_enabled_set, - _plugin_status, + _discover_all_plugins, _get_disabled_set, _get_enabled_set, _plugin_status ) entries = _discover_all_plugins() diff --git a/hermes_cli/cli_terminal_mixin.py b/hermes_cli/cli_terminal_mixin.py index 300b278540..7b1b87e8a5 100644 --- a/hermes_cli/cli_terminal_mixin.py +++ b/hermes_cli/cli_terminal_mixin.py @@ -394,14 +394,8 @@ class CLITerminalMixin: def _recover_terminal_input_modes(self, *, reason: str) -> None: """Best-effort reset when leaked mouse reports indicate mode drift.""" from cli import ( - CLI_CONFIG, - _DIM, - _RST, - _TERMINAL_INPUT_MODE_RESET_SEQ, - _cli_multiline_shortcuts_enabled, - _cprint, - _enable_extended_enter_keys, - logger, + CLI_CONFIG, _DIM, _RST, _TERMINAL_INPUT_MODE_RESET_SEQ, + _cli_multiline_shortcuts_enabled, _cprint, _enable_extended_enter_keys, logger, ) now = time.monotonic() # Rate-limit to avoid thrashing if a terminal floods reports. From 92945b7b5b01b49e1d4a3b36912fc43644464a70 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:51:39 -0700 Subject: [PATCH 07/13] refactor(hermes_cli): table-drive codex config migration translation, extract atomic write --- hermes_cli/codex_runtime_plugin_migration.py | 573 +++++++------------ 1 file changed, 202 insertions(+), 371 deletions(-) diff --git a/hermes_cli/codex_runtime_plugin_migration.py b/hermes_cli/codex_runtime_plugin_migration.py index 310867762f..6e5f3829b2 100644 --- a/hermes_cli/codex_runtime_plugin_migration.py +++ b/hermes_cli/codex_runtime_plugin_migration.py @@ -1,5 +1,4 @@ -"""Migrate Hermes' MCP server config and Codex's installed curated plugins to the format Codex expects -in ~/.codex/config.toml. +"""Migrate Hermes MCP server config and Codex's installed curated plugins into ~/.codex/config.toml. """ from __future__ import annotations @@ -13,14 +12,12 @@ from typing import Any, Optional logger = logging.getLogger(__name__) -# Marker comments wrapping the managed section so re-runs can detect -# what's ours and what's user-edited. Both must appear or strip is a no-op. +# Marker comments wrapping the managed section so re-runs can detect what's ours and what's +# user-edited. Both must appear or strip is a no-op. MIGRATION_MARKER = ( "# managed by hermes-agent — `hermes codex-runtime migrate` regenerates this section" ) -MIGRATION_END_MARKER = ( - "# end hermes-agent managed section" -) +MIGRATION_END_MARKER = "# end hermes-agent managed section" @dataclass @@ -47,179 +44,142 @@ class MigrationReport: lines.append(f"Migrated {len(self.migrated)} MCP server(s):") for name in self.migrated: skipped = self.skipped_keys_per_server.get(name, []) - note = ( - f" (skipped: {', '.join(skipped)})" if skipped else "" - ) + note = f" (skipped: {', '.join(skipped)})" if skipped else "" lines.append(f" - {name}{note}") else: lines.append("No MCP servers found in Hermes config.") if self.migrated_plugins: - lines.append( - f"Migrated {len(self.migrated_plugins)} native Codex plugin(s):" - ) - for name in self.migrated_plugins: - lines.append(f" - {name}") + lines.append(f"Migrated {len(self.migrated_plugins)} native Codex plugin(s):") + lines.extend(f" - {name}" for name in self.migrated_plugins) elif self.plugin_query_error: lines.append(f"Codex plugin discovery skipped: {self.plugin_query_error}") if self.wrote_permissions_default: - lines.append( - f"Wrote default_permissions = " - f"{self.wrote_permissions_default!r}" - ) - for err in self.errors: - lines.append(f"⚠ {err}") + lines.append(f"Wrote default_permissions = {self.wrote_permissions_default!r}") + lines.extend(f"⚠ {err}" for err in self.errors) return "\n".join(lines) -# Hermes keys that codex's MCP schema doesn't support — dropped during -# migration with a warning. Anything not on the keep list AND not the -# transport keys is added to skipped. +# Hermes MCP keys codex understands (transport stdio/http, timeouts, general). Any other key is +# dropped with a warning: ``sampling`` has no codex equivalent; the rest are unknown Hermes keys. _KNOWN_HERMES_KEYS = { - # transport — stdio "command", "args", "env", "cwd", - # transport — http "url", "headers", "transport", - # timeouts "timeout", "connect_timeout", - # general "enabled", "description", } +_KEYS_DROPPED_WITH_WARNING = {"sampling"} -# Subset that have a direct codex equivalent. -_KEYS_DROPPED_WITH_WARNING = { - # Hermes' sampling subsection — codex MCP has no equivalent - "sampling", -} +# (hermes key, codex key, skip note) — timeouts are emitted as floats or skipped when non-numeric. +_TIMEOUT_KEYS = ( + ("timeout", "tool_timeout_sec", "timeout (not numeric)"), + ("connect_timeout", "startup_timeout_sec", "connect_timeout (not numeric)"), +) -def _translate_one_server( - name: str, hermes_cfg: dict -) -> tuple[Optional[dict], list[str]]: - """Translate one Hermes MCP server config to the codex inline-table dict representation. Returns - (codex_entry, skipped_keys). +def _str_map(d: dict) -> dict[str, str]: + """Codex expects string keys and values in env / header tables.""" + return {str(k): str(v) for k, v in d.items()} + + +def _translate_one_server(name: str, hermes_cfg: dict) -> tuple[Optional[dict], list[str]]: + """Translate one Hermes MCP server config to codex's inline-table dict. + + Returns ``(codex_entry, skipped_keys)``; ``codex_entry`` is None when the config is unusable. + stdio (``command``) wins over ``url`` when both are set. Hermes' ``transport: sse`` hint is + informational only — codex auto-negotiates. ``enabled`` is emitted only when explicitly false + (codex defaults to true). """ if not isinstance(hermes_cfg, dict): return None, [] skipped: list[str] = [] out: dict[str, Any] = {} - - has_command = bool(hermes_cfg.get("command")) - has_url = bool(hermes_cfg.get("url")) - - if has_command and has_url: - skipped.append("url (both command and url set; preferring stdio)") - has_url = False - - if has_command: - # Stdio transport + if hermes_cfg.get("command"): + if hermes_cfg.get("url"): + skipped.append("url (both command and url set; preferring stdio)") out["command"] = str(hermes_cfg["command"]) - args = hermes_cfg.get("args") or [] - if args: - out["args"] = [str(a) for a in args] - env = hermes_cfg.get("env") or {} - if env: - # Codex expects string values - out["env"] = {str(k): str(v) for k, v in env.items()} - cwd = hermes_cfg.get("cwd") - if cwd: - out["cwd"] = str(cwd) - elif has_url: - # streamable_http transport (codex covers both http and SSE here) + if hermes_cfg.get("args"): + out["args"] = [str(a) for a in hermes_cfg["args"]] + if hermes_cfg.get("env"): + out["env"] = _str_map(hermes_cfg["env"]) + if hermes_cfg.get("cwd"): + out["cwd"] = str(hermes_cfg["cwd"]) + elif hermes_cfg.get("url"): out["url"] = str(hermes_cfg["url"]) - headers = hermes_cfg.get("headers") or {} - if headers: - out["http_headers"] = {str(k): str(v) for k, v in headers.items()} - # Hermes' transport: sse hint is informational; codex auto-negotiates + if hermes_cfg.get("headers"): + out["http_headers"] = _str_map(hermes_cfg["headers"]) if hermes_cfg.get("transport") == "sse": skipped.append("transport=sse (codex auto-negotiates)") else: return None, ["no command or url field"] - # Timeouts - if "timeout" in hermes_cfg: - try: - out["tool_timeout_sec"] = float(hermes_cfg["timeout"]) - except (TypeError, ValueError): - skipped.append("timeout (not numeric)") - if "connect_timeout" in hermes_cfg: - try: - out["startup_timeout_sec"] = float(hermes_cfg["connect_timeout"]) - except (TypeError, ValueError): - skipped.append("connect_timeout (not numeric)") - - # Enabled flag (codex defaults to true so we only emit when explicitly false) + for hermes_key, codex_key, note in _TIMEOUT_KEYS: + if hermes_key in hermes_cfg: + try: + out[codex_key] = float(hermes_cfg[hermes_key]) + except (TypeError, ValueError): + skipped.append(note) if hermes_cfg.get("enabled") is False: out["enabled"] = False - # Detect keys we explicitly drop with warning for key in hermes_cfg: if key in _KEYS_DROPPED_WITH_WARNING: skipped.append(f"{key} (no codex equivalent)") elif key not in _KNOWN_HERMES_KEYS: skipped.append(f"{key} (unknown Hermes key)") - return out, skipped -def _format_toml_value(value: Any) -> str: - """Minimal TOML value formatter for the value types we emit. +# TOML basic-string escapes. Order matters: backslash first so the others aren't re-escaped. +# Control chars must be \-escaped — a literal one is invalid TOML that codex refuses to load; +# env-var passthrough (HERMES_HOME, PYTHONPATH) could carry one in pathological cases. +_TOML_ESCAPES = ( + ("\\", "\\\\"), ('"', '\\"'), ("\b", "\\b"), ("\t", "\\t"), + ("\n", "\\n"), ("\f", "\\f"), ("\r", "\\r"), +) - We only emit strings, numbers, booleans, and tables of those — no nested arrays of tables. This - covers everything codex's MCP schema accepts. - """ + +def _escape_toml_string(value: str) -> str: + for raw, esc in _TOML_ESCAPES: + value = value.replace(raw, esc) + return f'"{value}"' + + +def _format_toml_value(value: Any) -> str: + """Minimal TOML formatter for what we emit: strings, numbers, bools, and flat lists/tables of + those (everything codex's MCP schema accepts; no nested arrays of tables).""" if isinstance(value, bool): return "true" if value else "false" if isinstance(value, (int, float)): return repr(value) if isinstance(value, str): - # Escape per TOML basic-string rules. Order matters: backslash - # first so the other escapes don't get re-escaped. - # Control characters (newline, tab, etc.) must use \-escapes - # because TOML basic strings don't allow literal control chars - # — passing them through would produce invalid TOML that codex - # would refuse to load. Paths usually don't contain control - # chars but env-var passthrough (HERMES_HOME, PYTHONPATH) could - # in pathological cases. - escaped = ( - value - .replace("\\", "\\\\") - .replace('"', '\\"') - .replace("\b", "\\b") - .replace("\t", "\\t") - .replace("\n", "\\n") - .replace("\f", "\\f") - .replace("\r", "\\r") - ) - return f'"{escaped}"' + return _escape_toml_string(value) if isinstance(value, list): - items = ", ".join(_format_toml_value(v) for v in value) - return f"[{items}]" + return f"[{', '.join(_format_toml_value(v) for v in value)}]" if isinstance(value, dict): - items = ", ".join( - f'{_quote_key(k)} = {_format_toml_value(v)}' for k, v in value.items() - ) + items = ", ".join(f'{_quote_key(k)} = {_format_toml_value(v)}' for k, v in value.items()) return "{ " + items + " }" if items else "{}" raise ValueError(f"Unsupported TOML value type: {type(value).__name__}") def _quote_key(key: str) -> str: """Return key bare-or-quoted depending on whether it's a valid bare key.""" - if all(c.isalnum() or c in "-_" for c in key) and key: + if key and all(c.isalnum() or c in "-_" for c in key): return key - escaped = key.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' + return '"' + key.replace("\\", "\\\\").replace('"', '\\"') + '"' + def render_codex_toml_section( servers: dict[str, dict], plugins: Optional[list[dict]] = None, default_permission_profile: Optional[str] = None, ) -> str: - """Render the managed [mcp_servers.] / [plugins.] / [permissions] block for - ~/.codex/config.toml. + """Render the managed [mcp_servers.] / [plugins.] / default_permissions block. - ``default_permission_profile`` (e.g. "workspace-write", "read-only", "full-access") writes - ``[permissions] default`` so the user is not prompted on every write attempt. + ``default_permission_profile`` (e.g. "workspace-write", "read-only", "full-access") is written + as the top-level ``default_permissions`` string so the user is not prompted on every write. + Built-in profile names start with ":" (":workspace-write", ...) and are normalized to that + form; the ``[permissions]`` table is for *user-defined* named profiles, not what we want. """ out = [MIGRATION_MARKER] if not servers and not plugins and not default_permission_profile: @@ -228,121 +188,76 @@ def render_codex_toml_section( return "\n".join(out) + "\n" if default_permission_profile: - # Codex's config schema: `default_permissions` is a top-level - # string referencing a profile name. Built-in profile names start - # with ":" (":workspace-write", ":read-only", ":full-access"). The - # [permissions] table is for *user-defined* named profiles with - # structured fields — not what we want. - normalized = ( - default_permission_profile - if default_permission_profile.startswith(":") - else f":{default_permission_profile}" - ) - out.append("") - out.append(f"default_permissions = {_format_toml_value(normalized)}") - - if servers: - for name in sorted(servers.keys()): - cfg = servers[name] - out.append("") - out.append(f"[mcp_servers.{_quote_key(name)}]") - for k, v in cfg.items(): - out.append(f"{_quote_key(k)} = {_format_toml_value(v)}") - - if plugins: - for plugin in sorted(plugins, key=lambda p: f"{p.get('name','')}@{p.get('marketplace','')}"): - name = plugin.get("name") or "" - marketplace = plugin.get("marketplace") or "openai-curated" - enabled = bool(plugin.get("enabled", True)) - qualified = f"{name}@{marketplace}" - out.append("") - out.append(f'[plugins.{_quote_key(qualified)}]') - out.append(f"enabled = {_format_toml_value(enabled)}") - - out.append("") - out.append(MIGRATION_END_MARKER) + profile = default_permission_profile + normalized = profile if profile.startswith(":") else f":{profile}" + out += ["", f"default_permissions = {_format_toml_value(normalized)}"] + for name in sorted(servers or ()): + out += ["", f"[mcp_servers.{_quote_key(name)}]"] + out += [f"{_quote_key(k)} = {_format_toml_value(v)}" for k, v in servers[name].items()] + plugin_sort_key = lambda p: f"{p.get('name','')}@{p.get('marketplace','')}" # noqa: E731 + for plugin in sorted(plugins or (), key=plugin_sort_key): + qualified = f"{plugin.get('name') or ''}@{plugin.get('marketplace') or 'openai-curated'}" + out += ["", f'[plugins.{_quote_key(qualified)}]', + f"enabled = {_format_toml_value(bool(plugin.get('enabled', True)))}"] + out += ["", MIGRATION_END_MARKER] return "\n".join(out) + "\n" def _insert_managed_block_at_top_level(user_text: str, managed_block: str) -> str: - """Insert Hermes' managed Codex TOML block while keeping root keys root-scoped. + """Insert the managed block before the user's first table header. - TOML has no syntax to return to the document root after a table header. Therefore appending a - root key like `default_permissions = ...` after a user table such as `[features]` actually - creates `features.default_permissions`, which Codex rejects. + TOML has no syntax to return to the document root after a table header, so appending a root + key like ``default_permissions = ...`` after a user ``[features]`` table would actually create + ``features.default_permissions``, which codex rejects. """ if not user_text.strip(): return managed_block lines = user_text.splitlines(keepends=True) - first_table_idx: Optional[int] = None - for idx, line in enumerate(lines): - stripped = line.lstrip() - if stripped.startswith("["): - first_table_idx = idx - break - + first_table_idx = next((i for i, ln in enumerate(lines) if ln.lstrip().startswith("[")), None) if first_table_idx is None: prefix = user_text.rstrip("\n") return f"{prefix}\n\n{managed_block}" if prefix else managed_block prefix = "".join(lines[:first_table_idx]).rstrip("\n") suffix = "".join(lines[first_table_idx:]).lstrip("\n") - if prefix: - return f"{prefix}\n\n{managed_block}\n{suffix}" - return f"{managed_block}\n{suffix}" + return f"{prefix}\n\n{managed_block}\n{suffix}" if prefix else f"{managed_block}\n{suffix}" def _strip_unmanaged_plugin_tables(toml_text: str) -> str: """Remove ``[plugins."@"]`` tables that live OUTSIDE the managed block. - Codex itself writes these tables when the user runs ``codex plugins enable`` directly (i.e. - before Hermes' migrate has ever touched the file). When we later run migrate, - ``_query_codex_plugins()`` reports the same plugins via the live ``plugin/list`` RPC and we re- - emit them inside the managed block. - - We own the ``[plugins.*]`` namespace once migrate has run, so dropping any pre-existing - ``[plugins.*]`` tables is safe: ``plugin/list`` is the source of truth for what's actually - installed. + Codex writes these when the user runs ``codex plugins enable`` directly, before migrate has + ever touched the file. Once migrate runs, ``plugin/list`` is the source of truth for what is + installed and we own the ``[plugins.*]`` namespace, so dropping pre-existing tables is safe — + and necessary, since re-emitting them inside the managed block would otherwise produce + duplicate-table-header parse errors on codex's next startup. """ - lines = toml_text.splitlines(keepends=True) out: list[str] = [] in_plugin_table = False - for line in lines: + for line in toml_text.splitlines(keepends=True): stripped = line.lstrip() - # Only treat a line as a table header when it has the shape - # ``[...]`` (optionally followed by a comment). Multi-line array - # continuations like ``["nested"],`` also start with ``[`` after - # lstrip but are not headers — without this guard they would - # falsely flip ``in_plugin_table`` to False mid-table and leak + # Only a real ``[...]`` header flips state: multi-line array continuations like + # ``["nested"],`` also start with ``[`` but must not end the table early and leak # array fragments into the output. if _looks_like_table_header(stripped): in_plugin_table = stripped.startswith("[plugins.") - if in_plugin_table: - continue - if in_plugin_table: - # Swallow keys/comments/blanks until the next table header. - continue - out.append(line) + if not in_plugin_table: # swallow keys/comments/blanks until the next table header + out.append(line) return "".join(out) def _looks_like_table_header(stripped_line: str) -> bool: - """Return True if ``stripped_line`` is a TOML table header. - - A header has the shape ``[name]`` or ``[[name]]`` (array-of-tables), optionally followed by a - comment. The closing ``]`` (or ``]]``) must appear on the same line, and no key-assignment ``=`` - can precede it. - """ + """True for ``[name]`` / ``[[name]]`` headers (optional trailing comment); the closing ``]`` + must be on the same line and no ``=`` may precede it (``key = [x]`` is not a header).""" if not stripped_line.startswith("["): return False - # Drop trailing comment so e.g. ``[features] # note`` still matches. head = stripped_line.split("#", 1)[0].rstrip() - if not head.endswith("]"): - return False - # ``key = [x]`` would have an ``=`` before the bracket; a header doesn't. - bracket_idx = head.index("]") - return "=" not in head[: bracket_idx + 1] + return head.endswith("]") and "=" not in head[: head.index("]") + 1] + + +# Section headers an old-format managed block (no end marker) may contain. +_LEGACY_MANAGED_HEADERS = ("[mcp_servers", "[plugins", "[permissions]", "[permissions.") def _strip_existing_managed_block(toml_text: str) -> str: @@ -350,38 +265,24 @@ def _strip_existing_managed_block(toml_text: str) -> str: The managed section spans MIGRATION_MARKER through MIGRATION_END_MARKER inclusive; user text outside it is preserved verbatim. If the start marker exists without an end marker (older - writers), fall back to swallowing lines until a section that is not [mcp_servers.*]/ - [plugins.*]/[permissions]/``default_permissions =``, so prior-version configs still migrate. + writers), swallow lines until a section that is not [mcp_servers.*]/[plugins.*]/[permissions] + so prior-version configs still migrate. """ - lines = toml_text.splitlines(keepends=True) out: list[str] = [] in_managed = False - saw_end_marker = False - for line in lines: - line_stripped_nl = line.rstrip("\n") - if line_stripped_nl == MIGRATION_MARKER: + for line in toml_text.splitlines(keepends=True): + bare = line.rstrip("\n") + if bare == MIGRATION_MARKER: in_managed = True - saw_end_marker = False continue if in_managed: - if line_stripped_nl == MIGRATION_END_MARKER: + if bare == MIGRATION_END_MARKER: in_managed = False - saw_end_marker = True continue stripped = line.lstrip() - if not saw_end_marker and stripped.startswith("[") and not ( - stripped.startswith("[mcp_servers") - or stripped.startswith("[plugins") - or stripped.startswith("[permissions]") - or stripped.startswith("[permissions.") - ): - # Old-format managed block without end marker: bail back - # to user content as soon as we see a non-managed section. - in_managed = False - out.append(line) + if not stripped.startswith("[") or stripped.startswith(_LEGACY_MANAGED_HEADERS): continue - # Otherwise swallow the line. - continue + in_managed = False # legacy block: first non-managed section ends it out.append(line) return "".join(out) @@ -395,6 +296,10 @@ def _query_codex_plugins( Spawns ``codex app-server`` briefly, sends initialize + plugin/list and keeps installed=true. Returns ``(plugins, error)``; any failure (not installed, RPC error, timeout) yields ``([], error)`` and is non-fatal — MCP servers and permissions still write through. + + Plugins codex reports as unavailable (broken install, missing OAuth, removed from marketplace) + are skipped: we write codex's config.toml directly, so a broken plugin would surface as a + codex error on the user's first turn. ``enabled`` is carried forward as codex reports it. """ try: from agent.transports.codex_app_server import CodexAppServerClient @@ -402,132 +307,106 @@ def _query_codex_plugins( return [], f"transport unavailable: {exc}" try: - with CodexAppServerClient( - codex_home=str(codex_home) if codex_home else None - ) as client: + with CodexAppServerClient(codex_home=str(codex_home) if codex_home else None) as client: client.initialize(client_name="hermes-migration") resp = client.request("plugin/list", {}, timeout=timeout) except Exception as exc: return [], f"plugin/list query failed: {exc}" - out: list[dict] = [] - seen: set[tuple[str, str]] = set() marketplaces = resp.get("marketplaces") or [] if not isinstance(marketplaces, list): return [], "plugin/list response missing 'marketplaces'" + out: list[dict] = [] + seen: set[tuple[str, str]] = set() for marketplace in marketplaces: if not isinstance(marketplace, dict): continue market_name = str(marketplace.get("name") or "openai-curated") plugins = marketplace.get("plugins") or [] - if not isinstance(plugins, list): - continue - for plugin in plugins: - if not isinstance(plugin, dict): + for plugin in plugins if isinstance(plugins, list) else (): + if not isinstance(plugin, dict) or not plugin.get("installed", False): continue - installed = bool(plugin.get("installed", False)) - if not installed: - continue - # Skip plugins codex itself reports as unavailable (broken - # install, missing OAuth, removed from marketplace, etc.). - # Cf. openclaw/openclaw#80815 — OpenClaw learned to gate - # migration on app readiness to avoid writing config that - # would fail at activation time. Our migration writes to - # codex's config.toml directly, so a broken plugin would - # surface as a codex error on first use. Skipping it here - # keeps the migrated config clean and the user's first - # codex turn from failing. availability = str(plugin.get("availability") or "").upper() if availability and availability != "AVAILABLE": - logger.debug( - "skipping plugin %s: availability=%s", - plugin.get("name"), availability, - ) + logger.debug("skipping plugin %s: availability=%s", plugin.get("name"), + availability) continue name = str(plugin.get("name") or "") - if not name: + if not name or (name, market_name) in seen: continue - key = (name, market_name) - if key in seen: - continue - seen.add(key) - # Carry forward whatever 'enabled' codex reports — defaults to - # true for installed plugins. This is the same shape OpenClaw - # writes when migrating native codex plugins. - out.append({ - "name": name, - "marketplace": market_name, - "enabled": bool(plugin.get("enabled", True)), - }) + seen.add((name, market_name)) + out.append({"name": name, "marketplace": market_name, + "enabled": bool(plugin.get("enabled", True))}) return out, None +# pytest tempdir shapes: ``pytest-of-/pytest-/``, macOS ``/private/var/folders/…/T``. +_TEST_TEMPDIR_NEEDLES = ("pytest-of-", "/pytest-", "/tmp/pytest", "/private/var/folders/") + + def _looks_like_test_tempdir(path: str) -> bool: """Heuristic: does ``path`` look like a pytest/transient tempdir? - pytest tempdirs (``pytest-of-/pytest-/``, on macOS under ``/private/var/folders/…/T``) - are reaped between sessions; a HERMES_HOME pointing there burned into ``~/.codex/config.toml`` - makes every codex-routed call fail silently once GC'd. Err on refusing: a false positive is far - less harmful than silently bricking codex's tool surface. + Such dirs are reaped between sessions; a HERMES_HOME pointing there burned into + ``~/.codex/config.toml`` makes every codex-routed call fail silently once GC'd. Err on + refusing: a false positive is far less harmful than silently bricking codex's tool surface. """ - if not path: - return False - needles = ( - "pytest-of-", - "/pytest-", - "/tmp/pytest", - "/private/var/folders/", # macOS tempdir root - ) - normalized = path.lower() - return any(needle in normalized for needle in needles) + return bool(path) and any(needle in path.lower() for needle in _TEST_TEMPDIR_NEEDLES) def _build_hermes_tools_mcp_entry() -> dict: - """Build the codex stdio-transport entry that launches Hermes' own tool surface as an MCP server. - Codex's subprocess will call back into this for browser/web/delegate_task/vision/memory/skills - tools. + """Build the codex stdio entry that launches Hermes' own tool surface as an MCP server, so the + codex subprocess can call back for browser/web/delegate_task/vision/memory/skills tools. + + HERMES_HOME passes through IF SET so the subprocess sees the same config/auth/sessions DB as + the parent CLI. Read from os.environ (not get_hermes_home()) on purpose: when unset, the codex + subprocess should inherit whatever HERMES_HOME its launcher sets at runtime (systemd unit, + gateway, kanban dispatcher, custom shell) rather than a migrate-time default burned into + config.toml, which would pin it to the wrong profile. The pytest-tempdir guard stops a sibling + test's ``monkeypatch.setenv("HERMES_HOME", tmp_path)`` from leaking a transient dir into the + user's real config.toml. PYTHONPATH passes through so a worktree-launched hermes finds the + branch's modules instead of the installed package. """ import sys env: dict[str, str] = {} - # HERMES_HOME passes through IF SET so the MCP subprocess sees the same - # config / auth / sessions DB as the parent CLI. Read from os.environ - # (not get_hermes_home()) on purpose: when the env var is unset we want - # codex's subprocess to inherit whatever HERMES_HOME its launcher sets - # at runtime (systemd unit, gateway, kanban dispatcher, custom shell), - # rather than burning the migrate-time resolved default into config.toml - # — that would override the launcher's HERMES_HOME and pin the subprocess - # to the wrong profile. - # - # The pytest-tempdir guard below catches the issue #26250 Bug C scenario: - # a sibling test's monkeypatch.setenv("HERMES_HOME", tmp_path) would - # otherwise leak a transient pytest tempdir into the user's real - # ~/.codex/config.toml and silently brick codex once the tempdir is GC'd. hermes_home = os.environ.get("HERMES_HOME") or "" - if hermes_home and _looks_like_test_tempdir(hermes_home): - hermes_home = "" - if hermes_home: + if hermes_home and not _looks_like_test_tempdir(hermes_home): env["HERMES_HOME"] = hermes_home - # PYTHONPATH passes through so a worktree-launched hermes finds the - # branch's modules instead of the installed package. - pythonpath = os.environ.get("PYTHONPATH") - if pythonpath: - env["PYTHONPATH"] = pythonpath + if os.environ.get("PYTHONPATH"): + env["PYTHONPATH"] = os.environ["PYTHONPATH"] # Quiet mode + redaction defaults so the MCP wire stays clean. env["HERMES_QUIET"] = "1" env["HERMES_REDACT_SECRETS"] = env.get("HERMES_REDACT_SECRETS", "true") - out: dict[str, Any] = { + return { "command": sys.executable, "args": ["-m", "agent.transports.hermes_tools_mcp_server"], + "env": env, + # Generous timeouts — browser_navigate or delegate_task can take a while. + "startup_timeout_sec": 30.0, + "tool_timeout_sec": 600.0, } - if env: - out["env"] = env - # Generous timeouts — browser_navigate or delegate_task can take a - # while; we don't want codex's MCP client to give up too early. - out["startup_timeout_sec"] = 30.0 - out["tool_timeout_sec"] = 600.0 - return out + + +def _write_atomic(target: Path, text: str) -> None: + """Write via a same-directory temp file + rename (atomic on POSIX, ReplaceFile on Windows) so a + crash mid-write never leaves a half-written config.toml that codex would refuse to load.""" + import tempfile + + tmp_fd, tmp_path_str = tempfile.mkstemp(prefix=".config.toml.", dir=str(target.parent)) + tmp_path = Path(tmp_path_str) + try: + with os.fdopen(tmp_fd, "w", encoding="utf-8") as fh: + fh.write(text) + tmp_path.replace(target) + except Exception: + try: + if tmp_path.exists(): + tmp_path.unlink() + except Exception: + pass + raise def migrate( @@ -541,11 +420,12 @@ def migrate( ) -> MigrationReport: """Translate Hermes mcp_servers config + Codex curated plugins into ~/.codex/config.toml. - ``discover_plugins`` spawns the live codex CLI (set False in tests). - ``default_permission_profile`` (default ":workspace"; built-ins carry a leading ":", user - profiles do not; None leaves codex's read-only default) avoids an approval prompt on every - write. ``expose_hermes_tools`` registers Hermes' own tool surface as an MCP server so the codex - subprocess can call back for tools it lacks. + ``discover_plugins`` spawns the live codex CLI (set False in tests); discovery is best-effort + and never blocks the migration. ``default_permission_profile`` (default ":workspace"; built-ins + carry a leading ":", user profiles do not; None leaves codex's read-only default) avoids an + approval prompt on every write. ``expose_hermes_tools`` registers Hermes' own tool surface + (agent/transports/hermes_tools_mcp_server.py, launched on demand by codex over stdio) as an MCP + server so the codex subprocess can call back for tools it lacks. """ report = MigrationReport(dry_run=dry_run) codex_home = codex_home or Path.home() / ".codex" @@ -554,64 +434,44 @@ def migrate( hermes_servers = (hermes_config or {}).get("mcp_servers") or {} if not isinstance(hermes_servers, dict): - report.errors.append( - "mcp_servers in Hermes config is not a dict; cannot migrate." - ) + report.errors.append("mcp_servers in Hermes config is not a dict; cannot migrate.") return report translated: dict[str, dict] = {} - for name, cfg in hermes_servers.items(): - out, skipped = _translate_one_server(str(name), cfg or {}) + for raw_name, cfg in hermes_servers.items(): + name = str(raw_name) + out, skipped = _translate_one_server(name, cfg or {}) if out is None: - report.errors.append( - f"server {name!r} skipped: {', '.join(skipped) or 'no transport configured'}" - ) + reason = ", ".join(skipped) or "no transport configured" + report.errors.append(f"server {raw_name!r} skipped: {reason}") continue - translated[str(name)] = out + translated[name] = out if skipped: - report.skipped_keys_per_server[str(name)] = skipped - report.migrated.append(str(name)) + report.skipped_keys_per_server[name] = skipped + report.migrated.append(name) - # Discover installed Codex curated plugins. Best-effort — never blocks - # the migration if codex is unreachable or the RPC fails. plugins: list[dict] = [] plugin_query_succeeded = False if discover_plugins and not dry_run: plugins, plugin_err = _query_codex_plugins(codex_home=codex_home) if plugin_err: report.plugin_query_error = plugin_err - else: - # plugin/list returned authoritatively (even if the list is empty). - # That means we own [plugins.*] for this re-render and can safely - # strip any pre-existing tables outside the managed block. - plugin_query_succeeded = True - for p in plugins: - report.migrated_plugins.append(f"{p['name']}@{p['marketplace']}") + # An authoritative plugin/list (even an empty one) means we own [plugins.*] for this + # re-render and may strip pre-existing tables outside the managed block. + plugin_query_succeeded = not plugin_err + report.migrated_plugins += [f"{p['name']}@{p['marketplace']}" for p in plugins] - # Track whether we wrote a default permission profile so the report - # surfaces it to the user. if default_permission_profile: report.wrote_permissions_default = default_permission_profile - - # Inject Hermes' own tool surface as an MCP server so the spawned - # codex subprocess can call back into Hermes for the tools codex - # doesn't ship with — web_search, browser_*, delegate_task, vision, - # memory, skills, session_search, image_generate, text_to_speech. - # The server itself is agent/transports/hermes_tools_mcp_server.py - # and is launched on demand by codex (stdio MCP). if expose_hermes_tools: translated["hermes-tools"] = _build_hermes_tools_mcp_entry() if "hermes-tools" not in report.migrated: report.migrated.append("hermes-tools") - # Build the new managed block managed_block = render_codex_toml_section( - translated, plugins=plugins, - default_permission_profile=default_permission_profile, + translated, plugins=plugins, default_permission_profile=default_permission_profile, ) - - # Read existing codex config if any, strip the prior managed block, - # append the new one. + new_text = managed_block if target.exists(): try: existing = target.read_text(encoding="utf-8") @@ -619,44 +479,15 @@ def migrate( report.errors.append(f"could not read {target}: {exc}") return report without_managed = _strip_existing_managed_block(existing) - # Bug B: when plugin/list ran authoritatively, codex's own - # [plugins."@"] tables outside our managed block - # would survive _strip_existing_managed_block and then collide with - # the entries we re-emit inside the managed block — producing - # duplicate-table-header parse errors on codex's next startup. Drop - # those pre-existing tables since plugin/list is the source of truth. if plugin_query_succeeded: without_managed = _strip_unmanaged_plugin_tables(without_managed) new_text = _insert_managed_block_at_top_level(without_managed, managed_block) - else: - new_text = managed_block if dry_run: return report - try: codex_home.mkdir(parents=True, exist_ok=True) - # Atomic write: write to a temp file in the same directory then - # rename. Same-directory rename is atomic on POSIX and ReplaceFile - # on Windows. Avoids leaving a half-written config.toml that - # codex would refuse to load if we crash mid-write. - import tempfile - tmp_fd, tmp_path_str = tempfile.mkstemp( - prefix=".config.toml.", dir=str(codex_home) - ) - tmp_path = Path(tmp_path_str) - try: - with os.fdopen(tmp_fd, "w", encoding="utf-8") as fh: - fh.write(new_text) - tmp_path.replace(target) - except Exception: - # Clean up the temp file if the rename didn't happen. - try: - if tmp_path.exists(): - tmp_path.unlink() - except Exception: - pass - raise + _write_atomic(target, new_text) report.written = True except Exception as exc: report.errors.append(f"could not write {target}: {exc}") From 952037ed25fdbb6beec00a80a0b55644812581a7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:31:08 -0700 Subject: [PATCH 08/13] refactor(hermes_cli): collapse claw confirm/report printing into shared helpers --- hermes_cli/claw.py | 363 +++++++++++++++++++-------------------------- 1 file changed, 151 insertions(+), 212 deletions(-) diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py index 5dd311f189..089348e012 100644 --- a/hermes_cli/claw.py +++ b/hermes_cli/claw.py @@ -12,9 +12,8 @@ from typing import Callable, Optional from hermes_cli.config import get_hermes_home, get_config_path, load_config, save_config from hermes_constants import get_optional_skills_dir -from hermes_cli.setup import ( - Colors, color, print_header, print_info, print_success, print_error, prompt_yes_no, -) +from hermes_cli.setup import (Colors, color, print_header, print_info, print_success, print_error, + prompt_yes_no) logger = logging.getLogger(__name__) @@ -28,9 +27,8 @@ _OPENCLAW_SCRIPT_INSTALLED = get_hermes_home() / "skills" / _SCRIPT_REL # Known OpenClaw directory names (current + legacy) _OPENCLAW_DIR_NAMES = (".openclaw", ".clawdbot", ".moltbot") -# `hermes claw migrate` flags and their defaults. Secrets are never included implicitly — they -# must be requested via --migrate-secrets even under --preset full (mirrors OpenClaw's two-phase -# migrate-hermes posture), so a full run cannot silently import API keys. +# `hermes claw migrate` flags/defaults. Secrets are never included implicitly: --migrate-secrets +# is required even under --preset full (OpenClaw's two-phase posture); no silent API-key import. _MIGRATE_ARG_DEFAULTS = ( ("source", None), ("dry_run", False), ("preset", "full"), ("overwrite", False), ("migrate_secrets", False), ("workspace_target", None), ("skill_conflict", "skip"), @@ -44,8 +42,11 @@ _REPORT_REASON_GROUPS = ( ("skipped", " ─ Skipped:", Colors.DIM, ""), ("error", " ✗ Errors:", Colors.RED, "unknown error"), ) +# Summary-line counters after the migrated count: (summary key, label). +_SUMMARY_COUNT_LABELS = (("conflict", "conflict(s)"), ("skipped", "skipped"), ("error", "error(s)")) -# Workspace marker files listed by `hermes claw cleanup` (name, display label, presence check). +# A subdir with any of these files is a workspace; the labelled subset is listed by cleanup. +_WORKSPACE_MARKERS = ("todo.json", "SOUL.md", "MEMORY.md", "USER.md") _WORKSPACE_ITEM_LABELS = ( ("todo.json", "todo.json", Path.exists), ("sessions", "sessions/", Path.is_dir), ("SOUL.md", "SOUL.md", Path.exists), ("MEMORY.md", "MEMORY.md", Path.exists), @@ -60,52 +61,51 @@ def _print_banner(title: str) -> None: print(color(line, Colors.MAGENTA)) -def _error_block(headline: str, *lines: str) -> None: - """Print a blank line, an error headline, then each line as info.""" +def _info(*lines: str) -> None: + """print_info each line; an empty string prints a bare blank line instead.""" + for line in lines: + print_info(line) if line else print() + + +def _error_block(headline: str, *lines: str, debug: Optional[str] = None) -> None: + """Print a blank line, an error headline, then info lines; ``debug`` logs the traceback.""" print() print_error(headline) - for line in lines: - print_info(line) + _info(*lines) + if debug: + logger.debug(debug, exc_info=True) def _confirm(auto_yes: bool, question: str, *, default: bool, declined: str, non_tty: Optional[tuple[str, ...]] = None) -> Optional[bool]: - """Ask to proceed unless --yes; print ``declined`` and return False when the user says no. - - With ``non_tty`` set, a non-interactive stdin prints those lines and returns None instead of - prompting (callers treat None as "don't apply" but never as an explicit refusal). - """ + """Ask to proceed unless --yes; print ``declined`` and return False on an explicit no. With + ``non_tty`` set, a non-interactive stdin prints those lines and returns None (not a refusal).""" if auto_yes: return True if non_tty is not None and not sys.stdin.isatty(): - for line in non_tty: - print_info(line) + _info(*non_tty) return None - if prompt_yes_no(question, default=default): - return True - print_info(declined) - return False + answer = prompt_yes_no(question, default=default) + if not answer: + print_info(declined) + return answer -def _warn_token_conflict(auto_yes: bool, headline: str, lines: list[str], declined: str, - non_tty: Optional[tuple[str, ...]] = None) -> None: - """Print a bot-token conflict warning and exit 0 if the user explicitly declines to continue.""" - _error_block(headline, *lines) +def _warn_running(auto_yes: bool, headline: str, running: list[str], lines: tuple[str, ...], + question: str, declined: str, non_tty: Optional[tuple[str, ...]] = None): + """Print the 'still running' block (headline, `* detail` per process, advice) then _confirm.""" + _error_block(headline, *(f" * {detail}" for detail in running), *lines) print() - if _confirm(auto_yes, "Continue anyway?", default=False, declined=declined, - non_tty=non_tty) is False: - sys.exit(0) + return _confirm(auto_yes, question, default=False, declined=declined, non_tty=non_tty) def _detect_openclaw_processes() -> list[str]: """Detect running OpenClaw processes and services.""" found: list[str] = [] if sys.platform == "win32": - # bounded_probe_run: a plain subprocess.run(timeout=...) can hang forever on Windows in - # post-timeout cleanup when a conhost.exe descendant holds duplicated pipe handles — and - # a hang is not an exception, so the try/except here can't save the caller. + # bounded_probe_run: plain subprocess.run(timeout=...) can hang forever on Windows when a + # conhost.exe descendant holds duplicated pipe handles — a hang is not an exception. from hermes_cli._subprocess_compat import bounded_probe_run - try: for exe in ("openclaw.exe", "clawd.exe"): result = bounded_probe_run(["tasklist", "/FI", f"IMAGENAME eq {exe}"], timeout=5) @@ -118,8 +118,8 @@ def _detect_openclaw_processes() -> list[str]: 'Select-Object -First 1 ProcessId' ) result = bounded_probe_run(["powershell", "-NoProfile", "-Command", ps_cmd], timeout=5) - if result is not None and (result.stdout or "").strip(): - pid = result.stdout.strip() + pid = (result.stdout or "").strip() if result is not None else "" + if pid: found.append(f"node.exe process with openclaw in command line (PID {pid})") except Exception: pass @@ -145,37 +145,34 @@ def _posix_probe(cmd: list[str], timeout: int): def _warn_if_openclaw_running(auto_yes: bool) -> None: """Warn if OpenClaw is still running: Telegram/Discord/Slack allow one session per bot token.""" running = _detect_openclaw_processes() - if running: - _warn_token_conflict( - auto_yes, "OpenClaw appears to be running:", - [f" * {detail}" for detail in running] + [ - "Messaging platforms (Telegram, Discord, Slack) only allow one " - "active session per bot token. If you continue, both OpenClaw and " - "Hermes may try to use the same token, causing disconnects.", - "Recommendation: stop OpenClaw before migrating."], - declined="Migration cancelled. Stop OpenClaw and try again.", - non_tty=("Non-interactive session — continuing to preview only.",), - ) + if running and _warn_running( + auto_yes, "OpenClaw appears to be running:", running, + ("Messaging platforms (Telegram, Discord, Slack) only allow one " + "active session per bot token. If you continue, both OpenClaw and " + "Hermes may try to use the same token, causing disconnects.", + "Recommendation: stop OpenClaw before migrating."), + "Continue anyway?", declined="Migration cancelled. Stop OpenClaw and try again.", + non_tty=("Non-interactive session — continuing to preview only.",), + ) is False: + sys.exit(0) def _warn_if_gateway_running(auto_yes: bool) -> None: """Warn if a Hermes gateway has connected platforms (token conflicts, e.g. Telegram 409).""" from gateway.status import get_running_pid, read_runtime_status - if not get_running_pid(): - return - platforms = (read_runtime_status() or {}).get("platforms") or {} + platforms = ((read_runtime_status() or {}).get("platforms") or {}) if get_running_pid() else {} connected = [name for name, info in platforms.items() if isinstance(info, dict) and info.get("state") == "connected"] - if connected: - _warn_token_conflict( - auto_yes, "Hermes gateway is running with active connections: " + ", ".join(connected), - ["Migrating bot tokens while the gateway is active will cause " - "conflicts (Telegram, Discord, and Slack only allow one active " - "session per token).", - "Recommendation: stop the gateway first with 'hermes gateway stop'."], - declined="Migration cancelled. Stop the gateway and try again.", - ) + if connected and _warn_running( + auto_yes, "Hermes gateway is running with active connections: " + ", ".join(connected), [], + ("Migrating bot tokens while the gateway is active will cause " + "conflicts (Telegram, Discord, and Slack only allow one active " + "session per token).", + "Recommendation: stop the gateway first with 'hermes gateway stop'."), + "Continue anyway?", declined="Migration cancelled. Stop the gateway and try again.", + ) is False: + sys.exit(0) def _find_migration_script() -> Path | None: @@ -188,9 +185,8 @@ def _load_migration_module(script_path: Path): spec = importlib.util.spec_from_file_location("openclaw_to_hermes", script_path) if spec is None or spec.loader is None: return None - mod = importlib.util.module_from_spec(spec) # Register in sys.modules so @dataclass can resolve the module (Python 3.11+ requires this). - sys.modules[spec.name] = mod + mod = sys.modules[spec.name] = importlib.util.module_from_spec(spec) try: spec.loader.exec_module(mod) except Exception: @@ -211,16 +207,11 @@ def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]: children = sorted(source_dir.iterdir()) except OSError: children = [] - candidates += [ - (child / name, "Workspace") - for child in children if child.is_dir() and not child.name.startswith(".") - for name in ("todo.json", "sessions", "logs", "memory") - ] - return [ - (p, f"{scope} {'directory' if p.is_dir() else 'file'}: " - f"{p.relative_to(source_dir).as_posix()}") - for p, scope in candidates if p.exists() - ] + candidates += [(child / name, "Workspace") for child in children + if child.is_dir() and not child.name.startswith(".") + for name in ("todo.json", "sessions", "logs", "memory")] + return [(p, f"{scope} {'directory' if p.is_dir() else 'file'}: " + f"{p.relative_to(source_dir).as_posix()}") for p, scope in candidates if p.exists()] def _archive_directory(source_dir: Path, dry_run: bool = False) -> Path: @@ -255,33 +246,26 @@ def _cmd_migrate(args): opts.source_dir = (Path(opts.source) if opts.source else next(iter(_find_openclaw_dirs()), Path.home() / ".openclaw")) _print_banner("OpenClaw Migration") - if not opts.source_dir.is_dir(): - _error_block( + return _error_block( f"OpenClaw directory not found: {opts.source_dir}", "Make sure your OpenClaw installation is at the expected path.", - "You can specify a custom path: hermes claw migrate --source /path/to/.openclaw", - ) - return + "You can specify a custom path: hermes claw migrate --source /path/to/.openclaw") script_path = _find_migration_script() if not script_path: - _error_block("Migration script not found.", "Expected at one of:", f" {_OPENCLAW_SCRIPT}", - f" {_OPENCLAW_SCRIPT_INSTALLED}", - "Make sure the openclaw-migration skill is installed.") - return - + return _error_block( + "Migration script not found.", "Expected at one of:", f" {_OPENCLAW_SCRIPT}", + f" {_OPENCLAW_SCRIPT_INSTALLED}", + "Make sure the openclaw-migration skill is installed.") opts.hermes_home = get_hermes_home() print() print_header("Migration Settings") - print_info(f"Source: {opts.source_dir}") - print_info(f"Target: {opts.hermes_home}") - print_info(f"Preset: {opts.preset}") - print_info(f"Overwrite: {'yes' if opts.overwrite else 'no (skip conflicts)'}") - print_info(f"Secrets: {'yes (allowlisted only)' if opts.migrate_secrets else 'no'}") - if opts.skill_conflict != "skip": - print_info(f"Skill conflicts: {opts.skill_conflict}") - if opts.workspace_target: - print_info(f"Workspace: {opts.workspace_target}") + _info(f"Source: {opts.source_dir}", f"Target: {opts.hermes_home}", + f"Preset: {opts.preset}", + f"Overwrite: {'yes' if opts.overwrite else 'no (skip conflicts)'}", + f"Secrets: {'yes (allowlisted only)' if opts.migrate_secrets else 'no'}", + *([f"Skill conflicts: {opts.skill_conflict}"] if opts.skill_conflict != "skip" else []), + *([f"Workspace: {opts.workspace_target}"] if opts.workspace_target else [])) print() # Migrating tokens while OpenClaw or the gateway is active causes conflicts (e.g. Telegram 409). _warn_if_openclaw_running(opts.yes) @@ -289,7 +273,6 @@ def _cmd_migrate(args): # Ensure config.yaml exists before migration tries to read it if not get_config_path().exists(): save_config(load_config()) - run_migrator = _load_migrator(script_path, opts) if run_migrator is None or not _preview_migration(run_migrator, opts): return @@ -306,24 +289,18 @@ def _load_migrator(script_path: Path, opts: SimpleNamespace) -> Optional[Callabl try: mod = _load_migration_module(script_path) except Exception as e: - _error_block(f"Could not load migration script: {e}") - logger.debug("OpenClaw migration error", exc_info=True) - return None + return _error_block(f"Could not load migration script: {e}", + debug="OpenClaw migration error") if mod is None: - print_error("Could not load migration script.") - return None + return print_error("Could not load migration script.") selected = mod.resolve_selected_options(None, None, preset=opts.preset) ws_target = Path(opts.workspace_target).resolve() if opts.workspace_target else None - - def _run_migrator(execute: bool) -> dict: - return mod.Migrator( - source_root=opts.source_dir.resolve(), target_root=opts.hermes_home.resolve(), - execute=execute, workspace_target=ws_target, overwrite=opts.overwrite, - migrate_secrets=opts.migrate_secrets, output_dir=None, selected_options=selected, - preset_name=opts.preset, skill_conflict_mode=opts.skill_conflict, - ).migrate() - - return _run_migrator + return lambda execute: mod.Migrator( + source_root=opts.source_dir.resolve(), target_root=opts.hermes_home.resolve(), + execute=execute, workspace_target=ws_target, overwrite=opts.overwrite, + migrate_secrets=opts.migrate_secrets, output_dir=None, selected_options=selected, + preset_name=opts.preset, skill_conflict_mode=opts.skill_conflict, + ).migrate() def _preview_migration(run_migrator: Callable[[bool], dict], opts: SimpleNamespace) -> bool: @@ -331,113 +308,88 @@ def _preview_migration(run_migrator: Callable[[bool], dict], opts: SimpleNamespa try: preview_report = run_migrator(False) except Exception as e: - _error_block(f"Migration preview failed: {e}") - logger.debug("OpenClaw migration preview error", exc_info=True) + _error_block(f"Migration preview failed: {e}", debug="OpenClaw migration preview error") return False - preview_summary = preview_report.get("summary", {}) - preview_count = preview_summary.get("migrated", 0) - preview_conflicts = preview_summary.get("conflict", 0) - + summary = preview_report.get("summary", {}) + count, conflicts = summary.get("migrated", 0), summary.get("conflict", 0) # "Nothing to migrate" means nothing migrated AND nothing blocked by conflicts. With # conflicts, still show the plan and surface the --overwrite guidance instead of bailing. - if preview_count == 0 and preview_conflicts == 0: - print() - print_info("Nothing to migrate from OpenClaw.") - _print_migration_report(preview_report, dry_run=True) - return False print() - print_header( - f"Migration Preview — {preview_count} item(s) would be imported" - if preview_count > 0 - else f"Migration Preview — {preview_conflicts} conflict(s), nothing would be imported" - ) - print_info("No changes have been made yet. Review the list below:") + if count == 0 and conflicts == 0: + print_info("Nothing to migrate from OpenClaw.") + else: + what = (f"{count} item(s) would be imported" if count > 0 + else f"{conflicts} conflict(s), nothing would be imported") + print_header(f"Migration Preview — {what}") + print_info("No changes have been made yet. Review the list below:") _print_migration_report(preview_report, dry_run=True) - if opts.dry_run: + if opts.dry_run or (count == 0 and conflicts == 0): return False - # Modelled on OpenClaw's assertConflictFreePlan(): apply is a safe no-op on conflicts unless # the user opts in to overwriting — otherwise "yes, proceed" would silently skip every # conflicting item. - if preview_conflicts > 0 and not opts.overwrite: + if conflicts > 0 and not opts.overwrite: _error_block( - f"Plan has {preview_conflicts} conflict(s). Refusing to apply.", + f"Plan has {conflicts} conflict(s). Refusing to apply.", "Each conflict is an item whose target already exists in ~/.hermes/. " "Re-run with --overwrite to replace conflicting targets (item-level " "backups are written to the migration report directory).", - "Or re-run with --dry-run to review the full plan.", - ) + "Or re-run with --dry-run to review the full plan.") return False return True def _apply_migration(run_migrator: Callable[[bool], dict], opts: SimpleNamespace) -> None: - """Take a pre-migration backup (unless --no-backup), execute, and print the report. - - The backup shares implementation with the pre-update backup (same exclusions, SQLite - safe-copy, zip format) so it is restorable with `hermes import` — one atomic restore point - before any mutation, auto-pruned to the last 5 pre-migration zips. - """ + """Take a pre-migration backup (unless --no-backup), execute, and print the report. The backup + shares the pre-update backup's implementation (exclusions, SQLite safe-copy, zip) so it is + restorable with `hermes import`: one restore point before any mutation, pruned to the last 5.""" backup_archive: Optional[Path] = None if not opts.no_backup: try: from hermes_cli.backup import create_pre_migration_backup, _format_size backup_archive = create_pre_migration_backup(hermes_home=opts.hermes_home) if backup_archive: - size_str = _format_size(backup_archive.stat().st_size) print() - print_success(f"Pre-migration backup: {backup_archive} ({size_str})") + print_success(f"Pre-migration backup: {backup_archive} " + f"({_format_size(backup_archive.stat().st_size)})") print_info(f"Restore with: hermes import {backup_archive.name}") except Exception as e: - _error_block( + return _error_block( f"Could not create pre-migration backup: {e}", "Re-run with --no-backup to skip, or free up disk space under the Hermes home.", - ) - logger.debug("Pre-migration backup error", exc_info=True) - return + debug="Pre-migration backup error") try: report = run_migrator(True) except Exception as e: - _error_block(f"Migration failed: {e}") - logger.debug("OpenClaw migration error", exc_info=True) + _error_block(f"Migration failed: {e}", debug="OpenClaw migration error") if backup_archive: - print_info(f"A pre-migration backup is available at: {backup_archive}") - print_info(f"Restore with: hermes import {backup_archive.name}") + _info(f"A pre-migration backup is available at: {backup_archive}", + f"Restore with: hermes import {backup_archive.name}") return _print_migration_report(report, dry_run=False) def _cmd_cleanup(args): """Offer to rename leftover OpenClaw directories to .pre-migration to free disk space.""" - dry_run = getattr(args, "dry_run", False) - auto_yes = getattr(args, "yes", False) - explicit_source = getattr(args, "source", None) - + dry_run, auto_yes = getattr(args, "dry_run", False), getattr(args, "yes", False) _print_banner("OpenClaw Cleanup") - dirs_to_check = [Path(explicit_source)] if explicit_source else _find_openclaw_dirs() + source = getattr(args, "source", None) + dirs_to_check = [Path(source)] if source else _find_openclaw_dirs() if not dirs_to_check: print() - print_success("No OpenClaw directories found. Nothing to clean up.") - return - + return print_success("No OpenClaw directories found. Nothing to clean up.") # Archiving while the service is active makes it recreate an empty skeleton directory. running = _detect_openclaw_processes() - if running: - _error_block( - "OpenClaw appears to be still running:", - *(f" * {detail}" for detail in running), - "Archiving .openclaw/ while the service is active may cause it to " - "immediately recreate an empty skeleton directory, destroying your config.", - "Stop OpenClaw first: systemctl --user stop openclaw-gateway.service", - ) - print() - if not _confirm( - auto_yes, "Proceed anyway?", default=False, - non_tty=("Non-interactive session — aborting. Stop OpenClaw and re-run.",), - declined="Aborted. Stop OpenClaw first, then re-run: hermes claw cleanup", - ): - return - + if running and not _warn_running( + auto_yes, "OpenClaw appears to be still running:", running, + ("Archiving .openclaw/ while the service is active may cause it to " + "immediately recreate an empty skeleton directory, destroying your config.", + "Stop OpenClaw first: systemctl --user stop openclaw-gateway.service"), + "Proceed anyway?", + declined="Aborted. Stop OpenClaw first, then re-run: hermes claw cleanup", + non_tty=("Non-interactive session — aborting. Stop OpenClaw and re-run.",), + ): + return total_archived = 0 for source_dir in dirs_to_check: _describe_openclaw_dir(source_dir) @@ -459,8 +411,8 @@ def _cmd_cleanup(args): n = len(dirs_to_check) if dry_run else total_archived word = "directory" if n == 1 else "directories" if dry_run: - print_info(f"Dry run complete. {n} {word} would be archived.") - print_info("Run without --dry-run to archive them.") + _info(f"Dry run complete. {n} {word} would be archived.", + "Run without --dry-run to archive them.") elif total_archived: print_success(f"Cleaned up {total_archived} OpenClaw {word}.") print_info("Directories were renamed, not deleted. You can undo by renaming them back.") @@ -474,17 +426,15 @@ def _describe_openclaw_dir(source_dir: Path) -> None: print_header(f"Found: {source_dir}") state_files = _scan_workspace_state(source_dir) try: - workspace_dirs = [ - d for d in source_dir.iterdir() - if d.is_dir() and not d.name.startswith(".") - and any((d / n).exists() for n in ("todo.json", "SOUL.md", "MEMORY.md", "USER.md")) - ] + workspace_dirs = [d for d in source_dir.iterdir() if d.is_dir() + and not d.name.startswith(".") + and any((d / n).exists() for n in _WORKSPACE_MARKERS)] except OSError: workspace_dirs = [] - if workspace_dirs: print_info(f"Workspace directories: {len(workspace_dirs)}") - _print_rows([_workspace_row(ws) for ws in workspace_dirs], 5) + _print_rows([f"{ws.name}/ ({', '.join(_workspace_items(ws)) or 'empty'})" + for ws in workspace_dirs], 5) if state_files: print() print(color(f" {len(state_files)} state file(s) found:", Colors.YELLOW)) @@ -492,9 +442,8 @@ def _describe_openclaw_dir(source_dir: Path) -> None: print() -def _workspace_row(ws: Path) -> str: - items = [label for name, label, check in _WORKSPACE_ITEM_LABELS if check(ws / name)] - return f"{ws.name}/ ({', '.join(items) or 'empty'})" +def _workspace_items(ws: Path) -> list[str]: + return [lbl for n, lbl, chk in _WORKSPACE_ITEM_LABELS if chk(ws / n)] def _print_rows(rows: list[str], shown: int) -> None: @@ -507,44 +456,37 @@ def _print_rows(rows: list[str], shown: int) -> None: def _print_migration_report(report: dict, dry_run: bool): """Print a formatted migration report.""" - summary = report.get("summary", {}) + summary, items = report.get("summary", {}), report.get("items", []) migrated = summary.get("migrated", 0) print() print_header("Dry Run Results" if dry_run else "Migration Results") if dry_run: print_info("No files were modified. This is a preview of what would happen.") print() - - items = report.get("items", []) - migrated_items = [i for i in items if i.get("status") == "migrated"] - if migrated_items: - print(color(f" ✓ {'Would migrate' if dry_run else 'Migrated'}:", Colors.GREEN)) - for item in migrated_items: + migrated_heading = f" ✓ {'Would migrate' if dry_run else 'Migrated'}:" + for status, heading, heading_color, default_reason in ( + ("migrated", migrated_heading, Colors.GREEN, None), *_REPORT_REASON_GROUPS): + rows = [] + for item in (i for i in items if i.get("status") == status): kind, dest = item.get("kind", "unknown"), item.get("destination", "") - print(f" {kind:<22s} → {str(dest).replace(str(Path.home()), '~')}" if dest - else f" {kind}") - print() - for status, heading, heading_color, default_reason in _REPORT_REASON_GROUPS: - group = [i for i in items if i.get("status") == status] - if not group: - continue - print(color(heading, heading_color)) - for item in group: - print(f" {item.get('kind', 'unknown'):<22s} {item.get('reason', default_reason)}") - print() - - counts = ((migrated, "would migrate" if dry_run else "migrated"), - (summary.get("conflict", 0), "conflict(s)"), (summary.get("skipped", 0), "skipped"), - (summary.get("error", 0), "error(s)")) + if default_reason is not None: # conflict / skipped / error rows show a reason + rows.append(f"{kind:<22s} {item.get('reason', default_reason)}") + else: + rows.append(f"{kind:<22s} → {str(dest).replace(str(Path.home()), '~')}" if dest + else kind) + if rows: + print(color(heading, heading_color)) + _print_rows(rows, len(rows)) + print() + counts = [(migrated, "would migrate" if dry_run else "migrated")] + [ + (summary.get(k, 0), label) for k, label in _SUMMARY_COUNT_LABELS] parts = [f"{count} {label}" for count, label in counts if count] print_info(f"Summary: {', '.join(parts)}" if parts else "Nothing to migrate.") if report.get("output_dir"): print_info(f"Full report saved to: {report['output_dir']}") - if dry_run: - print() - print_info("To execute the migration, run without --dry-run:") - print_info(f" hermes claw migrate --preset {report.get('preset', 'full')}") + _info("", "To execute the migration, run without --dry-run:", + f" hermes claw migrate --preset {report.get('preset', 'full')}") elif migrated: print() print_success("Migration complete!") @@ -556,9 +498,6 @@ def _print_migration_report(report: dict, dry_run: bool): " Your OPENROUTER_API_KEY and other provider keys must be added manually.", ): print(color(line, Colors.YELLOW)) - print() - print_info("To migrate API keys, re-run with:") - print_info(" hermes claw migrate --migrate-secrets") - print() - print_info("Or add your key manually:") - print_info(" hermes config set OPENROUTER_API_KEY sk-or-v1-...") + _info("", "To migrate API keys, re-run with:", + " hermes claw migrate --migrate-secrets", "", "Or add your key manually:", + " hermes config set OPENROUTER_API_KEY sk-or-v1-...") From 84f1bc5819a0b77dfa581c8cdf2ff8775460294f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:56:08 -0700 Subject: [PATCH 09/13] refactor(hermes_cli): tuple routing compare, debounce-timer helper, pack signatures across g6 files --- hermes_cli/cli_agent_setup_mixin.py | 39 ++++++------------ hermes_cli/cli_terminal_mixin.py | 42 ++++++++++---------- hermes_cli/codex_runtime_plugin_migration.py | 14 ++----- 3 files changed, 36 insertions(+), 59 deletions(-) diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index 19d91cebb8..582b1e4b5e 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -144,9 +144,10 @@ class CLIAgentSetupMixin: api_key = runtime.get("api_key") base_url = runtime.get("base_url") resolved_provider = runtime.get("provider", "openrouter") - resolved_api_mode = runtime.get("api_mode", self.api_mode) - resolved_acp_command = runtime.get("command") - resolved_acp_args = list(runtime.get("args") or []) + resolved_routing = ( + resolved_provider, runtime.get("api_mode", self.api_mode), runtime.get("command"), + list(runtime.get("args") or []), + ) # A callable api_key is a bearer-token provider (Azure Entra ID): the OpenAI SDK # invokes it per request, so skip string validation / placeholder substitution. _is_callable_provider = callable(api_key) and not isinstance(api_key, str) @@ -174,16 +175,8 @@ class CLIAgentSetupMixin: return False credentials_changed = api_key != self.api_key or base_url != self.base_url - routing_changed = ( - resolved_provider != self.provider - or resolved_api_mode != self.api_mode - or resolved_acp_command != self.acp_command - or resolved_acp_args != self.acp_args - ) - self.provider = resolved_provider - self.api_mode = resolved_api_mode - self.acp_command = resolved_acp_command - self.acp_args = resolved_acp_args + routing_changed = resolved_routing != (self.provider, self.api_mode, self.acp_command, self.acp_args) + self.provider, self.api_mode, self.acp_command, self.acp_args = resolved_routing self._credential_pool = runtime.get("credential_pool") self._provider_source = runtime.get("source") self.api_key = api_key @@ -315,12 +308,9 @@ class CLIAgentSetupMixin: from hermes_cli.config import load_config _model_cfg = (load_config().get("model") or {}) if isinstance(_model_cfg, dict): - _new_provider = (_model_cfg.get("provider") or "").strip() - if _new_provider: - self.requested_provider = _new_provider + self.requested_provider = (_model_cfg.get("provider") or "").strip() or self.requested_provider _new_model = (_model_cfg.get("default") or _model_cfg.get("model") or "").strip() - if _new_model: - self.model = _new_model + self.model = _new_model or self.model except Exception as exc: logger.debug("first-run config re-sync failed: %s", exc) # Force credential re-resolution + agent rebuild on next use. @@ -466,7 +456,6 @@ class CLIAgentSetupMixin: self._session_db = SessionDB() except Exception as e: logger.warning("SQLite session store not available — session will NOT be indexed: %s", e) - if ( self._resumed and self._session_db and not self.conversation_history and not self._load_resumed_history_late() @@ -671,7 +660,6 @@ class CLIAgentSetupMixin: display_kind = msg.get("display_kind") content = msg.get("content") tool_calls = msg.get("tool_calls") or [] - if display_kind == "hidden": continue if display_kind in _RESUME_EVENT_TEXT: @@ -702,9 +690,8 @@ class CLIAgentSetupMixin: text = text[:MAX_ASST_LEN] + "..." parts.append(text) if tool_calls: - tc_summary = _tool_calls_summary(tool_calls) - parts.append(tc_summary) - full_parts.append(tc_summary) + parts.append(_tool_calls_summary(tool_calls)) + full_parts.append(parts[-1]) # Skip pure-reasoning messages with no visible output, and tool-call-only # entries when SKIP_TOOL_ONLY is enabled. if not text and (SKIP_TOOL_ONLY or not tool_calls): @@ -716,10 +703,8 @@ class CLIAgentSetupMixin: if not entries: return - skipped = 0 - if len(entries) > MAX_DISPLAY_EXCHANGES * 2: - skipped = len(entries) - MAX_DISPLAY_EXCHANGES * 2 - entries = entries[skipped:] + skipped = max(0, len(entries) - MAX_DISPLAY_EXCHANGES * 2) + entries = entries[skipped:] # Show the last assistant entry in full so the user sees where they left off. if _last_asst_idx is not None and _last_asst_full: diff --git a/hermes_cli/cli_terminal_mixin.py b/hermes_cli/cli_terminal_mixin.py index 7b1b87e8a5..ed522b418d 100644 --- a/hermes_cli/cli_terminal_mixin.py +++ b/hermes_cli/cli_terminal_mixin.py @@ -270,6 +270,21 @@ class CLITerminalMixin: original_on_resize() self._schedule_status_bar_unsuppress(app) + def _restart_debounce_timer(self, attr: str, delay: float, fn) -> None: + """Cancel the daemon Timer stored on ``self.`` (if any) and start a new one. + + ``fn`` receives the new Timer so it can detect being superseded.""" + old_timer = getattr(self, attr, None) + if old_timer is not None: + try: + old_timer.cancel() + except Exception: + pass + timer = threading.Timer(delay, lambda: fn(timer)) + timer.daemon = True + setattr(self, attr, timer) + timer.start() + def _schedule_status_bar_unsuppress(self, app, delay: float = 0.35) -> None: """Clear the post-resize status-bar suppression after the reflow settles. @@ -277,13 +292,6 @@ class CLITerminalMixin: the bar only once it stops. """ try: - old_timer = getattr(self, "_status_bar_unsuppress_timer", None) - if old_timer is not None: - try: - old_timer.cancel() - except Exception: - pass - def _clear(): self._status_bar_suppressed_after_resize = False try: @@ -291,10 +299,9 @@ class CLITerminalMixin: except Exception: pass - timer = threading.Timer(delay, lambda: _run_on_app_loop(app, _clear)) - timer.daemon = True - self._status_bar_unsuppress_timer = timer - timer.start() + self._restart_debounce_timer( + "_status_bar_unsuppress_timer", delay, lambda _t: _run_on_app_loop(app, _clear) + ) except Exception: # Fail open: never leave the bar stuck hidden. self._status_bar_suppressed_after_resize = False @@ -302,7 +309,6 @@ class CLITerminalMixin: def _schedule_resize_recovery(self, app, original_on_resize, delay: float = 0.12) -> None: """Debounce resize redraws so footer chrome is not stamped into scrollback.""" try: - old_timer = getattr(self, "_resize_recovery_timer", None) lock = getattr(self, "_resize_recovery_lock", None) if lock is None: lock = threading.Lock() @@ -312,7 +318,7 @@ class CLITerminalMixin: def _run_recovery(): with lock: if getattr(self, "_resize_recovery_timer", None) is not timer_ref: - return + return # superseded by a newer resize self._resize_recovery_timer = None self._resize_recovery_pending = False self._recover_after_resize(app, original_on_resize) @@ -320,16 +326,8 @@ class CLITerminalMixin: _run_on_app_loop(app, _run_recovery) with lock: - if old_timer is not None: - try: - old_timer.cancel() - except Exception: - pass self._resize_recovery_pending = True - timer = threading.Timer(delay, lambda: _timer_fired(timer)) - timer.daemon = True - self._resize_recovery_timer = timer - timer.start() + self._restart_debounce_timer("_resize_recovery_timer", delay, _timer_fired) except Exception: self._resize_recovery_pending = False self._recover_after_resize(app, original_on_resize) diff --git a/hermes_cli/codex_runtime_plugin_migration.py b/hermes_cli/codex_runtime_plugin_migration.py index 6e5f3829b2..66878cdcfc 100644 --- a/hermes_cli/codex_runtime_plugin_migration.py +++ b/hermes_cli/codex_runtime_plugin_migration.py @@ -170,8 +170,7 @@ def _quote_key(key: str) -> str: def render_codex_toml_section( - servers: dict[str, dict], - plugins: Optional[list[dict]] = None, + servers: dict[str, dict], plugins: Optional[list[dict]] = None, default_permission_profile: Optional[str] = None, ) -> str: """Render the managed [mcp_servers.] / [plugins.] / default_permissions block. @@ -288,8 +287,7 @@ def _strip_existing_managed_block(toml_text: str) -> str: def _query_codex_plugins( - codex_home: Optional[Path] = None, - timeout: float = 8.0, + codex_home: Optional[Path] = None, timeout: float = 8.0 ) -> tuple[list[dict], Optional[str]]: """Query codex's `plugin/list` for installed curated plugins. @@ -410,12 +408,8 @@ def _write_atomic(target: Path, text: str) -> None: def migrate( - hermes_config: dict, - *, - codex_home: Optional[Path] = None, - dry_run: bool = False, - discover_plugins: bool = True, - default_permission_profile: Optional[str] = ":workspace", + hermes_config: dict, *, codex_home: Optional[Path] = None, dry_run: bool = False, + discover_plugins: bool = True, default_permission_profile: Optional[str] = ":workspace", expose_hermes_tools: bool = True, ) -> MigrationReport: """Translate Hermes mcp_servers config + Codex curated plugins into ~/.codex/config.toml. From da6991f8f5a8b7870bd6375e11d3236dd9891822 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:27:53 -0700 Subject: [PATCH 10/13] refactor(hermes_cli): extract recap-entry collection, compact docstrings, squeeze intra-function blanks across g6 files --- hermes_cli/claw.py | 15 +- hermes_cli/cli_agent_setup_mixin.py | 264 ++++++++----------- hermes_cli/cli_loops_mixin.py | 82 ++---- hermes_cli/cli_terminal_mixin.py | 139 ++++------ hermes_cli/clipboard.py | 7 +- hermes_cli/codex_runtime_plugin_migration.py | 72 ++--- 6 files changed, 201 insertions(+), 378 deletions(-) diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py index 089348e012..c1d6c0c1a6 100644 --- a/hermes_cli/claw.py +++ b/hermes_cli/claw.py @@ -115,8 +115,7 @@ def _detect_openclaw_processes() -> list[str]: ps_cmd = ( 'Get-CimInstance Win32_Process -Filter "Name = \'node.exe\'" | ' 'Where-Object { $_.CommandLine -match "openclaw|clawd" } | ' - 'Select-Object -First 1 ProcessId' - ) + 'Select-Object -First 1 ProcessId') result = bounded_probe_run(["powershell", "-NoProfile", "-Command", ps_cmd], timeout=5) pid = (result.stdout or "").strip() if result is not None else "" if pid: @@ -124,7 +123,6 @@ def _detect_openclaw_processes() -> list[str]: except Exception: pass return found - result = _posix_probe(["systemctl", "--user", "is-active", "openclaw-gateway.service"], 5) if result is not None and result.stdout.strip() == "active": found.append("systemd service: openclaw-gateway.service") @@ -160,7 +158,6 @@ def _warn_if_openclaw_running(auto_yes: bool) -> None: def _warn_if_gateway_running(auto_yes: bool) -> None: """Warn if a Hermes gateway has connected platforms (token conflicts, e.g. Telegram 409).""" from gateway.status import get_running_pid, read_runtime_status - platforms = ((read_runtime_status() or {}).get("platforms") or {}) if get_running_pid() else {} connected = [name for name, info in platforms.items() if isinstance(info, dict) and info.get("state") == "connected"] @@ -299,8 +296,7 @@ def _load_migrator(script_path: Path, opts: SimpleNamespace) -> Optional[Callabl source_root=opts.source_dir.resolve(), target_root=opts.hermes_home.resolve(), execute=execute, workspace_target=ws_target, overwrite=opts.overwrite, migrate_secrets=opts.migrate_secrets, output_dir=None, selected_options=selected, - preset_name=opts.preset, skill_conflict_mode=opts.skill_conflict, - ).migrate() + preset_name=opts.preset, skill_conflict_mode=opts.skill_conflict).migrate() def _preview_migration(run_migrator: Callable[[bool], dict], opts: SimpleNamespace) -> bool: @@ -387,8 +383,7 @@ def _cmd_cleanup(args): "Stop OpenClaw first: systemctl --user stop openclaw-gateway.service"), "Proceed anyway?", declined="Aborted. Stop OpenClaw first, then re-run: hermes claw cleanup", - non_tty=("Non-interactive session — aborting. Stop OpenClaw and re-run.",), - ): + non_tty=("Non-interactive session — aborting. Stop OpenClaw and re-run.",)): return total_archived = 0 for source_dir in dirs_to_check: @@ -406,7 +401,6 @@ def _cmd_cleanup(args): except OSError as e: print_error(f"Could not archive: {e}") print_info(f"Try manually: mv {source_dir} {source_dir}.pre-migration") - print() n = len(dirs_to_check) if dry_run else total_archived word = "directory" if n == 1 else "directories" @@ -495,8 +489,7 @@ def _print_migration_report(report: dict, dry_run: bool): print() for line in ( " ⚠ API keys were NOT migrated (secrets migration is disabled by default).", - " Your OPENROUTER_API_KEY and other provider keys must be added manually.", - ): + " Your OPENROUTER_API_KEY and other provider keys must be added manually."): print(color(line, Colors.YELLOW)) _info("", "To migrate API keys, re-run with:", " hermes claw migrate --migrate-secrets", "", "Or add your key manually:", diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index 582b1e4b5e..afda620ee5 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -40,16 +40,14 @@ def _current_runtime(cli) -> dict: "api_mode": cli.api_mode, "command": cli.acp_command, "args": list(cli.acp_args or []), - "credential_pool": getattr(cli, "_credential_pool", None), - } + "credential_pool": getattr(cli, "_credential_pool", None)} def _route_signature(model, runtime: dict) -> tuple: """Hashable identity of (model, routing) used to detect when the agent must be rebuilt.""" return ( model, runtime.get("provider"), runtime.get("requested_provider"), runtime.get("base_url"), - runtime.get("api_mode"), runtime.get("command"), tuple(runtime.get("args") or ()), - ) + runtime.get("api_mode"), runtime.get("command"), tuple(runtime.get("args") or ())) def _keyless_custom_base(base_url) -> bool: @@ -58,8 +56,7 @@ def _keyless_custom_base(base_url) -> bool: return bool( isinstance(base_url, str) and base_url - and not base_url_host_matches(base_url, "openrouter.ai") - ) + and not base_url_host_matches(base_url, "openrouter.ai")) def _compression_descendant(session_db, session_id): @@ -78,8 +75,7 @@ def _user_display_text(content) -> str: return " ".join( part.get("text", "") if part.get("type") == "text" else "[image]" for part in content - if isinstance(part, dict) and part.get("type") in ("text", "image_url") - ) + if isinstance(part, dict) and part.get("type") in ("text", "image_url")) return "" if content is None else str(content) @@ -103,6 +99,60 @@ _RESUME_EVENT_TEXT = { "auto_continue": "resumed interrupted turn", } +def _collect_resume_entries(display_history, disp: dict, clean_assistant): + """Displayable ``(role, text)`` recap entries from stored history, truncated per the + ``display.resume_*`` config; system and tool-result rows are skipped. Returns + ``(entries, index of last assistant entry, its un-truncated text)``. + + Stored history is untrusted for display: text is sanitized so replay can't clear the + screen, retitle the window or restyle the panel. Pure-reasoning assistant rows with no + visible output are skipped, as are tool-call-only rows when ``resume_skip_tool_only``. + """ + from tools.ansi_strip import sanitize_display_text as _sanitize_display_text + max_user_len = int(disp.get("resume_max_user_chars", 300)) + max_asst_len = int(disp.get("resume_max_assistant_chars", 200)) + max_asst_lines = int(disp.get("resume_max_assistant_lines", 3)) + skip_tool_only = disp.get("resume_skip_tool_only", True) + entries: list = [] + last_asst_idx = None + last_asst_full = None + for msg in display_history: + role = msg.get("role", "") + display_kind = msg.get("display_kind") + content = msg.get("content") + tool_calls = msg.get("tool_calls") or [] + if display_kind == "hidden": + continue + if display_kind in _RESUME_EVENT_TEXT: + entries.append(("event", _RESUME_EVENT_TEXT[display_kind])) + continue + if role == "user": + text = _sanitize_display_text(_user_display_text(content)) + if len(text) > max_user_len: + text = text[:max_user_len] + "..." + entries.append(("user", text)) + elif role == "assistant": + text = clean_assistant("" if content is None else str(content)) + parts, full_parts = [], [] + if text: + full_parts.append(text) + lines = text.splitlines() + if len(lines) > max_asst_lines: + text = "\n".join(lines[:max_asst_lines]) + " ..." + if len(text) > max_asst_len: + text = text[:max_asst_len] + "..." + parts.append(text) + if tool_calls: + parts.append(_tool_calls_summary(tool_calls)) + full_parts.append(parts[-1]) + if not text and (skip_tool_only or not tool_calls): + continue + entries.append(("assistant", " ".join(parts))) + last_asst_idx = len(entries) - 1 + last_asst_full = " ".join(full_parts) + return entries, last_asst_idx, last_asst_full + + # (skin key, fallback) for recap panel colors: body text, session label, border, assistant label. _RESUME_SKIN_COLORS = ( ("banner_text", "#FFF8DC"), ("session_label", "#DAA520"), ("session_border", "#8B8682"), @@ -110,6 +160,16 @@ _RESUME_SKIN_COLORS = ( ) +def _resume_panel_colors() -> tuple: + """Active-skin colors for ``_RESUME_SKIN_COLORS`` (fallbacks when no skin loads).""" + try: + from hermes_cli.skin_engine import get_active_skin + _skin = get_active_skin() + return tuple(_skin.get_color(key, default) for key, default in _RESUME_SKIN_COLORS) + except Exception: + return tuple(default for _, default in _RESUME_SKIN_COLORS) + + class CLIAgentSetupMixin: """Agent construction + session-resume display methods for ``HermesCLI``.""" @@ -118,36 +178,28 @@ class CLIAgentSetupMixin: refresh are picked up without restarting the CLI. False on auth failure.""" from cli import ChatConsole, logger from hermes_cli.runtime_provider import resolve_runtime_provider, format_runtime_provider_error - _primary_exc = None runtime = None try: runtime = resolve_runtime_provider( requested=self.requested_provider, explicit_api_key=self._explicit_api_key, - explicit_base_url=self._explicit_base_url, - ) + explicit_base_url=self._explicit_base_url) except Exception as exc: _primary_exc = exc - - if runtime is None and _primary_exc is not None: - from hermes_cli.auth import AuthError - if isinstance(_primary_exc, AuthError): - runtime = self._resolve_fallback_runtime(_primary_exc) - if runtime is not None: - _primary_exc = None - + if _primary_exc is not None: + runtime = self._resolve_fallback_runtime(_primary_exc) + if runtime is not None: + _primary_exc = None if runtime is None: message = format_runtime_provider_error(_primary_exc) if _primary_exc else "Provider resolution failed." ChatConsole().print(f"[bold red]{message}[/]") return False - api_key = runtime.get("api_key") base_url = runtime.get("base_url") resolved_provider = runtime.get("provider", "openrouter") resolved_routing = ( resolved_provider, runtime.get("api_mode", self.api_mode), runtime.get("command"), - list(runtime.get("args") or []), - ) + list(runtime.get("args") or [])) # A callable api_key is a bearer-token provider (Azure Entra ID): the OpenAI SDK # invokes it per request, so skip string validation / placeholder substitution. _is_callable_provider = callable(api_key) and not isinstance(api_key, str) @@ -158,8 +210,7 @@ class CLIAgentSetupMixin: logger.debug( "No API key for custom endpoint %s (source=%s), " "using placeholder — local servers typically ignore auth", - base_url, runtime.get("source", ""), - ) + base_url, runtime.get("source", "")) else: _prov = (resolved_provider or self.requested_provider or "").strip() if _prov and _prov != "auto": @@ -173,7 +224,6 @@ class CLIAgentSetupMixin: print("\n⚠️ Provider resolver returned an empty base URL. " "Check your provider config or run: hermes setup") return False - credentials_changed = api_key != self.api_key or base_url != self.base_url routing_changed = resolved_routing != (self.provider, self.api_mode, self.acp_command, self.acp_args) self.provider, self.api_mode, self.acp_command, self.acp_args = resolved_routing @@ -187,8 +237,7 @@ class CLIAgentSetupMixin: # would otherwise send the provider name as the model string -> 400). runtime_model = runtime.get("model") if runtime_model and isinstance(runtime_model, str) and ( - not self.model or self.model == self.provider or self.model == runtime.get("name") - ): + not self.model or self.model == self.provider or self.model == runtime.get("name")): self.model = runtime_model # Still empty (e.g. `hermes auth add` without `hermes model`): fall back to the @@ -201,8 +250,7 @@ class CLIAgentSetupMixin: self.model = _default logger.info( "No model configured — defaulting to %s for provider %s", - _default, resolved_provider, - ) + _default, resolved_provider) except Exception: pass @@ -213,14 +261,17 @@ class CLIAgentSetupMixin: if (credentials_changed or routing_changed or model_changed) and self.agent is not None: self.agent = None self._active_agent_route_signature = None - return True def _resolve_fallback_runtime(self, primary_exc): - """Primary provider auth failed: try each fallback entry in order and switch the - CLI's requested_provider/model to the first that resolves. None if none do.""" + """Primary provider resolution failed: on an AuthError try each fallback entry in + order and switch the CLI's requested_provider/model to the first that resolves. + None when the error is not auth-related or no fallback resolves.""" from cli import _cprint, logger + from hermes_cli.auth import AuthError from hermes_cli.runtime_provider import resolve_runtime_provider + if not isinstance(primary_exc, AuthError): + return None _fb_chain = self._fallback_model if isinstance(self._fallback_model, list) else [] for _fb in _fb_chain: _fb_provider = (_fb.get("provider") or "").strip().lower() @@ -229,7 +280,6 @@ class CLIAgentSetupMixin: continue try: from hermes_cli.fallback_config import resolve_entry_api_key - _fb_kwargs = {"requested": _fb_provider} if _fb.get("base_url"): _fb_kwargs["explicit_base_url"] = _fb["base_url"] @@ -239,8 +289,7 @@ class CLIAgentSetupMixin: runtime = resolve_runtime_provider(**_fb_kwargs) logger.warning( "Primary provider auth failed (%s). Falling through to fallback: %s/%s", - primary_exc, _fb_provider, _fb_model, - ) + primary_exc, _fb_provider, _fb_model) _cprint(f"⚠️ Primary auth failed — switching to fallback: {_fb_provider} / {_fb_model}") self.requested_provider = _fb_provider self.model = _fb_model @@ -255,12 +304,10 @@ class CLIAgentSetupMixin: Never prints or mutates CLI state, so the interactive first-run path can route a keyless install into onboarding before the user types into a chat that can't work.""" from hermes_cli.runtime_provider import resolve_runtime_provider - try: runtime = resolve_runtime_provider( requested=self.requested_provider, explicit_api_key=self._explicit_api_key, - explicit_base_url=self._explicit_base_url, - ) + explicit_base_url=self._explicit_base_url) except Exception: return False if not isinstance(runtime, dict): @@ -276,7 +323,6 @@ class CLIAgentSetupMixin: startup, TTY). Runs the same flow as ``hermes model`` so onboarding has a single source of truth. True when a provider was configured.""" from cli import _cprint, logger - _cprint("") _cprint("⚕ No inference provider is configured yet — let's fix that.") _cprint(" You'll pick a provider (Nous Portal OAuth is the fastest; " @@ -289,7 +335,6 @@ class CLIAgentSetupMixin: if answer in {"n", "no"}: _cprint(" Skipped. Run 'hermes model' or 'hermes setup' any time.") return False - try: from hermes_cli.main import select_provider_and_model select_provider_and_model() @@ -316,7 +361,6 @@ class CLIAgentSetupMixin: # Force credential re-resolution + agent rebuild on next use. self.agent = None self._active_agent_route_signature = None - if self._runtime_credentials_ready(): _cprint(" ✓ Provider configured — you're ready to chat.") return True @@ -328,17 +372,15 @@ class CLIAgentSetupMixin: provider. With `/fast` on (service_tier == "priority") attach request_overrides; auto/cold tiers are applied per request by agent.fast_mode instead.""" from hermes_cli.models import resolve_fast_mode_overrides - runtime = _current_runtime(self) route = {"model": self.model, "runtime": runtime, "signature": _route_signature(self.model, runtime)} overrides = None if getattr(self, "service_tier", None) == "priority": try: overrides = resolve_fast_mode_overrides( - route["model"], provider=runtime["provider"], base_url=runtime["base_url"], - ) + route["model"], provider=runtime["provider"], base_url=runtime["base_url"]) except Exception: - overrides = None + pass route["request_overrides"] = overrides return route @@ -374,7 +416,6 @@ class CLIAgentSetupMixin: print(plain, file=sys.stderr) else: ChatConsole().print(rich) - if not session_meta: if _quiet_mode: print(f"Session not found: {self.session_id}", file=sys.stderr) @@ -388,9 +429,7 @@ class CLIAgentSetupMixin: lambda rid: ChatConsole().print( f"[dim]Session {_escape(self.session_id)} was compressed into " f"{_escape(rid)}; resuming the descendant with your " - f"transcript.[/dim]" - ), - ) + f"transcript.[/dim]")) if getattr(self, "_resume_history_error", None): return False # Only the TIP session's rows are loaded here (no ancestors), so use the @@ -400,8 +439,7 @@ class CLIAgentSetupMixin: self._resume_history_error = resume_limit_error _say( f"Cannot resume session: {resume_limit_error}", - f"[bold red]Cannot resume session:[/] {_escape(resume_limit_error)}", - ) + f"[bold red]Cannot resume session:[/] {_escape(resume_limit_error)}") return False restored = self._session_db.get_messages_as_conversation(self.session_id, repair_alternation=True) if restored: @@ -413,8 +451,7 @@ class CLIAgentSetupMixin: _say( f"↻ Resumed session {self.session_id}{title_part} {counts}", f"[bold {_accent_hex()}]↻ Resumed session[/] [bold]{_escape(self.session_id)}[/]" - f"[bold {_accent_hex()}]{_escape(title_part)}[/] {counts}", - ) + f"[bold {_accent_hex()}]{_escape(title_part)}[/] {counts}") self._restore_session_cwd(session_meta, quiet=_quiet_mode) self._restore_session_yolo(session_meta, quiet=_quiet_mode) self._restore_session_model(session_meta, quiet=_quiet_mode) @@ -436,20 +473,14 @@ class CLIAgentSetupMixin: # Join the background preloaded-skills load (--skills/-s) BEFORE the agent # snapshots self.system_prompt below. No-op when nothing was requested. self.finalize_preloaded_skills() - _prepare_deferred_agent_startup() self._install_tool_callbacks() self._ensure_tirith_security() - if not self._ensure_runtime_credentials(): return False - from hermes_cli.mcp_startup import ensure_mcp_discovery_before_agent_build - ensure_mcp_discovery_before_agent_build( - logger=logger, single_query=getattr(self, "_single_query_mode", False) - ) - + logger=logger, single_query=getattr(self, "_single_query_mode", False)) if self._session_db is None: try: from hermes_state import SessionDB @@ -458,10 +489,8 @@ class CLIAgentSetupMixin: logger.warning("SQLite session store not available — session will NOT be indexed: %s", e) if ( self._resumed and self._session_db and not self.conversation_history - and not self._load_resumed_history_late() - ): + and not self._load_resumed_history_late()): return False - try: runtime = runtime_override or _current_runtime(self) effective_model = model_override or self.model @@ -470,8 +499,7 @@ class CLIAgentSetupMixin: clarify_callback = ( _single_query_clarify_callback if getattr(self, "_single_query_mode", False) - else self._clarify_callback - ) + else self._clarify_callback) self.agent = AIAgent( model=effective_model, api_key=runtime.get("api_key"), base_url=runtime.get("base_url"), provider=runtime.get("provider"), @@ -507,8 +535,7 @@ class CLIAgentSetupMixin: stream_delta_callback=self._stream_delta if self.streaming_enabled else None, tool_gen_callback=self._on_tool_gen_start if self.streaming_enabled else None, notice_callback=self._on_notice, notice_clear_callback=self._on_notice_clear, - reaction_callback=self._on_reaction, - ) + reaction_callback=self._on_reaction) # Reference for atexit memory-provider shutdown: ``_run_cleanup`` in cli.py # reads ``cli._active_agent_ref``, so this MUST write the ``cli`` module's # global — a ``global`` statement here would bind this module's namespace. @@ -521,7 +548,6 @@ class CLIAgentSetupMixin: # warning shows before the first message. Idempotent + fail-open in the helper. try: from agent.credits_tracker import seed_credits_at_session_start - seed_credits_at_session_start(self.agent) except Exception: pass @@ -544,7 +570,6 @@ class CLIAgentSetupMixin: console = ChatConsole() console.print(f"[bold red]Failed to initialize agent: {e}[/]") from hermes_constants import partial_update_hint - for line in partial_update_hint(e): console.print(line) return False @@ -559,7 +584,6 @@ class CLIAgentSetupMixin: return None from cli import logger from hermes_state import SessionResumeTooLargeError - try: safety_check = getattr(self._session_db, "assert_resume_safe", None) if not callable(safety_check): @@ -573,8 +597,7 @@ class CLIAgentSetupMixin: except Exception as exc: logger.warning( "Resume safety check failed for %s (proceeding without guard): %s", - self.session_id, exc, - ) + self.session_id, exc) return None def _preload_resumed_session(self) -> bool: @@ -584,35 +607,28 @@ class CLIAgentSetupMixin: from cli import _accent_hex if not self._resumed or not self._session_db: return False - session_meta = self._session_db.get_session(self.session_id) if not session_meta: self._console_print(f"[bold red]Session not found: {self.session_id}[/]") self._console_print("[dim]Use a session ID from a previous CLI run (hermes sessions list).[/]") return False - session_meta = self._follow_compression_chain( session_meta, lambda rid: self._console_print( f"[dim]Session {self.session_id} was compressed into " - f"{rid}; resuming the descendant with your transcript.[/]" - ), - ) + f"{rid}; resuming the descendant with your transcript.[/]")) resume_limit_error = self._resume_history_limit_error() if resume_limit_error: self._resume_history_error = resume_limit_error self._console_print(f"[bold red]Cannot resume session:[/] {resume_limit_error}") return False - restored, display_history = self._session_db.get_resume_conversations(self.session_id) accent_color = _accent_hex() if not restored: self._console_print( f"[{accent_color}]Session {self.session_id} found but has no " - f"messages. Starting fresh.[/]" - ) + f"messages. Starting fresh.[/]") return False - restored = [m for m in restored if m.get("role") != "session_meta"] self.conversation_history = restored self._resume_display_history = [m for m in display_history if m.get("role") != "session_meta"] @@ -626,12 +642,10 @@ class CLIAgentSetupMixin: f"[{accent_color}]↻ Resumed session [bold]{self.session_id}[/bold]" f"{title_part} " f"({msg_count} user message{'s' if msg_count != 1 else ''}, " - f"{len(restored)} total messages)[/]" - ) + f"{len(restored)} total messages)[/]") self._restore_session_cwd(session_meta) self._restore_session_yolo(session_meta) self._restore_session_model(session_meta) - self._reopen_session() return True @@ -643,88 +657,22 @@ class CLIAgentSetupMixin: display_history = getattr(self, "_resume_display_history", self.conversation_history) if not display_history or self.resume_display == "minimal": return - _disp = CLI_CONFIG.get("display", {}) - MAX_DISPLAY_EXCHANGES = int(_disp.get("resume_exchanges", 10)) - MAX_USER_LEN = int(_disp.get("resume_max_user_chars", 300)) - MAX_ASST_LEN = int(_disp.get("resume_max_assistant_chars", 200)) - MAX_ASST_LINES = int(_disp.get("resume_max_assistant_lines", 3)) - SKIP_TOOL_ONLY = _disp.get("resume_skip_tool_only", True) - - # Collect displayable (role, text) entries; system and tool-result rows are skipped. - entries = [] - _last_asst_idx = None # index of last assistant entry - _last_asst_full = None # un-truncated display text for last assistant - for msg in display_history: - role = msg.get("role", "") - display_kind = msg.get("display_kind") - content = msg.get("content") - tool_calls = msg.get("tool_calls") or [] - if display_kind == "hidden": - continue - if display_kind in _RESUME_EVENT_TEXT: - entries.append(("event", _RESUME_EVENT_TEXT[display_kind])) - continue - if role in ("system", "tool"): - continue - - # Stored history is untrusted for display: strip escape sequences/control - # chars so replay can't clear the screen, retitle the window or restyle the panel. - if role == "user": - text = _sanitize_display_text(_user_display_text(content)) - if len(text) > MAX_USER_LEN: - text = text[:MAX_USER_LEN] + "..." - entries.append(("user", text)) - - elif role == "assistant": - text = "" if content is None else str(content) - text = _sanitize_display_text(_strip_reasoning_tags(text)) - parts = [] - full_parts = [] # un-truncated version - if text: - full_parts.append(text) - lines = text.splitlines() - if len(lines) > MAX_ASST_LINES: - text = "\n".join(lines[:MAX_ASST_LINES]) + " ..." - if len(text) > MAX_ASST_LEN: - text = text[:MAX_ASST_LEN] + "..." - parts.append(text) - if tool_calls: - parts.append(_tool_calls_summary(tool_calls)) - full_parts.append(parts[-1]) - # Skip pure-reasoning messages with no visible output, and tool-call-only - # entries when SKIP_TOOL_ONLY is enabled. - if not text and (SKIP_TOOL_ONLY or not tool_calls): - continue - entries.append(("assistant", " ".join(parts))) - _last_asst_idx = len(entries) - 1 - _last_asst_full = " ".join(full_parts) - + entries, _last_asst_idx, _last_asst_full = _collect_resume_entries( + display_history, _disp, lambda t: _sanitize_display_text(_strip_reasoning_tags(t))) if not entries: return - - skipped = max(0, len(entries) - MAX_DISPLAY_EXCHANGES * 2) + skipped = max(0, len(entries) - int(_disp.get("resume_exchanges", 10)) * 2) entries = entries[skipped:] - # Show the last assistant entry in full so the user sees where they left off. if _last_asst_idx is not None and _last_asst_full: adj_idx = _last_asst_idx - skipped if 0 <= adj_idx < len(entries): entries[adj_idx] = ("assistant_last", _last_asst_full) - from rich.panel import Panel from rich.text import Text - - try: - from hermes_cli.skin_engine import get_active_skin - _skin = get_active_skin() - _history_text_c, _session_label_c, _session_border_c, _assistant_label_c = ( - _skin.get_color(key, default) for key, default in _RESUME_SKIN_COLORS - ) - except Exception: - _history_text_c, _session_label_c, _session_border_c, _assistant_label_c = ( - default for _, default in _RESUME_SKIN_COLORS - ) + _history_text_c, _session_label_c, _session_border_c, _assistant_label_c = ( + _resume_panel_colors()) # role -> (label, label style, body style, continuation indent) role_styles = { @@ -732,11 +680,9 @@ class CLIAgentSetupMixin: "assistant": (" ◆ Hermes: ", f"dim bold {_assistant_label_c}", "dim", " " * 12), "assistant_last": (" ◆ Hermes: ", f"bold {_assistant_label_c}", "", " " * 12), # full, non-dim } - lines = Text() if skipped: lines.append(f" ... {skipped} earlier messages ...\n\n", style="dim italic") - for i, (role, text) in enumerate(entries): if role == "event": lines.append(f" ◈ {text}\n", style="dim italic") @@ -749,11 +695,9 @@ class CLIAgentSetupMixin: lines.append(f"{indent}{ml}\n", style=body_style) if i < len(entries) - 1: lines.append("") # small gap - panel = Panel( lines, title=f"[dim {_session_label_c}]Previous Conversation[/]", - border_style=f"dim {_session_border_c}", padding=(0, 1), style=_history_text_c, - ) + border_style=f"dim {_session_border_c}", padding=(0, 1), style=_history_text_c) _record_output_history_entry(lambda: self._render_resume_history_panel_lines(panel)) with _suspend_output_history(): self._console_print(panel) diff --git a/hermes_cli/cli_loops_mixin.py b/hermes_cli/cli_loops_mixin.py index c3f91761cb..10aec28cc5 100644 --- a/hermes_cli/cli_loops_mixin.py +++ b/hermes_cli/cli_loops_mixin.py @@ -49,8 +49,7 @@ class CLILoopsMixin: def _cmd_clear(self, cmd_original: str): from cli import ( ChatConsole, _build_compact_banner, _clear_output_history, _cprint, - build_welcome_banner, get_tool_definitions, - ) + build_welcome_banner, get_tool_definitions) if self._confirm_destructive_slash( "clear", "This clears the screen and starts a new session.\n" @@ -78,14 +77,14 @@ class CLILoopsMixin: cc.print(_build_compact_banner()) else: tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True) + agent = getattr(self, "agent", None) ctx_len = None - if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'context_compressor'): - ctx_len = self.agent.context_compressor.context_length + if agent and hasattr(agent, "context_compressor"): + ctx_len = agent.context_compressor.context_length build_welcome_banner( console=cc, model=self.model, cwd=os.getenv("TERMINAL_CWD", os.getcwd()), tools=tools, enabled_toolsets=self.enabled_toolsets, session_id=self.session_id, - context_length=ctx_len, provider=self.provider, - ) + context_length=ctx_len, provider=self.provider) _cprint(_FRESH_START) self._print_random_tip() @@ -179,8 +178,7 @@ class CLILoopsMixin: _undo_desc = ( "This removes the last user/assistant exchange from history." if _undo_n == 1 - else f"This removes the last {_undo_n} user turns from history." - ) + else f"This removes the last {_undo_n} user turns from history.") if self._confirm_destructive_slash("undo", _undo_desc, cmd_original=cmd_original) is None: return True # confirmation cancelled — command handled, keep REPL alive self.undo_last(_undo_n) @@ -191,11 +189,9 @@ class CLILoopsMixin: def _cmd_egress(self, cmd_original: str): from hermes_cli.slash_exec import CommandContext, execute_command - self._console_print( execute_command("egress", CommandContext(surface="cli")).text, - highlight=False, markup=False, - ) + highlight=False, markup=False) def _cmd_statusbar(self, cmd_original: str): self._status_bar_visible = not self._status_bar_visible @@ -208,7 +204,6 @@ class CLILoopsMixin: def _cmd_version(self, cmd_original: str): from hermes_cli.main import _print_version_info - _print_version_info(check_updates=True) def _cmd_reload(self, cmd_original: str): @@ -227,9 +222,7 @@ class CLILoopsMixin: # installed-but-not-enabled plugins show up; the plugin manager only knows # *loaded* plugins and made fresh installs look like "nothing installed". from hermes_cli.plugins_cmd import ( - _discover_all_plugins, _get_disabled_set, _get_enabled_set, _plugin_status - ) - + _discover_all_plugins, _get_disabled_set, _get_enabled_set, _plugin_status) entries = _discover_all_plugins() enabled = _get_enabled_set() disabled = _get_disabled_set() @@ -238,7 +231,6 @@ class CLILoopsMixin: # on one line (full catalog behind `hermes plugins list`). user_entries = [e for e in entries if e[3] != "bundled"] bundled_count = len(entries) - len(user_entries) - if not user_entries: print("No user plugins installed.") print(" Install one: hermes plugins install owner/repo") @@ -246,15 +238,11 @@ class CLILoopsMixin: if bundled_count: print(f" ({bundled_count} bundled plugins available — see: hermes plugins list)") return - # Loaded-plugin details (tools/hooks/commands counts, errors) by name. - loaded: dict = {} - try: + try: # loaded-plugin details (tools/hooks/commands counts, errors) by name from hermes_cli.plugins import get_plugin_manager - for p in get_plugin_manager().list_plugins(): - loaded[p["name"]] = p + loaded = {p["name"]: p for p in get_plugin_manager().list_plugins()} except Exception: loaded = {} - print(f"User plugins ({len(user_entries)}):") for name, version, _desc, source, _dir, key in sorted(user_entries): state = _plugin_status(name, enabled, disabled, key=key) @@ -362,18 +350,13 @@ class CLILoopsMixin: return self._session_bound_manager("_loop_manager", "loop manager", load) def _start_heartbeat_watchdog(self): - """Start the idle-poll thread that fires due heartbeats. - - Same pattern as the wake-word watchdog: a daemon thread polls a few times a - minute; when the session is idle (no agent running, empty input queue) and the - heartbeat is due, its prompt is injected into ``_pending_input`` as a normal user - turn. Missed ticks coalesce — the anchor resets on fire, so a busy hour yields ONE - heartbeat turn. Idempotent; safe to call on every /heartbeat set. - """ + """Start the idle-poll daemon that injects a due heartbeat prompt into + ``_pending_input`` as a normal user turn when the session is idle. Missed ticks + coalesce (the anchor resets on fire, so a busy hour yields ONE heartbeat turn). + Idempotent; safe to call on every /heartbeat set.""" if getattr(self, "_heartbeat_watchdog_started", False): return self._heartbeat_watchdog_started = True - from hermes_cli.heartbeat import POLL_SECONDS def _loop(): @@ -388,8 +371,7 @@ class CLILoopsMixin: self._agent_running or getattr(self, "_voice_recording", False) or getattr(self, "_voice_processing", False) - or not self._pending_input.empty() - ) + or not self._pending_input.empty()) if busy: continue prompt = mgr.due_prompt() @@ -399,7 +381,6 @@ class CLILoopsMixin: logging.debug("heartbeat watchdog tick failed: %s", exc) finally: self._heartbeat_watchdog_started = False - threading.Thread(target=_loop, daemon=True, name="heartbeat-watchdog").start() def _maybe_fire_loop_tick(self) -> None: @@ -426,12 +407,10 @@ class CLILoopsMixin: return try: from hermes_cli.loops import goal_blocks_loop_tick - if goal_blocks_loop_tick(mgr.session_id): return except Exception: pass - wakeup = mgr.fire_tick() if not wakeup: return @@ -470,8 +449,7 @@ class CLILoopsMixin: parts = [ p.get("text", "") for p in content - if isinstance(p, dict) and p.get("type") in {"text", "output_text"} - ] + if isinstance(p, dict) and p.get("type") in {"text", "output_text"}] return "\n".join(t for t in parts if t) return str(content or "") except Exception: @@ -502,10 +480,8 @@ class CLILoopsMixin: pass _cprint( f" {_DIM}⏸ Loop paused — wakeup turn was interrupted. " - f"Use /loop resume to continue, or /loop stop to end it.{_RST}" - ) + f"Use /loop resume to continue, or /loop stop to end it.{_RST}") return - decision = mgr.complete_tick(self._last_assistant_response_text()) msg = decision.get("message") or "" if msg: @@ -514,15 +490,11 @@ class CLILoopsMixin: _cprint(f" {_DIM}↻ Loop: {mgr.state.remaining_label()}.{_RST}") def _maybe_continue_goal_after_turn(self) -> None: - """Hook run after every CLI turn: judge the goal and maybe re-queue a continuation. - - Safe when no goal is set. Preemption is automatic: a real user message already in - ``_pending_input`` skips judging (the user's turn goes first; we re-judge after). - A user-cancelled turn (Ctrl+C) AUTO-PAUSES the goal instead of judging — the judge - would run on partial output, almost always say "continue", and re-queue exactly - what the user cancelled; pausing is observable and recoverable via ``/goal - resume``. The empty-response skip mirrors ``_handle_message`` in ``gateway/run.py``. - """ + """Post-turn hook: judge the goal and maybe re-queue a continuation. A real user + message already queued preempts judging (re-judged after their turn). Ctrl+C + AUTO-PAUSES instead of judging — the judge on partial output nearly always says + "continue" and would re-queue exactly what was cancelled; pausing is recoverable + via ``/goal resume``. Empty-response skip mirrors ``gateway/run.py``.""" from cli import _DIM, _RST, _cprint, _looks_like_slash_command mgr = self._get_goal_manager() if mgr is None or not mgr.is_active(): @@ -551,7 +523,6 @@ class CLILoopsMixin: return except Exception: pass - if getattr(self, "_last_turn_interrupted", False): try: mgr.pause(reason="user-interrupted (Ctrl+C)") @@ -559,8 +530,7 @@ class CLILoopsMixin: logging.debug("goal pause-on-interrupt failed: %s", exc) _cprint( f" {_DIM}⏸ Goal paused — turn was interrupted. " - f"Use /goal resume to continue, or /goal clear to stop.{_RST}" - ) + f"Use /goal resume to continue, or /goal clear to stop.{_RST}") return # Empty/whitespace responses are almost always transient failures (API error, @@ -568,20 +538,16 @@ class CLILoopsMixin: last_response = self._last_assistant_response_text() if not last_response.strip(): return - try: from hermes_cli.goals import gather_background_processes as _gather_bg _bg_procs = _gather_bg() except Exception: _bg_procs = None - decision = mgr.evaluate_after_turn( - last_response, user_initiated=True, background_processes=_bg_procs - ) + last_response, user_initiated=True, background_processes=_bg_procs) msg = decision.get("message") or "" if msg: _cprint(f" {msg}") - if decision.get("should_continue"): prompt = decision.get("continuation_prompt") if prompt: diff --git a/hermes_cli/cli_terminal_mixin.py b/hermes_cli/cli_terminal_mixin.py index ed522b418d..5209de1d0a 100644 --- a/hermes_cli/cli_terminal_mixin.py +++ b/hermes_cli/cli_terminal_mixin.py @@ -65,8 +65,7 @@ class CLITerminalMixin: pass logger.warning( "Terminal I/O broken%s — freezing UI paints to avoid redraw storm (#81521)", - f" ({reason})" if reason else "", - ) + f" ({reason})" if reason else "") def _app_invalidate(self, app, where: str, *, swallow: bool) -> None: """``app.invalidate()``: EIO freezes paints, other OSErrors re-raise, and any @@ -83,17 +82,13 @@ class CLITerminalMixin: raise def _invalidate(self, min_interval: float = 0.25) -> None: - """Throttled UI repaint for high-frequency background updates. + """Throttled UI repaint for high-frequency background updates (spinner frames, + streaming flushes): the throttle prevents blinking on slow/SSH links and the + resize-recovery guard keeps footer chrome out of scrollback mid-SIGWINCH. - For spinner frames, streaming token flushes and other repaints that fire many - times per second: the throttle prevents blinking on slow/SSH links, and the - resize-recovery guard avoids stamping footer/status-bar chrome into scrollback - while a SIGWINCH reflow is in flight. - - Do NOT use for user-blocking modal prompts (approval / clarify / sudo): those - must paint immediately via ``_paint_now``. Sent through this throttle, an - unrelated repaint within the 250ms window — or an in-flight resize — silently - drops the modal's entry paint, so it never renders and times out unseen (#41098). + NOT for user-blocking modals (approval / clarify / sudo) — use ``_paint_now``: a + throttled or resize-gated entry paint is silently dropped, so the prompt never + renders and times out unseen (#41098). """ if getattr(self, "_terminal_io_broken", False): return @@ -139,14 +134,10 @@ class CLITerminalMixin: self._app_invalidate(app, "force_full_redraw", swallow=True) def _schedule_focus_regain_redraw(self, min_interval: float = 1.0) -> None: - """Repaint after a terminal focus-in report (``CSI I``), rate-limited. - - Terminals with focus tracking (Ghostty, iTerm2, xterm, muxes toggling DECSET - 1004) emit ``\\x1b[I`` when the Hermes tab becomes visible again; emulators may - coalesce hidden-tab output, so on regain the incremental diff stacks on stale - content (#60920 focus-regain variant, #25337). Self-gating — terminals without - focus tracking never emit it. Rate-limited so a burst (rapid Alt+Tab, pane hops) - repaints at most once per ``min_interval`` seconds. + """Repaint after a terminal focus-in report (``CSI I``), at most once per + ``min_interval`` (rapid Alt+Tab / pane-hop bursts). Emulators with focus tracking + (DECSET 1004) may coalesce hidden-tab output, so on regain the incremental diff + stacks on stale content (#60920, #25337); terminals without it never emit ``CSI I``. """ now = time.monotonic() if now - getattr(self, "_last_focus_regain_redraw", 0.0) < min_interval: @@ -172,18 +163,12 @@ class CLITerminalMixin: return bool(raw) def _recover_terminal_after_interrupt(self) -> None: - """Recover the terminal after an interrupted agent turn (#33271). - - An in-flight ``CSI 6n`` cursor query may answer (``ESC[;R``) after the - input parser tore down: the reply leaks as literal text and the VT100 parser can - stall mid-escape, so the terminal looks frozen. ``flush_stdin()`` drains stray - bytes (no-op on non-TTY), then ``_force_full_redraw()`` repaints cleanly; each - step self-guards so one failing never blocks the other. A dead PTY (EIO) skips - the redraw — painting a broken fd is the #81521 redraw storm. - - Do NOT clear output history here: the interruption marker is printed under - ``_suspend_output_history`` in chat(), so the replay reproduces the response - without duplicating the marker (#60920). + """Recover the terminal after an interrupted agent turn (#33271): an in-flight + ``CSI 6n`` reply arriving after the input parser tore down leaks as literal text + and can stall the VT100 parser mid-escape. Drain stdin, then full redraw; each step + self-guards. A dead PTY (EIO) skips the redraw (#81521 redraw storm). Never clear + output history here — the interruption marker is printed under + ``_suspend_output_history``, so replay already excludes it (#60920). """ if getattr(self, "_terminal_io_broken", False): return @@ -222,30 +207,20 @@ class CLITerminalMixin: def _recover_after_resize(self, app, original_on_resize) -> None: """Recover a resized classic CLI without desynchronizing cursor state. - Unlike ``_force_full_redraw`` this never clears scrollback: the startup banner - lives there and ``_replay_output_history`` cannot reconstruct it. prompt_toolkit's - own resize path runs with its renderer cursor cache intact — its - ``Application._on_resize()`` erases via the cached cursor position, and resetting - the renderer first loses the origin and strands stale prompt glyphs. - - ``_status_bar_suppressed_after_resize`` hides the dynamic status bar / input rules - while the reflow settles: on column shrink the terminal reflows already-painted - rows into scrollback before prompt_toolkit erases them, so a fresh bar looks - duplicated (#19280, #22976). Suppression alone cannot erase an already-reflowed - OLD bar (``renderer.erase()`` does ``cursor_up(_cursor_pos.y)`` with the y cached - at the OLD width, undershooting the reflowed rows), so on an OBSERVED width - change we wipe the viewport (CSI 2J — banner-safe; 3J only when - ``display.cli_rebuild_scrollback_on_redraw``) and replay the transcript before - delegating. Same-width SIGWINCH (tmux attach, GNOME tab bar, focus signals) and - the first signal without a baseline are left untouched: a 2J+replay against - preserved scrollback duplicates everything in ``_OUTPUT_HISTORY`` (#65293); - ``_install_resize_recovery`` seeds the baseline so an initial maximize still - counts. The stale-previous_screen crash on tmux attach is handled by - ``_hermes_call_output_screen_diff``'s retry (#83874). - - Suppression is transient: a debounced timer clears it and repaints once the - reflow settles, so the bar returns during idle (previously it stayed hidden - until the next submitted input). The next-submit clear remains as a fast path. + Never clears scrollback (the startup banner lives there and replay cannot rebuild + it) and never resets the renderer before prompt_toolkit's own ``_on_resize``, + which erases via the cached cursor position. The status bar / input rules are + suppressed while the reflow settles: on column shrink the terminal reflows + already-painted rows into scrollback first, so a fresh bar looks duplicated + (#19280, #22976). Suppression cannot erase the already-reflowed OLD bar + (``renderer.erase()`` uses ``_cursor_pos.y`` cached at the OLD width), so on an + OBSERVED width change we wipe the viewport (CSI 2J, banner-safe; 3J only via + ``display.cli_rebuild_scrollback_on_redraw``) and replay the transcript first. + Same-width SIGWINCH (tmux attach, GNOME tab bar, focus) and the first signal + without a seeded baseline are left alone — 2J+replay against preserved scrollback + duplicates ``_OUTPUT_HISTORY`` (#65293). tmux-attach's stale previous_screen is + handled by ``_hermes_call_output_screen_diff`` (#83874). Suppression is cleared by + a debounced timer so the bar returns during idle; next-submit stays a fast path. """ from cli import _replay_output_history self._status_bar_suppressed_after_resize = True @@ -258,8 +233,7 @@ class CLITerminalMixin: if width_changed: try: self._clear_prompt_toolkit_screen( - app, rebuild_scrollback=self._redraw_rebuilds_scrollback() - ) + app, rebuild_scrollback=self._redraw_rebuilds_scrollback()) _replay_output_history() except Exception: pass @@ -298,10 +272,8 @@ class CLITerminalMixin: app.invalidate() except Exception: pass - self._restart_debounce_timer( - "_status_bar_unsuppress_timer", delay, lambda _t: _run_on_app_loop(app, _clear) - ) + "_status_bar_unsuppress_timer", delay, lambda _t: _run_on_app_loop(app, _clear)) except Exception: # Fail open: never leave the bar stuck hidden. self._status_bar_suppressed_after_resize = False @@ -322,9 +294,7 @@ class CLITerminalMixin: self._resize_recovery_timer = None self._resize_recovery_pending = False self._recover_after_resize(app, original_on_resize) - _run_on_app_loop(app, _run_recovery) - with lock: self._resize_recovery_pending = True self._restart_debounce_timer("_resize_recovery_timer", delay, _timer_fired) @@ -334,16 +304,12 @@ class CLITerminalMixin: def _install_resize_recovery(self, app) -> None: """Route ``app._on_resize`` through the debounced ghost-clearing recovery - (#5474/#49120) and seed the width baseline for width-change detection. - - The seed keeps the session's FIRST SIGWINCH honest (#65293): without it a benign - signal is indistinguishable from a real resize. It reads ``app.output`` directly, - NOT ``_get_tui_terminal_width``: before ``app.run()`` ``get_app()`` is the - DummyApplication whose DummyOutput reports a hardcoded 80 columns, and seeding - that fake width would make the first real signal look like a change. - ``app.output`` is what the running resize handler measures, so both are comparable. + (#5474/#49120) and seed the width baseline so the FIRST SIGWINCH can tell a benign + signal from a real resize (#65293). Reads ``app.output`` directly, NOT + ``_get_tui_terminal_width``: before ``app.run()`` the DummyApplication's output + reports a hardcoded 80 columns, which would make the first real signal look like a + width change. ``app.output`` is what the running resize handler measures. """ - width = None try: width = app.output.get_size().columns except Exception: @@ -358,14 +324,12 @@ class CLITerminalMixin: def _resize_clear_ghosts(): self._schedule_resize_recovery(app, original_on_resize) - app._on_resize = _resize_clear_ghosts def _try_attach_clipboard_image(self) -> bool: """Save a clipboard image to ~/.hermes/images/ and attach it; True if attached.""" from cli import datetime from hermes_cli.clipboard import save_clipboard_image - self._image_counter += 1 ts = datetime.now().strftime("%Y%m%d_%H%M%S") img_path = get_hermes_home() / "images" / f"clip_{ts}_{self._image_counter}.png" @@ -393,14 +357,12 @@ class CLITerminalMixin: """Best-effort reset when leaked mouse reports indicate mode drift.""" from cli import ( CLI_CONFIG, _DIM, _RST, _TERMINAL_INPUT_MODE_RESET_SEQ, - _cli_multiline_shortcuts_enabled, _cprint, _enable_extended_enter_keys, logger, - ) + _cli_multiline_shortcuts_enabled, _cprint, _enable_extended_enter_keys, logger) now = time.monotonic() # Rate-limit to avoid thrashing if a terminal floods reports. if now - self._last_input_mode_recovery < 0.5: return self._last_input_mode_recovery = now - app = getattr(self, "_app", None) output = getattr(app, "output", None) if app else None try: @@ -415,24 +377,19 @@ class CLITerminalMixin: _enable_extended_enter_keys(output) except Exception: pass - logger.warning("Recovered terminal input modes after leak: %s", reason) if not self._input_mode_recovery_notice_shown: self._input_mode_recovery_notice_shown = True _cprint( f" {_DIM}Recovered terminal input modes after leaked mouse reports. " - f"If this repeats, run /new or restart this tab.{_RST}" - ) + f"If this repeats, run /new or restart this tab.{_RST}") def _check_termios_drift(self) -> None: - """Watchdog: heal the tty if it drifted back to cooked mode. - - See ``_heal_cooked_mode_drift``: a lost ``run_in_terminal`` cooked→raw restore - leaves the tty line-buffering while prompt_toolkit believes it owns raw mode — - the CLI looks dead but the process is healthy. Called from ``process_loop``'s - idle branch so it self-heals within ~1s of idling. Skipped while a - ``run_in_terminal`` window legitimately holds cooked mode, while the agent is - running (approval/sudo prompts manipulate the tty), and on Windows (no termios). + """Idle watchdog: heal a tty that drifted back to cooked mode (a lost + ``run_in_terminal`` cooked→raw restore leaves it line-buffering while prompt_toolkit + believes it owns raw mode — CLI looks dead, process is healthy). Skipped while + ``run_in_terminal`` legitimately holds cooked mode, while the agent runs + (approval/sudo prompts touch the tty), and on Windows (no termios). """ from cli import _DIM, _RST, _cprint, _heal_cooked_mode_drift, logger if os.name == "nt": @@ -455,8 +412,7 @@ class CLITerminalMixin: if _heal_cooked_mode_drift(fd): logger.warning( "Healed cooked-mode termios drift on stdin — a " - "run_in_terminal cooked→raw restore was lost." - ) + "run_in_terminal cooked→raw restore was lost.") try: self._invalidate() # so the prompt is visibly alive again except Exception: @@ -465,5 +421,4 @@ class CLITerminalMixin: self._termios_drift_notice_shown = True _cprint( f" {_DIM}Recovered terminal from cooked-mode drift " - f"(input should respond normally again).{_RST}" - ) + f"(input should respond normally again).{_RST}") diff --git a/hermes_cli/clipboard.py b/hermes_cli/clipboard.py index 3e7b82b384..50b6ed9843 100644 --- a/hermes_cli/clipboard.py +++ b/hermes_cli/clipboard.py @@ -177,8 +177,7 @@ _PS_IMAGE_STRATEGIES = ( "if ($null -eq $img) { exit 1 }" "$ms = New-Object System.IO.MemoryStream;" "$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png);" - "[System.Convert]::ToBase64String($ms.ToArray())", - ), + "[System.Convert]::ToBase64String($ms.ToArray())"), ( # Get-Clipboard -Format Image (System.Drawing.Image or WPF BitmapSource) "try { " "$img = Get-Clipboard -Format Image -ErrorAction Stop;" @@ -199,8 +198,7 @@ _PS_IMAGE_STRATEGIES = ( "$enc.Save($ms)" "} else { exit 2 }" "[System.Convert]::ToBase64String($ms.ToArray())" - "} catch { exit 1 }", - ), + "} catch { exit 1 }"), ( # copied image *file* (Explorer file drop) _PS_FILEDROP_HIT + "if ($null -ne $hit) { 'True' } else { 'False' }" @@ -322,7 +320,6 @@ def _convert_to_png(path: Path) -> bool: pass except Exception as e: logger.debug("Pillow BMP→PNG conversion failed: %s", e) - tmp = path.with_suffix(".bmp") try: path.rename(tmp) diff --git a/hermes_cli/codex_runtime_plugin_migration.py b/hermes_cli/codex_runtime_plugin_migration.py index 66878cdcfc..dbef3dcea1 100644 --- a/hermes_cli/codex_runtime_plugin_migration.py +++ b/hermes_cli/codex_runtime_plugin_migration.py @@ -15,8 +15,7 @@ logger = logging.getLogger(__name__) # Marker comments wrapping the managed section so re-runs can detect what's ours and what's # user-edited. Both must appear or strip is a no-op. MIGRATION_MARKER = ( - "# managed by hermes-agent — `hermes codex-runtime migrate` regenerates this section" -) + "# managed by hermes-agent — `hermes codex-runtime migrate` regenerates this section") MIGRATION_END_MARKER = "# end hermes-agent managed section" @@ -65,8 +64,7 @@ _KNOWN_HERMES_KEYS = { "command", "args", "env", "cwd", "url", "headers", "transport", "timeout", "connect_timeout", - "enabled", "description", -} + "enabled", "description"} _KEYS_DROPPED_WITH_WARNING = {"sampling"} # (hermes key, codex key, skip note) — timeouts are emitted as floats or skipped when non-numeric. @@ -91,7 +89,6 @@ def _translate_one_server(name: str, hermes_cfg: dict) -> tuple[Optional[dict], """ if not isinstance(hermes_cfg, dict): return None, [] - skipped: list[str] = [] out: dict[str, Any] = {} if hermes_cfg.get("command"): @@ -112,7 +109,6 @@ def _translate_one_server(name: str, hermes_cfg: dict) -> tuple[Optional[dict], skipped.append("transport=sse (codex auto-negotiates)") else: return None, ["no command or url field"] - for hermes_key, codex_key, note in _TIMEOUT_KEYS: if hermes_key in hermes_cfg: try: @@ -121,7 +117,6 @@ def _translate_one_server(name: str, hermes_cfg: dict) -> tuple[Optional[dict], skipped.append(note) if hermes_cfg.get("enabled") is False: out["enabled"] = False - for key in hermes_cfg: if key in _KEYS_DROPPED_WITH_WARNING: skipped.append(f"{key} (no codex equivalent)") @@ -171,8 +166,7 @@ def _quote_key(key: str) -> str: def render_codex_toml_section( servers: dict[str, dict], plugins: Optional[list[dict]] = None, - default_permission_profile: Optional[str] = None, -) -> str: + default_permission_profile: Optional[str] = None) -> str: """Render the managed [mcp_servers.] / [plugins.] / default_permissions block. ``default_permission_profile`` (e.g. "workspace-write", "read-only", "full-access") is written @@ -185,7 +179,6 @@ def render_codex_toml_section( out.append("# (no MCP servers, plugins, or permissions configured by Hermes)") out.append(MIGRATION_END_MARKER) return "\n".join(out) + "\n" - if default_permission_profile: profile = default_permission_profile normalized = profile if profile.startswith(":") else f":{profile}" @@ -211,13 +204,11 @@ def _insert_managed_block_at_top_level(user_text: str, managed_block: str) -> st """ if not user_text.strip(): return managed_block - lines = user_text.splitlines(keepends=True) first_table_idx = next((i for i, ln in enumerate(lines) if ln.lstrip().startswith("[")), None) if first_table_idx is None: prefix = user_text.rstrip("\n") return f"{prefix}\n\n{managed_block}" if prefix else managed_block - prefix = "".join(lines[:first_table_idx]).rstrip("\n") suffix = "".join(lines[first_table_idx:]).lstrip("\n") return f"{prefix}\n\n{managed_block}\n{suffix}" if prefix else f"{managed_block}\n{suffix}" @@ -287,30 +278,23 @@ def _strip_existing_managed_block(toml_text: str) -> str: def _query_codex_plugins( - codex_home: Optional[Path] = None, timeout: float = 8.0 -) -> tuple[list[dict], Optional[str]]: - """Query codex's `plugin/list` for installed curated plugins. - - Spawns ``codex app-server`` briefly, sends initialize + plugin/list and keeps installed=true. - Returns ``(plugins, error)``; any failure (not installed, RPC error, timeout) yields ``([], - error)`` and is non-fatal — MCP servers and permissions still write through. - - Plugins codex reports as unavailable (broken install, missing OAuth, removed from marketplace) - are skipped: we write codex's config.toml directly, so a broken plugin would surface as a - codex error on the user's first turn. ``enabled`` is carried forward as codex reports it. + codex_home: Optional[Path] = None, timeout: float = 8.0) -> tuple[list[dict], Optional[str]]: + """Spawn ``codex app-server`` briefly and return ``(installed plugins, error)`` from + ``plugin/list``. Any failure yields ``([], error)`` and is non-fatal (servers and + permissions still write). Plugins codex reports unavailable (broken install, missing OAuth, + delisted) are skipped — we write config.toml directly, so they would surface as a codex + error on the first turn. ``enabled`` is carried forward as reported. """ try: from agent.transports.codex_app_server import CodexAppServerClient except Exception as exc: return [], f"transport unavailable: {exc}" - try: with CodexAppServerClient(codex_home=str(codex_home) if codex_home else None) as client: client.initialize(client_name="hermes-migration") resp = client.request("plugin/list", {}, timeout=timeout) except Exception as exc: return [], f"plugin/list query failed: {exc}" - marketplaces = resp.get("marketplaces") or [] if not isinstance(marketplaces, list): return [], "plugin/list response missing 'marketplaces'" @@ -353,20 +337,16 @@ def _looks_like_test_tempdir(path: str) -> bool: def _build_hermes_tools_mcp_entry() -> dict: - """Build the codex stdio entry that launches Hermes' own tool surface as an MCP server, so the - codex subprocess can call back for browser/web/delegate_task/vision/memory/skills tools. + """Codex stdio entry launching Hermes' own tool surface as an MCP server (browser/web/ + delegate_task/vision/memory/skills call-backs). - HERMES_HOME passes through IF SET so the subprocess sees the same config/auth/sessions DB as - the parent CLI. Read from os.environ (not get_hermes_home()) on purpose: when unset, the codex - subprocess should inherit whatever HERMES_HOME its launcher sets at runtime (systemd unit, - gateway, kanban dispatcher, custom shell) rather than a migrate-time default burned into - config.toml, which would pin it to the wrong profile. The pytest-tempdir guard stops a sibling - test's ``monkeypatch.setenv("HERMES_HOME", tmp_path)`` from leaking a transient dir into the - user's real config.toml. PYTHONPATH passes through so a worktree-launched hermes finds the - branch's modules instead of the installed package. + HERMES_HOME passes through only IF SET, read from os.environ (not get_hermes_home()): when + unset the codex subprocess must inherit its launcher's runtime HERMES_HOME (systemd, gateway, + kanban), not a migrate-time default burned into config.toml that pins the wrong profile. The + pytest-tempdir guard keeps a sibling test's monkeypatched HERMES_HOME out of the user's real + config. PYTHONPATH passes through so a worktree-launched hermes finds the branch's modules. """ import sys - env: dict[str, str] = {} hermes_home = os.environ.get("HERMES_HOME") or "" if hermes_home and not _looks_like_test_tempdir(hermes_home): @@ -376,22 +356,19 @@ def _build_hermes_tools_mcp_entry() -> dict: # Quiet mode + redaction defaults so the MCP wire stays clean. env["HERMES_QUIET"] = "1" env["HERMES_REDACT_SECRETS"] = env.get("HERMES_REDACT_SECRETS", "true") - return { "command": sys.executable, "args": ["-m", "agent.transports.hermes_tools_mcp_server"], "env": env, # Generous timeouts — browser_navigate or delegate_task can take a while. "startup_timeout_sec": 30.0, - "tool_timeout_sec": 600.0, - } + "tool_timeout_sec": 600.0} def _write_atomic(target: Path, text: str) -> None: """Write via a same-directory temp file + rename (atomic on POSIX, ReplaceFile on Windows) so a crash mid-write never leaves a half-written config.toml that codex would refuse to load.""" import tempfile - tmp_fd, tmp_path_str = tempfile.mkstemp(prefix=".config.toml.", dir=str(target.parent)) tmp_path = Path(tmp_path_str) try: @@ -400,8 +377,7 @@ def _write_atomic(target: Path, text: str) -> None: tmp_path.replace(target) except Exception: try: - if tmp_path.exists(): - tmp_path.unlink() + tmp_path.unlink(missing_ok=True) except Exception: pass raise @@ -410,8 +386,7 @@ def _write_atomic(target: Path, text: str) -> None: def migrate( hermes_config: dict, *, codex_home: Optional[Path] = None, dry_run: bool = False, discover_plugins: bool = True, default_permission_profile: Optional[str] = ":workspace", - expose_hermes_tools: bool = True, -) -> MigrationReport: + expose_hermes_tools: bool = True) -> MigrationReport: """Translate Hermes mcp_servers config + Codex curated plugins into ~/.codex/config.toml. ``discover_plugins`` spawns the live codex CLI (set False in tests); discovery is best-effort @@ -425,12 +400,10 @@ def migrate( codex_home = codex_home or Path.home() / ".codex" target = codex_home / "config.toml" report.target_path = target - hermes_servers = (hermes_config or {}).get("mcp_servers") or {} if not isinstance(hermes_servers, dict): report.errors.append("mcp_servers in Hermes config is not a dict; cannot migrate.") return report - translated: dict[str, dict] = {} for raw_name, cfg in hermes_servers.items(): name = str(raw_name) @@ -443,7 +416,6 @@ def migrate( if skipped: report.skipped_keys_per_server[name] = skipped report.migrated.append(name) - plugins: list[dict] = [] plugin_query_succeeded = False if discover_plugins and not dry_run: @@ -454,17 +426,14 @@ def migrate( # re-render and may strip pre-existing tables outside the managed block. plugin_query_succeeded = not plugin_err report.migrated_plugins += [f"{p['name']}@{p['marketplace']}" for p in plugins] - if default_permission_profile: report.wrote_permissions_default = default_permission_profile if expose_hermes_tools: translated["hermes-tools"] = _build_hermes_tools_mcp_entry() if "hermes-tools" not in report.migrated: report.migrated.append("hermes-tools") - managed_block = render_codex_toml_section( - translated, plugins=plugins, default_permission_profile=default_permission_profile, - ) + translated, plugins=plugins, default_permission_profile=default_permission_profile) new_text = managed_block if target.exists(): try: @@ -476,7 +445,6 @@ def migrate( if plugin_query_succeeded: without_managed = _strip_unmanaged_plugin_tables(without_managed) new_text = _insert_managed_block_at_top_level(without_managed, managed_block) - if dry_run: return report try: From 48ef059a6c57ca3234f954fe0dd082f4614dd358 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:35:44 -0700 Subject: [PATCH 11/13] refactor(hermes_cli): dedupe decision-message printing, width-probe loop, plugin dedupe via dict --- hermes_cli/cli_agent_setup_mixin.py | 5 ++- hermes_cli/cli_loops_mixin.py | 39 +++++++++----------- hermes_cli/cli_terminal_mixin.py | 17 ++++----- hermes_cli/codex_runtime_plugin_migration.py | 14 +++---- 4 files changed, 34 insertions(+), 41 deletions(-) diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index afda620ee5..d36056e15f 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -417,12 +417,13 @@ class CLIAgentSetupMixin: else: ChatConsole().print(rich) if not session_meta: + hint = "Use a session ID from a previous CLI run (hermes sessions list)." if _quiet_mode: print(f"Session not found: {self.session_id}", file=sys.stderr) - print("Use a session ID from a previous CLI run (hermes sessions list).", file=sys.stderr) + print(hint, file=sys.stderr) else: _cprint(f"\033[1;31mSession not found: {self.session_id}{_RST}") - _cprint(f"{_DIM}Use a session ID from a previous CLI run (hermes sessions list).{_RST}") + _cprint(f"{_DIM}{hint}{_RST}") return False session_meta = self._follow_compression_chain( session_meta, diff --git a/hermes_cli/cli_loops_mixin.py b/hermes_cli/cli_loops_mixin.py index 10aec28cc5..2952479ebf 100644 --- a/hermes_cli/cli_loops_mixin.py +++ b/hermes_cli/cli_loops_mixin.py @@ -21,6 +21,15 @@ def _preview(payload: str) -> str: return f"{payload[:80]}{'...' if len(payload) > 80 else ''}" +def _print_decision_message(decision: dict) -> bool: + """Print a manager decision's ``message`` (if any) via _cprint; True when one was printed.""" + from cli import _cprint + msg = decision.get("message") or "" + if msg: + _cprint(f" {msg}") + return bool(msg) + + class CLILoopsMixin: """Simple slash-command wrappers plus goal/heartbeat/loop manager hooks for the interactive CLI""" @@ -246,10 +255,10 @@ class CLILoopsMixin: print(f"User plugins ({len(user_entries)}):") for name, version, _desc, source, _dir, key in sorted(user_entries): state = _plugin_status(name, enabled, disabled, key=key) - glyph = {"enabled": "✓", "disabled": "✗"}.get(state, "○") - ver = f" v{version}" if version else "" info = loaded.get(name) or {} bits = [f"{info[k]} {k}" for k in ("tools", "hooks", "commands") if info.get(k)] + glyph = {"enabled": "✓", "disabled": "✗"}.get(state, "○") + ver = f" v{version}" if version else "" detail = f" ({', '.join(bits)})" if bits else "" label = "" if state == "enabled" else f" [{state}]" error = f" — {info['error']}" if info.get("error") else "" @@ -432,10 +441,7 @@ class CLILoopsMixin: # judge; complete them immediately (caps and scheduling still apply). if wakeup.lstrip().startswith("/"): try: - decision = mgr.complete_tick("") - msg = decision.get("message") or "" - if msg: - _cprint(f" {msg}") + _print_decision_message(mgr.complete_tick("")) except Exception: pass @@ -483,10 +489,8 @@ class CLILoopsMixin: f"Use /loop resume to continue, or /loop stop to end it.{_RST}") return decision = mgr.complete_tick(self._last_assistant_response_text()) - msg = decision.get("message") or "" - if msg: - _cprint(f" {msg}") - elif decision.get("status") == "active" and mgr.state is not None: + if (not _print_decision_message(decision) and decision.get("status") == "active" + and mgr.state is not None): _cprint(f" {_DIM}↻ Loop: {mgr.state.remaining_label()}.{_RST}") def _maybe_continue_goal_after_turn(self) -> None: @@ -508,15 +512,10 @@ class CLILoopsMixin: try: pending = getattr(self, "_pending_input", None) if pending is not None and not pending.empty(): - has_real_message = False try: - for entry in list(pending.queue): - if isinstance(entry, tuple) and entry: - entry = entry[0] - if isinstance(entry, str) and _looks_like_slash_command(entry): - continue - has_real_message = True - break + entries = [e[0] if isinstance(e, tuple) and e else e for e in list(pending.queue)] + has_real_message = any( + not (isinstance(e, str) and _looks_like_slash_command(e)) for e in entries) except Exception: has_real_message = True # can't introspect — defer to be safe if has_real_message: @@ -545,9 +544,7 @@ class CLILoopsMixin: _bg_procs = None decision = mgr.evaluate_after_turn( last_response, user_initiated=True, background_processes=_bg_procs) - msg = decision.get("message") or "" - if msg: - _cprint(f" {msg}") + _print_decision_message(decision) if decision.get("should_continue"): prompt = decision.get("continuation_prompt") if prompt: diff --git a/hermes_cli/cli_terminal_mixin.py b/hermes_cli/cli_terminal_mixin.py index 5209de1d0a..76bb873831 100644 --- a/hermes_cli/cli_terminal_mixin.py +++ b/hermes_cli/cli_terminal_mixin.py @@ -310,21 +310,18 @@ class CLITerminalMixin: reports a hardcoded 80 columns, which would make the first real signal look like a width change. ``app.output`` is what the running resize handler measures. """ - try: - width = app.output.get_size().columns - except Exception: - width = None - if not width or width <= 0: + width = None + for probe in (lambda: app.output.get_size().columns, + lambda: shutil.get_terminal_size((80, 24)).columns): try: - width = shutil.get_terminal_size((80, 24)).columns + width = probe() except Exception: width = None + if width and width > 0: + break self._last_resize_width = width original_on_resize = app._on_resize - - def _resize_clear_ghosts(): - self._schedule_resize_recovery(app, original_on_resize) - app._on_resize = _resize_clear_ghosts + app._on_resize = lambda: self._schedule_resize_recovery(app, original_on_resize) def _try_attach_clipboard_image(self) -> bool: """Save a clipboard image to ~/.hermes/images/ and attach it; True if attached.""" diff --git a/hermes_cli/codex_runtime_plugin_migration.py b/hermes_cli/codex_runtime_plugin_migration.py index dbef3dcea1..ab49d9d529 100644 --- a/hermes_cli/codex_runtime_plugin_migration.py +++ b/hermes_cli/codex_runtime_plugin_migration.py @@ -298,8 +298,7 @@ def _query_codex_plugins( marketplaces = resp.get("marketplaces") or [] if not isinstance(marketplaces, list): return [], "plugin/list response missing 'marketplaces'" - out: list[dict] = [] - seen: set[tuple[str, str]] = set() + out: dict[tuple[str, str], dict] = {} # (name, marketplace) -> entry; first wins for marketplace in marketplaces: if not isinstance(marketplace, dict): continue @@ -314,12 +313,11 @@ def _query_codex_plugins( availability) continue name = str(plugin.get("name") or "") - if not name or (name, market_name) in seen: - continue - seen.add((name, market_name)) - out.append({"name": name, "marketplace": market_name, - "enabled": bool(plugin.get("enabled", True))}) - return out, None + if name: + out.setdefault((name, market_name), { + "name": name, "marketplace": market_name, + "enabled": bool(plugin.get("enabled", True))}) + return list(out.values()), None # pytest tempdir shapes: ``pytest-of-/pytest-/``, macOS ``/private/var/folders/…/T``. From c6f75a32ec03ac587760a122e10ed2d2a671fde2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:42:56 -0700 Subject: [PATCH 12/13] refactor(hermes_cli): unify resume state restore, fold trivial locals and module docstrings in g6 files --- hermes_cli/claw.py | 5 ++- hermes_cli/cli_agent_setup_mixin.py | 36 ++++++++------------ hermes_cli/cli_loops_mixin.py | 21 ++++-------- hermes_cli/cli_terminal_mixin.py | 16 +++------ hermes_cli/codex_runtime_plugin_migration.py | 3 +- 5 files changed, 30 insertions(+), 51 deletions(-) diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py index c1d6c0c1a6..5f83cf28b6 100644 --- a/hermes_cli/claw.py +++ b/hermes_cli/claw.py @@ -402,10 +402,9 @@ def _cmd_cleanup(args): print_error(f"Could not archive: {e}") print_info(f"Try manually: mv {source_dir} {source_dir}.pre-migration") print() - n = len(dirs_to_check) if dry_run else total_archived - word = "directory" if n == 1 else "directories" + word = "directory" if (len(dirs_to_check) if dry_run else total_archived) == 1 else "directories" if dry_run: - _info(f"Dry run complete. {n} {word} would be archived.", + _info(f"Dry run complete. {len(dirs_to_check)} {word} would be archived.", "Run without --dry-run to archive them.") elif total_archived: print_success(f"Cleaned up {total_archived} OpenClaw {word}.") diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index d36056e15f..237287c5ec 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -1,9 +1,6 @@ -"""Agent-construction and session-resume display methods for ``HermesCLI``. - -Runtime-credential resolution, per-turn agent config, first-use agent construction, and -resumed-session preload + history recap. ``cli.py`` helpers are imported lazily inside -each method so this module never imports ``cli`` at import time (import cycle). -""" +"""Agent construction + session-resume display for ``HermesCLI``: credential resolution, +per-turn agent config, first-use build, resume preload + recap. ``cli.py`` helpers are +imported lazily inside each method (import cycle).""" from __future__ import annotations @@ -202,8 +199,7 @@ class CLIAgentSetupMixin: list(runtime.get("args") or [])) # A callable api_key is a bearer-token provider (Azure Entra ID): the OpenAI SDK # invokes it per request, so skip string validation / placeholder substitution. - _is_callable_provider = callable(api_key) and not isinstance(api_key, str) - if not _is_callable_provider and (not isinstance(api_key, str) or not api_key): + if not callable(api_key) and not (isinstance(api_key, str) and api_key): if _keyless_custom_base(base_url): # Placeholder key so the SDK doesn't reject the keyless local endpoint. api_key = "no-key-required" @@ -314,7 +310,7 @@ class CLIAgentSetupMixin: return False api_key = runtime.get("api_key") base_url = runtime.get("base_url") - if (callable(api_key) and not isinstance(api_key, str)) or (isinstance(api_key, str) and api_key): + if callable(api_key) or (isinstance(api_key, str) and api_key): return bool(base_url) return _keyless_custom_base(base_url) @@ -394,6 +390,12 @@ class CLIAgentSetupMixin: session_meta = self._session_db.get_session(self.session_id) or session_meta return session_meta + def _restore_session_state(self, session_meta, *, quiet: bool = False) -> None: + """Restore cwd / yolo / model from the resumed session's metadata.""" + self._restore_session_cwd(session_meta, quiet=quiet) + self._restore_session_yolo(session_meta, quiet=quiet) + self._restore_session_model(session_meta, quiet=quiet) + def _reopen_session(self) -> None: """Clear ended_at so the resumed session is active again (best effort).""" try: @@ -453,9 +455,7 @@ class CLIAgentSetupMixin: f"↻ Resumed session {self.session_id}{title_part} {counts}", f"[bold {_accent_hex()}]↻ Resumed session[/] [bold]{_escape(self.session_id)}[/]" f"[bold {_accent_hex()}]{_escape(title_part)}[/] {counts}") - self._restore_session_cwd(session_meta, quiet=_quiet_mode) - self._restore_session_yolo(session_meta, quiet=_quiet_mode) - self._restore_session_model(session_meta, quiet=_quiet_mode) + self._restore_session_state(session_meta, quiet=_quiet_mode) else: _say( f"Session {self.session_id} found but has no messages. Starting fresh.", @@ -555,7 +555,7 @@ class CLIAgentSetupMixin: self._active_agent_route_signature = _route_signature(effective_model, runtime) # Force-create DB row on /title intent, then apply title. - if self._pending_title and self._session_db and self.agent: + if self._pending_title and self._session_db: try: self.agent._ensure_db_session() if self.agent._session_db_created: @@ -589,10 +589,7 @@ class CLIAgentSetupMixin: safety_check = getattr(self._session_db, "assert_resume_safe", None) if not callable(safety_check): return None - if tip_only: - safety_check(self.session_id, tip_only=True) - else: - safety_check(self.session_id) + safety_check(self.session_id, **({"tip_only": True} if tip_only else {})) except SessionResumeTooLargeError as exc: return str(exc) except Exception as exc: @@ -634,7 +631,6 @@ class CLIAgentSetupMixin: self.conversation_history = restored self._resume_display_history = [m for m in display_history if m.get("role") != "session_meta"] from agent.context_compressor import is_user_originated_turn - # Count only user-originated turns: legacy compaction handoffs are durable # role=user rows without display_kind. msg_count = len([m for m in self._resume_display_history if is_user_originated_turn(m)]) @@ -644,9 +640,7 @@ class CLIAgentSetupMixin: f"{title_part} " f"({msg_count} user message{'s' if msg_count != 1 else ''}, " f"{len(restored)} total messages)[/]") - self._restore_session_cwd(session_meta) - self._restore_session_yolo(session_meta) - self._restore_session_model(session_meta) + self._restore_session_state(session_meta) self._reopen_session() return True diff --git a/hermes_cli/cli_loops_mixin.py b/hermes_cli/cli_loops_mixin.py index 2952479ebf..65a45314b7 100644 --- a/hermes_cli/cli_loops_mixin.py +++ b/hermes_cli/cli_loops_mixin.py @@ -1,8 +1,5 @@ -"""Simple slash-command wrappers plus goal/heartbeat/loop manager hooks for the interactive CLI - -Mixin bound onto ``HermesCLI`` via the MRO. cli.py-internal symbols are imported LAZILY -inside each method (``from cli import ...``) — never at module load (import cycle). -""" +"""Simple slash-command wrappers plus goal/heartbeat/loop manager hooks for the interactive CLI. +Mixin on ``HermesCLI``; cli.py symbols are imported lazily inside methods (import cycle).""" from __future__ import annotations @@ -198,14 +195,12 @@ class CLILoopsMixin: def _cmd_egress(self, cmd_original: str): from hermes_cli.slash_exec import CommandContext, execute_command - self._console_print( - execute_command("egress", CommandContext(surface="cli")).text, - highlight=False, markup=False) + text = execute_command("egress", CommandContext(surface="cli")).text + self._console_print(text, highlight=False, markup=False) def _cmd_statusbar(self, cmd_original: str): self._status_bar_visible = not self._status_bar_visible - state = "visible" if self._status_bar_visible else "hidden" - self._console_print(f" Status bar {state}") + self._console_print(f" Status bar {'visible' if self._status_bar_visible else 'hidden'}") def _cmd_update(self, cmd_original: str) -> bool: # A truthy result means the process is relaunching — leave the REPL. @@ -276,10 +271,8 @@ class CLILoopsMixin: _cprint(" Usage: /queue ") else: self._pending_input.put(payload) - if self._agent_running: - _cprint(f" Queued for the next turn: {_preview(payload)}") - else: - _cprint(f" Queued: {_preview(payload)}") + when = " for the next turn" if self._agent_running else "" + _cprint(f" Queued{when}: {_preview(payload)}") def _cmd_steer(self, cmd_original: str): # Inject a message after the next tool call without interrupting: while the diff --git a/hermes_cli/cli_terminal_mixin.py b/hermes_cli/cli_terminal_mixin.py index 76bb873831..539415a519 100644 --- a/hermes_cli/cli_terminal_mixin.py +++ b/hermes_cli/cli_terminal_mixin.py @@ -1,8 +1,5 @@ -"""Terminal repaint/resize recovery, input-mode healing, and clipboard helpers for the interactive CLI - -Mixin bound onto ``HermesCLI`` via the MRO. cli.py-internal symbols are imported LAZILY -inside each method (``from cli import ...``) — never at module load (import cycle). -""" +"""Terminal repaint/resize recovery, input-mode healing, and clipboard helpers for the interactive +CLI. Mixin on ``HermesCLI``; cli.py symbols are imported lazily inside methods (import cycle).""" from __future__ import annotations @@ -90,9 +87,7 @@ class CLITerminalMixin: throttled or resize-gated entry paint is silently dropped, so the prompt never renders and times out unseen (#41098). """ - if getattr(self, "_terminal_io_broken", False): - return - if getattr(self, "_resize_recovery_pending", False): + if getattr(self, "_terminal_io_broken", False) or getattr(self, "_resize_recovery_pending", False): return now = time.monotonic() if hasattr(self, "_app") and self._app and (now - getattr(self, "_last_invalidate", 0.0)) >= min_interval: @@ -155,9 +150,8 @@ class CLITerminalMixin: """ from cli import CLI_CONFIG display_config = CLI_CONFIG.get("display") if isinstance(CLI_CONFIG, dict) else {} - if not isinstance(display_config, dict): - display_config = {} - raw = display_config.get("cli_rebuild_scrollback_on_redraw", False) + raw = (display_config.get("cli_rebuild_scrollback_on_redraw", False) + if isinstance(display_config, dict) else False) if isinstance(raw, str): return raw.strip().lower() in {"1", "true", "yes", "on", "always"} return bool(raw) diff --git a/hermes_cli/codex_runtime_plugin_migration.py b/hermes_cli/codex_runtime_plugin_migration.py index ab49d9d529..9002d89347 100644 --- a/hermes_cli/codex_runtime_plugin_migration.py +++ b/hermes_cli/codex_runtime_plugin_migration.py @@ -176,8 +176,7 @@ def render_codex_toml_section( """ out = [MIGRATION_MARKER] if not servers and not plugins and not default_permission_profile: - out.append("# (no MCP servers, plugins, or permissions configured by Hermes)") - out.append(MIGRATION_END_MARKER) + out += ["# (no MCP servers, plugins, or permissions configured by Hermes)", MIGRATION_END_MARKER] return "\n".join(out) + "\n" if default_permission_profile: profile = default_permission_profile From 1b8de3457d82e4e585e23ea1d7c0d50fb8bdc605 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:53:16 -0700 Subject: [PATCH 13/13] refactor(hermes_cli): repoint callable-api-key source-inspection test at the guard expression --- tests/run_agent/test_callable_api_key.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/run_agent/test_callable_api_key.py b/tests/run_agent/test_callable_api_key.py index 6588da27a1..e6b647b3d3 100644 --- a/tests/run_agent/test_callable_api_key.py +++ b/tests/run_agent/test_callable_api_key.py @@ -254,9 +254,9 @@ class TestCliEnsureRuntimeCredentialsCallable: # module the method actually lives in now. src = (Path(__file__).resolve().parent.parent.parent / "hermes_cli" / "cli_agent_setup_mixin.py").read_text() - # The fix introduces ``_is_callable_provider`` which gates the - # string-only check so callable token providers survive. - assert "_is_callable_provider = callable(api_key)" in src, ( + # The fix gates the string-only check on ``callable(api_key)`` so callable + # token providers survive. + assert "if not callable(api_key) and not (isinstance(api_key, str) and api_key):" in src, ( "_ensure_runtime_credentials must preserve a callable " "api_key (Entra ID bearer provider). Without the guard, the " "callable is stringified to 'no-key-required' and Azure 401s."