From c52cbf630a69d656b1a50533f71f1664ab4f7fa0 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Fri, 3 Apr 2026 09:33:45 -0400 Subject: [PATCH 01/44] feat: Ctrl+G external editor for input + /keys command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two features to the Hermes CLI: Ctrl+G — External Editor: - Opens current input in $VISUAL / $EDITOR / VS Code / Cursor / vi - Smart paste detection: if input contains a collapsed paste reference [Pasted text #N → path], opens that file directly for editing - Uses run_in_terminal() for clean TUI suspend/resume - Updates input buffer and paste line count on editor close /keys (/shortcuts) — Keyboard Shortcuts Display: - Categorized list of all keybindings (Input, Session, Drafting, Voice) - Reads voice key from config for accurate display - Registered in CommandDef with tab completion --- cli.py | 127 +++++++++++++++++++++++++++++++++++++++++ hermes_cli/commands.py | 2 + 2 files changed, 129 insertions(+) diff --git a/cli.py b/cli.py index de21d81e502f..bc841241d8f7 100644 --- a/cli.py +++ b/cli.py @@ -2846,6 +2846,44 @@ def _show_status(self): f"{toolsets_info}{provider_info}" ) + def _show_keyboard_shortcuts(self): + """Display all keyboard shortcuts (/keys command).""" + _voice_key_display = "Ctrl+B" + try: + from hermes_cli.config import load_config + _rk = load_config().get("voice", {}).get("record_key", "ctrl+b") + _voice_key_display = _rk.replace("ctrl+", "Ctrl+").replace("alt+", "Alt+") + except Exception: + pass + shortcuts = [ + ("Input", [ + ("Enter", "Send message"), + ("Alt+Enter", "Insert newline (multi-line input)"), + ("Ctrl+J", "Insert newline (alternate)"), + ("Tab", "Accept completion / trigger completions"), + ("Up / Down", "Browse input history"), + ]), + ("Session", [ + ("Ctrl+C", "Cancel prompt / interrupt agent / exit"), + ("Ctrl+D", "Exit"), + ("Ctrl+Z", "Suspend to background (fg to resume)"), + ]), + ("Drafting", [ + ("Ctrl+G", "Open input in external editor ($VISUAL / VS Code)"), + ("Ctrl+S", "Stash input (pop with Ctrl+S, auto-restores after response)"), + ("Ctrl+V", "Paste from clipboard (image-aware)"), + ]), + ("Voice", [ + (_voice_key_display, "Toggle voice recording (when voice mode is on)"), + ]), + ] + _cprint(f"\n {_BOLD}⌨ Keyboard Shortcuts{_RST}\n") + for category, bindings in shortcuts: + _cprint(f" {_GOLD}{category}{_RST}") + for key, desc in bindings: + _cprint(f" {_BOLD}{key:<20}{_RST}{_DIM}{desc}{_RST}") + _cprint("") + def show_help(self): """Display help information with categorized commands.""" from hermes_cli.commands import COMMANDS_BY_CATEGORY @@ -4029,6 +4067,8 @@ def process_command(self, command: str) -> bool: return False elif canonical == "help": self.show_help() + elif canonical in ("keys", "shortcuts"): + self._show_keyboard_shortcuts() elif canonical == "profile": self._handle_profile_command() elif canonical == "tools": @@ -7072,6 +7112,93 @@ def _suspend(): os.kill(0, _sig.SIGTSTP) run_in_terminal(_suspend) + @kb.add('c-g') + def handle_external_editor(event): + """Ctrl+G: open current input in $VISUAL / $EDITOR / VS Code. + + Smart file detection: + - If the input contains a paste reference [Pasted text #N ... → path], + open that paste file directly so the user edits the full content. + - Otherwise, write the input to a temp file and open that. + + The editor runs inside run_in_terminal() so the TUI is suspended + cleanly. On close the buffer is updated with the file contents. + """ + if cli_ref._agent_running or cli_ref._clarify_state or cli_ref._sudo_state: + return + + buf = event.app.current_buffer + original_text = buf.text + + from prompt_toolkit.application import run_in_terminal + + def _run_editor(): + import subprocess, shlex, re as _re + + # Detect paste file reference in the input + _paste_re = _re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]') + paste_match = _paste_re.search(original_text) + + if paste_match: + edit_file = Path(paste_match.group(1)) + if not edit_file.exists(): + _cprint(f" {_DIM}Paste file not found: {edit_file}{_RST}") + return + is_paste_file = True + else: + edit_dir = _hermes_home / "editor" + edit_dir.mkdir(parents=True, exist_ok=True) + edit_file = edit_dir / "prompt.md" + edit_file.write_text(original_text, encoding="utf-8") + is_paste_file = False + + # Resolve editor: $VISUAL > $EDITOR > code --wait > cursor --wait > vi + editor_cmd = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "" + if not editor_cmd: + for candidate in ("code", "cursor"): + if shutil.which(candidate): + editor_cmd = f"{candidate} --wait" + break + else: + editor_cmd = "vi" + + try: + _cprint(f" {_DIM}📝 {editor_cmd} {edit_file}{_RST}") + parts = shlex.split(editor_cmd) + subprocess.run(parts + [str(edit_file)], check=False) + new_text = edit_file.read_text(encoding="utf-8") + + if is_paste_file: + # Rebuild the paste reference with updated line count + line_count = new_text.count('\n') + 1 + ref = paste_match.group(0) + # Replace old ref keeping the rest of the input intact + updated_ref = _re.sub( + r'\d+ lines', + f'{line_count} lines', + ref, + ) + final_text = original_text.replace(ref, updated_ref) + else: + final_text = new_text + + # Update the buffer in the app thread + def _update(): + buf.text = final_text + buf.cursor_position = len(final_text) + event.app.invalidate() + + event.app.loop.call_soon_threadsafe(_update) + + if new_text != (edit_file.read_text(encoding="utf-8") if is_paste_file else original_text): + _cprint(f" {_DIM}📝 Editor content loaded ({len(new_text)} chars){_RST}") + else: + _cprint(f" {_DIM}📝 No changes from editor{_RST}") + except Exception as e: + _cprint(f" {_DIM}Editor error: {e}{_RST}") + + run_in_terminal(_run_editor) + # Voice push-to-talk key: configurable via config.yaml (voice.record_key) # Default: Ctrl+B (avoids conflict with Ctrl+R readline reverse-search) # Config uses "ctrl+b" format; prompt_toolkit expects "c-b" format. diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 07a8f5e1ebfd..e82caaf6210a 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -128,6 +128,8 @@ class CommandDef: CommandDef("commands", "Browse all commands and skills (paginated)", "Info", gateway_only=True, args_hint="[page]"), CommandDef("help", "Show available commands", "Info"), + CommandDef("keys", "Show keyboard shortcuts", "Info", + cli_only=True, aliases=("shortcuts",)), CommandDef("usage", "Show token usage for the current session", "Info"), CommandDef("insights", "Show usage insights and analytics", "Info", args_hint="[days]"), From 9cdf7c5a027ebe7035843a74d7cd552082168d45 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Fri, 3 Apr 2026 08:58:49 -0400 Subject: [PATCH 02/44] feat: Ctrl+S input stash with auto-restore, image support, and UI indicators Adds a Claude Code-style input stash to the Hermes CLI: - Ctrl+S stashes current input (text + attached images) and clears the field - Ctrl+S on empty input pops the stash back - Stashed input auto-restores after the agent finishes responding - Placeholder shows stash preview when idle, hint when agent is running - Status bar shows a pinned indicator when a stash is active - Uses (text, [images]) tuple so dragged/pasted images are preserved Alternative to #4259 with additional features: auto-restore after response (the key UX from Claude Code), image stashing, placeholder preview, status bar indicator, and proper buf.reset() cleanup. Closes #4255 --- cli.py | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/cli.py b/cli.py index bc841241d8f7..49ddd159a322 100644 --- a/cli.py +++ b/cli.py @@ -1349,6 +1349,7 @@ def __init__( self._interrupt_queue = queue.Queue() self._should_exit = False self._last_ctrl_c_time = 0 + self._stashed_input = None # Ctrl+S stash: (text, [images]) or None self._clarify_state = None self._clarify_freetext = False self._clarify_deadline = 0 @@ -1599,6 +1600,11 @@ def _get_status_bar_fragments(self): ("class:status-bar", " "), ] + # Append stash indicator when something is stashed + if self._stashed_input: + frags.append(("class:status-bar-dim", " │ ")) + frags.append(("class:status-bar-warn", "📌 stashed")) + total_width = sum(self._status_bar_display_width(text) for _, text in frags) if total_width > width: plain_text = "".join(text for _, text in frags) @@ -7199,6 +7205,39 @@ def _update(): run_in_terminal(_run_editor) + @kb.add('c-s') + def handle_stash(event): + """Ctrl+S: stash current input (text + images) or pop stash. + + When the input area has text or attached images, stash them and + clear the input so the user can type a different message. If the + input is empty *and* there's a stash, restore it immediately. + The stash is also auto-restored after the agent finishes responding + (see process_loop). + """ + buf = event.app.current_buffer + text = buf.text + has_images = bool(cli_ref._attached_images) + + if text or has_images: + # --- Stash current input --- + images_snapshot = list(cli_ref._attached_images) + cli_ref._stashed_input = (text, images_snapshot) + cli_ref._attached_images.clear() + buf.reset() + _cprint(f" {_DIM}📌 Input stashed (Ctrl+S to pop, auto-restores after response){_RST}") + event.app.invalidate() + elif cli_ref._stashed_input: + # --- Pop stash into input --- + stashed_text, stashed_images = cli_ref._stashed_input + cli_ref._stashed_input = None + if stashed_images: + cli_ref._attached_images.extend(stashed_images) + buf.text = stashed_text + buf.cursor_position = len(stashed_text) + _cprint(f" {_DIM}📌 Stash restored{_RST}") + event.app.invalidate() + # Voice push-to-talk key: configurable via config.yaml (voice.record_key) # Default: Ctrl+B (avoids conflict with Ctrl+R readline reverse-search) # Config uses "ctrl+b" format; prompt_toolkit expects "c-b" format. @@ -7479,7 +7518,12 @@ def _get_placeholder(): status = cli_ref._command_status or "Processing command..." return f"{frame} {status}" if cli_ref._agent_running: - return "type a message + Enter to interrupt, Ctrl+C to cancel" + stash_hint = " · 📌 stashed" if cli_ref._stashed_input else "" + return f"type a message + Enter to interrupt, Ctrl+C to cancel{stash_hint}" + if cli_ref._stashed_input: + stashed_text = cli_ref._stashed_input[0] + preview = stashed_text[:40] + ("..." if len(stashed_text) > 40 else "") + return f"📌 stashed: \"{preview}\" — Ctrl+S to pop" if cli_ref._voice_mode: return "type or Ctrl+B to record" return "" @@ -8070,6 +8114,20 @@ def _expand_ref(m): self._agent_running = False self._spinner_text = "" + # Auto-restore stashed input after agent finishes + if self._stashed_input: + stashed_text, stashed_images = self._stashed_input + self._stashed_input = None + if stashed_images: + self._attached_images.extend(stashed_images) + try: + buf = app.layout.current_buffer + buf.text = stashed_text + buf.cursor_position = len(stashed_text) + _cprint(f" {_DIM}📌 Stashed input restored{_RST}") + except Exception: + pass + app.invalidate() # Refresh status line # Continuous voice: auto-restart recording after agent responds. From 57b012d80e8b07312f2cf12add9ed527f0b81c09 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Fri, 3 Apr 2026 09:51:06 -0400 Subject: [PATCH 03/44] fix: don't treat bare file paths as slash commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Input starting with / is only routed to the command handler when the first word matches a known command (via resolve_command). Bare paths like /Users/ironin/file.md:45-46 now pass through as regular input to the agent instead of triggering 'Unknown command'. Fixes both the process_loop routing and the handle_enter interrupt bypass — both had the same startswith('/') assumption. --- cli.py | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/cli.py b/cli.py index 49ddd159a322..b3d1d484377a 100644 --- a/cli.py +++ b/cli.py @@ -6893,7 +6893,14 @@ def handle_enter(event): event.app.invalidate() # Bundle text + images as a tuple when images are present payload = (text, images) if images else text - if self._agent_running and not (text and _looks_like_slash_command(text)): + # Route to interrupt/queue unless it looks like a known slash command. + # Bare paths (/Users/..., /path/to/file:45) are NOT commands. + _is_slash_cmd = False + if text and text.startswith("/"): + _fw = text.split()[0].lstrip("/").split(":")[0] + from hermes_cli.commands import resolve_command as _resolve_cmd_fn + _is_slash_cmd = bool(_resolve_cmd_fn(_fw)) + if self._agent_running and not _is_slash_cmd: if self.busy_input_mode == "queue": # Queue for the next turn instead of interrupting self._pending_input.put(payload) @@ -8045,14 +8052,21 @@ def process_loop(): + (f"\n{_remainder}" if _remainder else "") ) - if not _file_drop and isinstance(user_input, str) and _looks_like_slash_command(user_input): - _cprint(f"\n⚙️ {user_input}") - if not self.process_command(user_input): - self._should_exit = True - # Schedule app exit - if app.is_running: - app.exit() - continue + if not _file_drop and isinstance(user_input, str) and user_input.startswith("/"): + # Only treat as a command if the first word is a known + # slash command. Bare paths like /Users/ironin/file.md + # or /path/to/file.md:45-46 should pass through as + # regular input, not trigger "Unknown command". + _first_word = user_input.split()[0].lstrip("/").split(":")[0] if user_input.strip() else "" + from hermes_cli.commands import resolve_command as _resolve_cmd_fn + if _resolve_cmd_fn(_first_word): + _cprint(f"\n⚙️ {user_input}") + if not self.process_command(user_input): + self._should_exit = True + # Schedule app exit + if app.is_running: + app.exit() + continue # Expand paste references back to full content import re as _re From b23b93fe456dd7f54b20da897317ea3e36a81639 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Fri, 3 Apr 2026 10:55:19 -0400 Subject: [PATCH 04/44] fix: Ctrl+D deletes char under cursor, only exits on empty input (bash/zsh behaviour) --- cli.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/cli.py b/cli.py index b3d1d484377a..6d61730a012d 100644 --- a/cli.py +++ b/cli.py @@ -7103,9 +7103,15 @@ def handle_ctrl_c(event): @kb.add('c-d') def handle_ctrl_d(event): - """Handle Ctrl+D - exit.""" - self._should_exit = True - event.app.exit() + """Ctrl+D: delete char under cursor (standard readline behaviour). + Only exit when the input is empty — same as bash/zsh. + """ + buf = event.app.current_buffer + if buf.text: + buf.delete() + else: + self._should_exit = True + event.app.exit() @kb.add('c-z') def handle_ctrl_z(event): From dce4eb6b6c767512670cd0fbb55f5cafe69d91c9 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Fri, 3 Apr 2026 10:55:34 -0400 Subject: [PATCH 05/44] docs: update /keys to reflect Ctrl+D delete-char behaviour --- cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli.py b/cli.py index 6d61730a012d..a76bd2e7a93f 100644 --- a/cli.py +++ b/cli.py @@ -2871,7 +2871,7 @@ def _show_keyboard_shortcuts(self): ]), ("Session", [ ("Ctrl+C", "Cancel prompt / interrupt agent / exit"), - ("Ctrl+D", "Exit"), + ("Ctrl+D", "Delete char under cursor (exit when input empty)"), ("Ctrl+Z", "Suspend to background (fg to resume)"), ]), ("Drafting", [ From 633eef13dbb27efabc6c55b8776d21391682229e Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Fri, 3 Apr 2026 11:37:31 -0400 Subject: [PATCH 06/44] feat: Alt+Enter queues follow-up messages without interrupting agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alt+Enter now queues the current input as a follow-up to be sent after the agent finishes responding, instead of inserting a newline. - Alt+Enter → puts message into _pending_input (non-interrupting) - Enter (agent running) → still interrupts via _interrupt_queue - _followup_queue list mirrors pending items for display - Status bar shows 📬 N when follow-ups are queued - Placeholder hints update: shows queue depth while agent runs, and persists after it finishes until queue drains - Ctrl+J remains the newline key for multi-line input Closes: the need for Shift+Enter queue (terminals can't distinguish Shift+Enter from Enter; Alt+Enter is the reliable alternative) --- cli.py | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/cli.py b/cli.py index a76bd2e7a93f..19484812e5c9 100644 --- a/cli.py +++ b/cli.py @@ -1347,6 +1347,7 @@ def __init__( self._agent_running = False self._pending_input = queue.Queue() self._interrupt_queue = queue.Queue() + self._followup_queue: list = [] # mirror of _pending_input for display (Alt+Enter queued messages) self._should_exit = False self._last_ctrl_c_time = 0 self._stashed_input = None # Ctrl+S stash: (text, [images]) or None @@ -1600,10 +1601,14 @@ def _get_status_bar_fragments(self): ("class:status-bar", " "), ] - # Append stash indicator when something is stashed + # Stash indicator if self._stashed_input: frags.append(("class:status-bar-dim", " │ ")) frags.append(("class:status-bar-warn", "📌 stashed")) + # Follow-up queue indicator + if self._followup_queue: + frags.append(("class:status-bar-dim", " │ ")) + frags.append(("class:status-bar-warn", f"📬 {len(self._followup_queue)}")) total_width = sum(self._status_bar_display_width(text) for _, text in frags) if total_width > width: @@ -6923,12 +6928,40 @@ def handle_enter(event): @kb.add('escape', 'enter') def handle_alt_enter(event): - """Alt+Enter inserts a newline for multi-line input.""" - event.current_buffer.insert_text('\n') + """Alt+Enter: queue message as follow-up (sent after current response). + + When agent is idle, behaves like Enter (sends immediately to _pending_input). + When agent is running, queues without interrupting — sent as the next turn. + _followup_queue mirrors what's pending for status display. + Use Ctrl+J / Ctrl+Enter for inserting a newline in multi-line input. + """ + if cli_ref._sudo_state or cli_ref._secret_state or cli_ref._clarify_state or cli_ref._approval_state: + return + + text = event.app.current_buffer.text.strip() + has_images = bool(cli_ref._attached_images) + if not text and not has_images: + return + + images = list(cli_ref._attached_images) + cli_ref._attached_images.clear() + payload = (text, images) if images else text + + cli_ref._pending_input.put(payload) + cli_ref._followup_queue.append(payload) + event.app.current_buffer.reset(append_to_history=True) + + queue_depth = len(cli_ref._followup_queue) + preview = text[:60] + ("..." if len(text) > 60 else "") + if cli_ref._agent_running: + _cprint(f" {_DIM}📬 Queued follow-up #{queue_depth}: \"{preview}\"{_RST}") + else: + _cprint(f" {_DIM}📬 Queued: \"{preview}\"{_RST}") + event.app.invalidate() @kb.add('c-j') def handle_ctrl_enter(event): - """Ctrl+Enter (c-j) inserts a newline. Most terminals send c-j for Ctrl+Enter.""" + """Ctrl+J (Ctrl+Enter in most terminals): insert a newline for multi-line input.""" event.current_buffer.insert_text('\n') @kb.add('tab', eager=True) @@ -7531,8 +7564,15 @@ def _get_placeholder(): status = cli_ref._command_status or "Processing command..." return f"{frame} {status}" if cli_ref._agent_running: - stash_hint = " · 📌 stashed" if cli_ref._stashed_input else "" - return f"type a message + Enter to interrupt, Ctrl+C to cancel{stash_hint}" + hints = [] + if cli_ref._followup_queue: + hints.append(f"📬 {len(cli_ref._followup_queue)} queued") + if cli_ref._stashed_input: + hints.append("📌 stashed") + suffix = " · " + " · ".join(hints) if hints else "" + return f"Enter to interrupt · Alt+Enter to queue follow-up{suffix}" + if cli_ref._followup_queue: + return f"📬 {len(cli_ref._followup_queue)} follow-up{'s' if len(cli_ref._followup_queue) > 1 else ''} queued — Alt+Enter to add more" if cli_ref._stashed_input: stashed_text = cli_ref._stashed_input[0] preview = stashed_text[:40] + ("..." if len(stashed_text) > 40 else "") @@ -8027,6 +8067,10 @@ def process_loop(): # Check for pending input with timeout try: user_input = self._pending_input.get(timeout=0.1) + # Keep _followup_queue in sync — pop the oldest entry if present + if self._followup_queue: + self._followup_queue.pop(0) + app.invalidate() except queue.Empty: # Periodic config watcher — auto-reload MCP on mcp_servers change if not self._agent_running: From f0b6ad5352230766df8a1d753594df518e92565d Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Fri, 3 Apr 2026 11:43:41 -0400 Subject: [PATCH 07/44] feat: Alt+Up recalls queued follow-ups back into input Alt+Up pops the most recently queued follow-up (LIFO) from _followup_queue, appends it to the current input with a newline--- separator, and marks it cancelled so process_loop skips it. Repeated Alt+Up recalls one at a time until queue is empty. _cancelled_followups set is checked in process_loop and discarded on match to avoid sending the recalled message twice. --- cli.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/cli.py b/cli.py index 19484812e5c9..bda30e55d7f4 100644 --- a/cli.py +++ b/cli.py @@ -1348,6 +1348,7 @@ def __init__( self._pending_input = queue.Queue() self._interrupt_queue = queue.Queue() self._followup_queue: list = [] # mirror of _pending_input for display (Alt+Enter queued messages) + self._cancelled_followups: set = set() # texts recalled via Alt+Up, skipped in process_loop self._should_exit = False self._last_ctrl_c_time = 0 self._stashed_input = None # Ctrl+S stash: (text, [images]) or None @@ -6964,6 +6965,42 @@ def handle_ctrl_enter(event): """Ctrl+J (Ctrl+Enter in most terminals): insert a newline for multi-line input.""" event.current_buffer.insert_text('\n') + @kb.add('escape', 'up') + def handle_recall_followup(event): + """Alt+Up: recall the most recently queued follow-up back into the input. + + Pops the last item from _followup_queue (LIFO — most recent first) and + appends its text to the current input, separated by '\\n---\\n'. + If multiple follow-ups are queued, repeated Alt+Up recalls them one by one. + The recalled item is added to _cancelled_followups so process_loop skips it. + """ + if not cli_ref._followup_queue: + return + + buf = event.app.current_buffer + + # Pop the most recently queued item (last = most recent) + payload = cli_ref._followup_queue.pop() + recalled_text = payload[0] if isinstance(payload, tuple) else payload + + # Mark as cancelled so process_loop skips it when dequeued + cli_ref._cancelled_followups.add(recalled_text) + + # Append to current buffer with separator + current = buf.text + if current.strip(): + buf.text = current.rstrip() + '\n---\n' + recalled_text + else: + buf.text = recalled_text + buf.cursor_position = len(buf.text) + + remaining = len(cli_ref._followup_queue) + if remaining: + _cprint(f" {_DIM}📬 Recalled follow-up ({remaining} still queued){_RST}") + else: + _cprint(f" {_DIM}📬 Follow-up recalled — queue empty{_RST}") + event.app.invalidate() + @kb.add('tab', eager=True) def handle_tab(event): """Tab: accept completion, auto-suggestion, or start completions. @@ -8071,6 +8108,11 @@ def process_loop(): if self._followup_queue: self._followup_queue.pop(0) app.invalidate() + # Skip items recalled via Alt+Up + _input_text = user_input[0] if isinstance(user_input, tuple) else user_input + if _input_text in self._cancelled_followups: + self._cancelled_followups.discard(_input_text) + continue except queue.Empty: # Periodic config watcher — auto-reload MCP on mcp_servers change if not self._agent_running: From 479bb9136d01ef5f758d085f58c9a950e73dd128 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Fri, 3 Apr 2026 11:45:59 -0400 Subject: [PATCH 08/44] fix: no separator before first recalled follow-up --- cli.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/cli.py b/cli.py index bda30e55d7f4..032538092b2c 100644 --- a/cli.py +++ b/cli.py @@ -1349,6 +1349,7 @@ def __init__( self._interrupt_queue = queue.Queue() self._followup_queue: list = [] # mirror of _pending_input for display (Alt+Enter queued messages) self._cancelled_followups: set = set() # texts recalled via Alt+Up, skipped in process_loop + self._followup_recall_count: int = 0 # how many recalls done in this recall session self._should_exit = False self._last_ctrl_c_time = 0 self._stashed_input = None # Ctrl+S stash: (text, [images]) or None @@ -6986,18 +6987,20 @@ def handle_recall_followup(event): # Mark as cancelled so process_loop skips it when dequeued cli_ref._cancelled_followups.add(recalled_text) - # Append to current buffer with separator + # Append to current buffer — separator only from the second recall onwards current = buf.text - if current.strip(): + if cli_ref._followup_recall_count > 0 and current.strip(): buf.text = current.rstrip() + '\n---\n' + recalled_text else: - buf.text = recalled_text + buf.text = (current + recalled_text) if current else recalled_text buf.cursor_position = len(buf.text) + cli_ref._followup_recall_count += 1 remaining = len(cli_ref._followup_queue) if remaining: _cprint(f" {_DIM}📬 Recalled follow-up ({remaining} still queued){_RST}") else: + cli_ref._followup_recall_count = 0 _cprint(f" {_DIM}📬 Follow-up recalled — queue empty{_RST}") event.app.invalidate() From e9963d1b884d491e4608e9bc79d6e6d3157c263b Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Fri, 3 Apr 2026 13:24:58 -0400 Subject: [PATCH 09/44] fix: add -m/--model and --provider flags to root hermes parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hermes -c "session name" -m anthropic/claude-sonnet-4-6 now works. Previously -m was only on 'hermes chat', so the shorthand root-level -c flag couldn't be combined with a model override. Also stop stomping args.model/provider with None in the root→chat passthrough — the values from the root parser are now preserved. --- hermes_cli/main.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 5150bfa1a7e7..312559bce577 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -3983,6 +3983,18 @@ def main(): metavar="SESSION_NAME", help="Resume a session by name, or the most recent if no name given" ) + parser.add_argument( + "-m", "--model", + default=None, + metavar="MODEL", + help="Model to use for this session (e.g. anthropic/claude-sonnet-4-6)" + ) + parser.add_argument( + "--provider", + default=None, + metavar="PROVIDER", + help="Inference provider (e.g. anthropic, openrouter)" + ) parser.add_argument( "--worktree", "-w", action="store_true", @@ -5375,10 +5387,11 @@ def cmd_acp(args): if (args.resume or args.continue_last) and args.command is None: args.command = "chat" args.query = None - args.model = None - args.provider = None - args.toolsets = None - args.verbose = False + # model and provider already set from root parser — don't stomp them + if not hasattr(args, "toolsets"): + args.toolsets = None + if not hasattr(args, "verbose"): + args.verbose = False if not hasattr(args, "worktree"): args.worktree = False cmd_chat(args) @@ -5387,10 +5400,11 @@ def cmd_acp(args): # Default to chat if no command specified if args.command is None: args.query = None - args.model = None - args.provider = None - args.toolsets = None - args.verbose = False + # model and provider already set from root parser — don't stomp them + if not hasattr(args, "toolsets"): + args.toolsets = None + if not hasattr(args, "verbose"): + args.verbose = False args.resume = None args.continue_last = None if not hasattr(args, "worktree"): From 7795321fe665583d93f0f5d1ee03c07202411d06 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Fri, 3 Apr 2026 13:36:44 -0400 Subject: [PATCH 10/44] fix: use UUID tags for followup cancellation, fix phantom queue pops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback from britrik (#4788): - Replace text-based cancellation with UUID tags — identical messages queued twice no longer cancel each other incorrectly - Wrap Alt+Enter payloads as {_followup_tag, payload} dicts so process_loop can identify followup items by ID, not content - Fix phantom _followup_queue pops: display sync now only happens for tagged (Alt+Enter) items, not regular Enter messages - _cancelled_followups stores UUIDs (bounded, auto-discarded on match) Note: the image-payload cancel check was already correct in the original — both sides extracted text via payload[0] — but UUID tagging makes the intent unambiguous regardless of payload shape. --- cli.py | 40 +++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/cli.py b/cli.py index 032538092b2c..4250857c83c2 100644 --- a/cli.py +++ b/cli.py @@ -1347,8 +1347,8 @@ def __init__( self._agent_running = False self._pending_input = queue.Queue() self._interrupt_queue = queue.Queue() - self._followup_queue: list = [] # mirror of _pending_input for display (Alt+Enter queued messages) - self._cancelled_followups: set = set() # texts recalled via Alt+Up, skipped in process_loop + self._followup_queue: list = [] # mirror of _pending_input for display; entries are {"id": str, "payload": ...} + self._cancelled_followups: set = set() # UUIDs recalled via Alt+Up, skipped in process_loop self._followup_recall_count: int = 0 # how many recalls done in this recall session self._should_exit = False self._last_ctrl_c_time = 0 @@ -6949,8 +6949,11 @@ def handle_alt_enter(event): cli_ref._attached_images.clear() payload = (text, images) if images else text - cli_ref._pending_input.put(payload) - cli_ref._followup_queue.append(payload) + import uuid as _uuid_mod + tag = _uuid_mod.uuid4().hex + # Wrap with tag so process_loop can identify and cancel by ID, not text + cli_ref._pending_input.put({"_followup_tag": tag, "payload": payload}) + cli_ref._followup_queue.append({"id": tag, "payload": payload, "text": text}) event.app.current_buffer.reset(append_to_history=True) queue_depth = len(cli_ref._followup_queue) @@ -6981,11 +6984,11 @@ def handle_recall_followup(event): buf = event.app.current_buffer # Pop the most recently queued item (last = most recent) - payload = cli_ref._followup_queue.pop() - recalled_text = payload[0] if isinstance(payload, tuple) else payload + item = cli_ref._followup_queue.pop() + recalled_text = item["text"] - # Mark as cancelled so process_loop skips it when dequeued - cli_ref._cancelled_followups.add(recalled_text) + # Cancel by UUID — immune to duplicate-text false positives + cli_ref._cancelled_followups.add(item["id"]) # Append to current buffer — separator only from the second recall onwards current = buf.text @@ -8107,15 +8110,18 @@ def process_loop(): # Check for pending input with timeout try: user_input = self._pending_input.get(timeout=0.1) - # Keep _followup_queue in sync — pop the oldest entry if present - if self._followup_queue: - self._followup_queue.pop(0) - app.invalidate() - # Skip items recalled via Alt+Up - _input_text = user_input[0] if isinstance(user_input, tuple) else user_input - if _input_text in self._cancelled_followups: - self._cancelled_followups.discard(_input_text) - continue + # Unwrap tagged followup items (queued via Alt+Enter) + if isinstance(user_input, dict) and "_followup_tag" in user_input: + tag = user_input["_followup_tag"] + user_input = user_input["payload"] + # Sync display mirror — only pop for tagged items, not regular Enter + if self._followup_queue: + self._followup_queue.pop(0) + app.invalidate() + # Skip items recalled via Alt+Up (cancelled by UUID, not text) + if tag in self._cancelled_followups: + self._cancelled_followups.discard(tag) + continue except queue.Empty: # Periodic config watcher — auto-reload MCP on mcp_servers change if not self._agent_running: From fc69dd2d341cfe0b4217af41968dbea7482214d4 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Fri, 3 Apr 2026 13:48:20 -0400 Subject: [PATCH 11/44] feat: set terminal window/tab title with session name and thinking indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sets the terminal title via OSC 0 escape sequence (\x1b]0;...\x07): ⚕ Hermes — session name (named session, idle) ⚕ Hermes ⏳ (agent thinking) ⚕ Hermes (unnamed session) Symbol comes from the active skin's response_label (⚕ default, ⚔ Ares, etc.) so it adapts to the current theme. Updated at: - run() startup - _preload_resumed_session() when a titled session is resumed - /title command when a title is set or committed from pending - process_loop when agent starts (thinking=True) and finishes Skipped when stdout is not a TTY, TERM=dumb, or NO_COLOR is set. --- cli.py | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/cli.py b/cli.py index 4250857c83c2..56c5db3c9e4a 100644 --- a/cli.py +++ b/cli.py @@ -1326,6 +1326,7 @@ def __init__( # Deferred title: stored in memory until the session is created in the DB self._pending_title: Optional[str] = None + self._terminal_title_session: str = "" # last session title written to terminal title # Session ID: reuse existing one when resuming, otherwise generate fresh if resume: @@ -2296,6 +2297,7 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No try: self._session_db.set_session_title(self.session_id, self._pending_title) _cprint(f" Session title applied: {self._pending_title}") + self._set_terminal_title(session_title=self._pending_title) self._pending_title = None except (ValueError, Exception) as e: _cprint(f" Could not apply pending title: {e}") @@ -2403,6 +2405,7 @@ def _preload_resumed_session(self) -> bool: title_part = "" if session_meta.get("title"): title_part = f' "{session_meta["title"]}"' + self._set_terminal_title(session_title=session_meta["title"]) self.console.print( f"[#DAA520]↻ Resumed session [bold]{self.session_id}[/bold]" f"{title_part} " @@ -2897,6 +2900,61 @@ def _show_keyboard_shortcuts(self): _cprint(f" {_BOLD}{key:<20}{_RST}{_DIM}{desc}{_RST}") _cprint("") + def _set_terminal_title(self, session_title: str = "", thinking: bool = False) -> None: + """Set the terminal window/tab title via OSC escape sequence. + + Format: + ⚕ Hermes — session name (idle, named session) + ⚕ Hermes ⏳ (agent running / thinking) + ⚕ Hermes (idle, unnamed session) + + Uses the skin's response_label symbol so the indicator matches the + active theme (⚕ default, ⚔ Ares, etc.). Skipped when stdout is not + a TTY, when TERM=dumb, or when NO_COLOR is set (indicates a terminal + that may not handle OSC sequences). + """ + import sys, os + if not sys.stdout.isatty(): + return + if os.environ.get("TERM", "") == "dumb": + return + if os.environ.get("NO_COLOR"): + return + + try: + from hermes_cli.skin_engine import get_active_skin + # Extract just the symbol from response_label (e.g. " ⚕ Hermes " → "⚕") + response_label = get_active_skin().get_branding("response_label", " ⚕ Hermes ") + # Pull first non-space char as the symbol + symbol = next((c for c in response_label.strip() if not c.isalpha() and not c.isspace()), "⚕") + agent_name = get_active_skin().get_branding("agent_name", "Hermes Agent") + # Shorten "Hermes Agent" → "Hermes" for compact title + short_name = agent_name.split()[0] + except Exception: + symbol, short_name = "⚕", "Hermes" + + if thinking: + title = f"{symbol} {short_name} ⏳" + elif session_title: + title = f"{symbol} {short_name} — {session_title}" + else: + title = f"{symbol} {short_name}" + + # OSC 0: set both icon name and window title + sys.stdout.write(f"\x1b]0;{title}\x07") + sys.stdout.flush() + self._terminal_title_session = session_title + + def _update_terminal_title(self, thinking: bool = False) -> None: + """Refresh the terminal title using the current session title.""" + title = self._terminal_title_session + if not title and self._session_db: + try: + title = self._session_db.get_session_title(self.session_id) or "" + except Exception: + title = "" + self._set_terminal_title(session_title=title, thinking=thinking) + def show_help(self): """Display help information with categorized commands.""" from hermes_cli.commands import COMMANDS_BY_CATEGORY @@ -4153,6 +4211,7 @@ def process_command(self, command: str) -> bool: try: if self._session_db.set_session_title(self.session_id, new_title): _cprint(f" Session title set: {new_title}") + self._set_terminal_title(session_title=new_title) else: _cprint(" Session not found in database.") except ValueError as e: @@ -6720,6 +6779,7 @@ def run(self): pass self.show_banner() + self._update_terminal_title() # Set initial terminal title on startup # One-line Honcho session indicator (TTY-only, not captured by agent). # Only show when the user explicitly configured Honcho for Hermes @@ -8221,6 +8281,7 @@ def _expand_ref(m): # Regular chat - run agent self._agent_running = True + self._update_terminal_title(thinking=True) app.invalidate() # Refresh status line try: @@ -8228,6 +8289,7 @@ def _expand_ref(m): finally: self._agent_running = False self._spinner_text = "" + self._update_terminal_title(thinking=False) # Auto-restore stashed input after agent finishes if self._stashed_input: From a85eb92f7383f0e60a2dec90a83c8e6d4f070ad9 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Fri, 3 Apr 2026 14:31:57 -0400 Subject: [PATCH 12/44] feat: double ESC clears input, Ctrl+P peeks paste, \r\n normalisation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three input UX improvements: 1. ESC ESC — clear input buffer (and attached images) Pressing ESC twice quickly discards the current draft without conflicting with Alt key sequences (escape+enter, escape+up, etc.) 2. Ctrl+P — peek collapsed paste content inline When input contains a [Pasted text #N → path] reference, prints the first 20 lines in the terminal so the user can verify content without opening an editor (Ctrl+G). Falls back to previewing the current input text when no paste reference is present. 3. \r\n normalisation in handle_paste Windows-style (CRLF) and old Mac-style (CR) line endings are normalised to LF before the 5-line collapse threshold is checked. Prevents markdown pasted from Windows sources being treated as single-line and bypassing the file-reference collapse. --- cli.py | 69 +- package-lock.json | 2416 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 2465 insertions(+), 20 deletions(-) diff --git a/cli.py b/cli.py index 56c5db3c9e4a..6c53708fb272 100644 --- a/cli.py +++ b/cli.py @@ -7024,6 +7024,20 @@ def handle_alt_enter(event): _cprint(f" {_DIM}📬 Queued: \"{preview}\"{_RST}") event.app.invalidate() + @kb.add('escape', 'escape') + def handle_double_escape(event): + """Double ESC: clear the input buffer. + + Press ESC twice quickly to discard the current draft. + Single ESC is the prefix for Alt key sequences (escape, enter etc.) + so the double-press avoids conflicting with those. + """ + buf = event.app.current_buffer + if buf.text or cli_ref._attached_images: + buf.reset() + cli_ref._attached_images.clear() + event.app.invalidate() + @kb.add('c-j') def handle_ctrl_enter(event): """Ctrl+J (Ctrl+Enter in most terminals): insert a newline for multi-line input.""" @@ -7387,6 +7401,59 @@ def handle_stash(event): _cprint(f" {_DIM}📌 Stash restored{_RST}") event.app.invalidate() + @kb.add('c-p') + def handle_peek(event): + """Ctrl+P: peek at collapsed paste content or current input inline. + + If the input contains a [Pasted text #N ... → path] reference, + prints the first 20 lines of that file right in the terminal so + the user can verify the content without opening an editor (Ctrl+G). + If multiple paste references exist, peeks at the first one. + If there is no paste reference, prints the first 20 lines of the + current buffer text as a preview. + """ + import re as _re + from prompt_toolkit.application import run_in_terminal + + buf = event.app.current_buffer + text = buf.text + + _paste_re = _re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]') + match = _paste_re.search(text) + + def _peek(): + _PEEK_LINES = 20 + if match: + p = Path(match.group(1)) + if not p.exists(): + _cprint(f" {_DIM}Paste file not found: {p}{_RST}") + return + lines = p.read_text(encoding="utf-8").splitlines() + total = len(lines) + shown = lines[:_PEEK_LINES] + _cprint(f"\n {_DIM}📄 {p.name} — {total} lines{_RST}") + _cprint(f" {_DIM}{'─' * 60}{_RST}") + for line in shown: + _cprint(f" {line}") + if total > _PEEK_LINES: + _cprint(f" {_DIM} ... ({total - _PEEK_LINES} more lines) — Ctrl+G to edit{_RST}") + else: + _cprint(f" {_DIM}{'─' * 60} Ctrl+G to edit{_RST}") + elif text.strip(): + lines = text.splitlines() + total = len(lines) + shown = lines[:_PEEK_LINES] + _cprint(f"\n {_DIM}📝 Current input — {total} line{'s' if total != 1 else ''}{_RST}") + _cprint(f" {_DIM}{'─' * 60}{_RST}") + for line in shown: + _cprint(f" {line}") + if total > _PEEK_LINES: + _cprint(f" {_DIM} ... ({total - _PEEK_LINES} more lines){_RST}") + else: + _cprint(f" {_DIM}(input is empty){_RST}") + + run_in_terminal(_peek) + # Voice push-to-talk key: configurable via config.yaml (voice.record_key) # Default: Ctrl+B (avoids conflict with Ctrl+R readline reverse-search) # Config uses "ctrl+b" format; prompt_toolkit expects "c-b" format. @@ -7472,7 +7539,7 @@ def handle_paste(event): """ pasted_text = event.data or "" # Normalise line endings — Windows \r\n and old Mac \r both become \n - # so the 5-line collapse threshold and display are consistent. + # so the line-count threshold and display are consistent cross-platform. pasted_text = pasted_text.replace('\r\n', '\n').replace('\r', '\n') if self._try_attach_clipboard_image(): event.app.invalidate() diff --git a/package-lock.json b/package-lock.json index 1e54db9aa55d..c0c5526740be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { + "@askjo/camoufox-browser": "^1.0.0", "agent-browser": "^0.13.0" }, "engines": { @@ -38,6 +39,26 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/@askjo/camoufox-browser": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@askjo/camoufox-browser/-/camoufox-browser-1.0.12.tgz", + "integrity": "sha512-MxRvjK6SkX6zJSNleoO32g9iwhJAcXpaAgj4pik7y2SrYXqcHllpG7FfLkKE7d5bnBt7pO82rdarVYu6xtW2RA==", + "deprecated": "Renamed to @askjo/camofox-browser", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "camoufox-js": "^0.8.5", + "dotenv": "^17.2.3", + "express": "^4.18.2", + "playwright": "^1.50.0", + "playwright-core": "^1.58.0", + "playwright-extra": "^4.3.6", + "puppeteer-extra-plugin-stealth": "^2.11.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -105,12 +126,39 @@ "node": ">=18" } }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.33", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", @@ -263,6 +311,28 @@ "node": ">=6.5" } }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -358,6 +428,21 @@ "node": ">= 0.4" } }, + "node_modules/arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -522,6 +607,18 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.8", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", + "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/basic-ftp": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", @@ -531,12 +628,135 @@ "node": ">=10.0.0" } }, + "node_modules/better-sqlite3": { + "version": "12.8.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", + "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "license": "MIT" }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -552,6 +772,39 @@ "balanced-match": "^1.0.0" } }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -591,6 +844,188 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camoufox-js": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/camoufox-js/-/camoufox-js-0.8.5.tgz", + "integrity": "sha512-20ihPbspAcOVSUTX9Drxxp0C116DON1n8OVA1eUDglWZiHwiHwFVFOMrIEBwAHMZpU11mIEH/kawJtstRIrDPA==", + "license": "MPL-2.0", + "dependencies": { + "adm-zip": "^0.5.16", + "better-sqlite3": "^12.2.0", + "commander": "^14.0.0", + "fingerprint-generator": "^2.1.66", + "glob": "^13.0.0", + "impit": "^0.7.0", + "language-tags": "^2.0.1", + "maxmind": "^5.0.0", + "progress": "^2.0.3", + "ua-parser-js": "^2.0.2", + "xml2js": "^0.6.2" + }, + "bin": { + "camoufox-js": "dist/__main__.js" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "playwright-core": "*" + } + }, + "node_modules/camoufox-js/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/camoufox-js/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/camoufox-js/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/camoufox-js/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/camoufox-js/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/camoufox-js/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/camoufox-js/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001780", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -645,6 +1080,12 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -732,6 +1173,22 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/clone-deep": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-0.2.4.tgz", + "integrity": "sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg==", + "license": "MIT", + "dependencies": { + "for-own": "^0.1.3", + "is-plain-object": "^2.0.1", + "kind-of": "^3.0.2", + "lazy-cache": "^1.0.3", + "shallow-clone": "^0.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -775,12 +1232,54 @@ "node": ">= 14" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", "license": "ISC" }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -924,9 +1423,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/deepmerge-ts": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", "license": "BSD-3-Clause", "engines": { @@ -947,6 +1479,54 @@ "node": ">= 14" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-europe-js": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/detect-europe-js/-/detect-europe-js-0.1.2.tgz", + "integrity": "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -1002,6 +1582,47 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dot-prop": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", + "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -1092,12 +1713,33 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.321", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", + "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", + "license": "ISC" + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/encoding-sniffer": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", @@ -1132,6 +1774,36 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1141,6 +1813,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escodegen": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", @@ -1193,6 +1871,15 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -1220,6 +1907,76 @@ "bare-events": "^2.7.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -1296,6 +2053,80 @@ "pend": "~1.2.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/fingerprint-generator": { + "version": "2.1.81", + "resolved": "https://registry.npmjs.org/fingerprint-generator/-/fingerprint-generator-2.1.81.tgz", + "integrity": "sha512-R8Cgnv9AhsTG8MN+DCuFolq2cJPdTNDKOM11EaRSCfRBnBGsPWTTm9e3INld1rzU+bMITvqAcghlCjXOVCrYUA==", + "license": "Apache-2.0", + "dependencies": { + "generative-bayesian-network": "^2.1.81", + "header-generator": "^2.1.81", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/for-own": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", + "integrity": "sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw==", + "license": "MIT", + "dependencies": { + "for-in": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -1312,6 +2143,73 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/geckodriver": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/geckodriver/-/geckodriver-6.1.0.tgz", @@ -1333,6 +2231,16 @@ "node": ">=20.0.0" } }, + "node_modules/generative-bayesian-network": { + "version": "2.1.81", + "resolved": "https://registry.npmjs.org/generative-bayesian-network/-/generative-bayesian-network-2.1.81.tgz", + "integrity": "sha512-LrYK+CY5n21p437oahz8jRqTgw0i+S08H+ypag1sgZilfCj33k8Tp8kcFtPiWKsEEJ6niN9gRFP12+r06xB4rQ==", + "license": "Apache-2.0", + "dependencies": { + "adm-zip": "^0.5.9", + "tslib": "^2.4.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -1342,6 +2250,30 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-port": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz", @@ -1354,6 +2286,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", @@ -1383,6 +2328,12 @@ "node": ">= 14" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -1404,6 +2355,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1425,14 +2388,53 @@ "node": ">=8" } }, - "node_modules/htmlfy": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/htmlfy/-/htmlfy-0.8.1.tgz", - "integrity": "sha512-xWROBw9+MEGwxpotll0h672KCaLrKKiCYzsyN8ZgL9cQbVumFnyvsk2JqiB9ELAV1GLj1GG/jxZUjV9OZZi/yQ==", - "license": "MIT" + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/htmlparser2": { - "version": "10.1.0", + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/header-generator": { + "version": "2.1.81", + "resolved": "https://registry.npmjs.org/header-generator/-/header-generator-2.1.81.tgz", + "integrity": "sha512-6+27UuqCHFx4xrTWIgcSF/x2WJ+PuVLxziXfPaVLRXi1lXIbTkXO+ffHJefVrdRT5/XEeWfJHrSIE2m1hAdWxw==", + "license": "Apache-2.0", + "dependencies": { + "browserslist": "^4.21.1", + "generative-bayesian-network": "^2.1.81", + "ow": "^0.28.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/htmlfy": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/htmlfy/-/htmlfy-0.8.1.tgz", + "integrity": "sha512-xWROBw9+MEGwxpotll0h672KCaLrKKiCYzsyN8ZgL9cQbVumFnyvsk2JqiB9ELAV1GLj1GG/jxZUjV9OZZi/yQ==", + "license": "MIT" + }, + "node_modules/htmlparser2": { + "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", "funding": [ @@ -1462,6 +2464,26 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -1526,6 +2548,165 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, + "node_modules/impit": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit/-/impit-0.7.6.tgz", + "integrity": "sha512-AkS6Gv63+E6GMvBrcRhMmOREKpq5oJ0J5m3xwfkHiEs97UIsbpEqFmW3sFw/sdyOTDGRF5q4EjaLxtb922Ta8g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "impit-darwin-arm64": "0.7.6", + "impit-darwin-x64": "0.7.6", + "impit-linux-arm64-gnu": "0.7.6", + "impit-linux-arm64-musl": "0.7.6", + "impit-linux-x64-gnu": "0.7.6", + "impit-linux-x64-musl": "0.7.6", + "impit-win32-arm64-msvc": "0.7.6", + "impit-win32-x64-msvc": "0.7.6" + } + }, + "node_modules/impit-darwin-arm64": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-darwin-arm64/-/impit-darwin-arm64-0.7.6.tgz", + "integrity": "sha512-M7NQXkttyzqilWfzVkNCp7hApT69m0etyJkVpHze4bR5z1kJnHhdsb8BSdDv2dzvZL4u1JyqZNxq+qoMn84eUw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-darwin-x64": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-darwin-x64/-/impit-darwin-x64-0.7.6.tgz", + "integrity": "sha512-kikTesWirAwJp9JPxzGLoGVc+heBlEabWS5AhTkQedACU153vmuL90OBQikVr3ul2N0LPImvnuB+51wV0zDE6g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-linux-arm64-gnu": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-linux-arm64-gnu/-/impit-linux-arm64-gnu-0.7.6.tgz", + "integrity": "sha512-H6GHjVr/0lG9VEJr6IHF8YLq+YkSIOF4k7Dfue2ygzUAj1+jZ5ZwnouhG/XrZHYW6EWsZmEAjjRfWE56Q0wDRQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-linux-arm64-musl": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-linux-arm64-musl/-/impit-linux-arm64-musl-0.7.6.tgz", + "integrity": "sha512-1sCB/UBVXLZTpGJsXRdNNSvhN9xmmQcYLMWAAB4Itb7w684RHX1pLoCb6ichv7bfAf6tgaupcFIFZNBp3ghmQA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-linux-x64-gnu": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-linux-x64-gnu/-/impit-linux-x64-gnu-0.7.6.tgz", + "integrity": "sha512-yYhlRnZ4fhKt8kuGe0JK2WSHc8TkR6BEH0wn+guevmu8EOn9Xu43OuRvkeOyVAkRqvFnlZtMyySUo/GuSLz9Gw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-linux-x64-musl": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-linux-x64-musl/-/impit-linux-x64-musl-0.7.6.tgz", + "integrity": "sha512-sdGWyu+PCLmaOXy7Mzo4WP61ZLl5qpZ1L+VeXW+Ycazgu0e7ox0NZLdiLRunIrEzD+h0S+e4CyzNwaiP3yIolg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-win32-arm64-msvc": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-win32-arm64-msvc/-/impit-win32-arm64-msvc-0.7.6.tgz", + "integrity": "sha512-sM5deBqo0EuXg5GACBUMKEua9jIau/i34bwNlfrf/Amnw1n0GB4/RkuUh+sKiUcbNAntrRq+YhCq8qDP8IW19w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-win32-x64-msvc": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-win32-x64-msvc/-/impit-win32-x64-msvc-0.7.6.tgz", + "integrity": "sha512-ry63ADGLCB/PU/vNB1VioRt2V+klDJ34frJUXUZBEv1kA96HEAg9AxUk+604o+UHS3ttGH2rkLmrbwHOdAct5Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/import-meta-resolve": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", @@ -1536,12 +2717,29 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -1551,6 +2749,30 @@ "node": ">= 12" } }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1560,6 +2782,15 @@ "node": ">=8" } }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -1572,6 +2803,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-standalone-pwa": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-standalone-pwa/-/is-standalone-pwa-0.1.1.tgz", + "integrity": "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -1599,6 +2862,15 @@ "node": ">=18" } }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -1623,6 +2895,18 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -1665,6 +2949,45 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-2.1.0.tgz", + "integrity": "sha512-D4CgpyCt+61f6z2jHjJS1OmZPviAWM57iJ9OKdFFWSNgS7Udj9QVWqyGs/cveVNF57XpZmhSvMdVIV5mjLA7Vg==", + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/lazy-cache": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", + "integrity": "sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/lazystream": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", @@ -1749,6 +3072,13 @@ "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.zip": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.zip/-/lodash.zip-4.2.0.tgz", @@ -1780,6 +3110,115 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/maxmind": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/maxmind/-/maxmind-5.0.5.tgz", + "integrity": "sha512-1lcH2kMjbBpCFhuHaMU32vz8CuOsKttRcWMQyXvtlklopCzN7NNHSVR/h9RYa8JPuFTGmkn2vYARm+7cIGuqDw==", + "license": "MIT", + "dependencies": { + "mmdb-lib": "3.0.2", + "tiny-lru": "11.4.7" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-deep": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/merge-deep/-/merge-deep-3.0.3.tgz", + "integrity": "sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA==", + "license": "MIT", + "dependencies": { + "arr-union": "^3.1.0", + "clone-deep": "^0.2.4", + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -1795,6 +3234,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -1810,6 +3258,44 @@ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "license": "MIT" }, + "node_modules/mixin-object": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mixin-object/-/mixin-object-2.0.1.tgz", + "integrity": "sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA==", + "license": "MIT", + "dependencies": { + "for-in": "^0.1.3", + "is-extendable": "^0.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mixin-object/node_modules/for-in": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-0.1.8.tgz", + "integrity": "sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/mmdb-lib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mmdb-lib/-/mmdb-lib-3.0.2.tgz", + "integrity": "sha512-7e87vk0DdWT647wjcfEtWeMtjm+zVGqNohN/aeIymbUfjHQ2T4Sx5kM+1irVDBSloNC3CkGKxswdMoo8yhqTDg==", + "license": "MIT", + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, "node_modules/modern-tar": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.4.tgz", @@ -1825,6 +3311,21 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/netmask": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", @@ -1834,6 +3335,24 @@ "node": ">= 0.4.0" } }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "license": "MIT" + }, "node_modules/node-simctl": { "version": "7.7.5", "resolved": "https://registry.npmjs.org/node-simctl/-/node-simctl-7.7.5.tgz", @@ -1877,6 +3396,30 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1886,6 +3429,25 @@ "wrappy": "1" } }, + "node_modules/ow": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/ow/-/ow-0.28.2.tgz", + "integrity": "sha512-dD4UpyBh/9m4X2NVjA+73/ZPBRF+uF4zIMFvvQsabMiEK8x41L3rQ8EENOi35kyyoaJwNxEeJcP6Fj1H4U409Q==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.2.0", + "callsites": "^3.1.0", + "dot-prop": "^6.0.1", + "lodash.isequal": "^4.5.0", + "vali-date": "^1.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pac-proxy-agent": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", @@ -1979,6 +3541,15 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-expression-matcher": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", @@ -1994,6 +3565,15 @@ "node": ">=14.0.0" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2019,16 +3599,46 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", "license": "MIT" }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, "node_modules/playwright-core": { - "version": "1.58.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz", - "integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -2037,6 +3647,99 @@ "node": ">=18" } }, + "node_modules/playwright-extra": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/playwright-extra/-/playwright-extra-4.3.6.tgz", + "integrity": "sha512-q2rVtcE8V8K3vPVF1zny4pvwZveHLH8KBuVU2MoE3Jw4OKVoBWsHI9CH9zPydovHHOCDxjGN2Vg+2m644q3ijA==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "playwright": "*", + "playwright-core": "*" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + }, + "playwright-core": { + "optional": true + } + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prebuild-install/node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/prebuild-install/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -2061,6 +3764,19 @@ "node": ">=0.4.0" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-agent": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", @@ -2105,12 +3821,243 @@ "once": "^1.3.1" } }, - "node_modules/query-selector-shadow-dom": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", - "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", - "license": "MIT" - }, + "node_modules/puppeteer-extra-plugin": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin/-/puppeteer-extra-plugin-3.2.3.tgz", + "integrity": "sha512-6RNy0e6pH8vaS3akPIKGg28xcryKscczt4wIl0ePciZENGE2yoaQJNd17UiEbdmh5/6WW6dPcfRWT9lxBwCi2Q==", + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.0", + "debug": "^4.1.1", + "merge-deep": "^3.0.1" + }, + "engines": { + "node": ">=9.11.2" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-stealth": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-stealth/-/puppeteer-extra-plugin-stealth-2.11.2.tgz", + "integrity": "sha512-bUemM5XmTj9i2ZerBzsk2AN5is0wHMNE6K0hXBzBXOzP5m5G3Wl0RHhiqKeHToe/uIH8AoZiGhc1tCkLZQPKTQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "puppeteer-extra-plugin": "^3.2.3", + "puppeteer-extra-plugin-user-preferences": "^2.4.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-data-dir/-/puppeteer-extra-plugin-user-data-dir-2.4.1.tgz", + "integrity": "sha512-kH1GnCcqEDoBXO7epAse4TBPJh9tEpVEK/vkedKfjOVOhZAvLkHGc9swMs5ChrJbRnf8Hdpug6TJlEuimXNQ+g==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^10.0.0", + "puppeteer-extra-plugin": "^3.2.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/puppeteer-extra-plugin-user-preferences": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-preferences/-/puppeteer-extra-plugin-user-preferences-2.4.1.tgz", + "integrity": "sha512-i1oAZxRbc1bk8MZufKCruCEC3CCafO9RKMkkodZltI4OqibLFXF3tj6HZ4LZ9C5vCXZjYcDWazgtY69mnmrQ9A==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "deepmerge": "^4.2.2", + "puppeteer-extra-plugin": "^3.2.3", + "puppeteer-extra-plugin-user-data-dir": "^2.4.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/query-selector-shadow-dom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", + "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, "node_modules/readable-stream": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", @@ -2250,6 +4197,15 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -2262,6 +4218,45 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/serialize-error": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-12.0.0.tgz", @@ -2289,6 +4284,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -2301,6 +4311,48 @@ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", "license": "MIT" }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shallow-clone": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-0.1.2.tgz", + "integrity": "sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.1", + "kind-of": "^2.0.1", + "lazy-cache": "^0.2.3", + "mixin-object": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shallow-clone/node_modules/kind-of": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-2.0.1.tgz", + "integrity": "sha512-0u8i1NZ/mg0b+W3MGGw5I7+6Eib2nx72S/QvXa0hYjEkjTknYmEYQJwGu3mLC0BrhtJjtQafTkyRUQ75Kx0LVg==", + "license": "MIT", + "dependencies": { + "is-buffer": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shallow-clone/node_modules/lazy-cache": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-0.2.7.tgz", + "integrity": "sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2334,6 +4386,78 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -2346,6 +4470,51 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -2428,6 +4597,15 @@ "node": ">= 10.x" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/streamx": { "version": "2.23.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", @@ -2544,6 +4722,15 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strnum": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", @@ -2628,12 +4815,42 @@ "b4a": "^1.6.4" } }, + "node_modules/tiny-lru": { + "version": "11.4.7", + "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz", + "integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-fest": { "version": "4.26.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.26.0.tgz", @@ -2646,6 +4863,70 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ua-is-frozen": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ua-is-frozen/-/ua-is-frozen-0.1.2.tgz", + "integrity": "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, + "node_modules/ua-parser-js": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-2.0.9.tgz", + "integrity": "sha512-OsqGhxyo/wGdLSXMSJxuMGN6H4gDnKz6Fb3IBm4bxZFMnyy0sdf6MN96Ie8tC6z/btdO+Bsy8guxlvLdwT076w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "AGPL-3.0-or-later", + "dependencies": { + "detect-europe-js": "^0.1.2", + "is-standalone-pwa": "^0.1.1", + "ua-is-frozen": "^0.1.2" + }, + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/undici": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", @@ -2661,6 +4942,54 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/urlpattern-polyfill": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", @@ -2682,6 +5011,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/uuid": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", @@ -2695,6 +5033,24 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/vali-date": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/vali-date/-/vali-date-1.0.0.tgz", + "integrity": "sha512-sgECfZthyaCKW10N0fm27cg8HYTFK5qMWgypqkXMQ4Wbl/zZKx7xZICgcoxIIE+WFAP/MBL2EFwC/YvLxw3Zeg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/wait-port": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/wait-port/-/wait-port-1.1.0.tgz", @@ -2973,6 +5329,28 @@ } } }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", From 599ee2ff3e6b56bb4433abd02c5dce502d65d220 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Fri, 3 Apr 2026 14:56:16 -0400 Subject: [PATCH 13/44] feat: Ctrl+P history pager + /history full (newest first, full text) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl+P is now context-aware: - paste ref in input → peek first 20 lines of paste file inline - text in input → preview first 20 lines of current input - empty input → full conversation pager (newest first, via less) New show_history_full() method: - Reverses conversation order so most recent message is at the top - No truncation — full text of every user and assistant turn - Tool call names listed inline on the header line - Strips REASONING_SCRATCHPAD blocks - Pipes through 'less -R --no-init --quit-if-one-screen' (falls back to plain print if less is unavailable) - Header shows message count and keyboard hints (q, /) New /history full (aliases: f, all): - Calls show_history_full() from the slash command interface - /history (no arg) still calls the existing show_history() --- cli.py | 188 +++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 155 insertions(+), 33 deletions(-) diff --git a/cli.py b/cli.py index 6c53708fb272..c2ed2d1dd795 100644 --- a/cli.py +++ b/cli.py @@ -3228,6 +3228,107 @@ def _show_recent_sessions(self, *, reason: str = "history", limit: int = 10) -> print() return True + def show_history_full(self) -> None: + """Show full conversation history newest-first, piped through a pager. + + Builds a plain-text representation of every user and assistant turn + (no truncation) in reverse chronological order so the most recent + exchange is visible immediately. Tool call names are listed inline. + Pipes through ``less -R`` when available, otherwise prints directly. + + Called by Ctrl+P (when input is empty) and ``/history full``. + """ + if not self.conversation_history: + print("(._.) No conversation history yet.") + return + + import re as _re + import shutil as _shutil + import subprocess as _subprocess + + def _strip_reasoning(t: str) -> str: + t = _re.sub(r".*?\s*", "", t, flags=_re.DOTALL) + return _re.sub(r".*$", "", t, flags=_re.DOTALL).strip() + + # Collect visible turns (skip system + tool-result rows) + turns = [] + for msg in self.conversation_history: + role = msg.get("role", "") + if role in ("system", "tool"): + continue + content = msg.get("content") + tool_calls = msg.get("tool_calls") or [] + + if role == "user": + text = "" + if isinstance(content, list): + parts = [] + for p in content: + if isinstance(p, dict): + if p.get("type") == "text": + parts.append(p.get("text", "")) + elif p.get("type") == "image_url": + parts.append("[image]") + text = "\n".join(parts) + else: + text = str(content) if content is not None else "" + turns.append(("user", text, [])) + + elif role == "assistant": + text = _strip_reasoning(str(content) if content is not None else "") + names = [] + for tc in tool_calls: + fn = tc.get("function", {}) + name = fn.get("name", "?") if isinstance(fn, dict) else "?" + if name not in names: + names.append(name) + turns.append(("assistant", text, names)) + + if not turns: + print("(._.) No displayable history.") + return + + W = _shutil.get_terminal_size((100, 24)).columns + total = len(turns) + + lines = [] + lines.append(f" ↻ {total} messages — newest first (q to quit, / to search)\n") + lines.append("═" * W + "\n") + + for i, (role, text, tools) in enumerate(reversed(turns)): + idx = total - i + if role == "user": + header = f"[{idx}/{total}] ● You" + else: + tc_str = f" [{', '.join(tools)}]" if tools else "" + header = f"[{idx}/{total}] ◆ Hermes{tc_str}" + lines.append(f"{header}\n") + if text: + for line in text.splitlines(): + lines.append(f" {line}\n") + elif tools and role == "assistant": + lines.append(f" [tool calls only: {', '.join(tools)}]\n") + lines.append("\n") + if i < total - 1: + lines.append("─" * W + "\n") + + output = "".join(lines) + + # Try to pipe through less -R; fall back to plain print + pager = _shutil.which("less") or _shutil.which("more") + if pager and pager.endswith("less"): + try: + proc = _subprocess.Popen( + [pager, "-R", "--no-init", "--quit-if-one-screen"], + stdin=_subprocess.PIPE, + ) + proc.communicate(output.encode("utf-8", errors="replace")) + return + except Exception: + pass + # Fallback: print directly (user can scroll iTerm) + print(output) + def show_history(self): """Display conversation history.""" if not self.conversation_history: @@ -4190,7 +4291,13 @@ def process_command(self, command: str) -> bool: self.show_banner() print(" ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n") elif canonical == "history": - self.show_history() + parts = cmd_original.split(maxsplit=1) + arg = parts[1].strip().lower() if len(parts) > 1 else "" + if arg in ("full", "f", "all"): + with self._busy_command(self._slow_command_status(cmd_original)): + self.show_history_full() + else: + self.show_history() elif canonical == "title": parts = cmd_original.split(maxsplit=1) if len(parts) > 1: @@ -7402,15 +7509,14 @@ def handle_stash(event): event.app.invalidate() @kb.add('c-p') - def handle_peek(event): - """Ctrl+P: peek at collapsed paste content or current input inline. - - If the input contains a [Pasted text #N ... → path] reference, - prints the first 20 lines of that file right in the terminal so - the user can verify the content without opening an editor (Ctrl+G). - If multiple paste references exist, peeks at the first one. - If there is no paste reference, prints the first 20 lines of the - current buffer text as a preview. + def handle_peek_or_history(event): + """Ctrl+P: context-aware inspect. + + - Input has a [Pasted text #N → path] reference → peek paste file + (first 20 lines inline; Ctrl+G to open in editor). + - Input has other text → preview current input (first 20 lines). + - Input is empty + session has history → full history pager + (newest message first, piped through less). """ import re as _re from prompt_toolkit.application import run_in_terminal @@ -7419,40 +7525,56 @@ def handle_peek(event): text = buf.text _paste_re = _re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]') - match = _paste_re.search(text) + paste_match = _paste_re.search(text) + + if paste_match: + # --- Peek paste file --- + p = Path(paste_match.group(1)) - def _peek(): - _PEEK_LINES = 20 - if match: - p = Path(match.group(1)) + def _peek_paste(): + _PEEK = 20 if not p.exists(): _cprint(f" {_DIM}Paste file not found: {p}{_RST}") return - lines = p.read_text(encoding="utf-8").splitlines() - total = len(lines) - shown = lines[:_PEEK_LINES] + plines = p.read_text(encoding="utf-8").splitlines() + total = len(plines) _cprint(f"\n {_DIM}📄 {p.name} — {total} lines{_RST}") _cprint(f" {_DIM}{'─' * 60}{_RST}") - for line in shown: - _cprint(f" {line}") - if total > _PEEK_LINES: - _cprint(f" {_DIM} ... ({total - _PEEK_LINES} more lines) — Ctrl+G to edit{_RST}") + for ln in plines[:_PEEK]: + _cprint(f" {ln}") + if total > _PEEK: + _cprint(f" {_DIM} ... ({total - _PEEK} more lines) — Ctrl+G to edit in full{_RST}") else: _cprint(f" {_DIM}{'─' * 60} Ctrl+G to edit{_RST}") - elif text.strip(): - lines = text.splitlines() - total = len(lines) - shown = lines[:_PEEK_LINES] + + run_in_terminal(_peek_paste) + + elif text.strip(): + # --- Preview current input --- + def _peek_input(): + _PEEK = 20 + ilines = text.splitlines() + total = len(ilines) _cprint(f"\n {_DIM}📝 Current input — {total} line{'s' if total != 1 else ''}{_RST}") _cprint(f" {_DIM}{'─' * 60}{_RST}") - for line in shown: - _cprint(f" {line}") - if total > _PEEK_LINES: - _cprint(f" {_DIM} ... ({total - _PEEK_LINES} more lines){_RST}") - else: - _cprint(f" {_DIM}(input is empty){_RST}") + for ln in ilines[:_PEEK]: + _cprint(f" {ln}") + if total > _PEEK: + _cprint(f" {_DIM} ... ({total - _PEEK} more lines){_RST}") + + run_in_terminal(_peek_input) + + elif cli_ref.conversation_history: + # --- Full history pager (newest first) --- + def _full_history(): + cli_ref.show_history_full() - run_in_terminal(_peek) + run_in_terminal(_full_history) + + else: + def _empty(): + _cprint(f" {_DIM}(no history yet){_RST}") + run_in_terminal(_empty) # Voice push-to-talk key: configurable via config.yaml (voice.record_key) # Default: Ctrl+B (avoids conflict with Ctrl+R readline reverse-search) From 001f34f2e8910d42b7af79fe35874c8ac3ede2d8 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Fri, 3 Apr 2026 15:26:25 -0400 Subject: [PATCH 14/44] =?UTF-8?q?fix:=20tab=20title=20shows=20symbol=20onl?= =?UTF-8?q?y=20(=E2=9A=95),=20drop=20text=20and=20Python=20process=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop 'Hermes' and session name from tab title — symbol only - Use OSC 1 (tab/icon title) + OSC 2 (window title) instead of OSC 0 so iTerm2 does not append the Python process name to the tab label - Thinking indicator: ⚕ ⏳ (was ⚕ Hermes ⏳) - Idle: ⚕ (was ⚕ Hermes / ⚕ Hermes — session) --- cli.py | 38 +++++++++++++++----------------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/cli.py b/cli.py index c2ed2d1dd795..44e391dcdda0 100644 --- a/cli.py +++ b/cli.py @@ -2901,17 +2901,18 @@ def _show_keyboard_shortcuts(self): _cprint("") def _set_terminal_title(self, session_title: str = "", thinking: bool = False) -> None: - """Set the terminal window/tab title via OSC escape sequence. + """Set the terminal tab title via OSC escape sequences. Format: - ⚕ Hermes — session name (idle, named session) - ⚕ Hermes ⏳ (agent running / thinking) - ⚕ Hermes (idle, unnamed session) - - Uses the skin's response_label symbol so the indicator matches the - active theme (⚕ default, ⚔ Ares, etc.). Skipped when stdout is not - a TTY, when TERM=dumb, or when NO_COLOR is set (indicates a terminal - that may not handle OSC sequences). + ⚕ (idle — symbol only) + ⚕ ⏳ (agent running / thinking) + + Uses the skin's symbol (⚕ default, ⚔ Ares, etc.). + + OSC 1 sets the tab/icon title explicitly; in iTerm2 this prevents + the process name (Python) from being appended to the tab label. + OSC 2 sets the window title (shown in the title bar). + Skipped when stdout is not a TTY, TERM=dumb, or NO_COLOR is set. """ import sys, os if not sys.stdout.isatty(): @@ -2923,25 +2924,16 @@ def _set_terminal_title(self, session_title: str = "", thinking: bool = False) - try: from hermes_cli.skin_engine import get_active_skin - # Extract just the symbol from response_label (e.g. " ⚕ Hermes " → "⚕") response_label = get_active_skin().get_branding("response_label", " ⚕ Hermes ") - # Pull first non-space char as the symbol symbol = next((c for c in response_label.strip() if not c.isalpha() and not c.isspace()), "⚕") - agent_name = get_active_skin().get_branding("agent_name", "Hermes Agent") - # Shorten "Hermes Agent" → "Hermes" for compact title - short_name = agent_name.split()[0] except Exception: - symbol, short_name = "⚕", "Hermes" + symbol = "⚕" - if thinking: - title = f"{symbol} {short_name} ⏳" - elif session_title: - title = f"{symbol} {short_name} — {session_title}" - else: - title = f"{symbol} {short_name}" + tab_title = f"{symbol} ⏳" if thinking else symbol - # OSC 0: set both icon name and window title - sys.stdout.write(f"\x1b]0;{title}\x07") + # OSC 1: tab/icon name (iTerm2 uses this as the tab label, no process name appended) + # OSC 2: window title (title bar) + sys.stdout.write(f"\x1b]1;{tab_title}\x07\x1b]2;{tab_title}\x07") sys.stdout.flush() self._terminal_title_session = session_title From 9b67e466b78cadc49c6affa64ccd56fbde6bb3ab Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Fri, 3 Apr 2026 15:57:13 -0400 Subject: [PATCH 15/44] fix: write OSC title sequences to real stdout, bypassing patch_stdout proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inside the TUI, sys.stdout is patched by prompt_toolkit's patch_stdout. OSC escape sequences written to StdoutProxy are buffered or discarded and never reach the terminal emulator — so /title didn't update the tab and Python still appeared as the process name. Fix: use sys.__stdout__ (the pre-patch original) and write via os.write() directly to the file descriptor, bypassing the proxy entirely. --- cli.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/cli.py b/cli.py index 44e391dcdda0..4b4f5f193f13 100644 --- a/cli.py +++ b/cli.py @@ -2915,7 +2915,15 @@ def _set_terminal_title(self, session_title: str = "", thinking: bool = False) - Skipped when stdout is not a TTY, TERM=dumb, or NO_COLOR is set. """ import sys, os - if not sys.stdout.isatty(): + # Use the real stdout (sys.__stdout__) to bypass prompt_toolkit's + # patch_stdout StdoutProxy — OSC escape sequences sent through the + # proxy are buffered / eaten and never reach the terminal emulator. + # Fall back to fd 1 if __stdout__ is unavailable. + real_out = getattr(sys, "__stdout__", None) or sys.stdout + try: + if not real_out.isatty(): + return + except Exception: return if os.environ.get("TERM", "") == "dumb": return @@ -2933,8 +2941,12 @@ def _set_terminal_title(self, session_title: str = "", thinking: bool = False) - # OSC 1: tab/icon name (iTerm2 uses this as the tab label, no process name appended) # OSC 2: window title (title bar) - sys.stdout.write(f"\x1b]1;{tab_title}\x07\x1b]2;{tab_title}\x07") - sys.stdout.flush() + seq = f"\x1b]1;{tab_title}\x07\x1b]2;{tab_title}\x07" + try: + os.write(real_out.fileno(), seq.encode()) + except Exception: + real_out.write(seq) + real_out.flush() self._terminal_title_session = session_title def _update_terminal_title(self, thinking: bool = False) -> None: From 9487b9da5840982518f258ca6366c2d7dbd82c75 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Fri, 3 Apr 2026 16:11:29 -0400 Subject: [PATCH 16/44] feat: add display.terminal_title config opt-out Users on tmux/screen, or whose iTerm2 profile appends the job name (Python) to the tab title, can now disable OSC title sequences: display: terminal_title: false Default: true (enabled). --- cli.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cli.py b/cli.py index 4b4f5f193f13..0d813b955ae3 100644 --- a/cli.py +++ b/cli.py @@ -206,7 +206,7 @@ def load_cli_config() -> Dict[str, Any]: "show_reasoning": False, "streaming": True, "busy_input_mode": "interrupt", - + "terminal_title": True, # Set tab/window title via OSC sequences (disable for tmux/screen or if job name is appended by your terminal profile) "skin": "default", }, "clarify": { @@ -2915,6 +2915,12 @@ def _set_terminal_title(self, session_title: str = "", thinking: bool = False) - Skipped when stdout is not a TTY, TERM=dumb, or NO_COLOR is set. """ import sys, os + # Respect display.terminal_title = false config opt-out + try: + if not CLI_CONFIG.get("display", {}).get("terminal_title", True): + return + except Exception: + pass # Use the real stdout (sys.__stdout__) to bypass prompt_toolkit's # patch_stdout StdoutProxy — OSC escape sequences sent through the # proxy are buffered / eaten and never reach the terminal emulator. From 24a0ef96c6655235990b7bc7069cd58a4144afb4 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Fri, 3 Apr 2026 16:28:27 -0400 Subject: [PATCH 17/44] =?UTF-8?q?feat:=20/browser=20connect=20profile=20?= =?UTF-8?q?=E2=80=94=20launch=20Chrome=20with=20real=20profile=20and=20log?= =?UTF-8?q?ins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /browser connect profile — Default profile (cookies/logins intact) /browser connect profile 'Profile 1' — specific Chrome profile /browser connect ws://... — custom CDP URL (unchanged) Passes --user-data-dir and --profile-directory to Chrome so existing sessions (X, LinkedIn, etc.) are available. Warns that Chrome must be fully quit first (Cmd+Q) since Chrome is single-instance per profile. --- cli.py | 79 +++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 65 insertions(+), 14 deletions(-) diff --git a/cli.py b/cli.py index 0d813b955ae3..22b37e2d521d 100644 --- a/cli.py +++ b/cli.py @@ -4844,17 +4844,24 @@ def run_btw(): thread.start() @staticmethod - def _try_launch_chrome_debug(port: int, system: str) -> bool: + def _try_launch_chrome_debug(port: int, system: str, + user_data_dir: Optional[str] = None, + profile_dir: Optional[str] = None) -> bool: """Try to launch Chrome/Chromium with remote debugging enabled. + user_data_dir: path to Chrome's user data root (e.g. ~/Library/Application Support/Google/Chrome) + When set, the launched Chrome uses the real profile with existing logins. + profile_dir: profile subfolder name inside user_data_dir (e.g. 'Default', 'Profile 1'). + Passed as --profile-directory. + Returns True if a launch command was executed (doesn't guarantee success). + NOTE: Chrome must not already be running on the same user_data_dir. """ import shutil import subprocess as _sp candidates = [] if system == "Darwin": - # macOS: try common app bundle locations for app in ( "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", @@ -4864,7 +4871,6 @@ def _try_launch_chrome_debug(port: int, system: str) -> bool: if os.path.isfile(app): candidates.append(app) else: - # Linux: try common binary names for name in ("google-chrome", "google-chrome-stable", "chromium-browser", "chromium", "brave-browser", "microsoft-edge"): path = shutil.which(name) @@ -4875,12 +4881,17 @@ def _try_launch_chrome_debug(port: int, system: str) -> bool: return False chrome = candidates[0] + cmd = [chrome, f"--remote-debugging-port={port}"] + if user_data_dir: + cmd.append(f"--user-data-dir={os.path.expanduser(user_data_dir)}") + if profile_dir: + cmd.append(f"--profile-directory={profile_dir}") try: _sp.Popen( - [chrome, f"--remote-debugging-port={port}"], + cmd, stdout=_sp.DEVNULL, stderr=_sp.DEVNULL, - start_new_session=True, # detach from terminal + start_new_session=True, ) return True except Exception: @@ -4897,9 +4908,34 @@ def _handle_browser_command(self, cmd: str): current = os.environ.get("BROWSER_CDP_URL", "").strip() if sub.startswith("connect"): - # Optionally accept a custom CDP URL: /browser connect ws://host:port - connect_parts = cmd.strip().split(None, 2) # ["/browser", "connect", "ws://..."] - cdp_url = connect_parts[2].strip() if len(connect_parts) > 2 else _DEFAULT_CDP + # Subcommands: + # /browser connect — fresh Chrome on port 9222 + # /browser connect profile — your default Chrome profile (with logins) + # /browser connect profile N — specific profile ('Default', 'Profile 1', etc.) + # /browser connect ws://... — custom CDP URL + connect_parts = cmd.strip().split(None, 3) # ["/browser", "connect", arg1?, arg2?] + arg1 = connect_parts[2].strip() if len(connect_parts) > 2 else "" + arg2 = connect_parts[3].strip() if len(connect_parts) > 3 else "" + + _user_data_dir = None + _profile_dir = None + cdp_url = _DEFAULT_CDP + + if arg1.startswith(("ws://", "http://", "https://")): + cdp_url = arg1 + elif arg1 == "profile": + # Use the real Chrome profile — requires Chrome to be closed first + sys_name = _plat.system() + if sys_name == "Darwin": + _user_data_dir = os.path.expanduser( + "~/Library/Application Support/Google/Chrome" + ) + elif sys_name == "Linux": + _user_data_dir = os.path.expanduser("~/.config/google-chrome") + else: + _user_data_dir = os.path.expanduser("~/AppData/Local/Google/Chrome/User Data") + # Profile subfolder: 'Default', 'Profile 1', etc. + _profile_dir = arg2 if arg2 else "Default" # Clear any existing browser sessions so the next tool call uses the new backend try: @@ -4909,6 +4945,10 @@ def _handle_browser_command(self, cmd: str): pass print() + if _user_data_dir: + print(f" Profile mode: {_user_data_dir}/{_profile_dir}") + print(f" ⚠ Chrome must be fully quit (Cmd+Q) before connecting with your profile.") + print() # Extract port for connectivity checks _port = 9222 @@ -4934,9 +4974,12 @@ def _handle_browser_command(self, cmd: str): elif cdp_url == _DEFAULT_CDP: # Try to auto-launch Chrome with remote debugging print(" Chrome isn't running with remote debugging — attempting to launch...") - _launched = self._try_launch_chrome_debug(_port, _plat.system()) + _launched = self._try_launch_chrome_debug( + _port, _plat.system(), + user_data_dir=_user_data_dir, + profile_dir=_profile_dir, + ) if _launched: - # Wait for the port to come up import time as _time for _wait in range(10): try: @@ -4950,20 +4993,28 @@ def _handle_browser_command(self, cmd: str): _time.sleep(0.5) if _already_open: print(f" ✓ Chrome launched and listening on port {_port}") + if _user_data_dir: + print(f" ✓ Using profile: {_profile_dir} (your logins are available)") else: print(f" ⚠ Chrome launched but port {_port} isn't responding yet") - print(" You may need to close existing Chrome windows first and retry") + if _user_data_dir: + print(" If Chrome was already open, quit it completely (Cmd+Q) and retry") + else: + print(" You may need to close existing Chrome windows first and retry") else: print(" ⚠ Could not auto-launch Chrome") - # Show manual instructions as fallback sys_name = _plat.system() if sys_name == "Darwin": - chrome_cmd = 'open -a "Google Chrome" --args --remote-debugging-port=9222' + udd = _user_data_dir or "" + prof = f' --profile-directory="{_profile_dir}"' if _profile_dir else "" + udd_arg = f' --user-data-dir="{udd}"' if udd else "" + chrome_cmd = f'"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222{udd_arg}{prof}' elif sys_name == "Windows": chrome_cmd = 'chrome.exe --remote-debugging-port=9222' else: chrome_cmd = "google-chrome --remote-debugging-port=9222" - print(f" Launch Chrome manually: {chrome_cmd}") + print(f" Launch Chrome manually:") + print(f" {chrome_cmd}") else: print(f" ⚠ Port {_port} is not reachable at {cdp_url}") From 3bcce0d2d2492bb053b080cfca12a9fdcb236121 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sat, 4 Apr 2026 09:03:10 -0400 Subject: [PATCH 18/44] feat: /browser connect auto-launches Chrome with Hermes profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete rewrite of browser command handling: /browser connect — auto-launches Chrome with ~/.hermes/chrome-profile (or browser.hermes_profile_dir from config) auto-detects if already running, no URL needed /browser connect setup — first-time setup: creates profile dir, opens Chrome so user can log in, then close and run connect /browser connect — explicit CDP URL (unchanged) Config options: browser.hermes_profile_dir: ~/.hermes/chrome-profile browser.cdp_port: 9222 Fixes BROWSER_CDP_URL being set to literal words ('profile', 'hermes') instead of the actual http://localhost:PORT endpoint. Adds _chrome_candidates(), _ensure_chrome_debug() helpers. --- cli.py | 214 ++++++++++++++++++++++++--------------------------------- 1 file changed, 91 insertions(+), 123 deletions(-) diff --git a/cli.py b/cli.py index 22b37e2d521d..cbc0aa19f05b 100644 --- a/cli.py +++ b/cli.py @@ -4844,22 +4844,9 @@ def run_btw(): thread.start() @staticmethod - def _try_launch_chrome_debug(port: int, system: str, - user_data_dir: Optional[str] = None, - profile_dir: Optional[str] = None) -> bool: - """Try to launch Chrome/Chromium with remote debugging enabled. - - user_data_dir: path to Chrome's user data root (e.g. ~/Library/Application Support/Google/Chrome) - When set, the launched Chrome uses the real profile with existing logins. - profile_dir: profile subfolder name inside user_data_dir (e.g. 'Default', 'Profile 1'). - Passed as --profile-directory. - - Returns True if a launch command was executed (doesn't guarantee success). - NOTE: Chrome must not already be running on the same user_data_dir. - """ + def _chrome_candidates(system: str) -> list: + """Return Chrome/Chromium binary paths to try.""" import shutil - import subprocess as _sp - candidates = [] if system == "Darwin": for app in ( @@ -4876,68 +4863,101 @@ def _try_launch_chrome_debug(port: int, system: str, path = shutil.which(name) if path: candidates.append(path) + return candidates + + @staticmethod + def _try_launch_chrome_debug(port: int, system: str, + user_data_dir: Optional[str] = None) -> bool: + """Launch Chrome with remote debugging on *port*. + user_data_dir: dedicated Chrome user-data dir (e.g. ~/.hermes/chrome-profile). + Chrome's security policy blocks CDP on the real default profile, + so a dedicated directory is required for persistent logins. + """ + import subprocess as _sp + candidates = HermesCLI._chrome_candidates(system) if not candidates: return False - - chrome = candidates[0] - cmd = [chrome, f"--remote-debugging-port={port}"] + cmd = [candidates[0], f"--remote-debugging-port={port}"] if user_data_dir: cmd.append(f"--user-data-dir={os.path.expanduser(user_data_dir)}") - if profile_dir: - cmd.append(f"--profile-directory={profile_dir}") try: - _sp.Popen( - cmd, - stdout=_sp.DEVNULL, - stderr=_sp.DEVNULL, - start_new_session=True, - ) + _sp.Popen(cmd, stdout=_sp.DEVNULL, stderr=_sp.DEVNULL, + start_new_session=True) return True except Exception: return False + @classmethod + def _ensure_chrome_debug(cls, port: int, user_data_dir: Optional[str] = None) -> bool: + """Ensure Chrome is listening on *port*, launching it if needed. + Returns True if the port is (or becomes) reachable within ~5 s. + """ + import platform as _plat, socket, time as _time + + def _check(): + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(1) + s.connect(("127.0.0.1", port)) + s.close() + return True + except (OSError, socket.timeout): + return False + + if _check(): + return True + if not cls._try_launch_chrome_debug(port, _plat.system(), user_data_dir): + return False + for _ in range(10): + _time.sleep(0.5) + if _check(): + return True + return False + def _handle_browser_command(self, cmd: str): - """Handle /browser connect|disconnect|status — manage live Chrome CDP connection.""" - import platform as _plat + """Handle /browser connect|disconnect|status — manage live Chrome CDP connection. + Usage: + /browser connect — auto-launch Chrome with Hermes profile (from config) + /browser connect setup — first-time: create profile dir, open Chrome to log in + /browser connect — connect to an already-running Chrome at a custom CDP URL + /browser disconnect — revert to default headless / Browserbase mode + /browser status — show current connection state + """ parts = cmd.strip().split(None, 1) sub = parts[1].lower().strip() if len(parts) > 1 else "status" - _DEFAULT_CDP = "http://localhost:9222" + # Read browser config + _browser_cfg = CLI_CONFIG.get("browser", {}) + _profile_dir = str(_browser_cfg.get("hermes_profile_dir") or "~/.hermes/chrome-profile").strip() + _cdp_port = int(_browser_cfg.get("cdp_port") or 9222) + _DEFAULT_CDP = f"http://localhost:{_cdp_port}" current = os.environ.get("BROWSER_CDP_URL", "").strip() if sub.startswith("connect"): - # Subcommands: - # /browser connect — fresh Chrome on port 9222 - # /browser connect profile — your default Chrome profile (with logins) - # /browser connect profile N — specific profile ('Default', 'Profile 1', etc.) - # /browser connect ws://... — custom CDP URL - connect_parts = cmd.strip().split(None, 3) # ["/browser", "connect", arg1?, arg2?] - arg1 = connect_parts[2].strip() if len(connect_parts) > 2 else "" - arg2 = connect_parts[3].strip() if len(connect_parts) > 3 else "" + connect_parts = cmd.strip().split(None, 2) + arg = connect_parts[2].strip() if len(connect_parts) > 2 else "" _user_data_dir = None - _profile_dir = None - cdp_url = _DEFAULT_CDP - - if arg1.startswith(("ws://", "http://", "https://")): - cdp_url = arg1 - elif arg1 == "profile": - # Use the real Chrome profile — requires Chrome to be closed first - sys_name = _plat.system() - if sys_name == "Darwin": - _user_data_dir = os.path.expanduser( - "~/Library/Application Support/Google/Chrome" - ) - elif sys_name == "Linux": - _user_data_dir = os.path.expanduser("~/.config/google-chrome") - else: - _user_data_dir = os.path.expanduser("~/AppData/Local/Google/Chrome/User Data") - # Profile subfolder: 'Default', 'Profile 1', etc. - _profile_dir = arg2 if arg2 else "Default" + if arg.startswith(("ws://", "http://", "https://")): + cdp_url = arg + else: + cdp_url = _DEFAULT_CDP + _user_data_dir = _profile_dir + + if arg == "setup": + _pdir = os.path.expanduser(_profile_dir) + os.makedirs(_pdir, exist_ok=True) + print() + print(f" 📂 Hermes browser profile: {_pdir}") + print(" Opening Chrome — log in to any sites you want Hermes to access,") + print(" then close Chrome and run /browser connect to reconnect.") + print() + self._try_launch_chrome_debug(_cdp_port, __import__("platform").system(), + user_data_dir=_user_data_dir) + return - # Clear any existing browser sessions so the next tool call uses the new backend try: from tools.browser_tool import cleanup_all_browsers cleanup_all_browsers() @@ -4946,77 +4966,25 @@ def _handle_browser_command(self, cmd: str): print() if _user_data_dir: - print(f" Profile mode: {_user_data_dir}/{_profile_dir}") - print(f" ⚠ Chrome must be fully quit (Cmd+Q) before connecting with your profile.") - print() + _pdir_expanded = os.path.expanduser(_user_data_dir) + if not os.path.exists(_pdir_expanded): + print(" ℹ Profile dir doesn't exist yet — run /browser connect setup first") + print(" to log into your sites, then reconnect.") + os.makedirs(_pdir_expanded, exist_ok=True) + print(f" Profile: {_pdir_expanded}") - # Extract port for connectivity checks - _port = 9222 - try: - _port = int(cdp_url.rsplit(":", 1)[-1].split("/")[0]) - except (ValueError, IndexError): - pass - - # Check if Chrome is already listening on the debug port - import socket - _already_open = False - try: - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.settimeout(1) - s.connect(("127.0.0.1", _port)) - s.close() - _already_open = True - except (OSError, socket.timeout): - pass + _already_open = self._ensure_chrome_debug(_cdp_port, _user_data_dir) if _already_open: - print(f" ✓ Chrome is already listening on port {_port}") - elif cdp_url == _DEFAULT_CDP: - # Try to auto-launch Chrome with remote debugging - print(" Chrome isn't running with remote debugging — attempting to launch...") - _launched = self._try_launch_chrome_debug( - _port, _plat.system(), - user_data_dir=_user_data_dir, - profile_dir=_profile_dir, - ) - if _launched: - import time as _time - for _wait in range(10): - try: - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.settimeout(1) - s.connect(("127.0.0.1", _port)) - s.close() - _already_open = True - break - except (OSError, socket.timeout): - _time.sleep(0.5) - if _already_open: - print(f" ✓ Chrome launched and listening on port {_port}") - if _user_data_dir: - print(f" ✓ Using profile: {_profile_dir} (your logins are available)") - else: - print(f" ⚠ Chrome launched but port {_port} isn't responding yet") - if _user_data_dir: - print(" If Chrome was already open, quit it completely (Cmd+Q) and retry") - else: - print(" You may need to close existing Chrome windows first and retry") - else: - print(" ⚠ Could not auto-launch Chrome") - sys_name = _plat.system() - if sys_name == "Darwin": - udd = _user_data_dir or "" - prof = f' --profile-directory="{_profile_dir}"' if _profile_dir else "" - udd_arg = f' --user-data-dir="{udd}"' if udd else "" - chrome_cmd = f'"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222{udd_arg}{prof}' - elif sys_name == "Windows": - chrome_cmd = 'chrome.exe --remote-debugging-port=9222' - else: - chrome_cmd = "google-chrome --remote-debugging-port=9222" - print(f" Launch Chrome manually:") - print(f" {chrome_cmd}") + print(f" ✓ Chrome listening on port {_cdp_port}") + if _user_data_dir: + print(" ✓ Using Hermes profile — existing logins available") else: - print(f" ⚠ Port {_port} is not reachable at {cdp_url}") + print(f" ⚠ Chrome didn't respond on port {_cdp_port}") + if _user_data_dir: + chrome_bin = (self._chrome_candidates(__import__("platform").system()) or ["Google Chrome"])[0] + print(" Try manually:") + print(f' "{chrome_bin}" --remote-debugging-port={_cdp_port} --user-data-dir="{os.path.expanduser(_user_data_dir)}"') os.environ["BROWSER_CDP_URL"] = cdp_url print() From 72215e031eff497d5e62eca56a1be9ea91b416a8 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sat, 4 Apr 2026 10:32:47 -0400 Subject: [PATCH 19/44] fix: write terminal title through prompt_toolkit Output to avoid rendering race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit os.write() to fd 1 races with prompt_toolkit's own rendering writes, causing ESC to appear as '?' and the OSC sequence to leak as literal text (e.g. '?]0;⚕ Hermes' visible in the terminal output). Fix: when inside the TUI, write via get_app().output.write_raw() which is synchronised with the render loop. Falls back to direct fd write when outside the TUI (startup, single-query mode). --- cli.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/cli.py b/cli.py index cbc0aa19f05b..eebad8b4a5ca 100644 --- a/cli.py +++ b/cli.py @@ -2948,11 +2948,25 @@ def _set_terminal_title(self, session_title: str = "", thinking: bool = False) - # OSC 1: tab/icon name (iTerm2 uses this as the tab label, no process name appended) # OSC 2: window title (title bar) seq = f"\x1b]1;{tab_title}\x07\x1b]2;{tab_title}\x07" + + # When inside the prompt_toolkit TUI, write through the app's Output + # object so the sequence is synchronised with the render loop and + # doesn't interleave with prompt_toolkit's own writes to fd 1 + # (which would cause the raw bytes to appear as literal text). + # Outside the TUI (startup, non-interactive mode) fall back to a + # direct write on the real stdout fd. try: - os.write(real_out.fileno(), seq.encode()) + from prompt_toolkit.application import get_app as _get_app + _app = _get_app() + _app.output.write_raw(seq) + _app.output.flush() except Exception: - real_out.write(seq) - real_out.flush() + # Not in app context — write directly to the real fd + try: + os.write(real_out.fileno(), seq.encode()) + except Exception: + real_out.write(seq) + real_out.flush() self._terminal_title_session = session_title def _update_terminal_title(self, thinking: bool = False) -> None: From c0ccc0d260b9564f825a05fb0f2c2c162ae3c909 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sat, 4 Apr 2026 10:49:50 -0400 Subject: [PATCH 20/44] =?UTF-8?q?feat:=20dual=20queue=20=E2=80=94=20?= =?UTF-8?q?=F0=9F=93=AC=20follow-up=20(Alt+Enter)=20+=20=F0=9F=8E=AF=20ste?= =?UTF-8?q?ering=20(Enter/queue=20mode)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two distinct queues with dedicated icons and recall shortcuts: 📬 Follow-up queue (Alt+Enter, always non-interrupting) - New: independent follow-up task after current response - Recall: Alt+Up (LIFO) - Status bar: 📬 N - UUID-tagged, cancellable 🎯 Steering queue (Enter during agent run, busy_input_mode=queue) - Contextual guidance for current/upcoming work - Recall: Alt+Down (LIFO) - Status bar: 🎯 N - UUID-tagged, cancellable Placeholder hints adapt to busy_input_mode: queue mode: 'Enter to steer (🎯) · Alt+Enter to follow-up (📬)' interrupt mode: 'Enter to interrupt · Alt+Enter to queue follow-up (📬)' Idle placeholder shows both counts with their recall shortcuts. --- cli.py | 107 +++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 85 insertions(+), 22 deletions(-) diff --git a/cli.py b/cli.py index eebad8b4a5ca..861b64b30d6a 100644 --- a/cli.py +++ b/cli.py @@ -1348,9 +1348,12 @@ def __init__( self._agent_running = False self._pending_input = queue.Queue() self._interrupt_queue = queue.Queue() - self._followup_queue: list = [] # mirror of _pending_input for display; entries are {"id": str, "payload": ...} + self._followup_queue: list = [] # 📬 Alt+Enter queue — entries are {"id": str, "payload": ...} self._cancelled_followups: set = set() # UUIDs recalled via Alt+Up, skipped in process_loop self._followup_recall_count: int = 0 # how many recalls done in this recall session + self._steering_queue: list = [] # 🎯 Enter-during-run queue (busy_input_mode=queue) + self._cancelled_steerings: set = set() # UUIDs recalled via Alt+Down + self._steering_recall_count: int = 0 self._should_exit = False self._last_ctrl_c_time = 0 self._stashed_input = None # Ctrl+S stash: (text, [images]) or None @@ -1608,10 +1611,13 @@ def _get_status_bar_fragments(self): if self._stashed_input: frags.append(("class:status-bar-dim", " │ ")) frags.append(("class:status-bar-warn", "📌 stashed")) - # Follow-up queue indicator + # Follow-up queue (📬) and steering queue (🎯) indicators if self._followup_queue: frags.append(("class:status-bar-dim", " │ ")) frags.append(("class:status-bar-warn", f"📬 {len(self._followup_queue)}")) + if self._steering_queue: + frags.append(("class:status-bar-dim", " │ ")) + frags.append(("class:status-bar-warn", f"🎯 {len(self._steering_queue)}")) total_width = sum(self._status_bar_display_width(text) for _, text in frags) if total_width > width: @@ -7119,10 +7125,16 @@ def handle_enter(event): _is_slash_cmd = bool(_resolve_cmd_fn(_fw)) if self._agent_running and not _is_slash_cmd: if self.busy_input_mode == "queue": - # Queue for the next turn instead of interrupting - self._pending_input.put(payload) - preview = text if text else f"[{len(images)} image{'s' if len(images) != 1 else ''} attached]" - _cprint(f" Queued for the next turn: {preview[:80]}{'...' if len(preview) > 80 else ''}") + # Tag and track in the 🎯 steering queue + import uuid as _uuid_mod + _stag = _uuid_mod.uuid4().hex + cli_ref._pending_input.put({"_steering_tag": _stag, "payload": payload}) + _steer_text = text if text else f"[{len(images)} image{'s' if len(images) != 1 else ''} attached]" + cli_ref._steering_queue.append({"id": _stag, "payload": payload, "text": _steer_text}) + _sdepth = len(cli_ref._steering_queue) + _spreview = _steer_text[:60] + ("..." if len(_steer_text) > 60 else "") + _cprint(f" {_DIM}🎯 Steering queued #{_sdepth}: \"{_spreview}\"{_RST}") + event.app.invalidate() else: self._interrupt_queue.put(payload) # Debug: log to file when message enters interrupt queue @@ -7231,6 +7243,39 @@ def handle_recall_followup(event): _cprint(f" {_DIM}📬 Follow-up recalled — queue empty{_RST}") event.app.invalidate() + @kb.add('escape', 'down') + def handle_recall_steering(event): + """Alt+Down: recall the most recently queued steering message into the input. + + Symmetric to Alt+Up (follow-up recall) but operates on the 🎯 steering queue. + Items are popped LIFO and appended with \\n---\\n separators from the second + recall onwards. Recalled items are UUID-cancelled so process_loop skips them. + """ + if not cli_ref._steering_queue: + return + + buf = event.app.current_buffer + + item = cli_ref._steering_queue.pop() + recalled_text = item["text"] + cli_ref._cancelled_steerings.add(item["id"]) + + current = buf.text + if cli_ref._steering_recall_count > 0 and current.strip(): + buf.text = current.rstrip() + '\n---\n' + recalled_text + else: + buf.text = (current + recalled_text) if current else recalled_text + buf.cursor_position = len(buf.text) + cli_ref._steering_recall_count += 1 + + remaining = len(cli_ref._steering_queue) + if remaining: + _cprint(f" {_DIM}🎯 Recalled steering ({remaining} still queued){_RST}") + else: + cli_ref._steering_recall_count = 0 + _cprint(f" {_DIM}🎯 Steering recalled — queue empty{_RST}") + event.app.invalidate() + @kb.add('tab', eager=True) def handle_tab(event): """Tab: accept completion, auto-suggestion, or start completions. @@ -7901,13 +7946,23 @@ def _get_placeholder(): if cli_ref._agent_running: hints = [] if cli_ref._followup_queue: - hints.append(f"📬 {len(cli_ref._followup_queue)} queued") + hints.append(f"📬 {len(cli_ref._followup_queue)}") + if cli_ref._steering_queue: + hints.append(f"🎯 {len(cli_ref._steering_queue)}") if cli_ref._stashed_input: hints.append("📌 stashed") suffix = " · " + " · ".join(hints) if hints else "" - return f"Enter to interrupt · Alt+Enter to queue follow-up{suffix}" - if cli_ref._followup_queue: - return f"📬 {len(cli_ref._followup_queue)} follow-up{'s' if len(cli_ref._followup_queue) > 1 else ''} queued — Alt+Enter to add more" + # Hint depends on busy_input_mode + if cli_ref.busy_input_mode == "queue": + return f"Enter to steer (🎯) · Alt+Enter to follow-up (📬){suffix}" + return f"Enter to interrupt · Alt+Enter to queue follow-up (📬){suffix}" + if cli_ref._followup_queue or cli_ref._steering_queue: + parts = [] + if cli_ref._followup_queue: + parts.append(f"📬 {len(cli_ref._followup_queue)} follow-up{'s' if len(cli_ref._followup_queue) > 1 else ''} — Alt+Up to recall") + if cli_ref._steering_queue: + parts.append(f"🎯 {len(cli_ref._steering_queue)} steering — Alt+Down to recall") + return " · ".join(parts) if cli_ref._stashed_input: stashed_text = cli_ref._stashed_input[0] preview = stashed_text[:40] + ("..." if len(stashed_text) > 40 else "") @@ -8402,18 +8457,26 @@ def process_loop(): # Check for pending input with timeout try: user_input = self._pending_input.get(timeout=0.1) - # Unwrap tagged followup items (queued via Alt+Enter) - if isinstance(user_input, dict) and "_followup_tag" in user_input: - tag = user_input["_followup_tag"] - user_input = user_input["payload"] - # Sync display mirror — only pop for tagged items, not regular Enter - if self._followup_queue: - self._followup_queue.pop(0) - app.invalidate() - # Skip items recalled via Alt+Up (cancelled by UUID, not text) - if tag in self._cancelled_followups: - self._cancelled_followups.discard(tag) - continue + # Unwrap tagged items (📬 followup or 🎯 steering) + if isinstance(user_input, dict): + if "_followup_tag" in user_input: + tag = user_input["_followup_tag"] + user_input = user_input["payload"] + if self._followup_queue: + self._followup_queue.pop(0) + app.invalidate() + if tag in self._cancelled_followups: + self._cancelled_followups.discard(tag) + continue + elif "_steering_tag" in user_input: + tag = user_input["_steering_tag"] + user_input = user_input["payload"] + if self._steering_queue: + self._steering_queue.pop(0) + app.invalidate() + if tag in self._cancelled_steerings: + self._cancelled_steerings.discard(tag) + continue except queue.Empty: # Periodic config watcher — auto-reload MCP on mcp_servers change if not self._agent_running: From ba9d73ccf76578571dc9f3046c6df463dc23e021 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sat, 4 Apr 2026 11:18:22 -0400 Subject: [PATCH 21/44] fix: show recall shortcuts in placeholder when queues are active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-running placeholder now shows: 📬 2 (Alt+↑ to recall) · 🎯 1 (Alt+↓ to recall) instead of just the counts. --- cli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli.py b/cli.py index 861b64b30d6a..ba6a27f56308 100644 --- a/cli.py +++ b/cli.py @@ -7946,16 +7946,16 @@ def _get_placeholder(): if cli_ref._agent_running: hints = [] if cli_ref._followup_queue: - hints.append(f"📬 {len(cli_ref._followup_queue)}") + hints.append(f"📬 {len(cli_ref._followup_queue)} (Alt+↑ to recall)") if cli_ref._steering_queue: - hints.append(f"🎯 {len(cli_ref._steering_queue)}") + hints.append(f"🎯 {len(cli_ref._steering_queue)} (Alt+↓ to recall)") if cli_ref._stashed_input: hints.append("📌 stashed") suffix = " · " + " · ".join(hints) if hints else "" # Hint depends on busy_input_mode if cli_ref.busy_input_mode == "queue": return f"Enter to steer (🎯) · Alt+Enter to follow-up (📬){suffix}" - return f"Enter to interrupt · Alt+Enter to queue follow-up (📬){suffix}" + return f"Enter to interrupt · Alt+Enter to follow-up (📬){suffix}" if cli_ref._followup_queue or cli_ref._steering_queue: parts = [] if cli_ref._followup_queue: From 48cbde245a82e2f0de19e50384b3b58cef34c5d9 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sat, 4 Apr 2026 11:20:26 -0400 Subject: [PATCH 22/44] =?UTF-8?q?fix:=20shorten=20idle=20queue=20placehold?= =?UTF-8?q?er=20to=20'=F0=9F=93=AC=202=20(Alt+=E2=86=91)'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli.py b/cli.py index ba6a27f56308..93bf7ec17f9e 100644 --- a/cli.py +++ b/cli.py @@ -7959,9 +7959,9 @@ def _get_placeholder(): if cli_ref._followup_queue or cli_ref._steering_queue: parts = [] if cli_ref._followup_queue: - parts.append(f"📬 {len(cli_ref._followup_queue)} follow-up{'s' if len(cli_ref._followup_queue) > 1 else ''} — Alt+Up to recall") + parts.append(f"📬 {len(cli_ref._followup_queue)} (Alt+↑)") if cli_ref._steering_queue: - parts.append(f"🎯 {len(cli_ref._steering_queue)} steering — Alt+Down to recall") + parts.append(f"🎯 {len(cli_ref._steering_queue)} (Alt+↓)") return " · ".join(parts) if cli_ref._stashed_input: stashed_text = cli_ref._stashed_input[0] From 0b5144a8c573caeccbecc008323aaaa478e9fb42 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sat, 4 Apr 2026 11:49:22 -0400 Subject: [PATCH 23/44] fix: set terminal title from inside running app via call_from_executor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The startup call to _update_terminal_title() fired before app.run(), so get_app() raised RuntimeError and the os.write() fallback ran — but iTerm2 then reset the tab title when prompt_toolkit took over the terminal, leaving the tab showing 'hermes (Python)' with no ⚕ symbol. Fix: remove the premature pre-app call and instead schedule _update_terminal_title() via app.call_from_executor() at the start of process_loop. This runs in the event loop after the TUI is live, so get_app() returns the running app and write_raw() reaches the terminal at the right moment. --- cli.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/cli.py b/cli.py index 93bf7ec17f9e..6944d85c9126 100644 --- a/cli.py +++ b/cli.py @@ -6935,7 +6935,8 @@ def run(self): pass self.show_banner() - self._update_terminal_title() # Set initial terminal title on startup + # Terminal title is set from inside process_loop (via call_from_executor) + # so it fires after prompt_toolkit takes over the terminal, not before. # One-line Honcho session indicator (TTY-only, not captured by agent). # Only show when the user explicitly configured Honcho for Hermes @@ -8452,6 +8453,14 @@ def spinner_loop(): # Background thread to process inputs and run agent def process_loop(): + # Set terminal title on first iteration — runs inside the live app so + # get_app() works and write_raw() reaches the terminal after prompt_toolkit + # has taken over (iTerm2 resets the title when the TUI starts otherwise). + try: + app.call_from_executor(self._update_terminal_title) + except Exception: + pass + while not self._should_exit: try: # Check for pending input with timeout From caaa77c532333fbaa772455abd09f916ed6e5538 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sat, 4 Apr 2026 12:17:17 -0400 Subject: [PATCH 24/44] =?UTF-8?q?fix:=20write=20terminal=20title=20via=20o?= =?UTF-8?q?s.ctermid()=20=E2=80=94=20bypasses=20all=20I/O=20layers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_app().output.write_raw() and os.write(sys.__stdout__.fileno()) both fail to reliably reach the terminal because prompt_toolkit's output buffers and patch_stdout intercept or defer the write. Fix: open the controlling terminal device via os.ctermid() (returns '/dev/tty' on macOS/Linux) with O_WRONLY|O_NOCTTY and write directly. This bypasses Python's I/O, prompt_toolkit's buffers, patch_stdout's StdoutProxy, and any stdout redirections — bytes go straight to the TTY the user is looking at, from any thread, at any time. --- cli.py | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/cli.py b/cli.py index 6944d85c9126..310952be3e25 100644 --- a/cli.py +++ b/cli.py @@ -2951,28 +2951,35 @@ def _set_terminal_title(self, session_title: str = "", thinking: bool = False) - tab_title = f"{symbol} ⏳" if thinking else symbol - # OSC 1: tab/icon name (iTerm2 uses this as the tab label, no process name appended) + # OSC 1: tab/icon name (iTerm2 uses this as the explicit tab label) # OSC 2: window title (title bar) seq = f"\x1b]1;{tab_title}\x07\x1b]2;{tab_title}\x07" + seq_b = seq.encode("utf-8") - # When inside the prompt_toolkit TUI, write through the app's Output - # object so the sequence is synchronised with the render loop and - # doesn't interleave with prompt_toolkit's own writes to fd 1 - # (which would cause the raw bytes to appear as literal text). - # Outside the TUI (startup, non-interactive mode) fall back to a - # direct write on the real stdout fd. + # Write directly to the controlling terminal device via os.ctermid(). + # This bypasses ALL Python I/O layers, prompt_toolkit's output buffers, + # patch_stdout's StdoutProxy, and any stdout redirections — the bytes + # go straight to the TTY the user is looking at. + written = False try: - from prompt_toolkit.application import get_app as _get_app - _app = _get_app() - _app.output.write_raw(seq) - _app.output.flush() + _tty_fd = os.open(os.ctermid(), os.O_WRONLY | os.O_NOCTTY) + os.write(_tty_fd, seq_b) + os.close(_tty_fd) + written = True except Exception: - # Not in app context — write directly to the real fd + pass + + if not written: + # Fallback: try real stdout fd directly try: - os.write(real_out.fileno(), seq.encode()) + os.write(real_out.fileno(), seq_b) except Exception: - real_out.write(seq) - real_out.flush() + try: + real_out.write(seq) + real_out.flush() + except Exception: + pass + self._terminal_title_session = session_title def _update_terminal_title(self, thinking: bool = False) -> None: From 6a463f62515a8f06b5d2235c1d75895dcfb5091c Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sat, 4 Apr 2026 12:18:43 -0400 Subject: [PATCH 25/44] docs: update /keys with Ctrl+P, ESC ESC, and dual-queue shortcuts --- cli.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cli.py b/cli.py index 310952be3e25..c78eae56dbc3 100644 --- a/cli.py +++ b/cli.py @@ -2893,7 +2893,15 @@ def _show_keyboard_shortcuts(self): ("Drafting", [ ("Ctrl+G", "Open input in external editor ($VISUAL / VS Code)"), ("Ctrl+S", "Stash input (pop with Ctrl+S, auto-restores after response)"), + ("Ctrl+P", "Peek paste / preview input / full history pager (empty input)"), ("Ctrl+V", "Paste from clipboard (image-aware)"), + ("ESC ESC", "Clear input buffer and attached images"), + ]), + ("Queues", [ + ("Alt+Enter", "📬 Queue follow-up (sent after current response)"), + ("Enter (queue mode)", "🎯 Queue steering (busy_input_mode: queue in config)"), + ("Alt+↑", "Recall most recent 📬 follow-up into input"), + ("Alt+↓", "Recall most recent 🎯 steering into input"), ]), ("Voice", [ (_voice_key_display, "Toggle voice recording (when voice mode is on)"), From b5e00c4eec9570a1ee8d39501c884285234a0c29 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sat, 4 Apr 2026 12:42:08 -0400 Subject: [PATCH 26/44] debug: explicit title write with error reporting in /title handler --- cli.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cli.py b/cli.py index c78eae56dbc3..c440016ca918 100644 --- a/cli.py +++ b/cli.py @@ -4363,6 +4363,15 @@ def process_command(self, command: str) -> bool: try: if self._session_db.set_session_title(self.session_id, new_title): _cprint(f" Session title set: {new_title}") + try: + import os as _os + _seq = f"\x1b]0;⚕\x07".encode("utf-8") + _fd = _os.open(_os.ctermid(), _os.O_WRONLY | _os.O_NOCTTY) + _os.write(_fd, _seq) + _os.close(_fd) + _cprint(f" {_DIM}Tab title updated via ctermid{_RST}") + except Exception as _te: + _cprint(f" {_DIM}Tab title error: {_te}{_RST}") self._set_terminal_title(session_title=new_title) else: _cprint(" Session not found in database.") From 72cef2f3b80a6e890874788037125a2ae080ae56 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sat, 4 Apr 2026 12:44:00 -0400 Subject: [PATCH 27/44] fix: use OSC 0 + ST terminator (\x1b\\) matching what works in iTerm2 Debug test showed Test 5 (OSC 0 + ST terminator) is what iTerm2 accepts for tab title updates. Previous code used OSC 1+2 with BEL (\x07). Changes: - OSC 0 instead of separate OSC 1 + OSC 2 - ST terminator (\x1b\\) instead of BEL (\x07) - Write via sys.__stdout__ first (simpler), ctermid as fallback --- cli.py | 47 ++++++++++++++--------------------------------- 1 file changed, 14 insertions(+), 33 deletions(-) diff --git a/cli.py b/cli.py index c440016ca918..8de2728be305 100644 --- a/cli.py +++ b/cli.py @@ -2959,34 +2959,24 @@ def _set_terminal_title(self, session_title: str = "", thinking: bool = False) - tab_title = f"{symbol} ⏳" if thinking else symbol - # OSC 1: tab/icon name (iTerm2 uses this as the explicit tab label) - # OSC 2: window title (title bar) - seq = f"\x1b]1;{tab_title}\x07\x1b]2;{tab_title}\x07" - seq_b = seq.encode("utf-8") - - # Write directly to the controlling terminal device via os.ctermid(). - # This bypasses ALL Python I/O layers, prompt_toolkit's output buffers, - # patch_stdout's StdoutProxy, and any stdout redirections — the bytes - # go straight to the TTY the user is looking at. - written = False + # OSC 0 sets both icon (tab) and window title. + # Use ST terminator (\x1b\\) rather than BEL (\x07) — ST is more + # reliably processed by iTerm2 for tab title updates mid-session. + seq_b = f"\x1b]0;{tab_title}\x1b\\".encode("utf-8") + + # Write via sys.__stdout__ (pre-patch real stdout) then fall back to + # ctermid. Both reach the PTY; __stdout__ is simpler and avoids + # prompt_toolkit's internal buffers entirely. try: - _tty_fd = os.open(os.ctermid(), os.O_WRONLY | os.O_NOCTTY) - os.write(_tty_fd, seq_b) - os.close(_tty_fd) - written = True + real_out.write(seq_b.decode("utf-8")) + real_out.flush() except Exception: - pass - - if not written: - # Fallback: try real stdout fd directly try: - os.write(real_out.fileno(), seq_b) + _tty_fd = os.open(os.ctermid(), os.O_WRONLY | os.O_NOCTTY) + os.write(_tty_fd, seq_b) + os.close(_tty_fd) except Exception: - try: - real_out.write(seq) - real_out.flush() - except Exception: - pass + pass self._terminal_title_session = session_title @@ -4363,15 +4353,6 @@ def process_command(self, command: str) -> bool: try: if self._session_db.set_session_title(self.session_id, new_title): _cprint(f" Session title set: {new_title}") - try: - import os as _os - _seq = f"\x1b]0;⚕\x07".encode("utf-8") - _fd = _os.open(_os.ctermid(), _os.O_WRONLY | _os.O_NOCTTY) - _os.write(_fd, _seq) - _os.close(_fd) - _cprint(f" {_DIM}Tab title updated via ctermid{_RST}") - except Exception as _te: - _cprint(f" {_DIM}Tab title error: {_te}{_RST}") self._set_terminal_title(session_title=new_title) else: _cprint(" Session not found in database.") From 8c28f70d39bb881c33d4e5b5b8d77d82096d3dbc Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sat, 4 Apr 2026 12:47:16 -0400 Subject: [PATCH 28/44] debug: try all title write paths with visible output --- cli.py | 56 +++++++++++++++++++++++++------------------------------- 1 file changed, 25 insertions(+), 31 deletions(-) diff --git a/cli.py b/cli.py index 8de2728be305..36e76385f39b 100644 --- a/cli.py +++ b/cli.py @@ -2929,26 +2929,14 @@ def _set_terminal_title(self, session_title: str = "", thinking: bool = False) - Skipped when stdout is not a TTY, TERM=dumb, or NO_COLOR is set. """ import sys, os - # Respect display.terminal_title = false config opt-out + + if os.environ.get("TERM", "") == "dumb" or os.environ.get("NO_COLOR"): + return try: if not CLI_CONFIG.get("display", {}).get("terminal_title", True): return except Exception: pass - # Use the real stdout (sys.__stdout__) to bypass prompt_toolkit's - # patch_stdout StdoutProxy — OSC escape sequences sent through the - # proxy are buffered / eaten and never reach the terminal emulator. - # Fall back to fd 1 if __stdout__ is unavailable. - real_out = getattr(sys, "__stdout__", None) or sys.stdout - try: - if not real_out.isatty(): - return - except Exception: - return - if os.environ.get("TERM", "") == "dumb": - return - if os.environ.get("NO_COLOR"): - return try: from hermes_cli.skin_engine import get_active_skin @@ -2958,25 +2946,31 @@ def _set_terminal_title(self, session_title: str = "", thinking: bool = False) - symbol = "⚕" tab_title = f"{symbol} ⏳" if thinking else symbol - - # OSC 0 sets both icon (tab) and window title. - # Use ST terminator (\x1b\\) rather than BEL (\x07) — ST is more - # reliably processed by iTerm2 for tab title updates mid-session. + # OSC 0 + ST terminator — confirmed working in iTerm2 debug test seq_b = f"\x1b]0;{tab_title}\x1b\\".encode("utf-8") - # Write via sys.__stdout__ (pre-patch real stdout) then fall back to - # ctermid. Both reach the PTY; __stdout__ is simpler and avoids - # prompt_toolkit's internal buffers entirely. - try: - real_out.write(seq_b.decode("utf-8")) - real_out.flush() - except Exception: + _cprint(f" {_DIM}[title dbg] writing: {seq_b!r}{_RST}") + + # Try every available path — no isatty guard, catch exceptions silently + for _attempt in ("ctermid", "__stdout__", "stdout_fd", "stdout_write"): try: - _tty_fd = os.open(os.ctermid(), os.O_WRONLY | os.O_NOCTTY) - os.write(_tty_fd, seq_b) - os.close(_tty_fd) - except Exception: - pass + if _attempt == "ctermid": + _fd = os.open(os.ctermid(), os.O_WRONLY | os.O_NOCTTY) + os.write(_fd, seq_b) + os.close(_fd) + elif _attempt == "__stdout__": + _s = getattr(sys, "__stdout__", None) + if _s: + os.write(_s.fileno(), seq_b) + elif _attempt == "stdout_fd": + os.write(1, seq_b) + elif _attempt == "stdout_write": + sys.stdout.write(seq_b.decode("utf-8")) + sys.stdout.flush() + _cprint(f" {_DIM}[title dbg] {_attempt}: OK{_RST}") + break + except Exception as _e: + _cprint(f" {_DIM}[title dbg] {_attempt}: {_e}{_RST}") self._terminal_title_session = session_title From d30ad38f684a083cd5a7844d1a9eb341f819d7ff Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sat, 4 Apr 2026 12:48:01 -0400 Subject: [PATCH 29/44] =?UTF-8?q?fix:=20show=20session=20title=20in=20tab?= =?UTF-8?q?=20=E2=80=94=20'=E2=9A=95=20My=20Session'=20not=20just=20'?= =?UTF-8?q?=E2=9A=95'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab title format: ⚕ My Session (when /title is set) ⚕ ⏳ (when agent is thinking) ⚕ (no title set) --- cli.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/cli.py b/cli.py index 36e76385f39b..c716f8a92464 100644 --- a/cli.py +++ b/cli.py @@ -2945,13 +2945,16 @@ def _set_terminal_title(self, session_title: str = "", thinking: bool = False) - except Exception: symbol = "⚕" - tab_title = f"{symbol} ⏳" if thinking else symbol + if thinking: + tab_title = f"{symbol} ⏳" + elif session_title: + tab_title = f"{symbol} {session_title}" + else: + tab_title = symbol + # OSC 0 + ST terminator — confirmed working in iTerm2 debug test seq_b = f"\x1b]0;{tab_title}\x1b\\".encode("utf-8") - _cprint(f" {_DIM}[title dbg] writing: {seq_b!r}{_RST}") - - # Try every available path — no isatty guard, catch exceptions silently for _attempt in ("ctermid", "__stdout__", "stdout_fd", "stdout_write"): try: if _attempt == "ctermid": @@ -2967,10 +2970,9 @@ def _set_terminal_title(self, session_title: str = "", thinking: bool = False) - elif _attempt == "stdout_write": sys.stdout.write(seq_b.decode("utf-8")) sys.stdout.flush() - _cprint(f" {_DIM}[title dbg] {_attempt}: OK{_RST}") break - except Exception as _e: - _cprint(f" {_DIM}[title dbg] {_attempt}: {_e}{_RST}") + except Exception: + pass self._terminal_title_session = session_title From 14e432b38d42273dcf7c03d0085c42d46ca63376 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sat, 4 Apr 2026 12:57:50 -0400 Subject: [PATCH 30/44] feat: configurable full user message display + Ctrl+O toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multiline user messages are always displayed truncated (first line + '+N lines'). This adds a config option and runtime toggle. - display.show_full_user_message: false (default) in config.yaml - Ctrl+O toggles at runtime with '↕ Full user message display: ON/OFF' feedback — no restart required - Status bar shows '↕ full msg' indicator when active - To enable permanently: hermes config set display.show_full_user_message true --- cli.py | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/cli.py b/cli.py index c716f8a92464..74074e03766c 100644 --- a/cli.py +++ b/cli.py @@ -207,6 +207,7 @@ def load_cli_config() -> Dict[str, Any]: "streaming": True, "busy_input_mode": "interrupt", "terminal_title": True, # Set tab/window title via OSC sequences (disable for tmux/screen or if job name is appended by your terminal profile) + "show_full_user_message": False, # When true, show all lines instead of first + (+N lines) "skin": "default", }, "clarify": { @@ -1169,6 +1170,9 @@ def __init__( # busy_input_mode: "interrupt" (Enter interrupts current run) or "queue" (Enter queues for next turn) _bim = CLI_CONFIG["display"].get("busy_input_mode", "interrupt") self.busy_input_mode = "queue" if str(_bim).strip().lower() == "queue" else "interrupt" + self._show_full_user_message: bool = bool( + CLI_CONFIG["display"].get("show_full_user_message", False) + ) self.verbose = verbose if verbose is not None else (self.tool_progress_mode == "verbose") @@ -1618,6 +1622,9 @@ def _get_status_bar_fragments(self): if self._steering_queue: frags.append(("class:status-bar-dim", " │ ")) frags.append(("class:status-bar-warn", f"🎯 {len(self._steering_queue)}")) + if self._show_full_user_message: + frags.append(("class:status-bar-dim", " │ ")) + frags.append(("class:status-bar-warn", "↕ full msg")) total_width = sum(self._status_bar_display_width(text) for _, text in frags) if total_width > width: @@ -7666,6 +7673,19 @@ def _empty(): _cprint(f" {_DIM}(no history yet){_RST}") run_in_terminal(_empty) + @kb.add('c-o') + def handle_ctrl_o(event): + """Ctrl+O: toggle full user message display. + + When on, multiline messages are printed in full instead of + showing only the first line + (+N lines). + Indicated by '↕ full msg' in the status bar. + """ + cli_ref._show_full_user_message = not cli_ref._show_full_user_message + state = "ON" if cli_ref._show_full_user_message else "OFF" + _cprint(f" {_DIM}↕ Full user message display: {state}{_RST}") + event.app.invalidate() + # Voice push-to-talk key: configurable via config.yaml (voice.record_key) # Default: Ctrl+B (avoids conflict with Ctrl+R readline reverse-search) # Config uses "ctrl+b" format; prompt_toolkit expects "c-b" format. @@ -8566,14 +8586,18 @@ def _expand_ref(m): else: _user_bar = f"[{_accent_hex()}]{'─' * 40}[/]" if '\n' in user_input: - first_line = user_input.split('\n')[0] - line_count = user_input.count('\n') + 1 print() ChatConsole().print(_user_bar) - ChatConsole().print( - f"[bold {_accent_hex()}]●[/] [bold]{_escape(first_line)}[/] " - f"[dim](+{line_count - 1} lines)[/]" - ) + if self._show_full_user_message: + for _line in user_input.splitlines(): + ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(_line)}[/]") + else: + first_line = user_input.split('\n')[0] + line_count = user_input.count('\n') + 1 + ChatConsole().print( + f"[bold {_accent_hex()}]●[/] [bold]{_escape(first_line)}[/] " + f"[dim](+{line_count - 1} lines)[/]" + ) else: print() ChatConsole().print(_user_bar) From 277f55c219c751fd17f2d93766ab3f66586d7aeb Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sat, 4 Apr 2026 15:20:07 -0400 Subject: [PATCH 31/44] feat(gateway): model override via chat completions request model field Lets Open WebUI (or any OpenAI-compatible frontend) select the underlying LLM via the model field in the chat completions request. When model_override is set and is not 'hermes-agent', it is used instead of the value from config.yaml. GET /v1/models now returns hermes-agent plus the per-provider model list. --- gateway/platforms/api_server.py | 93 +++++++++++++++++++++++++++------ 1 file changed, 76 insertions(+), 17 deletions(-) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 86af84307d65..e4fd84dfcfc6 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -400,6 +400,7 @@ def _create_agent( session_id: Optional[str] = None, stream_delta_callback=None, tool_progress_callback=None, + model_override: Optional[str] = None, ) -> Any: """ Create an AIAgent instance using the gateway's runtime config. @@ -408,13 +409,21 @@ def _create_agent( base_url, etc. from config.yaml / env vars. Toolsets are resolved from config.yaml platform_toolsets.api_server (same as all other gateway platforms), falling back to the hermes-api-server default. + + If *model_override* is provided and is not "hermes-agent", it is used + as the model instead of the value from config.yaml. This lets Open + WebUI (or any OpenAI-compatible frontend) select the underlying LLM + via the ``model`` field in the chat completions request. """ from run_agent import AIAgent from gateway.run import _resolve_runtime_agent_kwargs, _resolve_gateway_model, _load_gateway_config from hermes_cli.tools_config import _get_platform_tools runtime_kwargs = _resolve_runtime_agent_kwargs() - model = _resolve_gateway_model() + if model_override and model_override not in ("hermes-agent",): + model = model_override + else: + model = _resolve_gateway_model() user_config = _load_gateway_config() enabled_toolsets = sorted(_get_platform_tools(user_config, "api_server")) @@ -446,25 +455,71 @@ async def _handle_health(self, request: "web.Request") -> "web.Response": return web.json_response({"status": "ok", "platform": "hermes-agent"}) async def _handle_models(self, request: "web.Request") -> "web.Response": - """GET /v1/models — return hermes-agent as an available model.""" - auth_err = self._check_auth(request) + """GET /v1/models — return hermes-agent plus per-provider model list.""" + auth_err=self._...est) if auth_err: return auth_err - return web.json_response({ - "object": "list", - "data": [ - { - "id": "hermes-agent", - "object": "model", - "created": int(time.time()), - "owned_by": "hermes", - "permission": [], - "root": "hermes-agent", - "parent": None, - } - ], - }) + now = int(time.time()) + + # Always include the default hermes-agent entry (uses config.yaml model) + models = [ + { + "id": "hermes-agent", + "object": "model", + "created": now, + "owned_by": "hermes", + "permission": [], + "root": "hermes-agent", + "parent": None, + "description": "Default model from config.yaml", + } + ] + + # Add per-provider models based on which API keys are configured + try: + from hermes_cli.models import _PROVIDER_MODELS + import os + + _provider_env: list[tuple[str, str]] = [ + ("anthropic", "ANTHROPIC_API_KEY"), + ("openrouter", "OPENROUTER_API_KEY"), + ("nous", "NOUS_API_KEY"), + ("deepseek", "DEEPSEEK_API_KEY"), + ("zai", "GLM_API_KEY"), + ("kimi-coding", "KIMI_API_KEY"), + ("minimax", "MINIMAX_API_KEY"), + ("opencode-zen", "OPENCODE_ZEN_API_KEY"), + ("opencode-go", "OPENCODE_GO_API_KEY"), + ] + + seen: set[str] = {"hermes-agent"} + for provider, env_var in _provider_env: + if not os.getenv(env_var): + continue + for model_id in _PROVIDER_MODELS.get(provider, []): + if model_id in seen: + continue + seen.add(model_id) + # Normalise to provider/model format for anthropic native + display_id = ( + f"anthropic/{model_id}" + if provider == "anthropic" and "/" not in model_id + else model_id + ) + models.append({ + "id": display_id, + "object": "model", + "created": now, + "owned_by": provider, + "permission": [], + "root": display_id, + "parent": None, + }) + except Exception: + pass # Fall back to hermes-agent only + + return web.json_response({"object": "list", "data": models}) async def _handle_chat_completions(self, request: "web.Request") -> "web.Response": """POST /v1/chat/completions — OpenAI Chat Completions format.""" @@ -571,6 +626,7 @@ def _on_tool_progress(name, preview, args): stream_delta_callback=_on_delta, tool_progress_callback=_on_tool_progress, agent_ref=agent_ref, + model_override=model_name, )) return await self._write_sse_chat_completion( @@ -585,6 +641,7 @@ async def _compute_completion(): conversation_history=history, ephemeral_system_prompt=system_prompt, session_id=session_id, + model_override=model_name, ) idempotency_key = request.headers.get("Idempotency-Key") @@ -1245,6 +1302,7 @@ async def _run_agent( stream_delta_callback=None, tool_progress_callback=None, agent_ref: Optional[list] = None, + model_override: Optional[str] = None, ) -> tuple: """ Create an agent and run a conversation in a thread executor. @@ -1265,6 +1323,7 @@ def _run(): session_id=session_id, stream_delta_callback=stream_delta_callback, tool_progress_callback=tool_progress_callback, + model_override=model_override, ) if agent_ref is not None: agent_ref[0] = agent From 1860d3e0970d5b3a3d0a6d055dc85e1878aa4131 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sat, 4 Apr 2026 15:20:17 -0400 Subject: [PATCH 32/44] =?UTF-8?q?fix:=20browser=20CDP=20from=20config=20+?= =?UTF-8?q?=20tab=20title=20appends=20=E2=8F=B3=20instead=20of=20replacing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit browser_tool: read browser.cdp_url from config.yaml as a persistent fallback for BROWSER_CDP_URL, so /browser connect is not needed every session when cdp_url is set in config. cli: when thinking and a session title exists, append ⏳ to the title rather than replacing it — was: '⚕ ⏳', now: '⚕ My Title ⏳'. --- cli.py | 6 +++--- tools/browser_tool.py | 23 ++++++++++++++++++++++- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/cli.py b/cli.py index 74074e03766c..b851cea27e1c 100644 --- a/cli.py +++ b/cli.py @@ -2952,10 +2952,10 @@ def _set_terminal_title(self, session_title: str = "", thinking: bool = False) - except Exception: symbol = "⚕" - if thinking: + if session_title: + tab_title = f"{symbol} {session_title} ⏳" if thinking else f"{symbol} {session_title}" + elif thinking: tab_title = f"{symbol} ⏳" - elif session_title: - tab_title = f"{symbol} {session_title}" else: tab_title = symbol diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 546ed3cd1698..56f3ba4c618c 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -224,8 +224,29 @@ def _get_cdp_override() -> str: When ``BROWSER_CDP_URL`` is set (e.g. via ``/browser connect``), we skip both Browserbase and the local headless launcher and connect directly to the supplied Chrome DevTools Protocol endpoint. + + Also checks ``config["browser"]["cdp_url"]`` as a persistent fallback so + the user doesn't need to re-run ``/browser connect`` every session. """ - return _resolve_cdp_override(os.environ.get("BROWSER_CDP_URL", "")) + env_val = os.environ.get("BROWSER_CDP_URL", "") + if env_val.strip(): + return _resolve_cdp_override(env_val) + # Fallback: read cdp_url from config.yaml + try: + hermes_home = __import__("pathlib").Path( + os.environ.get("HERMES_HOME", __import__("pathlib").Path.home() / ".hermes") + ) + config_path = hermes_home / "config.yaml" + if config_path.exists(): + import yaml + with open(config_path) as _f: + _cfg = yaml.safe_load(_f) or {} + cfg_cdp = _cfg.get("browser", {}).get("cdp_url", "") + if cfg_cdp: + return _resolve_cdp_override(cfg_cdp) + except Exception: + pass + return "" # ============================================================================ From 252154e52d13af91b0a363423eb10cddeb16b0e6 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sat, 4 Apr 2026 16:24:51 -0400 Subject: [PATCH 33/44] feat: show session title in response panel header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four response panel sites (streaming box, TTS box, background task panel, main Rich panel) now append '— {title}' to the label when a session title exists: ╭─ ⚕ Hermes — My Session Title ────────╮ Also: tab title appends ⏳ instead of replacing the session title when thinking (was: '⚕ ⏳', now: '⚕ My Title ⏳'). delegate_tool: max_concurrent_children now reads from delegation.max_concurrent_children in config.yaml (default 6). --- cli.py | 18 +++++++++++++++++- tools/delegate_tool.py | 27 ++++++++++++++++++++++----- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/cli.py b/cli.py index b851cea27e1c..6dc4e6b40be1 100644 --- a/cli.py +++ b/cli.py @@ -1992,6 +1992,9 @@ def _emit_stream_text(self, text: str) -> None: except Exception: label = "⚕ Hermes" _text_hex = "#FFF8DC" + _stitle = getattr(self, "_terminal_title_session", "") + if _stitle: + label = f"{label} — {_stitle}" # Build a true-color ANSI escape for the response text color # so streamed content matches the Rich Panel appearance. try: @@ -4718,6 +4721,9 @@ def _bg_thinking(text: str) -> None: label = "⚕ Hermes" _resp_color = "#CD7F32" _resp_text = "#FFF8DC" + _stitle = getattr(self, "_terminal_title_session", "") + if _stitle: + label = f"{label} — {_stitle}" _chat_console = ChatConsole() _chat_console.print(Panel( @@ -6430,7 +6436,14 @@ def display_callback(sentence: str): if not _streaming_box_opened: _streaming_box_opened = True w = self.console.width - label = " ⚕ Hermes " + try: + from hermes_cli.skin_engine import get_active_skin + label = get_active_skin().get_branding("response_label", " ⚕ Hermes ") + except Exception: + label = " ⚕ Hermes " + _stitle = getattr(self, "_terminal_title_session", "") + if _stitle: + label = f"{label.rstrip()} — {_stitle} " fill = w - 2 - len(label) _cprint(f"\n{_GOLD}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}") _cprint(sentence.rstrip()) @@ -6637,6 +6650,9 @@ def run_agent(): label = "⚕ Hermes" _resp_color = "#CD7F32" _resp_text = "#FFF8DC" + _stitle = getattr(self, "_terminal_title_session", "") + if _stitle: + label = f"{label} — {_stitle}" is_error_response = result and (result.get("failed") or result.get("partial")) already_streamed = self._stream_started and self._stream_box_opened and not is_error_response diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 7b75838001df..933e084e6177 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -34,8 +34,24 @@ "execute_code", # children should reason step-by-step, not write scripts ]) -MAX_CONCURRENT_CHILDREN = 3 +_MAX_CONCURRENT_CHILDREN_DEFAULT = 3 MAX_DEPTH = 2 # parent (0) -> child (1) -> grandchild rejected (2) + + +def _get_max_concurrent_children() -> int: + """Return max parallel subagents from config, defaulting to 6.""" + try: + cfg = _load_config() + v = cfg.get("max_concurrent_children") + if v is not None: + return max(1, int(v)) + except Exception: + pass + return _MAX_CONCURRENT_CHILDREN_DEFAULT + + +# Module-level alias kept for backwards compat with any external references +MAX_CONCURRENT_CHILDREN = _MAX_CONCURRENT_CHILDREN_DEFAULT DEFAULT_MAX_ITERATIONS = 50 DEFAULT_TOOLSETS = ["terminal", "file", "web"] @@ -447,7 +463,7 @@ def delegate_task( # Normalize to task list if tasks and isinstance(tasks, list): - task_list = tasks[:MAX_CONCURRENT_CHILDREN] + task_list = tasks[:_get_max_concurrent_children()] elif goal and isinstance(goal, str) and goal.strip(): task_list = [{"goal": goal, "context": context, "toolsets": toolsets}] else: @@ -505,7 +521,7 @@ def delegate_task( completed_count = 0 spinner_ref = getattr(parent_agent, '_delegate_spinner', None) - with ThreadPoolExecutor(max_workers=MAX_CONCURRENT_CHILDREN) as executor: + with ThreadPoolExecutor(max_workers=_get_max_concurrent_children()) as executor: futures = {} for i, t, child in children: future = executor.submit( @@ -768,9 +784,10 @@ def _load_config() -> dict: }, "required": ["goal"], }, - "maxItems": 3, + "maxItems": 6, "description": ( - "Batch mode: up to 3 tasks to run in parallel. Each gets " + "Batch mode: up to 3 tasks to run in parallel by default (configurable via " + "delegation.max_concurrent_children in config.yaml). Each gets " "its own subagent with isolated context and terminal session. " "When provided, top-level goal/context/toolsets are ignored." ), From 1311512e7953807cd25690b36326a96371e3d318 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sat, 4 Apr 2026 16:28:37 -0400 Subject: [PATCH 34/44] fix(stash): don't auto-restore over non-empty buffer Auto-restore after agent response now checks buf.text.strip() first. If the user started typing while the agent was responding, the stash is left intact with a 'Ctrl+S to pop' reminder instead of clobbering their input. --- cli.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/cli.py b/cli.py index 6dc4e6b40be1..b60f264e8688 100644 --- a/cli.py +++ b/cli.py @@ -2902,7 +2902,7 @@ def _show_keyboard_shortcuts(self): ]), ("Drafting", [ ("Ctrl+G", "Open input in external editor ($VISUAL / VS Code)"), - ("Ctrl+S", "Stash input (pop with Ctrl+S, auto-restores after response)"), + ("Ctrl+S", "Stash input (pop with Ctrl+S; auto-restores after response if buffer empty)"), ("Ctrl+P", "Peek paste / preview input / full history pager (empty input)"), ("Ctrl+V", "Paste from clipboard (image-aware)"), ("ESC ESC", "Clear input buffer and attached images"), @@ -7608,7 +7608,7 @@ def handle_stash(event): cli_ref._stashed_input = (text, images_snapshot) cli_ref._attached_images.clear() buf.reset() - _cprint(f" {_DIM}📌 Input stashed (Ctrl+S to pop, auto-restores after response){_RST}") + _cprint(f" {_DIM}📌 Input stashed (Ctrl+S to pop; auto-restores if buffer empty after response){_RST}") event.app.invalidate() elif cli_ref._stashed_input: # --- Pop stash into input --- @@ -8636,17 +8636,24 @@ def _expand_ref(m): self._spinner_text = "" self._update_terminal_title(thinking=False) - # Auto-restore stashed input after agent finishes + # Auto-restore stashed input after agent finishes, + # but only if the buffer is empty — never clobber text + # the user started typing while the agent was responding. if self._stashed_input: stashed_text, stashed_images = self._stashed_input - self._stashed_input = None - if stashed_images: - self._attached_images.extend(stashed_images) try: buf = app.layout.current_buffer - buf.text = stashed_text - buf.cursor_position = len(stashed_text) - _cprint(f" {_DIM}📌 Stashed input restored{_RST}") + if buf.text.strip(): + # Buffer has content — leave stash intact, + # user can pop it manually with Ctrl+S. + _cprint(f" {_DIM}📌 Stash kept (buffer not empty — Ctrl+S to pop){_RST}") + else: + self._stashed_input = None + if stashed_images: + self._attached_images.extend(stashed_images) + buf.text = stashed_text + buf.cursor_position = len(stashed_text) + _cprint(f" {_DIM}📌 Stashed input restored{_RST}") except Exception: pass From 77c2485f92f9ee5153a28ed812efa33194af959c Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sat, 4 Apr 2026 16:30:56 -0400 Subject: [PATCH 35/44] fix: maxItems schema matches default of 3 --- tools/delegate_tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 933e084e6177..d7a1e2c05d2e 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -784,7 +784,7 @@ def _load_config() -> dict: }, "required": ["goal"], }, - "maxItems": 6, + "maxItems": 3, "description": ( "Batch mode: up to 3 tasks to run in parallel by default (configurable via " "delegation.max_concurrent_children in config.yaml). Each gets " From 38aa3fae4f6f83f61a88d98bd5a8dcc2405eeea4 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sat, 4 Apr 2026 16:35:50 -0400 Subject: [PATCH 36/44] feat(/resume): pipe all sessions through less pager when no arg given MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /resume with no argument now calls show_sessions_full() which fetches up to 200 sessions and pipes them through less (same mechanism as Ctrl+P history pager) — scrollable, searchable with '/'. Replaces the hardcoded 10-session inline table. --- cli.py | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/cli.py b/cli.py index b60f264e8688..5b0d1c7c8781 100644 --- a/cli.py +++ b/cli.py @@ -3269,6 +3269,56 @@ def _show_recent_sessions(self, *, reason: str = "history", limit: int = 10) -> print() return True + def show_sessions_full(self) -> None: + """Show all resumable sessions through a pager (same mechanism as Ctrl+P history). + + Fetches up to 200 recent sessions and pipes the formatted list through + ``less`` so the user can scroll, search with '/', and pick an ID to + resume with ``/resume ``. + """ + import shutil as _shutil + import subprocess as _subprocess + + sessions = self._list_recent_sessions(limit=200) + if not sessions: + print(" No other sessions found.") + return + + from hermes_cli.main import _relative_time + + W = min(_shutil.get_terminal_size().columns, 120) + id_w, time_w, title_w = 24, 13, 34 + prev_w = max(W - id_w - time_w - title_w - 6, 20) + + header = ( + f" {'Title':<{title_w}} {'Last Active':<{time_w}} {'Preview':<{prev_w}} {'ID'}\n" + f" {'─' * title_w} {'─' * time_w} {'─' * prev_w} {'─' * id_w}\n" + ) + rows = [] + for s in sessions: + title = (s.get("title") or "—")[:title_w - 1] + last_act = _relative_time(s.get("last_active")) + preview = (s.get("preview") or "")[:prev_w - 1] + rows.append( + f" {title:<{title_w}} {last_act:<{time_w}} {preview:<{prev_w}} {s['id']}\n" + ) + + footer = "\n /resume to continue a session\n" + output = header + "".join(rows) + footer + + pager = _shutil.which("less") or _shutil.which("more") + if pager and pager.endswith("less"): + try: + proc = _subprocess.Popen( + [pager, "-R", "--no-init", "--quit-if-one-screen"], + stdin=_subprocess.PIPE, + ) + proc.communicate(output.encode("utf-8", errors="replace")) + return + except Exception: + pass + print(output) + def show_history_full(self) -> None: """Show full conversation history newest-first, piped through a pager. @@ -3498,10 +3548,7 @@ def _handle_resume_command(self, cmd_original: str) -> None: target = parts[1].strip() if len(parts) > 1 else "" if not target: - _cprint(" Usage: /resume ") - if self._show_recent_sessions(reason="resume"): - return - _cprint(" Tip: Use /history or `hermes sessions list` to find sessions.") + self.show_sessions_full() return if not self._session_db: From 85f217b99e17ec35aa756cc4e6c0da6a40b7d75e Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sat, 4 Apr 2026 16:38:08 -0400 Subject: [PATCH 37/44] feat(/resume): interactive prompt_toolkit session picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /resume with no arg now opens a mini prompt_toolkit Application: - type to fuzzy-filter by label/preview/id in real-time - ↑↓ navigate, Enter to select and auto-resume, Esc/q/Ctrl+C cancel - sessions with no title show first user message as label instead of '—' - up to 200 sessions listed - falls back to less pager on non-interactive terminals --- cli.py | 214 +++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 176 insertions(+), 38 deletions(-) diff --git a/cli.py b/cli.py index 5b0d1c7c8781..a635478314c6 100644 --- a/cli.py +++ b/cli.py @@ -3269,55 +3269,193 @@ def _show_recent_sessions(self, *, reason: str = "history", limit: int = 10) -> print() return True - def show_sessions_full(self) -> None: - """Show all resumable sessions through a pager (same mechanism as Ctrl+P history). + def _pick_session_interactive(self, sessions: list) -> "str | None": + """Interactive fuzzy session picker built with prompt_toolkit. - Fetches up to 200 recent sessions and pipes the formatted list through - ``less`` so the user can scroll, search with '/', and pick an ID to - resume with ``/resume ``. + Shows a filter input + scrollable list. Returns the selected session + ID, or None if the user cancelled (Esc / Ctrl+C / q on empty filter). """ import shutil as _shutil - import subprocess as _subprocess - - sessions = self._list_recent_sessions(limit=200) - if not sessions: - print(" No other sessions found.") - return + from prompt_toolkit import Application + from prompt_toolkit.buffer import Buffer + from prompt_toolkit.formatted_text import HTML, to_formatted_text + from prompt_toolkit.key_binding import KeyBindings + from prompt_toolkit.layout import Layout + from prompt_toolkit.layout.containers import HSplit, Window + from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl + from prompt_toolkit.styles import Style from hermes_cli.main import _relative_time - W = min(_shutil.get_terminal_size().columns, 120) - id_w, time_w, title_w = 24, 13, 34 - prev_w = max(W - id_w - time_w - title_w - 6, 20) + W = min(_shutil.get_terminal_size().columns - 4, 116) + + def _label(s: dict) -> str: + """Best available label: title if set, else first user message.""" + t = (s.get("title") or "").strip() + if not t: + t = (s.get("preview") or "").strip() + return t or s["id"] + + # State + selected_id: list = [None] + cursor: list = [0] + filter_buf = Buffer() + + def _filtered() -> list: + q = filter_buf.text.lower() + if not q: + return sessions + return [ + s for s in sessions + if q in _label(s).lower() + or q in (s.get("preview") or "").lower() + or q in s["id"].lower() + ] + + def _render_list(): + filtered = _filtered() + # Clamp cursor + if cursor[0] >= len(filtered): + cursor[0] = max(len(filtered) - 1, 0) - header = ( - f" {'Title':<{title_w}} {'Last Active':<{time_w}} {'Preview':<{prev_w}} {'ID'}\n" - f" {'─' * title_w} {'─' * time_w} {'─' * prev_w} {'─' * id_w}\n" + lines = [] + # header + lines.append(HTML( + f" {'Label':<40} {'Age':<12} {'Preview':<{W - 58}} ID\n" + f" {'─' * 40} {'─' * 12} {'─' * (W - 58)} {'─' * 8}\n" + )) + if not filtered: + lines.append(HTML(" (no matches)\n")) + for i, s in enumerate(filtered): + label = _label(s)[:39] + age = _relative_time(s.get("last_active")) + preview = (s.get("preview") or "")[:W - 59] + sid = s["id"][:8] + row = f" {label:<40} {age:<12} {preview:<{W - 58}} {sid}" + if i == cursor[0]: + lines.append(HTML(f"{row}\n")) + else: + lines.append(row + "\n") + lines.append(HTML( + "\n ↑↓ navigate Enter select Esc cancel" + )) + return to_formatted_text(lines) + + list_control = FormattedTextControl(_render_list, focusable=False) + list_window = Window(list_control, dont_extend_height=False) + + filter_window = Window( + BufferControl(buffer=filter_buf), + height=1, + get_line_prefix=lambda *_: HTML(" filter: "), ) - rows = [] - for s in sessions: - title = (s.get("title") or "—")[:title_w - 1] - last_act = _relative_time(s.get("last_active")) - preview = (s.get("preview") or "")[:prev_w - 1] - rows.append( - f" {title:<{title_w}} {last_act:<{time_w}} {preview:<{prev_w}} {s['id']}\n" - ) - footer = "\n /resume to continue a session\n" - output = header + "".join(rows) + footer + layout = Layout(HSplit([ + Window( + FormattedTextControl(lambda: HTML( + " Resume session" + )), + height=1, + ), + filter_window, + list_window, + ]), focused_element=filter_window) - pager = _shutil.which("less") or _shutil.which("more") - if pager and pager.endswith("less"): - try: - proc = _subprocess.Popen( - [pager, "-R", "--no-init", "--quit-if-one-screen"], - stdin=_subprocess.PIPE, + kb = KeyBindings() + + @kb.add("up") + def _up(event): + cursor[0] = max(cursor[0] - 1, 0) + + @kb.add("down") + def _down(event): + filtered = _filtered() + cursor[0] = min(cursor[0] + 1, max(len(filtered) - 1, 0)) + + @kb.add("enter") + def _enter(event): + filtered = _filtered() + if filtered and 0 <= cursor[0] < len(filtered): + selected_id[0] = filtered[cursor[0]]["id"] + event.app.exit() + + @kb.add("escape") + @kb.add("c-c") + def _cancel(event): + event.app.exit() + + # 'q' cancels only when filter is empty + @kb.add("q") + def _q(event): + if not filter_buf.text: + event.app.exit() + else: + filter_buf.insert_text("q") + + # Reset cursor to 0 whenever filter changes + def _on_filter_change(_): + cursor[0] = 0 + + filter_buf.on_text_changed += _on_filter_change # type: ignore[operator] + + app = Application( + layout=layout, + key_bindings=kb, + style=Style.from_dict({"": ""}), + full_screen=False, + mouse_support=False, + ) + app.run() + return selected_id[0] + + def show_sessions_full(self) -> None: + """Open an interactive session picker (prompt_toolkit mini-app). + + Type to filter, ↑↓ to navigate, Enter to select and auto-resume, + Esc/q to cancel. Falls back to a plain ``less`` list if the picker + fails (e.g. non-interactive terminal). + """ + sessions = self._list_recent_sessions(limit=200) + if not sessions: + print(" No other sessions found.") + return + + try: + chosen_id = self._pick_session_interactive(sessions) + if chosen_id: + self._handle_resume_command(f"/resume {chosen_id}") + except Exception: + # Fallback: less pager + import shutil as _shutil, subprocess as _subprocess + from hermes_cli.main import _relative_time + + W = min(_shutil.get_terminal_size().columns, 120) + id_w, time_w, label_w = 24, 13, 38 + prev_w = max(W - id_w - time_w - label_w - 6, 20) + rows = [ + f" {'Label':<{label_w}} {'Age':<{time_w}} {'Preview':<{prev_w}} ID\n", + f" {'─' * label_w} {'─' * time_w} {'─' * prev_w} {'─' * id_w}\n", + ] + for s in sessions: + label = ((s.get("title") or s.get("preview") or s["id"]))[:label_w - 1] + rows.append( + f" {label:<{label_w}} {_relative_time(s.get('last_active')):<{time_w}} " + f"{(s.get('preview') or '')[:prev_w - 1]:<{prev_w}} {s['id']}\n" ) - proc.communicate(output.encode("utf-8", errors="replace")) - return - except Exception: - pass - print(output) + rows.append("\n /resume to continue a session\n") + output = "".join(rows) + pager = _shutil.which("less") + if pager: + try: + proc = _subprocess.Popen( + [pager, "-R", "--no-init", "--quit-if-one-screen"], + stdin=_subprocess.PIPE, + ) + proc.communicate(output.encode("utf-8", errors="replace")) + return + except Exception: + pass + print(output) def show_history_full(self) -> None: """Show full conversation history newest-first, piped through a pager. From 47705b1304232b1d18b53fc6374ce92d5eac1058 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sat, 4 Apr 2026 16:41:41 -0400 Subject: [PATCH 38/44] feat(queues): steering_dispatch + followup_dispatch config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit display.steering_dispatch: all_at_once | one_by_one (default) display.followup_dispatch: one_by_one (default) | all_at_once all_at_once: items held in the queue list only until the current agent turn completes, then drained and joined with \n---\n into a single combined message for the next turn. one_by_one: existing behaviour — each queued item goes straight into _pending_input and triggers its own agent turn. --- cli.py | 51 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/cli.py b/cli.py index a635478314c6..8ecc332a68c9 100644 --- a/cli.py +++ b/cli.py @@ -1170,6 +1170,13 @@ def __init__( # busy_input_mode: "interrupt" (Enter interrupts current run) or "queue" (Enter queues for next turn) _bim = CLI_CONFIG["display"].get("busy_input_mode", "interrupt") self.busy_input_mode = "queue" if str(_bim).strip().lower() == "queue" else "interrupt" + # Dispatch mode for each queue: + # "one_by_one" — each queued message triggers its own agent turn (default) + # "all_at_once" — after a turn, all queued messages are joined and sent as one turn + _sdm = CLI_CONFIG["display"].get("steering_dispatch", "one_by_one") + self.steering_dispatch = "all_at_once" if str(_sdm).strip().lower() == "all_at_once" else "one_by_one" + _fdm = CLI_CONFIG["display"].get("followup_dispatch", "one_by_one") + self.followup_dispatch = "all_at_once" if str(_fdm).strip().lower() == "all_at_once" else "one_by_one" self._show_full_user_message: bool = bool( CLI_CONFIG["display"].get("show_full_user_message", False) ) @@ -7338,9 +7345,10 @@ def handle_enter(event): # Tag and track in the 🎯 steering queue import uuid as _uuid_mod _stag = _uuid_mod.uuid4().hex - cli_ref._pending_input.put({"_steering_tag": _stag, "payload": payload}) _steer_text = text if text else f"[{len(images)} image{'s' if len(images) != 1 else ''} attached]" cli_ref._steering_queue.append({"id": _stag, "payload": payload, "text": _steer_text}) + if cli_ref.steering_dispatch == "one_by_one": + cli_ref._pending_input.put({"_steering_tag": _stag, "payload": payload}) _sdepth = len(cli_ref._steering_queue) _spreview = _steer_text[:60] + ("..." if len(_steer_text) > 60 else "") _cprint(f" {_DIM}🎯 Steering queued #{_sdepth}: \"{_spreview}\"{_RST}") @@ -7383,9 +7391,10 @@ def handle_alt_enter(event): import uuid as _uuid_mod tag = _uuid_mod.uuid4().hex - # Wrap with tag so process_loop can identify and cancel by ID, not text - cli_ref._pending_input.put({"_followup_tag": tag, "payload": payload}) cli_ref._followup_queue.append({"id": tag, "payload": payload, "text": text}) + if cli_ref.followup_dispatch == "one_by_one": + # Wrap with tag so process_loop can identify and cancel by ID, not text + cli_ref._pending_input.put({"_followup_tag": tag, "payload": payload}) event.app.current_buffer.reset(append_to_history=True) queue_depth = len(cli_ref._followup_queue) @@ -8842,6 +8851,42 @@ def _expand_ref(m): except Exception: pass + # all_at_once dispatch: drain queues and combine into one message + for _qname, _queue, _tag_key, _mode, _icon in [ + ("steering", self._steering_queue, "_steering_tag", self.steering_dispatch, "🎯"), + ("followup", self._followup_queue, "_followup_tag", self.followup_dispatch, "📬"), + ]: + if _mode == "all_at_once" and _queue: + # Filter out cancelled items, then drain the whole queue + _items = [ + it for it in _queue + if it["id"] not in ( + self._cancelled_steerings if _qname == "steering" + else self._cancelled_followups + ) + ] + _queue.clear() + if _qname == "steering": + self._cancelled_steerings.clear() + else: + self._cancelled_followups.clear() + if _items: + # Join text with separator; images from last item only + _texts = [it["text"] for it in _items] + _combined_text = "\n---\n".join(_texts) + # Carry images from all items + _all_images = [] + for it in _items: + p = it["payload"] + if isinstance(p, tuple): + _all_images.extend(p[1]) + _combined = (_combined_text, _all_images) if _all_images else _combined_text + _cprint( + f" {_DIM}{_icon} Dispatching {len(_items)} queued message" + f"{'s' if len(_items) != 1 else ''} as one turn{_RST}" + ) + self._pending_input.put(_combined) + app.invalidate() # Refresh status line # Continuous voice: auto-restart recording after agent responds. From 8e879f4e28769c2d7faf491652cfa057df84f809 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sat, 4 Apr 2026 17:30:04 -0400 Subject: [PATCH 39/44] =?UTF-8?q?fix(/resume):=20label=E2=86=92title=20con?= =?UTF-8?q?sistency=20+=20order=20by=20last=5Factive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename 'Label' column header → 'Title' everywhere in the picker and fallback pager; rename _label() helper → _title() - list_sessions_rich: ORDER BY last_active DESC instead of started_at DESC — most recently used sessions appear first - Only CLI sessions shown (source=cli, excludes tool/gateway/cron) --- cli.py | 22 +++++++++++----------- hermes_state.py | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/cli.py b/cli.py index 8ecc332a68c9..3d47d3693148 100644 --- a/cli.py +++ b/cli.py @@ -3296,8 +3296,8 @@ def _pick_session_interactive(self, sessions: list) -> "str | None": W = min(_shutil.get_terminal_size().columns - 4, 116) - def _label(s: dict) -> str: - """Best available label: title if set, else first user message.""" + def _title(s: dict) -> str: + """Best available title: set title, else first user message as fallback.""" t = (s.get("title") or "").strip() if not t: t = (s.get("preview") or "").strip() @@ -3314,7 +3314,7 @@ def _filtered() -> list: return sessions return [ s for s in sessions - if q in _label(s).lower() + if q in _title(s).lower() or q in (s.get("preview") or "").lower() or q in s["id"].lower() ] @@ -3328,13 +3328,13 @@ def _render_list(): lines = [] # header lines.append(HTML( - f" {'Label':<40} {'Age':<12} {'Preview':<{W - 58}} ID\n" + f" {'Title':<40} {'Age':<12} {'Preview':<{W - 58}} ID\n" f" {'─' * 40} {'─' * 12} {'─' * (W - 58)} {'─' * 8}\n" )) if not filtered: lines.append(HTML(" (no matches)\n")) for i, s in enumerate(filtered): - label = _label(s)[:39] + label = _title(s)[:39] age = _relative_time(s.get("last_active")) preview = (s.get("preview") or "")[:W - 59] sid = s["id"][:8] @@ -3437,16 +3437,16 @@ def show_sessions_full(self) -> None: from hermes_cli.main import _relative_time W = min(_shutil.get_terminal_size().columns, 120) - id_w, time_w, label_w = 24, 13, 38 - prev_w = max(W - id_w - time_w - label_w - 6, 20) + id_w, time_w, title_w = 24, 13, 38 + prev_w = max(W - id_w - time_w - title_w - 6, 20) rows = [ - f" {'Label':<{label_w}} {'Age':<{time_w}} {'Preview':<{prev_w}} ID\n", - f" {'─' * label_w} {'─' * time_w} {'─' * prev_w} {'─' * id_w}\n", + f" {'Title':<{title_w}} {'Age':<{time_w}} {'Preview':<{prev_w}} ID\n", + f" {'─' * title_w} {'─' * time_w} {'─' * prev_w} {'─' * id_w}\n", ] for s in sessions: - label = ((s.get("title") or s.get("preview") or s["id"]))[:label_w - 1] + t = (s.get("title") or s.get("preview") or s["id"])[:title_w - 1] rows.append( - f" {label:<{label_w}} {_relative_time(s.get('last_active')):<{time_w}} " + f" {t:<{title_w}} {_relative_time(s.get('last_active')):<{time_w}} " f"{(s.get('preview') or '')[:prev_w - 1]:<{prev_w}} {s['id']}\n" ) rows.append("\n /resume to continue a session\n") diff --git a/hermes_state.py b/hermes_state.py index 54cec8437af0..ffe64f846567 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -823,7 +823,7 @@ def list_sessions_rich( ) AS last_active FROM sessions s {where_sql} - ORDER BY s.started_at DESC + ORDER BY last_active DESC LIMIT ? OFFSET ? """ params.extend([limit, offset]) From 2b872220a3c115986d437426e97936f7843bc3ca Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sat, 4 Apr 2026 17:33:24 -0400 Subject: [PATCH 40/44] feat(/resume): display.resume_include_gateway config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Off by default — only CLI sessions shown. Set to true to include gateway sessions (Telegram, Discord, etc.) in the /resume picker. Tool sessions always excluded. --- cli.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cli.py b/cli.py index 3d47d3693148..d41188b7ac40 100644 --- a/cli.py +++ b/cli.py @@ -3234,12 +3234,18 @@ def show_config(self): print() def _list_recent_sessions(self, limit: int = 10) -> list[dict[str, Any]]: - """Return recent CLI sessions for in-chat browsing/resume affordances.""" + """Return recent sessions for in-chat browsing/resume affordances. + + With display.resume_include_gateway: true, gateway sessions + (Telegram, Discord, etc.) are included alongside CLI sessions. + Tool-spawned sessions are always excluded. + """ if not self._session_db: return [] + include_gateway = CLI_CONFIG.get("display", {}).get("resume_include_gateway", False) try: sessions = self._session_db.list_sessions_rich( - source="cli", + source=None if include_gateway else "cli", exclude_sources=["tool"], limit=limit, ) From 3c233af156d1b4e34cc820f5f880718b5e7ad063 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sat, 4 Apr 2026 17:38:23 -0400 Subject: [PATCH 41/44] feat(gateway): GET /v1/sessions endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lists sessions from the shared DB. Query params: source — filter by source (cli, telegram, discord, etc); omit for all limit — max results (default 50, max 200) offset — pagination offset (default 0) Returns: {object: list, data: [{id, title, preview, last_active, source, message_count}], count: N} Tool-spawned sessions always excluded. Also fixes pre-existing syntax error in _handle_models (garbled auth check line restored to self._check_auth(request)). --- gateway/platforms/api_server.py | 48 ++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index e4fd84dfcfc6..5f47f7869fe6 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -7,6 +7,7 @@ - GET /v1/responses/{response_id} — Retrieve a stored response - DELETE /v1/responses/{response_id} — Delete a stored response - GET /v1/models — lists hermes-agent as an available model +- GET /v1/sessions — lists sessions from the shared DB (source/limit/offset) - GET /health — health check Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat, @@ -456,7 +457,7 @@ async def _handle_health(self, request: "web.Request") -> "web.Response": async def _handle_models(self, request: "web.Request") -> "web.Response": """GET /v1/models — return hermes-agent plus per-provider model list.""" - auth_err=self._...est) + auth_err = self._check_auth(request) if auth_err: return auth_err @@ -1237,6 +1238,50 @@ async def _handle_run_job(self, request: "web.Request") -> "web.Response": except Exception as e: return web.json_response({"error": str(e)}, status=500) + async def _handle_sessions(self, request: "web.Request") -> "web.Response": + """GET /v1/sessions — list sessions from the shared DB.""" + auth_err = self._check_auth(request) + if auth_err: + return auth_err + + source_param = request.rel_url.query.get("source") or None + try: + limit = min(int(request.rel_url.query.get("limit", 50)), 200) + except (ValueError, TypeError): + limit = 50 + try: + offset = max(int(request.rel_url.query.get("offset", 0)), 0) + except (ValueError, TypeError): + offset = 0 + + db = self._ensure_session_db() + if db is None: + return web.json_response({"object": "list", "data": [], "count": 0}) + + try: + sessions = db.list_sessions_rich( + source=source_param, + exclude_sources=["tool"], + limit=limit, + offset=offset, + ) + except Exception as e: + logger.warning("list_sessions_rich failed: %s", e) + return web.json_response({"error": str(e)}, status=500) + + data = [ + { + "id": s.get("id"), + "title": s.get("title"), + "preview": s.get("preview"), + "last_active": s.get("last_active"), + "source": s.get("source"), + "message_count": s.get("message_count"), + } + for s in sessions + ] + return web.json_response({"object": "list", "data": data, "count": len(data)}) + # ------------------------------------------------------------------ # Output extraction helper # ------------------------------------------------------------------ @@ -1357,6 +1402,7 @@ async def connect(self) -> bool: self._app.router.add_get("/health", self._handle_health) self._app.router.add_get("/v1/health", self._handle_health) self._app.router.add_get("/v1/models", self._handle_models) + self._app.router.add_get("/v1/sessions", self._handle_sessions) self._app.router.add_post("/v1/chat/completions", self._handle_chat_completions) self._app.router.add_post("/v1/responses", self._handle_responses) self._app.router.add_get("/v1/responses/{response_id}", self._handle_get_response) From 852150d750f22a383a9f78bb807186ed24665914 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sun, 5 Apr 2026 07:52:25 -0400 Subject: [PATCH 42/44] feat: subagent control panel (Ctrl+X) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live overlay showing running/completed subagents with progress. Ctrl+X toggles panel, ↑↓ navigates rows, K interrupts selected agent. Status bar shows 🔀 N badge when N subagents are running. three-file change: - hermes_cli/subagent_panel.py: SubagentRecord dataclass + render_panel() - cli.py: panel state, Ctrl+X keybinding, status badge, TUI widget - tools/delegate_tool.py: panel record lifecycle (spawn/progress/complete) --- cli.py | 90 +++++++++++++++++++++++++- hermes_cli/subagent_panel.py | 118 +++++++++++++++++++++++++++++++++++ tools/delegate_tool.py | 83 +++++++++++++++++++++++- 3 files changed, 287 insertions(+), 4 deletions(-) create mode 100644 hermes_cli/subagent_panel.py diff --git a/cli.py b/cli.py index d41188b7ac40..cfeff4131a97 100644 --- a/cli.py +++ b/cli.py @@ -1170,6 +1170,10 @@ def __init__( # busy_input_mode: "interrupt" (Enter interrupts current run) or "queue" (Enter queues for next turn) _bim = CLI_CONFIG["display"].get("busy_input_mode", "interrupt") self.busy_input_mode = "queue" if str(_bim).strip().lower() == "queue" else "interrupt" + # Subagent control panel state + self._subagent_panel: dict = {} # task_index -> SubagentRecord + self._subagent_panel_open: bool = False + self._subagent_panel_cursor: int = 0 # Dispatch mode for each queue: # "one_by_one" — each queued message triggers its own agent turn (default) # "all_at_once" — after a turn, all queued messages are joined and sent as one turn @@ -1629,6 +1633,12 @@ def _get_status_bar_fragments(self): if self._steering_queue: frags.append(("class:status-bar-dim", " │ ")) frags.append(("class:status-bar-warn", f"🎯 {len(self._steering_queue)}")) + if self._subagent_panel: + n_running = sum(1 for r in self._subagent_panel.values() if r.status == "running") + if n_running: + frags.append(("class:status-bar-dim", " │ ")) + label = f"🔀 {n_running}" + (" [panel]" if self._subagent_panel_open else "") + frags.append(("class:status-bar-warn", label)) if self._show_full_user_message: frags.append(("class:status-bar-dim", " │ ")) frags.append(("class:status-bar-warn", "↕ full msg")) @@ -2307,6 +2317,19 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No # Route agent status output through prompt_toolkit so ANSI escape # sequences aren't garbled by patch_stdout's StdoutProxy (#2262). self.agent._print_fn = _cprint + # Attach subagent panel registry so delegate_tool can update it + if hasattr(self, '_subagent_panel'): + def _invalidate_panel(): + try: + from prompt_toolkit.application import get_app as _gapp + _gapp().invalidate() + except Exception: + pass + self.agent._cli_subagent_registry = ( + self._subagent_panel, + threading.Lock(), + _invalidate_panel, + ) self._active_agent_route_signature = ( effective_model, runtime.get("provider"), @@ -2914,6 +2937,9 @@ def _show_keyboard_shortcuts(self): ("Ctrl+V", "Paste from clipboard (image-aware)"), ("ESC ESC", "Clear input buffer and attached images"), ]), + ("Subagents", [ + ("Ctrl+X", "Toggle subagent panel (↑↓ navigate, K interrupt)"), + ]), ("Queues", [ ("Alt+Enter", "📬 Queue follow-up (sent after current response)"), ("Enter (queue mode)", "🎯 Queue steering (busy_input_mode: queue in config)"), @@ -7085,7 +7111,29 @@ def _get_extra_tui_widgets(self) -> list: overlay menu) into the layout without overriding ``run()``. Widgets are inserted between the spacer and the status bar. """ - return [] + try: + from hermes_cli.subagent_panel import render_panel as _render_panel + from prompt_toolkit.application import get_app as _get_app + _cli = self + _panel_visible = Condition( + lambda: _cli._subagent_panel_open and bool(_cli._subagent_panel) + ) + _panel_widget = ConditionalContainer( + content=Window( + content=FormattedTextControl( + lambda: _render_panel( + sorted(_cli._subagent_panel.values(), key=lambda r: r.index), + _cli._subagent_panel_cursor, + _get_app().output.get_size().columns, + ) + ), + dont_extend_height=True, + ), + filter=_panel_visible, + ) + return [_panel_widget] + except Exception: + return [] def _register_extra_tui_keybindings(self, kb, *, input_area) -> None: """Register extra keybindings on the TUI ``KeyBindings`` object. @@ -8043,6 +8091,38 @@ def handle_alt_v(event): # or answer prompt when clarify freetext mode is active. cli_ref = self + @kb.add('c-x') + def handle_ctrl_x(event): + """Ctrl+X: toggle subagent control panel.""" + cli_ref._subagent_panel_open = not cli_ref._subagent_panel_open + cli_ref._subagent_panel_cursor = 0 + event.app.invalidate() + + @kb.add('up', filter=Condition(lambda: cli_ref._subagent_panel_open and bool(cli_ref._subagent_panel)), eager=True) + def panel_up(event): + cli_ref._subagent_panel_cursor = max(0, cli_ref._subagent_panel_cursor - 1) + event.app.invalidate() + + @kb.add('down', filter=Condition(lambda: cli_ref._subagent_panel_open and bool(cli_ref._subagent_panel)), eager=True) + def panel_down(event): + n = len(cli_ref._subagent_panel) + cli_ref._subagent_panel_cursor = min(n - 1, cli_ref._subagent_panel_cursor + 1) + event.app.invalidate() + + @kb.add('k', filter=Condition(lambda: cli_ref._subagent_panel_open and bool(cli_ref._subagent_panel))) + def panel_kill(event): + """K: interrupt the selected subagent.""" + records = sorted(cli_ref._subagent_panel.values(), key=lambda r: r.index) + if records: + target = records[cli_ref._subagent_panel_cursor % len(records)] + if target.child_ref: + try: + target.child_ref.interrupt() + except Exception: + pass + target.status = "interrupted" + event.app.invalidate() + def get_prompt(): return cli_ref._get_tui_prompt_fragments() @@ -8610,6 +8690,14 @@ def _get_voice_status(): 'voice-processing': '#FFA500 italic', 'voice-status': 'bg:#1a1a2e #87CEEB', 'voice-status-recording': 'bg:#1a1a2e #FF4444 bold', + # Subagent control panel + 'subagent-border': '#CD7F32', + 'subagent-running': 'ansiyellow', + 'subagent-done': 'ansigreen', + 'subagent-error': 'ansired', + 'subagent-warn': 'ansiyellow', + 'subagent-sub': '#888888', + 'subagent-selected': 'reverse', } style = PTStyle.from_dict(self._build_tui_style_dict()) diff --git a/hermes_cli/subagent_panel.py b/hermes_cli/subagent_panel.py new file mode 100644 index 000000000000..1a16fe9e4f3f --- /dev/null +++ b/hermes_cli/subagent_panel.py @@ -0,0 +1,118 @@ +"""Subagent control panel — live tracking overlay for delegate_task children.""" +from __future__ import annotations +import time +from dataclasses import dataclass, field +from typing import Any, Optional + + +@dataclass +class SubagentRecord: + index: int # 0-based task_index + goal: str # full goal string + start_time: float # time.monotonic() at spawn + session_id: str = "" + + # Live-updated by progress callback + status: str = "running" # running|completed|failed|interrupted|error + last_tool: str = "" + last_tool_preview: str = "" + tool_count: int = 0 + + # Filled on completion + duration_seconds: float = 0.0 + api_calls: int = 0 + exit_reason: str = "" + error: Optional[str] = None + + child_ref: Any = field(default=None, repr=False) # AIAgent; None after done + + @property + def elapsed(self) -> float: + if self.status == "running": + return time.monotonic() - self.start_time + return self.duration_seconds + + +STATUS_ICONS = { + "running": "●", + "completed": "✓", + "failed": "✗", + "error": "✗", + "interrupted": "⚡", +} + +STATUS_STYLES = { + "running": "class:subagent-running", + "completed": "class:subagent-done", + "failed": "class:subagent-error", + "error": "class:subagent-error", + "interrupted": "class:subagent-warn", +} + + +def _fmt_elapsed(r: SubagentRecord) -> str: + secs = int(r.elapsed) + m, s = divmod(secs, 60) + suffix = "" if r.status == "running" else " done" + return f"{m}:{s:02d}{suffix}" + + +def _tool_emoji(tool_name: str) -> str: + t = tool_name.lower() + if "web" in t or "search" in t or "browser" in t: + return "🌐" + if "file" in t or "read" in t or "write" in t: + return "📁" + if "terminal" in t or "bash" in t or "shell" in t: + return "💻" + if "memory" in t: + return "🧠" + if "skill" in t: + return "📚" + return "🔧" + + +def render_panel( + records: list, + cursor: int, + width: int, +) -> list: + """Return a prompt_toolkit formatted_text fragment list for the full panel box.""" + W = min(width - 4, 80) + n_running = sum(1 for r in records if r.status == "running") + title = f" Subagents ({n_running} running) " + fill = W - len(title) - 14 # '╭─' + ' Ctrl+X ─╮' + frags = [] + + def line(text: str, style: str = "") -> None: + frags.append((style, text + "\n")) + + # Header + line(f"╭─{title}{'─' * max(fill, 0)} Ctrl+X ─╮", "class:subagent-border") + + if not records: + line(f"│ (no subagents){'':>{W - 17}}│", "class:subagent-border") + else: + for i, r in enumerate(records): + icon = STATUS_ICONS.get(r.status, "?") + istyle = STATUS_STYLES.get(r.status, "") + elapsed = _fmt_elapsed(r) + goal_w = W - 12 # icon+index+elapsed+padding + goal = r.goal[:goal_w - 1] if len(r.goal) > goal_w else r.goal + row = f"│ {icon} [{r.index+1}] {goal:<{goal_w}} {elapsed:>7} │" + if i == cursor: + frags.append(("class:subagent-selected", row + "\n")) + else: + frags.append(("", "│ ")) + frags.append((istyle, f"{icon} [{r.index+1}]")) + frags.append(("", f" {goal:<{goal_w}} {elapsed:>7} │\n")) + # Tool sub-row (running only) + if r.status == "running" and r.last_tool: + emoji = _tool_emoji(r.last_tool) + preview = r.last_tool_preview[:W - 18] + sub = f"│ └─ {emoji} {r.last_tool:<14} {preview:<{W-18}} │" + line(sub, "class:subagent-sub") + + # Footer + line(f"╰{'─' * (W - 2)} ↑↓ K=interrupt ─╯", "class:subagent-border") + return frags diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index d7a1e2c05d2e..ac2250b1ba84 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -91,7 +91,7 @@ def _strip_blocked_tools(toolsets: List[str]) -> List[str]: return [t for t in toolsets if t not in blocked_toolset_names] -def _build_child_progress_callback(task_index: int, parent_agent, task_count: int = 1) -> Optional[callable]: +def _build_child_progress_callback(task_index: int, parent_agent, task_count: int = 1, panel_rec=None, invalidate=None) -> Optional[callable]: """Build a callback that relays child agent tool calls to the parent display. Two display paths: @@ -104,9 +104,13 @@ def _build_child_progress_callback(task_index: int, parent_agent, task_count: in spinner = getattr(parent_agent, '_delegate_spinner', None) parent_cb = getattr(parent_agent, 'tool_progress_callback', None) - if not spinner and not parent_cb: + if not spinner and not parent_cb and panel_rec is None: return None # No display → no callback → zero behavior change + # Mutable containers so delegate_task can wire up records after child build + _panel_rec = [panel_rec] + _inv = [invalidate] + # Show 1-indexed prefix only in batch mode (multiple tasks) prefix = f"[{task_index + 1}] " if task_count > 1 else "" @@ -149,6 +153,19 @@ def _callback(tool_name: str, preview: str = None): logger.debug("Parent callback failed: %s", e) _batch.clear() + # Update panel record if available + _rec = _panel_rec[0] + if _rec is not None: + _rec.last_tool = tool_name + _rec.last_tool_preview = (preview or "")[:50] + _rec.tool_count += 1 + _inv_fn = _inv[0] + if _inv_fn: + try: + _inv_fn() + except Exception: + pass + def _flush(): """Flush remaining batched tool names to gateway on completion.""" if parent_cb and _batch: @@ -160,6 +177,8 @@ def _flush(): _batch.clear() _callback._flush = _flush + _callback._panel_rec = _panel_rec # expose for delegate_task wiring + _callback._inv = _inv # expose for delegate_task wiring return _callback @@ -269,6 +288,8 @@ def _run_single_child( goal: str, child=None, parent_agent=None, + _panel_dict=None, + _panel_invalidate_fn=None, **_kwargs, ) -> Dict[str, Any]: """ @@ -381,6 +402,21 @@ def _run_single_child( if status == "failed": entry["error"] = result.get("error", "Subagent did not produce a response.") + # Update panel record on completion + if _panel_dict is not None and task_index in _panel_dict: + try: + rec = _panel_dict[task_index] + rec.status = entry.get("status", "error") + rec.duration_seconds = entry.get("duration_seconds", 0.0) + rec.api_calls = entry.get("api_calls", 0) + rec.exit_reason = entry.get("exit_reason", "") + rec.error = entry.get("error") + rec.child_ref = None + if _panel_invalidate_fn: + _panel_invalidate_fn() + except Exception: + pass + return entry except Exception as exc: @@ -477,6 +513,12 @@ def delegate_task( if not task.get("goal", "").strip(): return json.dumps({"error": f"Task {i} is missing a 'goal'."}) + # Hook into CLI subagent panel if available + _panel_registry = getattr(parent_agent, '_cli_subagent_registry', None) + _panel: dict = _panel_registry[0] if _panel_registry else {} + _panel_lock = _panel_registry[1] if _panel_registry else None + _panel_invalidate = _panel_registry[2] if _panel_registry else None + overall_start = time.monotonic() results = [] @@ -511,10 +553,43 @@ def delegate_task( # Authoritative restore: reset global to parent's tool names after all children built _model_tools._last_resolved_tool_names = _parent_tool_names + # Create panel records and wire up progress callbacks + if _panel_registry is not None: + try: + from hermes_cli.subagent_panel import SubagentRecord as _SubagentRecord + _panel_lock_ctx = _panel_lock if _panel_lock else __import__('contextlib').nullcontext() + with _panel_lock_ctx: + for i, t, child in children: + rec = _SubagentRecord( + index=i, + goal=t["goal"], + start_time=time.monotonic(), + session_id=getattr(child, 'session_id', ''), + child_ref=child, + ) + _panel[i] = rec + # Wire up the mutable containers in each child's progress callback + for i, t, child in children: + rec = _panel.get(i) + cb = getattr(child, 'tool_progress_callback', None) + if cb and rec: + if hasattr(cb, '_panel_rec'): + cb._panel_rec[0] = rec + if hasattr(cb, '_inv') and _panel_invalidate: + cb._inv[0] = _panel_invalidate + if _panel_invalidate: + _panel_invalidate() + except Exception: + pass + if n_tasks == 1: # Single task -- run directly (no thread pool overhead) _i, _t, child = children[0] - result = _run_single_child(0, _t["goal"], child, parent_agent) + result = _run_single_child( + 0, _t["goal"], child, parent_agent, + _panel_dict=_panel if _panel_registry is not None else None, + _panel_invalidate_fn=_panel_invalidate, + ) results.append(result) else: # Batch -- run in parallel with per-task progress lines @@ -530,6 +605,8 @@ def delegate_task( goal=t["goal"], child=child, parent_agent=parent_agent, + _panel_dict=_panel if _panel_registry is not None else None, + _panel_invalidate_fn=_panel_invalidate, ) futures[future] = i From 86be2cbbc79c44686f10fba58431e93a20248c2a Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sun, 5 Apr 2026 08:12:20 -0400 Subject: [PATCH 43/44] fix(subagent-panel): correct box frame math + status bar hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Footer dashes: was W-2 (way too wide), now W-19 to match suffix length - Header dashes: was W-len(title)-14, now W-len(title)-12 - goal_w: was W-12 (too wide), now W-20 (accounts for icon+index+elapsed) - elapsed field: padded to 9 chars ('0:00 done' max) so row stays fixed-width - ⚡ (2-wide emoji) replaced with ~ (1-wide) so column math holds - Tool emoji replaced with ASCII symbols for same reason - Status bar: '🔀 N Ctrl+X' when closed, '🔀 N ▲' when open --- cli.py | 2 +- hermes_cli/subagent_panel.py | 84 +++++++++++++++++++++++------------- 2 files changed, 56 insertions(+), 30 deletions(-) diff --git a/cli.py b/cli.py index cfeff4131a97..85930c300cfc 100644 --- a/cli.py +++ b/cli.py @@ -1637,7 +1637,7 @@ def _get_status_bar_fragments(self): n_running = sum(1 for r in self._subagent_panel.values() if r.status == "running") if n_running: frags.append(("class:status-bar-dim", " │ ")) - label = f"🔀 {n_running}" + (" [panel]" if self._subagent_panel_open else "") + label = f"🔀 {n_running}" + (" ▲" if self._subagent_panel_open else " Ctrl+X") frags.append(("class:status-bar-warn", label)) if self._show_full_user_message: frags.append(("class:status-bar-dim", " │ ")) diff --git a/hermes_cli/subagent_panel.py b/hermes_cli/subagent_panel.py index 1a16fe9e4f3f..83c1ef72e93e 100644 --- a/hermes_cli/subagent_panel.py +++ b/hermes_cli/subagent_panel.py @@ -33,12 +33,13 @@ def elapsed(self) -> float: return self.duration_seconds +# All icons exactly 1 display column wide (avoid emoji that render as 2-wide) STATUS_ICONS = { "running": "●", "completed": "✓", "failed": "✗", "error": "✗", - "interrupted": "⚡", + "interrupted": "~", } STATUS_STYLES = { @@ -53,23 +54,24 @@ def elapsed(self) -> float: def _fmt_elapsed(r: SubagentRecord) -> str: secs = int(r.elapsed) m, s = divmod(secs, 60) - suffix = "" if r.status == "running" else " done" - return f"{m}:{s:02d}{suffix}" + if r.status == "running": + return f"{m}:{s:02d}" + return f"{m}:{s:02d} done" def _tool_emoji(tool_name: str) -> str: t = tool_name.lower() if "web" in t or "search" in t or "browser" in t: - return "🌐" + return ">" # avoid 2-wide emoji in fixed-width box if "file" in t or "read" in t or "write" in t: - return "📁" + return "f" if "terminal" in t or "bash" in t or "shell" in t: - return "💻" + return "$" if "memory" in t: - return "🧠" + return "m" if "skill" in t: - return "📚" - return "🔧" + return "s" + return "*" def render_panel( @@ -77,42 +79,66 @@ def render_panel( cursor: int, width: int, ) -> list: - """Return a prompt_toolkit formatted_text fragment list for the full panel box.""" + """Return prompt_toolkit formatted_text fragments for the panel box. + + All measurements use display-column counts, assuming every character is + exactly 1 column wide (no emoji, no CJK). W is the total box width + including the │ border characters on both sides. + """ W = min(width - 4, 80) + + # Fixed border strings — measure by len() since all chars are 1-wide + HDR_PREFIX = "╭─" # 2 + HDR_SUFFIX = " Ctrl+X ─╮" # 10 + FTR_PREFIX = "╰" # 1 + FTR_SUFFIX = " ↑↓ K=interrupt ─╯" # 18 + n_running = sum(1 for r in records if r.status == "running") title = f" Subagents ({n_running} running) " - fill = W - len(title) - 14 # '╭─' + ' Ctrl+X ─╮' - frags = [] + + hdr_dashes = max(0, W - len(HDR_PREFIX) - len(title) - len(HDR_SUFFIX)) + ftr_dashes = max(0, W - len(FTR_PREFIX) - len(FTR_SUFFIX)) + + # Row layout (all 1-wide): + # │ I [N] │ + # 1+1+1+1+1+1+1+1 + goal_w + 1+ELAPSED_W+1+1 = goal_w + ELAPSED_W + 11 = W + ELAPSED_W = 9 # "0:00 done" = 9 chars; running "0:00" left-padded to 9 + goal_w = max(10, W - ELAPSED_W - 11) + + frags: list = [] def line(text: str, style: str = "") -> None: frags.append((style, text + "\n")) - # Header - line(f"╭─{title}{'─' * max(fill, 0)} Ctrl+X ─╮", "class:subagent-border") + line(f"{HDR_PREFIX}{title}{'─' * hdr_dashes}{HDR_SUFFIX}", + "class:subagent-border") if not records: - line(f"│ (no subagents){'':>{W - 17}}│", "class:subagent-border") + content = "(no subagents)" + pad = max(0, W - 4 - len(content)) + line(f"│ {content}{' ' * pad} │", "class:subagent-border") else: for i, r in enumerate(records): - icon = STATUS_ICONS.get(r.status, "?") + icon = STATUS_ICONS.get(r.status, "?") istyle = STATUS_STYLES.get(r.status, "") - elapsed = _fmt_elapsed(r) - goal_w = W - 12 # icon+index+elapsed+padding - goal = r.goal[:goal_w - 1] if len(r.goal) > goal_w else r.goal - row = f"│ {icon} [{r.index+1}] {goal:<{goal_w}} {elapsed:>7} │" + elapsed = _fmt_elapsed(r).ljust(ELAPSED_W) + goal = r.goal[:goal_w].ljust(goal_w) + idx = str(r.index + 1) + row = f"│ {icon} [{idx}] {goal} {elapsed} │" if i == cursor: frags.append(("class:subagent-selected", row + "\n")) else: frags.append(("", "│ ")) - frags.append((istyle, f"{icon} [{r.index+1}]")) - frags.append(("", f" {goal:<{goal_w}} {elapsed:>7} │\n")) - # Tool sub-row (running only) + frags.append((istyle, f"{icon} [{idx}]")) + frags.append(("", f" {goal} {elapsed} │\n")) + + # Tool sub-row (running agents only) if r.status == "running" and r.last_tool: - emoji = _tool_emoji(r.last_tool) - preview = r.last_tool_preview[:W - 18] - sub = f"│ └─ {emoji} {r.last_tool:<14} {preview:<{W-18}} │" - line(sub, "class:subagent-sub") + sym = _tool_emoji(r.last_tool) + tool_s = r.last_tool[:12].ljust(12) + prev_w = max(0, W - 23) + preview = r.last_tool_preview[:prev_w].ljust(prev_w) + line(f"│ └─ {sym} {tool_s} {preview} │", "class:subagent-sub") - # Footer - line(f"╰{'─' * (W - 2)} ↑↓ K=interrupt ─╯", "class:subagent-border") + line(f"{FTR_PREFIX}{'─' * ftr_dashes}{FTR_SUFFIX}", "class:subagent-border") return frags From ebd04c5e9b9cccd56f93bb998ce5f4c4e6d181da Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sun, 5 Apr 2026 08:25:52 -0400 Subject: [PATCH 44/44] fix(skill_manager): use patchable SKILLS_DIR in _find_skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _find_skill() was calling get_all_skills_dirs() which always returns the real ~/.hermes/skills/ as the first entry, ignoring any unittest.mock.patch on the module-level SKILLS_DIR constant. Tests patch SKILLS_DIR to a tmp_path, so _create_skill wrote skills there but _find_skill never found them — causing every subsequent edit/patch/delete/write_file/remove_file test to fail. Fix: build search_dirs by replacing the first entry with the module-level SKILLS_DIR (patchable) and keeping external dirs from config (indices 1+) unchanged. In production the two are identical, so no behaviour change. --- tools/skill_manager_tool.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index b8d8d62232e7..f1755e5f9213 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -207,10 +207,15 @@ def _find_skill(name: str) -> Optional[Dict[str, Any]]: Searches the local skills dir (~/.hermes/skills/) first, then any external dirs configured via skills.external_dirs. Returns - {"path": Path} or None. + {\"path\": Path} or None. """ from agent.skill_utils import get_all_skills_dirs - for skills_dir in get_all_skills_dirs(): + # Use the module-level SKILLS_DIR as the primary search dir so that + # tests can patch it via unittest.mock.patch. External dirs (indices + # 1+) still come from the config via get_all_skills_dirs(). + all_dirs = get_all_skills_dirs() + search_dirs = [SKILLS_DIR] + list(all_dirs[1:]) + for skills_dir in search_dirs: if not skills_dir.exists(): continue for skill_md in skills_dir.rglob("SKILL.md"):