From 86e10dd8741b68567ebcd4fdf10476c638e43434 Mon Sep 17 00:00:00 2001 From: Ian Culling Date: Sun, 31 May 2026 18:12:42 -0400 Subject: [PATCH 01/69] fix(agent): route 'thinking blocks cannot be modified' 400 to recovery Anthropic returns a 400 when the thinking/redacted_thinking blocks in the latest assistant message are mutated upstream: 'thinking or redacted_thinking blocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response.' The classifier's thinking_signature branch only matched on the substring 'signature', so this variant fell through to a non-retryable client error and hard-aborted the turn -- even though the existing strip-reasoning_details -and-retry recovery would have healed it. Broaden the 400 match to also catch 'cannot be modified' / 'must remain as they were' (still gated on 'thinking'), routing it to the same recovery. Adds a negative-case test so unrelated 'cannot be modified' 400s are not swept in. Defense-in-depth, orthogonal to the root-cause work in #35975 / #17861 (which prevent the block mutation in the first place). Only changes a terminal-failure into a one-shot recovery. Signed-off-by: Ian Culling --- agent/error_classifier.py | 24 ++++++++++++++++--- tests/agent/test_error_classifier.py | 36 ++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/agent/error_classifier.py b/agent/error_classifier.py index a2045b5f8cd..c39c24a6a5d 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -549,14 +549,32 @@ def classify_api_error( should_fallback=True, ) - # Anthropic thinking block signature invalid (400). + # Anthropic thinking block recovery (400). Two distinct failure modes, + # same recovery (strip all reasoning_details and retry without thinking + # blocks — see the thinking_signature handler in conversation_loop.py): + # 1. Signature mismatch: a thinking block is signed against the full + # turn content; any upstream mutation (context compression, session + # truncation, message merging) invalidates the signature. + # Pattern: "signature" + "thinking". + # 2. Frozen-block mutation: Anthropic rejects any change to the + # thinking/redacted_thinking blocks in the *latest* assistant + # message — "`thinking` or `redacted_thinking` blocks in the latest + # assistant message cannot be modified. These blocks must remain as + # they were in the original response." This carries no "signature" + # token, so the original pattern missed it and the turn hard-aborted + # as a non-retryable client error instead of self-healing. + # Pattern: "thinking" + ("cannot be modified" | "must remain as they were"). # Don't gate on provider — OpenRouter proxies Anthropic errors, so the # provider may be "openrouter" even though the error is Anthropic-specific. - # The message pattern ("signature" + "thinking") is unique enough. + # The combined patterns are unique enough. if ( status_code == 400 - and "signature" in error_msg and "thinking" in error_msg + and ( + "signature" in error_msg + or "cannot be modified" in error_msg + or "must remain as they were" in error_msg + ) ): return _result( FailoverReason.thinking_signature, diff --git a/tests/agent/test_error_classifier.py b/tests/agent/test_error_classifier.py index ab6f27d6965..9708d7aadc3 100644 --- a/tests/agent/test_error_classifier.py +++ b/tests/agent/test_error_classifier.py @@ -661,6 +661,42 @@ class TestClassifyApiError: # Without "thinking" in the message, it shouldn't be thinking_signature assert result.reason != FailoverReason.thinking_signature + def test_anthropic_thinking_blocks_cannot_be_modified(self): + """Frozen-block mutation 400 (no 'signature' token) must route to + thinking_signature recovery, not hard-abort. Regression for the + real-world error: latest-assistant thinking blocks 'cannot be + modified' after upstream message mutation.""" + e = MockAPIError( + "messages.73.content.10: `thinking` or `redacted_thinking` blocks " + "in the latest assistant message cannot be modified. These blocks " + "must remain as they were in the original response.", + status_code=400, + ) + result = classify_api_error(e, provider="anthropic") + assert result.reason == FailoverReason.thinking_signature + assert result.retryable is True + + def test_anthropic_thinking_cannot_be_modified_via_openrouter(self): + """Same frozen-block error proxied through OpenRouter must also be + caught (provider is not gated).""" + e = MockAPIError( + "`thinking` or `redacted_thinking` blocks in the latest assistant " + "message cannot be modified.", + status_code=400, + ) + result = classify_api_error(e, provider="openrouter") + assert result.reason == FailoverReason.thinking_signature + assert result.retryable is True + + def test_400_cannot_be_modified_without_thinking_not_classified(self): + """A 400 'cannot be modified' that has nothing to do with thinking + blocks must NOT be swept into thinking_signature recovery.""" + e = MockAPIError( + "this field cannot be modified after creation", status_code=400, + ) + result = classify_api_error(e, provider="anthropic", approx_tokens=0) + assert result.reason != FailoverReason.thinking_signature + def test_invalid_encrypted_content_classified_as_retryable_replay_failure(self): body = { "error": { From 9f95f72b987fcad6c9b11b22d595691af9f9168c Mon Sep 17 00:00:00 2001 From: 0xyg3n Date: Sat, 30 May 2026 09:15:02 +0000 Subject: [PATCH 02/69] fix(agent): strip api_messages in thinking-signature recovery so the retry actually omits thinking blocks The thinking-signature recovery in agent/conversation_loop.py popped reasoning_details from messages, then continued to retry. That had two defects. First, the strip never reached the wire payload. api_messages is built once at the start of the turn by shallow-copying every entry in messages (line 919 area). Each api_messages entry has its own reference to the same reasoning_details list. When build_api_kwargs runs on every retry iteration of the inner while-loop, it consumes api_messages, not messages. Popping reasoning_details from messages left api_messages untouched, so the retry's request still carried the same thinking blocks Anthropic had just rejected. The classifier latched thinking_sig_retry_attempted = True after the first attempt, and the loop terminated with max_retries_exhausted on the same 400. Second, the pop mutated the canonical message list. messages is the same list _persist_session writes to state.db and the session transcript, so a single recovery permanently wiped every signed thinking block from the stored conversation. Subsequent turns reloaded the stripped state, hit the same 400 ('invalid signature' or 'cannot be modified', see #24107), and the agent stopped responding entirely. Cascading compaction-ended sessions then chained off the corrupted parent and the affected chat could not produce a response on any future turn. Move the strip onto api_messages, which is the API-call-time list rebuilt into kwargs on every retry. messages is no longer touched, so disk I/O stays clean and the recovery actually reaches the wire. Observed against the native Anthropic Messages API on claude-opus-4-7 and claude-opus-4-8 with the interleaved-thinking-2025-05-14 beta on hermes-agent 0.12.0 and 0.14.0. PR #24107 narrows the trigger; this change makes the recovery do what it always claimed to do, and prevents the destructive aftermath. Tests cover the api_messages strip in isolation: pop on a shallow copy does not affect the source, the canonical messages list survives the strip, idempotency on a duplicate firing path, and a no-op when no reasoning_details exist on the messages. Related: #24107, #26959, #17861. --- agent/conversation_loop.py | 46 ++++++--- .../test_thinking_sig_recovery_persistence.py | 93 +++++++++++++++++++ 2 files changed, 128 insertions(+), 11 deletions(-) create mode 100644 tests/run_agent/test_thinking_sig_recovery_persistence.py diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 73bed6b0670..8850b7fd565 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -2221,30 +2221,54 @@ def run_conversation( print(f"{agent.log_prefix} • Legacy cleanup: hermes config set ANTHROPIC_TOKEN \"\"") print(f"{agent.log_prefix} • Clear stale keys: hermes config set ANTHROPIC_API_KEY \"\"") - # ── Thinking block signature recovery ───────────────── + # Thinking block signature recovery. + # # Anthropic signs thinking blocks against the full turn - # content. Any upstream mutation (context compression, + # content. Any upstream mutation (context compression, # session truncation, message merging) invalidates the - # signature → HTTP 400. Recovery: strip reasoning_details - # from all messages so the next retry sends no thinking - # blocks at all. One-shot — don't retry infinitely. + # signature and the API replies HTTP 400 ("invalid + # signature" or "cannot be modified"). Recovery strips + # ``reasoning_details`` so the retry sends no thinking + # blocks at all. One-shot per outer loop. + # + # The strip targets ``api_messages``, which is the + # API-call-time list that ``_build_api_kwargs`` consumes + # on every retry. ``api_messages`` was populated once at + # the start of the turn from shallow copies of + # ``messages``, so mutating it does not touch the + # canonical store. The previous implementation popped + # ``reasoning_details`` from ``messages`` instead, which + # had two problems: ``api_messages`` carried its own + # reference to the field through the shallow copy, so the + # retry's wire payload still included thinking blocks and + # the recovery never reached the API; and the mutation + # persisted into ``state.db`` through any subsequent + # ``_persist_session`` call, permanently corrupting the + # conversation. Future turns would replay the stripped + # state, hit the same 400, and the agent would terminate + # with ``max_retries_exhausted``, often spawning + # cascading compaction-ended sessions chained off the + # corrupted parent. if ( classified.reason == FailoverReason.thinking_signature and not _retry.thinking_sig_retry_attempted ): _retry.thinking_sig_retry_attempted = True - for _m in messages: - if isinstance(_m, dict): + _api_stripped = 0 + for _m in api_messages: + if isinstance(_m, dict) and "reasoning_details" in _m: _m.pop("reasoning_details", None) + _api_stripped += 1 agent._vprint( - f"{agent.log_prefix}⚠️ Thinking block signature invalid — " - f"stripped all thinking blocks, retrying...", + f"{agent.log_prefix}⚠️ Thinking block signature invalid, " + f"stripped reasoning_details from api_messages for retry...", force=True, ) logger.warning( "%sThinking block signature recovery: stripped " - "reasoning_details from %d messages", - agent.log_prefix, len(messages), + "reasoning_details from %d api_messages " + "(canonical messages unchanged)", + agent.log_prefix, _api_stripped, ) continue diff --git a/tests/run_agent/test_thinking_sig_recovery_persistence.py b/tests/run_agent/test_thinking_sig_recovery_persistence.py new file mode 100644 index 00000000000..e518af5145d --- /dev/null +++ b/tests/run_agent/test_thinking_sig_recovery_persistence.py @@ -0,0 +1,93 @@ +"""Regression tests for the thinking-block signature recovery. + +The recovery in ``agent/conversation_loop.py`` strips ``reasoning_details`` +from ``api_messages`` (the API-call-time list rebuilt on every retry) and +leaves ``messages`` (the canonical store) untouched. The previous +implementation popped from ``messages`` directly, which never reached +``api_messages`` because each entry in ``api_messages`` was a shallow +copy of the corresponding entry in ``messages``, and the mutation also +landed in ``state.db`` on the next ``_persist_session`` call, corrupting +the conversation. + +These tests cover the surface that the recovery touches in isolation: +shallow copies share inner field references; popping a key from one dict +does not remove it from the other; and a list of shallow copies behaves +the same way. +""" + + +def _shallow_copies(messages): + return [m.copy() for m in messages] + + +def test_pop_on_shallow_copy_does_not_affect_source(): + rd = [{"type": "thinking", "thinking": "r", "signature": "s"}] + src = {"role": "assistant", "content": "x", "reasoning_details": rd} + cp = src.copy() + + cp.pop("reasoning_details", None) + + assert "reasoning_details" not in cp + assert "reasoning_details" in src + assert src["reasoning_details"] is rd + + +def test_strip_api_messages_leaves_canonical_messages_intact(): + """Mirrors the recovery: pop reasoning_details from api_messages only. + + The canonical ``messages`` list keeps its reasoning_details so future + persists carry the original signed blocks. + """ + rd_one = [{"type": "thinking", "thinking": "one", "signature": "sig_one"}] + rd_two = [{"type": "thinking", "thinking": "two", "signature": "sig_two"}] + messages = [ + {"role": "user", "content": "q1"}, + {"role": "assistant", "content": "a1", "reasoning_details": rd_one}, + {"role": "user", "content": "q2"}, + {"role": "assistant", "content": "a2", "reasoning_details": rd_two}, + ] + api_messages = _shallow_copies(messages) + + stripped = 0 + for m in api_messages: + if isinstance(m, dict) and "reasoning_details" in m: + m.pop("reasoning_details", None) + stripped += 1 + + assert stripped == 2 + assert all("reasoning_details" not in m for m in api_messages) + canonical_rd = [ + m.get("reasoning_details") for m in messages if m["role"] == "assistant" + ] + assert canonical_rd == [rd_one, rd_two] + + +def test_strip_is_idempotent_when_run_twice(): + """A second strip is a no-op when reasoning_details has already been + removed from api_messages. Guards against a duplicate firing path. + """ + api_messages = [ + {"role": "assistant", "content": "a", "reasoning_details": [{"x": 1}]}, + {"role": "user", "content": "q"}, + ] + for _ in range(2): + for m in api_messages: + if isinstance(m, dict) and "reasoning_details" in m: + m.pop("reasoning_details", None) + + assert all("reasoning_details" not in m for m in api_messages) + + +def test_strip_skips_messages_without_reasoning_details(): + api_messages = [ + {"role": "user", "content": "q"}, + {"role": "assistant", "content": "a"}, + {"role": "tool", "tool_call_id": "1", "content": "ok"}, + ] + snapshot = [dict(m) for m in api_messages] + + for m in api_messages: + if isinstance(m, dict) and "reasoning_details" in m: + m.pop("reasoning_details", None) + + assert api_messages == snapshot From 2d75833abeca369808b014d5ecc4a1c0360f6d86 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:10:01 -0700 Subject: [PATCH 03/69] chore(release): map ianculling for #36087 salvage --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index a9d08577b76..cd0fe475d5d 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -943,6 +943,7 @@ AUTHOR_MAP = { "michel.belleau@malaiwah.com": "malaiwah", "gnanasekaran.sekareee@gmail.com": "gnanam1990", "jz.pentest@gmail.com": "0xyg3n", + "ian@culling.ca": "ianculling", # PR #36087 "7093928+0xyg3n@users.noreply.github.com": "0xyg3n", "nftpoetrist@gmail.com": "nftpoetrist", # PR #18982 "millerc79@users.noreply.github.com": "millerc79", # PR #19033 From a8f404b29fa9ffde4ba4763e2bc30a077f430fa0 Mon Sep 17 00:00:00 2001 From: Tranquil-Flow Date: Sun, 7 Jun 2026 02:09:04 +0200 Subject: [PATCH 04/69] fix(gateway): probe launchd domain instead of hardcoding user/ (#40831) The previous fix for #23387 changed _launchd_domain() from gui/ to user/ to support Background/SSH sessions on macOS 26+. However, this broke Aqua sessions where gui/ is the only working domain and user/ cannot bootstrap or manage the service. Now _launchd_domain() probes which domain actually contains the loaded service: 1. Try gui/ first (Aqua sessions) 2. Fall back to user/ (Background/SSH sessions) 3. Use launchctl managername as heuristic when neither has the service 4. Cache the result for the process lifetime Regression tests cover all four paths plus caching behavior. --- hermes_cli/gateway.py | 75 ++++++++++++- tests/hermes_cli/test_gateway_service.py | 129 ++++++++++++++++++++++- 2 files changed, 196 insertions(+), 8 deletions(-) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 5ff74259185..1e455555e9a 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -3067,12 +3067,77 @@ def get_launchd_label() -> str: return f"ai.hermes.gateway-{suffix}" if suffix else "ai.hermes.gateway" +# Cached launchd domain result — probing is cheap but should only run once per +# process invocation (each ``hermes gateway start/stop/status`` call). +_resolved_launchd_domain: str | None = None + + def _launchd_domain() -> str: - # The `user/` domain (vs the older `gui/`) is reachable from - # non-Aqua/background sessions (SSH, headless, login items) and is the only - # one that supports service management on macOS 26+. `gui/` returns - # error 125 ("Domain does not support specified action") there. See #23387. - return f"user/{os.getuid()}" # windows-footgun: ok — POSIX launchd (macOS) helper, never invoked on Windows + """Return the launchd domain that actually manages the gateway service. + + Probes ``gui/`` first (Aqua sessions), then ``user/`` + (Background/SSH sessions). When neither domain contains a loaded + service, falls back to ``launchctl managername`` as a heuristic. + + The result is cached for the lifetime of the process so that repeated + calls (``start``, ``stop``, ``restart``) use a consistent domain. + + See #40831, #23387. + """ + global _resolved_launchd_domain + if _resolved_launchd_domain is not None: + return _resolved_launchd_domain + + uid = os.getuid() # windows-footgun: ok — POSIX launchd (macOS) helper, never invoked on Windows + label = get_launchd_label() + gui_domain = f"gui/{uid}" + user_domain = f"user/{uid}" + + # 1. Probe gui/ first — in Aqua sessions the service is loaded here. + try: + subprocess.run( + ["launchctl", "print", f"{gui_domain}/{label}"], + check=True, + timeout=5, + capture_output=True, + ) + _resolved_launchd_domain = gui_domain + return gui_domain + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + pass + + # 2. Probe user/ — in Background/SSH sessions this is the working domain. + try: + subprocess.run( + ["launchctl", "print", f"{user_domain}/{label}"], + check=True, + timeout=5, + capture_output=True, + ) + _resolved_launchd_domain = user_domain + return user_domain + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + pass + + # 3. Neither domain has the service loaded — use managername as heuristic. + # Aqua → gui/, anything else (Background, loginwindow) → user/. + try: + result = subprocess.run( + ["launchctl", "managername"], + capture_output=True, + text=True, + timeout=5, + ) + if "Aqua" in (result.stdout or ""): + _resolved_launchd_domain = gui_domain + return gui_domain + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + pass + + # 4. Default to user/ (matches the pre-probing behavior for + # Background/SSH sessions and is the recommended domain on macOS 26+). + _resolved_launchd_domain = user_domain + return user_domain # On macOS, exit code 125 ("Domain does not support specified action") and diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index 0b897af01f8..13f1636e4a6 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -495,7 +495,10 @@ class TestLaunchdServiceRecovery: label = gateway_cli.get_launchd_label() domain = gateway_cli._launchd_domain() assert "--replace" in plist_path.read_text(encoding="utf-8") - assert calls[:2] == [ + # The calls list includes launchctl print probes from _launchd_domain() + # before the bootout/bootstrap calls. Filter to only bootout/bootstrap. + service_calls = [c for c in calls if "bootout" in c or "bootstrap" in c] + assert service_calls[:2] == [ ["launchctl", "bootout", f"{domain}/{label}"], ["launchctl", "bootstrap", domain, str(plist_path)], ] @@ -679,10 +682,22 @@ class TestLaunchdServiceRecovery: assert "stale" in output.lower() assert "not loaded" in output.lower() - def test_launchd_domain_uses_user_domain(self): + def test_launchd_domain_uses_user_domain(self, monkeypatch): # The user/ domain (not gui/) is the one reachable from # non-Aqua/background sessions on macOS 26+ (issue #23387). - assert gateway_cli._launchd_domain() == f"user/{os.getuid()}" + # When gui/ fails to probe and user/ succeeds, + # _launchd_domain() must return user/. + gateway_cli._resolved_launchd_domain = None + monkeypatch.setattr(os, "getuid", lambda: 501) + label = gateway_cli.get_launchd_label() + + def fake_run(cmd, check=False, **kwargs): + if "print" in cmd and "gui/" in " ".join(cmd): + raise subprocess.CalledProcessError(1, cmd, stderr="Domain error") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + assert gateway_cli._launchd_domain() == "user/501" def test_launchctl_domain_unsupported_recognizes_macos26_codes(self): # Codes that persist after a fresh bootstrap → launchd truly unavailable. @@ -836,6 +851,114 @@ class TestLaunchdServiceRecovery: assert "nohup hermes gateway run" in out +class TestLaunchdDomainDetection: + """Regression tests for _launchd_domain() probing (#40831). + + The function must detect which launchd domain actually contains (or can + manage) the service, rather than hardcoding ``user/`` or ``gui/``. + """ + + def _reset_domain_cache(self): + """Clear any cached domain result between tests.""" + gateway_cli._resolved_launchd_domain = None + + def test_prefers_gui_domain_when_service_loaded_there(self, monkeypatch): + """In an Aqua session where the service is loaded under gui/, + _launchd_domain() must return ``gui/`` — not ``user/``.""" + self._reset_domain_cache() + monkeypatch.setattr(os, "getuid", lambda: 501) + label = gateway_cli.get_launchd_label() + + run_calls = [] + + def fake_run(cmd, check=False, **kwargs): + run_calls.append(cmd) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + + domain = gateway_cli._launchd_domain() + assert domain == f"gui/501" + # Should have probed gui first + assert run_calls[0] == ["launchctl", "print", f"gui/501/{label}"] + + def test_falls_back_to_user_domain_when_gui_fails(self, monkeypatch): + """In a Background/SSH session where gui/ fails but user/ + works, _launchd_domain() must return ``user/``.""" + self._reset_domain_cache() + monkeypatch.setattr(os, "getuid", lambda: 501) + label = gateway_cli.get_launchd_label() + + run_calls = [] + + def fake_run(cmd, check=False, **kwargs): + run_calls.append(cmd) + if "print" in cmd and "gui/" in " ".join(cmd): + raise subprocess.CalledProcessError(1, cmd, stderr="Domain error") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + + domain = gateway_cli._launchd_domain() + assert domain == f"user/501" + # Should have tried gui first, then user + assert len(run_calls) >= 2 + + def test_uses_managername_heuristic_when_both_probe_fail(self, monkeypatch): + """When neither domain contains a loaded service, use + ``launchctl managername`` as a tiebreaker: Aqua -> gui, else -> user.""" + self._reset_domain_cache() + monkeypatch.setattr(os, "getuid", lambda: 501) + label = gateway_cli.get_launchd_label() + + def fake_run(cmd, check=False, **kwargs): + if "print" in cmd: + raise subprocess.CalledProcessError(1, cmd, stderr="not found") + if "managername" in cmd: + return SimpleNamespace(returncode=0, stdout="Aqua\n", stderr="") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + + domain = gateway_cli._launchd_domain() + assert domain == f"gui/501" + + def test_managername_background_selects_user_domain(self, monkeypatch): + """When managername is Background (non-Aqua), use user/.""" + self._reset_domain_cache() + monkeypatch.setattr(os, "getuid", lambda: 501) + + def fake_run(cmd, check=False, **kwargs): + if "print" in cmd: + raise subprocess.CalledProcessError(1, cmd, stderr="not found") + if "managername" in cmd: + return SimpleNamespace(returncode=0, stdout="Background\n", stderr="") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + + domain = gateway_cli._launchd_domain() + assert domain == f"user/501" + + def test_caches_result_across_calls(self, monkeypatch): + """Domain detection should run once and cache the result.""" + self._reset_domain_cache() + monkeypatch.setattr(os, "getuid", lambda: 501) + + run_count = [0] + + def fake_run(cmd, check=False, **kwargs): + run_count[0] += 1 + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + + d1 = gateway_cli._launchd_domain() + d2 = gateway_cli._launchd_domain() + assert d1 == d2 + assert run_count[0] == 1 # Only probed once + + class TestGatewayServiceDetection: def test_supports_systemd_services_requires_systemctl_binary(self, monkeypatch): monkeypatch.setattr(gateway_cli, "is_linux", lambda: True) From 1e7316ced2261576bc4054aa915d3642ebd2b133 Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:44:37 -0600 Subject: [PATCH 05/69] fix(desktop): use sudo callback without interactive env --- tests/tools/test_terminal_tool.py | 24 ++++++++++++++++++++++++ tools/terminal_tool.py | 9 +++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/tests/tools/test_terminal_tool.py b/tests/tools/test_terminal_tool.py index fe2f5e3f514..ea113e63c27 100644 --- a/tests/tools/test_terminal_tool.py +++ b/tests/tools/test_terminal_tool.py @@ -90,6 +90,30 @@ def test_cached_sudo_password_is_used_when_env_is_unset(monkeypatch): assert sudo_stdin == "cached-pass\n" +def test_registered_sudo_callback_is_used_without_interactive_env(monkeypatch): + monkeypatch.delenv("SUDO_PASSWORD", raising=False) + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + monkeypatch.setattr(terminal_tool, "_sudo_nopasswd_works", lambda: False) + + calls = [] + + def sudo_callback(): + calls.append("called") + return "callback-pass" + + terminal_tool.set_sudo_password_callback(sudo_callback) + try: + transformed, sudo_stdin = terminal_tool._transform_sudo_command( + "echo ok | sudo tee /tmp/hermes-test" + ) + finally: + terminal_tool.set_sudo_password_callback(None) + + assert calls == ["called"] + assert transformed == "echo ok | sudo -S -p '' tee /tmp/hermes-test" + assert sudo_stdin == "callback-pass\n" + + def test_cached_sudo_password_isolated_by_session_key(monkeypatch): monkeypatch.delenv("SUDO_PASSWORD", raising=False) monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index d9edd7a5d5d..2ad882fba25 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -777,7 +777,8 @@ def _transform_sudo_command(command: str | None) -> tuple[str | None, str | None the password in the command string themselves; see their execute() methods for how they handle the non-None sudo_stdin case. - If SUDO_PASSWORD is not set and in interactive mode (HERMES_INTERACTIVE=1): + If SUDO_PASSWORD is not set and an interactive UI is available + (HERMES_INTERACTIVE=1 or a registered sudo password callback): Prompts user for password with 45s timeout, caches for session. If SUDO_PASSWORD is not set and NOT interactive: @@ -805,7 +806,11 @@ def _transform_sudo_command(command: str | None) -> tuple[str | None, str | None if not has_configured_password and not sudo_password and _sudo_nopasswd_works(): return command, None - if not has_configured_password and not sudo_password and env_var_enabled("HERMES_INTERACTIVE"): + has_sudo_prompt_callback = _get_sudo_password_callback() is not None + should_prompt_for_sudo = ( + env_var_enabled("HERMES_INTERACTIVE") or has_sudo_prompt_callback + ) + if not has_configured_password and not sudo_password and should_prompt_for_sudo: sudo_password = _prompt_for_sudo_password(timeout_seconds=45) if sudo_password: _set_cached_sudo_password(sudo_password) From acd4f34e65ae23289358fef3c428c8e02941ff33 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Wed, 10 Jun 2026 18:55:04 +0700 Subject: [PATCH 06/69] fix(cron): resolve per-job provider "custom" to providers.custom instead of codex A cron job stored with `provider: "custom"` and a matching `providers.custom` entry in config failed at execution with `auth_unavailable: providers=codex`. Two layers conspired: - `_get_named_custom_provider` returned None for bare "custom" *before* scanning config, so a literal `providers.custom` entry was never matched and resolution fell through to the global default (codex). Now it scans config for an entry literally named "custom"; with none it still returns None, preserving the legacy model.base_url trust path. - `_resolve_model_override` blindly stripped bare "custom" at job creation and pinned `model.provider` (e.g. codex). It now keeps "custom" when a configured custom endpoint resolves, pinning the main provider only when it doesn't. --- hermes_cli/runtime_provider.py | 32 +++++++++++++++++++++++++++++--- tools/cronjob_tools.py | 22 +++++++++++++++------- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index b8165978538..c53a930e9e4 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -491,15 +491,27 @@ def _lift_max_output_tokens(entry: Dict[str, Any], result: Dict[str, Any]) -> No def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, Any]]: requested_norm = _normalize_custom_provider_name(requested_provider or "") - if not requested_norm or requested_norm == "custom": + if not requested_norm: return None + # Bare "custom" is normally an incomplete spec — the canonical form is + # "custom:" — and is otherwise owned by the model.base_url "bare + # custom" trust path. BUT a user may literally name a ``providers:`` (or + # legacy ``custom_providers:``) entry "custom" (e.g. ``providers.custom`` + # pointing at cliproxy). We used to return None here *before* scanning + # config, so such an entry was never matched and resolution fell through to + # the global default (Codex) — the cause of cron jobs with + # ``provider: "custom"`` failing with ``auth_unavailable: providers=codex``. + # Fall through to the config scan instead; if no entry is literally named + # "custom" it still returns None at the end, preserving the trust path. + # Raw names should only map to custom providers when they are not already # valid built-in providers or aliases. Explicit menu keys like - # ``custom:local`` always target the saved custom provider. + # ``custom:local`` always target the saved custom provider. Bare "custom" + # is exempt from the shadow check — it is not a built-in to defer to. if requested_norm == "auto": return None - if not requested_norm.startswith("custom:"): + if requested_norm != "custom" and not requested_norm.startswith("custom:"): try: canonical = auth_mod.resolve_provider(requested_norm) except AuthError: @@ -634,6 +646,20 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An return None +def has_named_custom_provider(requested_provider: str) -> bool: + """Return True when config defines a custom provider matching the request. + + Thin public wrapper around :func:`_get_named_custom_provider` so other + modules (e.g. the cronjob tool) can decide whether a provider name will + actually resolve to a configured ``providers:`` / ``custom_providers:`` + entry — without reaching into a private helper or duplicating the scan. + """ + try: + return _get_named_custom_provider(requested_provider) is not None + except Exception: + return False + + def _custom_provider_request_overrides(custom_provider: Dict[str, Any]) -> Dict[str, Any]: extra_body = custom_provider.get("extra_body") if not isinstance(extra_body, dict) or not extra_body: diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 3b1c46ec3d7..2ec49760715 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -326,15 +326,23 @@ def _resolve_model_override(model_obj: Optional[Dict[str, Any]]) -> tuple: return (None, None) model_name = (model_obj.get("model") or "").strip() or None provider_name = (model_obj.get("provider") or "").strip() or None - # Bare "custom" is an incomplete spec — the canonical form is - # "custom:" matching a custom_providers entry. LLMs frequently + # Bare "custom" is usually an incomplete spec — the canonical form is + # "custom:" matching a custom_providers entry, and LLMs frequently # supply the bare type because the schema does not advertise the - # ":" suffix, which used to bypass the pinning path below and - # leave the job stored with an unresolvable "custom" provider. Treat - # the bare value as "no provider supplied" so the current main - # provider gets pinned instead. + # ":" suffix. It is only a problem when it can't resolve at runtime: + # a user may literally name a ``providers.custom`` (or custom_providers + # "custom") entry, in which case the job should keep ``provider="custom"`` + # and run against that endpoint. Only when no such entry exists do we treat + # the bare value as "no provider supplied" and pin the current main + # provider below — otherwise pinning to ``model.provider`` (e.g. codex) + # silently hijacks a job that meant to use the configured custom endpoint. if provider_name == "custom": - provider_name = None + try: + from hermes_cli.runtime_provider import has_named_custom_provider + if not has_named_custom_provider("custom"): + provider_name = None + except Exception: + provider_name = None if model_name and not provider_name: # Pin to the current main provider so the job is stable try: From f7a6d6a6a1bc57a1ffb085281957606df4b46cda Mon Sep 17 00:00:00 2001 From: xxxigm Date: Wed, 10 Jun 2026 18:55:04 +0700 Subject: [PATCH 07/69] =?UTF-8?q?test(cron):=20cover=20provider=20"custom"?= =?UTF-8?q?=20=E2=86=92=20providers.custom=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add execution-time coverage that bare `provider="custom"` resolves a literal providers.custom endpoint (and still falls through when none exists), plus creation-time coverage that `_resolve_model_override` keeps a resolvable "custom" and only pins the main provider when it is unresolvable. --- .../test_runtime_provider_resolution.py | 70 +++++++++++++++++++ tests/tools/test_cronjob_tools.py | 51 ++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index 2f89be93368..3e788fe3d53 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -712,6 +712,76 @@ def test_named_custom_provider_uses_saved_credentials(monkeypatch): assert resolved["source"] == "custom_provider:Local" +def test_bare_custom_resolves_providers_dict_entry_named_custom(monkeypatch): + """A request for bare ``provider="custom"`` must resolve a literal + ``providers.custom`` entry (e.g. a cliproxy endpoint) instead of falling + through to the global default. Regression for cron jobs stored with + ``provider: "custom"`` failing with ``auth_unavailable: providers=codex``. + """ + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "providers": { + "custom": { + "api": "https://cliproxy.example.com/v1", + "api_key": "cliproxy-key", + "default_model": "gpt-5.4", + "name": "CLIProxy", + } + } + }, + ) + # Reaching resolve_provider for bare custom with a matching entry means the + # named-custom path was bypassed — that is the bug we are fixing. + monkeypatch.setattr( + rp, + "resolve_provider", + lambda *a, **k: (_ for _ in ()).throw( + AssertionError( + "resolve_provider must not be called; providers.custom should match" + ) + ), + ) + + resolved = rp.resolve_runtime_provider(requested="custom") + + assert resolved["provider"] == "custom" + assert resolved["base_url"] == "https://cliproxy.example.com/v1" + assert resolved["api_key"] == "cliproxy-key" + assert resolved["requested_provider"] == "custom" + + +def test_bare_custom_without_named_entry_still_falls_through(monkeypatch): + """No literal providers.custom entry → bare custom keeps the legacy + model.base_url trust-path behavior, unchanged by the fix.""" + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter") + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: { + "provider": "openrouter", + "base_url": "http://127.0.0.1:8082/v1", + "default": "my-local-model", + }, + ) + monkeypatch.setattr( + rp, + "load_config", + lambda: {"providers": {"some-other-proxy": {"api": "https://x.example/v1"}}}, + ) + monkeypatch.delenv("CUSTOM_BASE_URL", raising=False) + monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False) + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + + resolved = rp.resolve_runtime_provider(requested="custom") + + assert resolved["provider"] == "custom" + assert resolved["base_url"] == "http://127.0.0.1:8082/v1" + + def test_named_custom_provider_uses_providers_dict_when_list_missing(monkeypatch): """After v11→v12 migration deletes custom_providers, resolution should still find entries in the providers dict via get_compatible_custom_providers.""" diff --git a/tests/tools/test_cronjob_tools.py b/tests/tools/test_cronjob_tools.py index fc03ab1d330..1ca877064a7 100644 --- a/tests/tools/test_cronjob_tools.py +++ b/tests/tools/test_cronjob_tools.py @@ -452,3 +452,54 @@ class TestUnifiedCronjobTool: assert updated["success"] is True stored = get_job(created["job_id"]) assert stored["deliver"] == "telegram" + + +# ========================================================================= +# Per-job model/provider override resolution +# ========================================================================= + +from tools.cronjob_tools import _resolve_model_override # noqa: E402 + + +class TestResolveModelOverride: + """`_resolve_model_override` must not silently hijack a job that meant to + use a configured custom endpoint (e.g. ``providers.custom`` → cliproxy). + Regression for cron jobs with ``provider: "custom"`` falling back to codex. + """ + + def test_keeps_bare_custom_when_a_named_entry_exists(self, monkeypatch): + import hermes_cli.runtime_provider as rp_mod + + monkeypatch.setattr(rp_mod, "has_named_custom_provider", lambda name: True) + provider, model = _resolve_model_override( + {"provider": "custom", "model": "gpt-5.4"} + ) + assert provider == "custom" + assert model == "gpt-5.4" + + def test_pins_main_provider_when_bare_custom_unresolvable(self, monkeypatch): + import hermes_cli.config as cfg_mod + import hermes_cli.runtime_provider as rp_mod + + monkeypatch.setattr(rp_mod, "has_named_custom_provider", lambda name: False) + monkeypatch.setattr( + cfg_mod, "load_config", lambda: {"model": {"provider": "openai-codex"}} + ) + provider, model = _resolve_model_override( + {"provider": "custom", "model": "gpt-5.4"} + ) + # No matching custom entry → fall back to pinning the main provider. + assert provider == "openai-codex" + assert model == "gpt-5.4" + + def test_keeps_explicit_custom_name_unchanged(self, monkeypatch): + import hermes_cli.runtime_provider as rp_mod + + # Even if the resolver claims no entry, the canonical "custom:" + # form is never stripped or pinned. + monkeypatch.setattr(rp_mod, "has_named_custom_provider", lambda name: False) + provider, model = _resolve_model_override( + {"provider": "custom:cliproxy", "model": "gpt-5.4"} + ) + assert provider == "custom:cliproxy" + assert model == "gpt-5.4" From 88fcf0c8c02d4a5f7c465efffaf4122b8ded793a Mon Sep 17 00:00:00 2001 From: xxxigm Date: Wed, 10 Jun 2026 19:05:38 +0700 Subject: [PATCH 08/69] docs(memory): clarify that memory does not auto-compact when full MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Persistent Memory" callout said "when memory is full, the agent consolidates or replaces entries to make room," which reads as if the store self-compacts automatically. It does not: the `memory` tool returns an overflow error and the agent does the consolidation in-turn (the design from #41755). Also note that `replace` is bound by the same limit — swapping in a longer entry can still overflow — which is the exact case that confused a user (replace rejected near the cap even though the math was correct). --- website/docs/user-guide/features/memory.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/website/docs/user-guide/features/memory.md b/website/docs/user-guide/features/memory.md index d0873d6b955..b4814d2a178 100644 --- a/website/docs/user-guide/features/memory.md +++ b/website/docs/user-guide/features/memory.md @@ -20,7 +20,13 @@ Two files make up the agent's memory: Both are stored in `~/.hermes/memories/` and are injected into the system prompt as a frozen snapshot at session start. The agent manages its own memory via the `memory` tool — it can add, replace, or remove entries. :::info -Character limits keep memory focused. When memory is full, the agent consolidates or replaces entries to make room for new information. +Character limits keep memory focused. Memory does **not** auto-compact: when a +write would exceed the limit, the `memory` tool returns an error instead of +silently dropping entries. The agent then makes room itself — consolidating or +removing entries in the same turn before retrying (see [What Happens When Memory +is Full](#what-happens-when-memory-is-full)). Note that `replace` is also bound +by the limit: swapping an entry for a longer one can still overflow, so the new +content must be shortened (or another entry removed) to fit. ::: ## How Memory Appears in the System Prompt From 590b3c0d7eae8693a99f9ae4eb1906ba7af8c5b4 Mon Sep 17 00:00:00 2001 From: GodsBoy Date: Tue, 9 Jun 2026 12:28:43 +0200 Subject: [PATCH 09/69] fix(gateway): recover partial Telegram overflow streams --- .../topic-final-response-clipped.jpg | Bin 0 -> 437826 bytes ...gram-stream-overflow-continuations-plan.md | 240 ++++++++++++++++++ gateway/platforms/base.py | 7 + gateway/platforms/telegram.py | 31 ++- gateway/stream_consumer.py | 41 ++- .../gateway/test_telegram_overflow_partial.py | 140 ++++++++++ 6 files changed, 453 insertions(+), 6 deletions(-) create mode 100644 .github/pr-screenshots/telegram-overflow/topic-final-response-clipped.jpg create mode 100644 docs/plans/2026-06-09-003-fix-telegram-stream-overflow-continuations-plan.md create mode 100644 tests/gateway/test_telegram_overflow_partial.py diff --git a/.github/pr-screenshots/telegram-overflow/topic-final-response-clipped.jpg b/.github/pr-screenshots/telegram-overflow/topic-final-response-clipped.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2f3529648e796c50aa53c92734b9ab267e0afad6 GIT binary patch literal 437826 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 KN@pkWm&*LN}4{q_R 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�J5FD#UXH5?52}@@&Vprb!#*5G6Jk_5cyowNz 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

oMiP zrxDvyVR^jYfq1^cPud6&h>5AL@pDJQMM=oDL5v11ny%@4kmM05!8+=zGDNf*bpI4^ zN~fm6HuQN)Xcn=fJnpm95Ib3zb*Pmt?yD9*P4eW|IY!Xgl-ZSNG%G~b6;}!~H_(ry zA0-7o4v(vP4xQFnwIXQ&GHSkkc7})xlNEP>H4|+k9ga;CepGv&Q&41tcaDiM*K6Pp z&kqN9+`!kk3;^wJ`nKYx%6Pwl=?&J#ph=FVsJ)w5NK*_Br1v{@?>w-5gIgKB4-gu&F@u_nWF`(e zcOi8@@IEQmEnnd<>V~b%t!Nop%zH5+3j9Tgtz>VQXlyoaZ4`abvSmlL=;& ze6ay~h;hMd26Jv1{-h^gZfA4VcBHBT(~ct=$I1slt+__w4H>ORryMReQ3|B-g;^iV z>w2qSsVESU3Cw~4gY335YFc;inWObpnup+}t;7UUhXKvimV7{M$Zgz^PcqB$A5hGc zpI;a{TA@$^l4=JDt21;(ni<8Gbd|tWD}b?ANKLuCNjNxgN!`uE(8zE&w8r0%#ZL>S zY!^y0I&h{z;C3Kh_q*lpb z+`ult?q+-a_x2rxGmna=29r;4Q=vC-&rre8ipp?b9t@n-1wWoaT6@QvbpacywK#p! zVn=f{?#x-*Vb@kC&0TR8w=IJwiWOE+$vTn-nwJ>%gVzFxW%fM{$7%sQsMiF&p10cc z%t9ejPA*s3$p5zg0O2ss30Sngg4jW2MTSn4IVoKJRani<^3%zZ@f*W=>1$_ej7Oj( zEv_9I?Nh9Vq$t*>+kol4O+mJ0c*e4DSHUAFdwNT>ifbhgnZWX z{iS>p0M7YM>4cz2kHbKJWY|!`BNE>`%*(Qxpk(I{cXDFKtc}+aJf4p;2jH4?N-JfV z8RbU5DMRN9SB6xUS4KPwIvp1+bf*Sr4gQ9Pm~N*?%fCps0fsjIH`{$$U)B!x6j`p+ zK`HflmW*d%L60sKL&~oLRP2mix88e=tRH_lLO4HySDO7tW>sUm?wxB^bjg@Rj@C?5 z>$}uJr(K;^$pAsgqW*PaxV!>-X~#&qUaX|lk5G$}q#IHIvP9A*U5j#_d=NYUqJjp=8ZNIJqH-Ud8D*kS^Z6K>XhaG&h0a4u)+rC`c#EIW z2$kJ3tPL^Bz&E(Qe|F7URQw!)ank-Dfc6F^t_S#Z%L~FeOvrK=&!L2%%bUJAiO(K* zFo|KsC?}wlFJYok8}}K2<3Bmu&E&Rk09rT{NC%*s{HulQSQ5#btOuw_=;{G4prb}S zHPZ8ZCw~or0(4!^Un`paBNa;i7N58KP0wstU0Ou(d<(^JYfPtW3&uQ0?hYopoU4_qJ0tYQuGFn^*5M|i|b8JbaoiXv84c6;+d;8dN{vsX2XMZC=FQz zeTn;k2(mN@aCvqYTCjTo!;%E8U#T-0UgUR3vNMi(@=0Uu z@^h(@sJfC%7PxhJYF;Jd6q=<|IW5d|9)xb?7(!@b!NFIzp!k}4e@U_m{ZQ_kS zX04qtM*HHVrL7ZjA&E)YJ76Iu`95K$u%u`n4Oj`9=?qcakZcp?{j2?DPhyOOA%Sem_) zli=K)jb;7xHK%H2+x$4q$0)gPuJ^7Ekm9;Sl$HD&CjbDA5dy)Q8LHIzuzp1CBP2;~o2wB7*&*B!m>BPAtC|Zh0 z9dN%NSg;n?w=Vu4towLg{#xCjNQdhm#*HJ!?lEq6Ycl5^v?DU)gH@&EuHIyN6L^M2eg`C38e(_9Z)$_pt2=gA=`O2gum)(R6 z85315{*{?=>Ez}arvCW=)h&w2-sgOW6WC_i(6EYGS33RtEa zX>H3!#P@1BpphGc+o&*TxlIZIvO^JvXGcb{8oF3>PLA+XN9aCPu3fTd^~ z>@Psm(gd)MGQ;^Y1^got4qw9^_Dnd!gblru(9{J_z>yxC%+t*1UJNB#Y3}Iv-YZcW zIMX*%NrRL5Xx2ty3s`Ig{%8c1;$0Ak&CNEy+??5uP?A>j5YMXbayL!pv!uv&+9rLrkSe_DPO zGqqVTF5A2i>?qs=GkzH#dWAW-PJQ+qw2y?4^e_Sl<>SoQTK!DRu~q>>2<)iTPl>Ub zojhUyt38`_+XP9{{LRKe0D6Hzalh=}f~Te>jMnwTZ;pO0DZaqCZwjXZ(a|YiYdoTC z#mvFYiD3@{2AB{G-IB!&EHl3T_kAX~xBJngv^?*YI9m)tzFIKzaEEQqs21W?Fm2l1 z8ayu+R;X*POMU*#-xyhcwOqVA5LK4l1ZIi9DcR;lc0i>wcI8p^b2@HKsQ{KLWyH*c zPUciQ1-GoEaQ!0)`|-UKzPK`vqgQzVyYh{(EV=J-B{LB@nf=&TB^6n`aR6p9HyaWJeqsbblQBYUN(I%w_L%v0^<*UAm)kvDui6fxXss z_l->SL4TIZA)pIUv$ zcRQ*NNxNd)I5X)qBH38EiQh5y7ptirvSE>pkzyBV&4(c^*n)hnVAkjsmcGDqWnKU|EpuP@W6EUZUKaa z1xk==()!99S}6;y{%=h{kIi>7?jM^^SQ%&FSoBR?-0Q8nm%zvoA>T1r3fov_cE08; zk==n==?q?ZUU9scx}a=fS`pyz^?`mmhPe%Y;tQc_W@s4gWLL+lY&Xl6wSC>mUl%yr zvjgi*k9*sqLRdZ<7!{NIs&&287q@N@@lE;yVlC4P0Ntmc^_DAG*2Rf8W4Iv$ z`A3h8Fn9Re3Yhq3khIz^?y!r}fqoh8b_Q~TJ@|$TVIY#nN6oW4_!i_cKHbZ;wCtfv zgD?vj!hF5wlC=abS=64B))+WxiB62v6j3|($=__DJ`voy7U~r|=LoNuVw#cg@BVR8 zLOzhA_PC96{m_+Vpu(bua5l*8i3`8kT!ui=!O%zh)qH zsib1ih{X$AT+BYBsl25!1(nEPCg2<}!==r;k-2nPIEs&^N3kFxHg32R=SQm!bRLLU zg5kTH;WRPh<{EiYWCH2Cr=|G7#x==jm7(L&X6y1v-Brj**Vm{VlO)8>**^95fNs1-tR&B;m!m1VQJtM#)TA2t=*$!@0(;fJGxZWly|J6C zD}N=SQtsqkTh4UpJd2CC&N<^bLmT?QmMTvSMi`0^x;4J$y8x7CVDBSlqTZuQl>;YH zAFvRV>R^ErW-jTct@?B0l*F?loj9p-cU*b*J0)#0C&h`p@Xq(lw~pKug^EukQ~dpQ ze&aEmSgBNjHN*1%X6X`mL z;|p;+QC&J*3_!PUGkkCN>DO0x*9T0SCEuM@$u+;rU7~1Fgl$Ez>7LdrbGgWTQ3jMNO6hc;QU&^C z{;#ZgdV6@srH6zj^v2M#Di3$D)Z4k3>XW(c{_qtP2hNFW6lvPGg^Y;Tn1mj{KPv5V zyw_6LSlyJFq|RCcZQZJI_m%dM`WlgkvHS?22i5Xf%`R1|zB9WMK~1Tsn708aoQ`{H z(^J(`O;x)Gay$LKk@dHMrwS4QV9S+!?~sj~oY=+}lZ-IjXlqxQU*8jxrbb?!7-sfO zU~8Be*nKn|N+b=Q>#IP%Z-2HkLN3KOL-MH`dP5Vp%PC2|w!D2<^igd#^u;j;tkon? z1=ru@H=5=w%=xgHy50}cW%6Yp1CXnq< zh_2JEp_4QMNzo`wjXV(FcI|@XY{5+Sn7jM`VDC+%n$Es$QOjN_L{UJYAf8he`@+W$f#>vWT zY9ea=c2d&K#B+DMZv5KwOjIFg6aFfhfRhyNhsr2pJmQ!5CYQB+&1I^6Q;Wm?C|_!* z(;XZK>qV@Feuu|R7zu2Jry|hr{@JpBZO@wf-OdH^Y|V?_QMu*mkd4$AST8ShlgM$+ zR?ac_>L1@;U&eHHyr(M|=+TO&c!T|hdRyHWv^l*&ON#PO7&{&~muZg4-|>6{(s5&Q zD6!r*zoygy|14hjcR)v%wabK07Qpsme-;5}YMDo%Jn)}#AKF)iy2rceexR{%{B89T zLF71$ZS~l~jc<_|EqQOgmphZJ`?O+y-sJ0O=Ulh^{CMV!rI)b6W6>gD5xY2=*}c3H ziw%>E;cL+C>_n(e^G}Lugc3tMLtu;_4Z+nR+YEj=R|fBcgF-X-EiI68ExqzL=K{%Q_l z&uX5f8}T{AEbt{t5&y0mde3vx(=82U*i@J+{oeI$erV z%pbHcQ@nI%71$$u93SC}Lvk;dIWKHGReHGu~pM~PEV zE1G8Y9wyr;$B_tcaE7wcg%zLk4t+TpY~auU@BCb`Qi@~{d!tTVFPk^bYj-Qr=W2su z9$Z_tXa+l}LNu=DMvAr`#I7t0%c(5wK#=)`QH?@m?!(F9OXryG*JbX91xNHZkEL|v z{*Jp-_Rp4apTtpTwf3FgK8Fo2c*%tj-uQhvv|-mpSFral#l%`S`iP5ieRG)dp>cXuJ`GqIqFlwKH+SDblQ zRQ9Qp+9GmXyy0|XdTIL8abdN&8CZKSk%AzPW5s|aHlF3$yf;>X4d{_pL($t;TBn&S z$J{{~|BJA55X_->AUKS5Jy+ND#;}Fa7-KHE4H|85v6 z1OX{ehj54{Zrn=d^j2xmun=b5dI=aen*!v zII`Xpv^>-NvL>b1eNgwnIQfj@hNClOJ?5arl`83On<}%-RoEG*87@(bm}0<|dh#PQ znR@ZFy`cox#SQO&g{xU_%dd~SR<)t1CiIkia#;k9fk-w`mhG-&v)7^tDBo*kwUr+_ z39AS%3m>htXX2M{o0dHpbb&fZ$kEBrHjLqv4J;{Fs(~XKOBczG6}6_qvy!rJR<`>K z{Uv8KUcZeq>@w5MgBof6ZE_A}v*y8_ZavRMAX9(vyW}%m$$Y1vrF|^E4Y|c5B8qmS zKag1yotOt14!M7#s**XwbAgBKorom-FD8F?%-Sv43JpbQfsfElFwE3^LV-=ptEdbf z9T*zoIp>9BcE545OIOaGLGn-w_^}S22ZzeC;d~R=-2D3{N->)B8CUw~YhVd&Ra=H> zx#1Wuq>d8<^Z(`^d^W)Ren+mb-~9nKgChD)G+?^8z#gb_sc`|x8oB3OpXb}swc2Tq z5~xctgX1o%A`LMG9snKi%K!mI9lozo?$+JR)2_dDNl}Kz>kTZueI$$e7}BPz1?nV% z9||M=ikHJ2=k%mt<=jO_xvFmdif5NI>hJXR=x&TUIy$-2=}wM;^a&Ty{1@zY3Ak3P zb|o5KGT$-UF@@Z@a1daPKEI_|Y4V*JVLzsMZoBJaidsLN^f^PlW&F0oG+Yl80~Z}C z;8pd(7*B|t1(}@@XnUGUk$#zLVQIti2jL6gRqD7wU;(=e1TP#EZ{Fxf8AzFOB849D zdAPo4`Ij0u0QCp$D@={_qv@IcHColA z8(cHqgI+n-nOe9R{5CQXb^sG9yeE)!vfE86OBpFf$_`$&nOAsRa&EG`!ZI&PtVetf zzeOM2JH@-rX|E`aXt-WGi1A}Oi^ean(C!^ORuhnTB;A<;hLiKNDEOkpzrElgFFJLE z#WKK&y&L&%Q<7OitL2Ah1-9;Qho_7laRgb6K%KuNJ6UWyRmSrapXS~E{73(fiUSN`sJtASWxVBzxV3C+C4?vto_CMKh&VN@m=TY z*M5N`?Cu*R&$S5qve(gO|K!^{l}RaA-DSFjGNVWyMO=gZ*>6HiTsmv6z^TjD_}2gM z*qP+;PpKCll`Ts&(OFa*j3J{*p;@BR->An}JXF&k(r~Ai^g#W6o2>4jOMiZGMWmyK zLx2cmqYoZnJ0V50bKGQb0oLN)s1Fm_p+wytQNu8Pq&ZB|F*2=yUyGZ#l-epyVg@wmb(UAcCqvhKmXMq<;iS(J zn*nWGjDki;cwHNolV19ZprXJ}4gB7d)TB;r|VnOEoC;OQf08;y-tT)$dY zTWFm&fz@}?BT*}8hJxrI=wy9YJq7CM-5hG%_6Joa1b(Qew1v>rs&V4y`?;tv8Ws99 zFDsr{-|VqCMCeQlK6j+^C{hZe`2`9BVnBVwfLU0I?(}egnP0QF@VSodb8GE%YqmIaLl%Gy z|Hb;?zu6A_S3sd*6Qt(Aa3ciT-2861A#W@=*_YY=bxTW$^hd zVyCey4VvoyO`1B8bxXY>jXhS;1O`+&mGo9jJ^6S9fueqDf7!Ud(7JFGI3iZk8Au-C z>hX-jX@+xzy8EFp03Q4T8rXpIrL{$S^n0XQ$AbH{atJi17u)~acU)X^BH+y2DX4>pqJN4KiVN9_CZACIn9`daEaE4~rMH zNy9U7UN8e&@dDFaR%AGg`Joi`*MjAlz~zfDpDZIGBYb%SiZ2`9H8{QZYeyVs-xKXI zE5=#HB=;2Ykm%J2eH`7{AmeuVM3^z(b)~=)3Z-go`M+QN-zV{Z)GjF6|6$gjaBZvQ z<7GLN2f@`f60W~uH}B+Ea1jI~`*R);a8|eqs6oin(44f#{r~#4+jBbO()XzIOIZH> zu_VE2?|%${H^=s^Th!J4(j}GY&@hc`vh~#?>SBBSf`;fU`Syjc?*Zq+Z;-dEd8FA! z`uvf{X=5FCqvfM&KUTPJ z!q?Z9g4I zm~z`Oe@!nTZ)P!Z^)NG7-0Lwd)cH#Kq@uoh)Hg?0hrdlSHT)N%7W;|5@Vx=!UIF@- z3WS2UqYorUm%#%G%^msQ;lcl>eIJ1rIsdc8p#ZQ=KjAmGAzuEk-_S4?TsJ7<7Ccsd z^)f7F*S#hmjE1qRmgWePg6EET`h(O+8OFGIK(^B+OB~L>;dnpe_Y4R~T-36DNJoUo0R+ zBbT}*#8&+J7#+-Xj^dPpIke`87uIVq!)WvpIa2wXx%wTaAA)ow;1zByUWN{UMLfX< zix{HvZ;R-G6k1`Nhb6m2T7%{qbLP^}L20D`az>(Y#v)pDoye~fk*4WYc-BV-G&W(k z-|tjJXye%mUsp}xp_)W9Az}kF2wx(1(%E`^!lmB73qB#V&GYh;I=E0$Ewo+Wd4n59 z4apb_OZ8Kq{!|%EIuL%t(yTT65TX^Z-i$?u?CgPbO9vP4At$&Hj4F9w{dL^G+9ODw z*Tr|wL_1AQ6YI2Quij`H-Df`y4pz;7c>exBJ&6D9p1Wb#?Q~YkV!*JJk#=)!(K#qb z^$2+J06n1_q8(YiOLXY^Dlc2mO~9NSpeS|ZTPWwW@$jM2A79j6Tqv(0XtBKaQNsmq zDvnw@B>%M$i6eu2;uA;?|I>2*|NS{e3g8$H;ymnW%&s~?2;e+mLL(XNgY@eP6Bkdx z@zY|wH>MTc&{PjaQ7Xk*jkbUcF{ybU^$g)xI*^ zacX7$UjK1o99AFH^wvQXIqZPZKU;pt6ZnYpimf12$pn(rrQW_ViU_eLqqLSK{BC4$ z$FYyHmlTu+U3V6T6kRpv{5lc?yr2?}6F-9<0g|Q7?U=Mn5y=&cGz1T$8MHtjrC1l`K0Gx?_S~Z36Xt&Jb3;! zAhg!r;`g8bEkXKEqdVUQ-eYFA;D1D&VD#)4Oj-7mtVXLp&v0cOsLP`A@O$p(t6%?^ z^4Gw$$~F0<33|;yPv>-%6t7_ykHS<$4I1!DXoCI87!IEKLsz67N`SLjekpH*(!W_1{v(O{u}& zuZzvlem*z`^)dn9liSkL`7A8C*xh%tqCDIQ@ui8>dRn^cgR!2FyI0&y{0o{aLe`uE&3>b))(PeQPZB-O z@yr{q4;0^dOQK0q(F=D8O;-HBWSEtWIv$9RIxh;3h~(%lHz(({q$0{*}SN z{KlHMiDb3c^>J3nve(2Oq(B?bQUubg;%wnrfrn6{;c=7}S)M?fdGZA|-b!baB_^Z` zl07ekx8|ad@6d-tCW6e0!qXW2D!lXS44;jnzWQq2sON>I!Q#;yVZ=S+49p&;46Tdz zVUY4m#KM@h*_k3Fgrckcou5zM)Ojh&gj@Pg96I~#YtfgYeNHieCc1r0oTV@cZ)ad&qaap{VQxhL1uc-+If=#PY?Cg&G#?60fY z$f^aG z(8f2P&0B{XZLi)w_I8vHMB8ZmsM7nag1ODi_6kLt=U0b?$87#c{%6a!i(Y7f6ru>D zFBs=Mol<{_#GK;tQklD_lnQN}sZq(20o!7i;~ z?=m!n)YCv&K_gh5e=Ppcbb0!UIF)#Y3u8%h?3yL|KyYfu)wb+!ywumcyWczgk#NS* zH&Y2pj$&;N?OMv@0vdjrKr>Hd_2l@=fx-)ZWwfJA?bZ#XWGg)aYC=9o)(OA&!E#){ z$Cvb2Xw9+6%St-(O|_>IzSi-RfX}r1QzGwM(2CsOXy=vZm!kDfH;C?!M%QZ>6XW5N zMC1zU3!wRe=BRi1T>s;RyGf^JQh5sZ8)GKviCC57HtaU8bS}BDTeUSaBjuU5%|;BwhZb-BSz0QFme-rko|pzOVze;Nc&1Y2=c zm^Z1#-Vy!D1od3}kP9R=RYDz+{*W5DWs|YL__Ir7anW00v6Np1)Ov~DerxWw11GC&yx{93@>m;1DPtI;(1jnkc69s1JcCjiY51&AB; z%xrH7F@h{;*SK{c$Q1osP`e7nKXv}nvfAB#{;}>7mV~hqS3};oNA;@$ zWCv0{BwNQxgJxKGnJ;qyaBe%L$>RQ11aUqpCyrD+hEnu;|J8Zod=2fxuLw(}XA;Ma zUhs4Qn}{K8a}nn(80Olw4r4VbNb?PY_}F7fi0MqsE0*d`iLSnQY%`vhBN#2hc0e@& zlw(!5B1><&Hs=YVCZ^*zEkEu0NjweAt`OWOSmg=sO&L8NUacF+tCtCEqK^7G zB7nq(*aM9$@^prwK+_ZlUZS`_E^kZev{@;yQ zvtJ=!xIm5GJZ}#3cNu=yK3Hpa&@YS~9=M}T__aB_>aJ(thLn6VtO*kE_gB+`N>@NV8fEc($7xO&k*}e{O_D>bF%L)1>tXqSQ zuHk(Y9k6MxN%}obaAPQBp*iNlfM)#w#!zM~grQ8<8GV*i<_QFliRoe zUdNl;Uc}X$!rg~_dZbOS^0d-E%Ko_ASRa!JbP6wl5v14oc(aW=>PjEP798r6M&&7@ zRLNsAZKBJ<*rB_+V_^F50oz=6Lvg%l0lSHSK=A98=kk4l^0&w3M) zVX?1!MLfQYPu{!OxQnw2oLM-kc7pKY^Hptmg2)P2JF$*RrrC|tBS z$t6>gIqLMfDB|-c*Ef)xY)&4eCx^NedMZ98V+E+MLRqt zy+}m^+^BGpogmTjpQ}z$p^4?U2J@0jOphf?wojv0$UJZH-{xvi^F_(AMd}fAV?oFD zcwk**?%w*^NQLP^Km}B!CmA3cqcPN0TG{by*WFeH=MPY-;TE+p!Eu?ThfEz?gBRH^d%zo4+JT}Azbj~8; zugA66^y_@9Z+!claWqlko+(tl;~un6Vj$8Mka;}O9k?>1mk7DS#JSb6+vne=WuBga+iQOgl^KIg=02TzB{{Ny zlr=y>hq@x=bF&;eC!*ahcfNW~z$a0amR7+t-k0`T~?@iwjk{vuNQ=ImVN_Akq=oGwBK4Xh2bU zZ&)zye_M}TYp)M(x(?KeCeK`qX9FhLOQd|GGgo0M^KqzlvW`w)^oL2j>{pu;fvHCW zHiD0POr(drvb64W-FJ#oGOrmAp?gMUS~s)&OwjF3M|IT$OL@2aF zeH;*aa-?VCsyVkTN9%69p~e~Ydib@u{3NFSuX+hoWyyY#T4Qa$L>=>6AI3BoI%pRt z_bpcOh7&D)N;oLfYBwe|+40_8gXRWA>PnWqI>g(~tj3L+lXLXYH1)36d>S%~&T+wQ zoIuKeWcSNnNPOIX&2F?ovZp%_gXU-?a|yg0ZhS(cb-5tFU8>97+_a9I^m*{F$V=UP zegJKyauC@-p6(8}e5AVLOwCnC6#k|_llM*hO!D&rcTPn~@Ug%=?+opbu}5GXIK^H? zj!02d)6-U!@Aq?TNTdk#mV>&$Zgjv5XF8m;2M@2dyAA7v9fxsj@jr-;2;3vNPbZ7> z%3Ry>%&Z*xh?=XzAAO`|-m_K-+P|DUg6rz1KM-e%l(=Nc-o0a1EqV#i0g@>)9=Y#i zZ;;AXv(aSZ<$fLhH`+>R0LziE*>t8dqG{KMihFCfA;EFPQY5-8N5d{93YkTG{ig1X zZ;eFKO=3x8xx`19n0yPS-HwRNtHTBj_;rMne)+48er>)htpXFw#0k_8OD$!ByMe|n z`VlzG!A};y4`3&T4`<nNWKCD|c`&Zx~DVFs5)yk{)Eb2R zVpDDctSY)DTE}LJdSnjTCpuCgSZoc~MiC`u8gOq@tgiT7|& zWHkjHnD8^-)2s9;f?=A`IiAgTdaaG2prC_zURG7!1^h(j`{V7ib9Fi2FBvf|)ZH;8 z8cNB!{+U&!=o0G#FukPh#d7|-);D5GY|I*hJ?LlqheH45w!|V|MExN_kzThYssIGf5*8DtZPp}zb zuQ`0wM_PcGg)JQw%Fn^;wyPWkDa>%2VqIxaR$`reulu2lWlT_b2C}hW#yv0C{``aB z+1%E4lY9DW7n&im@lwH*`081O7V44O3o+U|E>-f!(D0?&G6EQfgQzd)&^noVsq%^O z)_oWDie|(`-^r}7J%L!I1&Nkm{}?5esl2bxb);OQA^Me`}-aeWTe~VCK4k{$2uSDjKA=pMc;pG|DYP&h~v*3ug0FS%KcGY z4pp$Wr8++&>>aWZ)ryG}q9k3A^tx3q#(|rB;mM|kSDKV}bcQ9evHZ4^BU&Jb0da8$ zO8gS|7Z4c4c=Mkk^zHFi-*N{skwJ}rrU&~49mMc zC7wfPW+W5num+!*T4QO+!ezN^$+oO@7g3V94nCQS5}M7|quXUh+l8&@?Z#Ej=Gjt> ze5W*(>%Q*rNV4|ld*ta20-HLL$t0&>^&~wmyAmj>^O+e~jhfT*W0_;DF@+pvz)6}j zf?YfWrU;X4U9Mf5eRwFpUS%p8@+x-je&vsN?czc!ur}y_bJ4=1{L|Ot#GPDY7FVbm z;cz`h(?Kv?V14hv*rI>hR}h8X^&WARY#m%dhz@yA@$v=Z+>Dk*QSmZSVEQ&#nYAzXhb;*!tR5& z02S=dVT?A{l=n`YYe*dVuD*=L9>vIWy^D5czWOk1a?e)Rt1ee{wfU%9WWo);M7{;F z$Zy6c8~|2h4uJ8pzvUb5z#OkW(Qet`)saleW-0O9xf3n7y`=n~RBlQ-uGHYK4G@pk z){!r$^2Ub5zCqUQ!CSR=m97m08P#4hns}AK{Tqs;!NZy$#x9Yp(qwMR zq|it}eT7o_nXBHa#eXogQBe^%9{(wI`$jg}_7mM$Xjtfbe*a8aM`vbhO#H46^uuuM z_Dp6>No(LfNb0z?fK&BJa@yedvSEs4V2)Ut-F7 z$r|gz_f*j13qp@{xQNrt4u&$3k0fUXeFWjm_z+e%-Xgc& zBa$^t6hF=H5$_@b$X&e%A+y6S_oVucpgS={4;`)&?}PXSMb00W2go8b$$_t0Y3j9P zpSL0Kar6mSRJ0+c9~>|%ai!TgPJvZFcBe>xV4|mW;>1IiN%~irx@a4Xz=TBcaQef? zyc=ltUXZ_d69wuDE!otqeiA)#-m+mJvNdb4-ov`MIfBP^)~=kkyt6TJ#khObGyHIV z&Th+})-4Vb-0?mlZE>;rc8ve?F_FF?yAXxAT;4vYod2dnd^gkyEs&0i#a_hHsIOzm zON1gnz=o&#;P<2CK4Au&txC^3ZSa91U7L5$9wQbTxh<00cx^T&wEpXaDwWTxISx5Hr~wWr~HbDT%pCG;n0*H(@y6f{|bQQ-IW%l zFP<`q3Ifk}W9l7=X1AD9(n>a@BUuYP#_dE%p3U-50`FtTE-?;m7+i3HTXXqf=E}pS zruP?J#XQ^Z6#H-Dyhey?D%tyk(8Y@5`fluL3!}nZ;;L+Zl03?IpjsC_>WJk|0i4}M zco3{M=Mr@^+K#j2;@xDNNOHf{Ru_0_K|`J{o=!2hQ2CR9fL@neHWwO9+uQ^1aI$)H z0>`wI!N7*i;ANu=QK5{%B634*x>wQ-8mhBiRDle~et@q8Af@3;9)PF%1;t(DEs9?> zx(l@)v4ur%tSmO1&IxfZe*3W@3iFW-Zcm5%|f9@0*k?P5zof9nLl6 z&OQKm%3VQrhlgf=?OEM@Dl@#$M1Aq#I6r$dlBm!JtTK?*J8KBV$zELICFjDJg#HU= z_NPFFnfkp(*~pv1$%<@2UHV)y6gWwgUOYV-adnIt+JAs-2uL%nSX7B!W(YABoh`_5 z2pg2+HQ_|En!KB2&Ric`jcYrU?>RPB!pr>_4kGX|V!9x(LQXMcn6eJja>axEve}Dd z3rBFtE$$ANDdfhQH(yH9l#(Om`fljwfc#mG*iXDqU$HWi`>q?*6U&kW!NLGZkK|Wz z76vL7=?{yLp-h>5>ILHw{(jRNX@leBQoG6Dh2+2uTkMo*-#=T@&P)=8TSlR|^s+X! zSM4piA6N@g4Gqys&g{yWn=i>JMd`HV{Nr7)&XRJO1o+BPppJUS$8V}H_QW^oI;i3~ z3LUtI_(McufH@3vvCpt8#YoSbrKA4!izYw&j}5DxZ}bjh$Enrf_t#&T!HF2}UI|Sq zFjXhRHQkQTT22+LV@bl<4%{zewwcM{;}a>XB_QvqA-1H*T;)QdgKXY>4JRF+;9cMM`+&}^ z49^waqC$627~OoB((D{BoPQgQU=5K8^P4+UqbnE4Qqs9O+rECiWcM{i;U287ig+Q- zCi7L1lAM_Xh>KDJRg3Ia6+P~bxRhdmNK`a5y}9X$3?M`PoKpB?9;k>+Qu|?39+@+{ zM!e*QcLK3(Q#JJrN!gW27abDva^7%AaScY9lQAbIF516oE}8EX&}#}lgO&tdoI&~W z@cVtKX~AF=esvL~x&E_7yUT>gt0@<9`8=phWOSRvJ2Vf;rFY43VQvEB4s&V2!~8a; z-ZlZ=cG%!!giW?o>xVb5n69My*XMF7@2UIn@;cM(c*E`SE>$R-8Y$8I%CgtmcpQ@LbnoTM0pfCEd&jgMYl@3tu%AGI z?s^>_`?+2j1cuPDHwz{7_DRGPJ7@-t5QkxL^je>H^u)1rYAogluAb$vAymEb&lXS8 zUo}e>?%gTWmULn}9#A;L1mPSMkJwMyCr~t9gk&ZvpB=XS!#f@OrBm%FZ4p^UulX)! z<{acjb3>`EP<)1Bqf3`xk9W_#SB)R8yBA5ErUXv)RR>Czdhd~Ng}eNLjY9!o!2G8M z&;Rl{7$8z3DX{`#p%?IfA1LylzOKB2<`jOnBtL9w{~OB!6Zu^}^skWxOA$?n>_La- z@V|Fxe((^B2xsmfmv2N}u~B}6WJwQ*t&K-MQJ3tCD8k)J!qU1wPxk)Xtc5KIlH?P0 zp%J|5zqPrEhlnVk6dR1dNgBY8nA1bp-2(*M3tvIEW$x_je}DhKVWmM}xnRECMbd+> zeNs^vaUNYg=W)7DaWJJ^x+-ug3*j7?0DD(m5U6aFzD6AE8L^pEdCHODMR6ogW!p5W zn7fY+k)of$7QNE9X+iY~ju4yZBG@I7QvPR)rV@xJ@;?Jf`rF0zdkia48XItMN2n#x zya}QxAv@%DcZpuGvhsQ}spicai?6*Gmv>dlQx&O`nH;@jYLGj+)rBjxUC&J~UkJ87 zzCro2YH`qb_)ecpJJtJzW3$sYSUQ1g!^kM3zU~nnvRvtD4{pkgFB}M}{kZI=OdTD@ z8yFvvERdgdOFBuGG82z_e_6l|5C18dh*EyiBvcbwKM;E!qgzG4a^6&{s;(wQlhUVi z3;g87u>%OB;oa3240M&1l?t@=CWw!r$x^)XnoK;kW~PACr(=1>XH z-Pt0r>Z)}zxTWVGD_1%L<5*O+L)cQKC2WE2Jdv$1){2;%jY?MhMi6MK6Xx?szK@AY@j< zBXUnv1&zp!UxaTe4KJ~j& z14C9L1L5fHEm0n+{2SJC^qshom;_B$x~SmGBp=bG?v}+qzLkoD0wenREqfU;5mqg&~>*B-jhzF>8qP_HHV8IR}M)9s~R}-SGGHD zY;c8=>jW9`<9ehd=}GUM8QeK`xu8d{4UT)GrdHLjDKi&nkPavd zEkMB833)AQfGBVc+)NTJN)p(LUKlKg4wRfdzj2iOujwUXq=F2x8#H1nhd$8Xys!!# zB|#IG#Mf#WzaA*gI;VJS+1vY_W5vRteeP>5TcjtKDA_a5=t+`?>Yk|>4ohGtMd@Oa zN|E|!;gQ=9^o(am-wrP?6A55(;omv=reb`6M8hv5*Sly^n`>5N>AIxwV-?s+80udW zofXLIB6IOe^8=t7*L^z)=50odwKxun&Ynuz98=iHM054{!*$b@cyL+tN{&VVUjw~_ zTdN^G`~CQefqhW_3HwwkEUP9xGTKY~rrRdo1Zp;s7`Te8#HnUyA%X|NL!hxIMA|xe zrE2We%JYA=&>AcRal~Y*XN3lOtVXhT2NVB9u~1{y>WiMPVT3#R(1vSg4UGT6iRPir zp-n+apo1!d!HV&Q_}Y##)PD06f6w_HB8S5&j*^91U3LSdmG}iP^I^?Y0RpPY7mR^% zYXgnJVZ)=UGeRv)s*G5g%Fdem+tX4vrpy0>P^JyOq61FOIs2A!Cu;L54;V*M{ddfHrV7t{*Tv8YAEIlke$nS?$6%>$F3aproCI zCe!dLjc_LMp)|(>OpJ?z?T>Joan`BG;nfb^&1p`Bl2Jk9l66E|9N(%@223#MkVwfD zHs?wDunOFrSxytH_MEn+Pu(z?nYMdo{@_jz-4DG(@wAw}fw$6_Gk21XXWnb5lS2$- zi5$e`X6LDObQY~haFFpe1uDK^NQ~1V9^H6GYhB_aV9VNO-T37S%e^|bACq!t=Vw`Z zueu46Hv0Zh6mgwT+0yv-sVYo=RNq(iUT983mUit$@QKBX!IgyR)VV|j*cz|E>(Q1uYYmuftpXGT1;6IHKGP&JxOMf z`QNE(5AxF8GLh1PIvFA+MzIfz>^tz zn4@k*=8O}PT?rrc7aNO*hsR5=5@6m0*;yxm1vA8e*)G}0aE@FjviR&Ew- zFFY&trDVwCr@{5@eL8D8K9}C!EbccSoH6~aYo%*VI8b`^4_n$fi&{+SBvNqFvL=7L ztxyyyPzt?0cit|t@XCi~YO+}659+4D@C1B2%Pa+9-<$0%ModKr~OhuyNF7pY=_G25!s*By&>- zS8OCw08>)eXVAreg5%W}N9@Nvz^`1wfN1=?_V-$90|#nu0KKNY0M+C>aK7d*Am%%o zksE3rKLVEkYY zJv}@~C*wp}dZ3&ZZs|dufSL~i10M@P-uFAy6!_hAUCk8M{Dj?!eTJCYm)s$e8{~F~ z+_Zbd1Z2&ipgI4aEuHRoNvHCxWbjpUO#ZC_Ar)Xr6?9)}6*YON-6@e66mareC>Zc; z7b?#MG4lwroq7iDi5_d18eZC%tnj=bu;Y02w}qJ$ZGIg`8m0_9QAPy4=PEZsOM{z5 z!h3*9W`0W2MfsHzmLg?SDrGQma>}mhWZmZ)Ej`d}{!Rp#f(g`ibr5CdGA2|7dzh%X zazm-?9@bfPA5*hgTHQ?wdTPV5O45zCw7wSQ_J;ZgT4dJ3>-%R*fk*B4a`vAsU`Pwn zID+Ud*x=Ui$~g26sY%7Tuj}gDn+KPwTl4tlGcYY(6d#qfN+6CMoLtXr z^EKZ)9#(t4xtskS7fc) z=0kKbusQu>tt??@(lsSP`g`CnLDe>p*(#w!oU5$p8+O|wLPgdLske=zSrgsFHChp} zLHpP*8M`{_zyHB&NM4(`FcdxN!^kOVO_tdP`erskn}trs!Qz6CLK747QRg}`w~Q#- zR}1%ojUj#$zrxDX1pF)Hhjvuw!a8bSA9UKrS&rg9&grV1DNeV3uRyNfQ;Yixs|+=J zh6G2;r*j)7c||YJXyKxZY-f*4Rt-*8g}gqGDn)o zZ=?eGwzATUC2ZV(lIlmj58GjW9HYn8>1f4C&GnLUvxDzuwVY3Nb1_jpUEjrjqs_M- zL#?tUy9dh!Nwg_F97e}0*npz=^rFyB^w)!V-2*=dgn3&5?>k=Ev!mCu{|^mxJPFs5t)z3%p8T5Y6IYzUw#uCX!+YMB*DXhtQAReDkH}NxIaaJa!Wch;aq*IR~4`WXnvrBIl#Jv)o#_zlLZnHlQVEA3E&$ zd`P?=@yON2fr);I=OB;|%})>f{Dq<&fpFl+tvyynWoEL%wCF68vU=#!~EU3MM7_kUouk zpW|{Z-8^D@enm)>70XBX2j;Z+SzyH)%lin4E$6@g$kLkYnXQ!1$tNqJL5*p?=g3cp zt}!_4EeJe^1vQF`$UTs^juHK0PPl7#Xwmp*5VT(uB`N_E#jS!8C{OTEviE5i^~i3M zpFH$QH5sNY!aUSri$9In>7HJ@UBy#K(lxaMv<~I`{NG*?WZU>#L5E_zsmk$q`(uTM zKf*4F&WIbaKVwJC4f`>gGOTkPwk>C{qMR>X*i@8#VYWew{o@s4!!~pgwnEYhTk(VT zg4pZ=s^>4-AS)A`1X95rg3Oy7L`#g!%{bvrj?mr1|AZ8wf|^xU_P9kLxx%C@ixWWT zXi*nwgKYEsI9_re;eyDo-GkXR6W+XL2W-rmZ?2yqtsjiRcWi#^EiW)N@6ze`i^@45 z3(*aQ78BqIm^2M9rGtl}`;=)^mo=ftS>NG{c!y`THO9TBoO-ZM`1z3yxfLnEURbumhQN27brZs{1iKvts1*9Ul}% zGz5ucjU~3`dO{f_sGV(Y0+1AeOQ0zAQhlP)@hkpyAWN*DsY01KxvuJsGWWT2MXKs3 zKzULE=D2uy_U6C+oMY!yJpbVm{s#%&q07QZLujZ>1n)(rRP$#7O_Att=4=ALJzc}|6^ zE%}?VQ&Osp!teo0I>$upDR2PVT=-$pol0%FTpZdye}bNKdfK%^0W(W?4^`qf2P(mr9#p|*f7W(2i8s+*Eo==Zvo zub@vEbo6Kuc8IeIIK#~F`&d1}_5OCcjM+IPX&G4C}s&rvsJ4 z86B;A6s)TUrr&Tx)mKL@O*@CX))@2ch{E$!-_)TSvKKoSy55|wb0iRyj$FpHryIld z00drls$GY2(6-7Z(J)r}$$4PGCZZO_bO?WjK2~82K#KI8?{|*agA<%&<3ylkPGzwF zG)VE7U|;8m6jO#uwp-~0_!u$O0jqAMXagE2et|}}NoZY{&&6O{IPIF+H zFcNSXFPyRhzcagqx@n>)z9}vVMX|Q={t%Ru`&Ao+hhe3MyvV8MD<)$$BT-wM)ueD&cNb+KKp7Hq9-oe_7@h(?h%(FocnzO*ifQ)#7{5_|D>v@sj8;6(KrAV@b?Yyaft#X1vZ6NEx!luFX1{@FdVq05^2OZv_D;(uBXHgX6}h;wOR{?vfL7b4aKde5 z0B#H~EP&G?r|9@qKVxVU4HB%-0eGh5w z!FGJWYwo4G#19*drGehM1(F@Iu=h=%W`9fY0GDDF{l1F?5nlU*IVe?kwJnDSc97!= zOeDvj3d2n)hrfr{%s#4o>Okq!Q@Szew2j(XA9Cv4E?5U?{6%z-i*Sl(Ew~lR3Fn1y zjQ=DZ{uIu0xor~>&px?Qb9sTj%m#t~2LfUFi~V0`f5v)3&0uR!{~VYs)lS%t0^#0T$oqN2^h z?Gy6?;z~qi=0Vy1agP-~rl(>p)9Vl;u&uuTLArm^@TF8(-heW;g49d;r-fXjY2X!6 zyJ&j-f$ulP^nA&R9tGZ5`$K`nIlpk}Z^wMI%?pGT4qtw-Kv2_s1J$x%6ufcwbyr(n za>NX*z2Vi?%RK66rWgTK!HXoSKodwnCZ4wWFKk_^Meqnw&bds774NzwHU3EY( z^DkX077I0>hi(B@A-s0DjlC~|#;BRwcC~Nq(i$kaYtdu7R-KrL4M%&5dnJi)d_N?* zM{ab@xdsk>^oIP6jbSgmkUD18@j!GsQC&{MA{s*+)kvyu_7RIeCwrINQm-^1zB z_5^#kI-h#gxAKLjhD5fh;KaO(6F$!`qu%_fpIlM;vlBZSPgTmQ*~dD{t-@Jt!h^m* zNR5v-5VG`c0RNo02bCEL)x~qgiEn+?B{~fhud~m|e8R$I?cFz?sdpc+bb_mzof3k0 z*?dj%lV7husTscCr+@9H`K9aqAWN{c0L-wfuyw&1H58B;Vt!I1f-6gJr5++K z|9;1{i!UF>2@)^QJKLCgE{n=Yn>7|8F<}>GeR}Ax>F0tdn)>b5* z{)GSijbHxg$%f1fM-~hD+kxD*T7>a6O&-gftjPp;-pq%fO6s@dY9 z+W(T;w{99vYN*RGR_pz>MSE{=e$7l~tLn-W6EBjbG3hNhB`S`32XyY5uf#H{-!w*} zk{vzy@?M=EDBaG$J{T^N15?*taWbD^wQT+JKT{mLzM_!YnlW99%l_h<`Sp~y@LFW8 zPdBWJzGO3r8@023L$R5vjA*!FVIy|ANFzmU*z0kM*}?@%W`5F4j`dZyKF#dsqH%F8 zNDQ)}1C%q8FS9`JbEZ})M%9f>3JlZ>8awhoID5~qrqZr$*crwKDk>rhO2&c+p^PG+ zfMhIyh=_m?ijc7&C5DJnf`sf5rHfG!P!KZGC4|sJk8}kA=@3dtkY17ihCs@Emv{Nz z`?;U@`}6(qKsk2Y+1Fmzy4E_^TIXrff`2}p6I1DXIfZnxgb<1|t5-yYh~Q}gvx}j( z^E-3@DkwcC{sbz?D~bcD#mRx!qnqD}o~71Ei9--K zi81sdsJShOZPkF}^1A#qH?3ZM>VPWlS}PoJZm9&m$E5(lLr&Z6`OtSiI%auEYoFlU8_$bM!4 zZC+)JH0acY-RZ*AjxfSNvat_ys)B5`bc{y*BvQXLQNyEt$fl@9z3UB|96a!~*+*`u zO|j|${K)%73Mv7mj2L@^PylJH-V~cSPhm%Bbg^eRhl_$ri^~f9F<%M{^7^iTb`@$* zBK*hqx@meNYr-UaJ_s1}5y8h!0g4Uee1fX=>up{h>@4e3yk-0K;-B7t)=SsrD`G63 zw8vP9&@I`<>-dvej3)hzLp3NK%UpJy-H3I2KI-7gs-h$X>^0ZR<6IVBPA$cKUV$@o z5H~tck>ou3Yhe8vx2n3o?^9+>ZZ)-Wc!zNVm7J3`s$l19Nlu7#d0!;P;#bB;wj1le z{=EHlKhe|2D=Rs~?$gU)DQU4uYGH<}Z|Sj<9-@>U`dur5dO(8^A-IS~^UmXn1N&Rb zh4$qo`wbVDy1`mP1=#r$EX1I3-SUm9o_-WBlPWzl>g8(R5T=!ReDkPvnOhP^uNSOl zPKuW8jf^1EI&co&l6?X=-@$Q?ZL(s-Ho5NZH{P!oqUq~$(%FKv0PmKJBy>V+bPyDnKV-&o1@7pVadDjSaY?JPA}=qNceePp9zEFo zp{Fq17cfFC_!DIZ6|=nF(-7l!yF&~RUQfy82W5Q8kA)6c^8zD<&aAkwY1Fg^{`!r1+d zayvrd+Kj!+$k8NdF7Y^1vWQ-!55o8sT&wx_W8@f_^JT#nPQ6ZN{4n+x8LKO?7#pIFU~%6XGu{UGLZQ6u;QgYF+uC1!s%hZ;G!wq7grCtdEndCjf(^>a|#k*HAr zEC$_z5@|K=CN@TCETQp`USw4<>Q?U+btdQhSPo%3DtE;O5Wy%MS35WnY<`Ye0WaD;v zy9|*H0hf~Ar0Qs;w3=N*JGG+W+aOPM;WMx#P7#3I-t#W7Mgb!bu2Zv1rnu~evzPjl zE$yu>2GrI{VI@?F()B_@!G}Tb5!h z)0W6=7*LmKqH>3>lyS43Edc73y=-w-pH>MxE2Nj@Cd|G+|Aiv5?Dq?12@i`51&_J0 zd;%z`L6O!o^-f&TNyqa1a?ex`yP=jtQPj2~OpvC$ypIl9*?S zR37lQ`iq(YVX4m$CG?NfCo%7z=9veYWrwzGKj&ccg29&*y56f-clMD`vY zD(yePq6;)SK1L@io)I~7rnpChWv*zE<)zchP7lXt!{n5>N39JS=*dN)$*7 z3#fgvgWY3&NnDw+{xE{P=duQKps%bv8Hr84KkteJZrSieRz%YEa<{UpP7exU3_1QI z0VKR6LX>e8h`Elbnp2LW7aI7s;}dbRlBe`WRI;_?BZ6i%|2@wIsy7f>%S|uWOrTv% zC@#&y`zH&5r}se4Y05WS$f;Yrj%Z6;%0moj^C!jcOPtXV!{5)ih^kLuCU31HIj z&gGmWI^$mdG_4j90`v`~xT{tIzp;aec}DPyk)zKaY61$-34h5kFHUwUVDGu{*EcYB zF8^4y5r~1WisZV@#OWL)TlLQpi(skdou(h)E%fR25bDS%auNw+Qs3hBg<3#1q!{iX zl1mp^R&=l6!e3Cq0k&iMJXAQef9`uu)NGi#&y9=^#RMDCRgiF+;ms?#S6^o1P)+^> z6)>F)X*Ne_mpD&_$*RRZjh^toD=RXkn-VtQjyMU>T<6+RDW2*e2x?EARhKcO9$Iaj zR2L07do4PxD5|?hB-^z#Ob)Jf@`QXD!3+=1Z_5|(SpD2FZf|K(I`ec{kE0fz>}gH< zHZaWEaR%_+9nVfi+8te&wwW|m5SP-&-r1vIMi=xOp{MI5rBBAqe->BlFN|M*LN(K% zpL(z$HOX<^%;F3NNu(2hCDz_%Qx=m_bJjLa@HX9YsNul0_-wl4amxE=?~oc%w(j3n z8-ZzAH9+&*e@2DZsCR>DhCqfV-W(z`&sJC*n!vvY;0I%(oZ3wftJny-c7dMa!+Ha1 z9KTKb3ayCLa>nY!F1u3F6^d_zXqD%oWmWrarK_*Bf2 z@|8+DB2ES|WUjqEj2k+Hzgneui~8!QOR)bvVP(KPvy|?TP4Q%B$$M_MBuPm=F}YXB zR-q{xG(ib(s9`6L+?a39C!;TFYF1Z?@%;g78TSFzRWQ#)KE|mqhjZxijpllQV_qW; zTxdWPH%=z-WLTN+-LPzm7IXfg)2N||{IFqR z;ilpF;1VU&P@_WZ-CPcBV#9U*Z@N{6C{B^Lm*CUlqe}%Qd+b9uf_5rp`P>^N)o-s0 zmy)Ax9i7FAU8jR`L`;soo%kCz=Ws<)27Tn5$kILlt$;YG;XgBI^##zEd`$GUzoW0; z*YAlAE@`0q^c7G4yU|O^c-->UnVR0Llg+BWlD+BC4h|g&kjzI(SxaBck#ujp3Nq2} zFOa6(D|jm{PCb0ke_`bM3OSUPXnFRVZSi6ZEqH=#l`+HV8-b+5#!X5<^4Y1-@{;h! zxxcn6{#Ob==3kN7FVy^d!{QW*E!j&A62Dn{(r^Va^^XMKH^6|4YB<{jg1jd~|72*% zajKzYOdI$$Q7NsmDaZRSDC z^G_Ua>R~lN0B>K7V9}ZpPf-eE%QZ^)-#bg# zA0*lUKd>CGY*};}FFEj+0pOJW`NyBKv#MjrT8wNXbsUteyt?nu43vBI1FkG~y_;i&yPp6R)5bj&PxVO)cJT(vlho8*q_o7W8jbz7}n?x4e z33_zZX13DVho%*G9k7}x->WSH_Y`X!n~drWz;plg|AWAyYc^N@?Ky<6HE+Z0@zg*6 zxqAQcQ~gZl^DR!E>2}Xm-2KnSq1$UjHLv}7Wwwe zM@hoHdjL0XyWJ0j^-=ZRsIvBJ0*A$iDKdh@-M(Cvm~3o0Q0gJM8+KAc&3D3^Gk|JoKF3w%X7hQ<7lZ=AyDY znpgp7DRgcIC~y2#S}kXGu6*Clk$tw=|8?8{OHX8CUS8$P$I4B~|6^(Xu}t@2dlP&W zPs9J-p#6`HAMpJn-tk`~HN!thYLpED3mUP>=XdoLyFJC$s9zs>fbPXjKWq^9YREY~ z=49RbA+l{|qM`4}D+4AuNX6a?5+K-Pn@;bvJ(OQo<>Qm1XXb%iqyz_dJl@T0@73Nr zGd}nW>Y+HN=e_BsuVs5||N9A~V0Iyh(%Mt4Z!*LHsw{H-F4S}ij%PL$Tk=rdu$%4u=h3Kn>E5kLpWuD)djSwIm^i`BVv)Gg@G3weZ&pId2@ z4xK?#-F}Zi!>jNoys`L=F&J9iS*66bw3ooeXL4^9)odZOJJ##KsiR|<$+C_?G=E6& zo!82q2nW^*lXNJ!?&@5mhFU!2JasL`=jY46FO&x$ zrvPEB0KO?HTV%=84+HEO0bqnW+~}-3WWXG<)SxNY)~ctT+jEZ)fG?E~MugfWmcKvm zXT1A}!V!Snii&wwB6&0Z3tmiPQxR8{|1i)<&3oyWnyPMXzs-KOtI1<-kq8DV2{xK1 zraaluXtdDv!Wl7W~O>Bxfz)v`y_HZ#rEpJBg z)T@&@$A`aINyC?i_)o!irOpuKcyaypq`}pX(c>=meg4H5Q&zfOVfzm0bK6jW$ycA} zjk?!&Kh1zTvgg(}LTD%IwGN0&5ojR#*RQvp)kq!eYH2q80sE&8QRVF6P^XUP(H^Lhmn&swW7qTWz^kb z5(3NFL;yK^A@PamCsy(%)P5X)7M-S?fH|}kQ-O+YSKm+5zhhknHmJ+4C*<&nNFU-U z*G326tGflTNpZ0XVbE*pXR=@dr$avAhWrVVzjz#X-iUPwprOG|>+gldu=$yPC$ z0Mm~aX=0BhWqU4ZmQvH(YbT0GN(qyaQ*J^kQyjmoq}AvYOZl$F6EoV6i2o;(z=UQF zLxwdqKQg8!f_QS*a0@+adj9t}^ecdR@-}O2$y25udU=!s+D3)fL}oo&?gBaC8I!bOp|K0^Ql9;(qERA`VWbLZ)@%m&y{pX?(Qg%qo}Qkx{g%nCJA zbdW78xGYjj6C}+8I9t+N5VqVpQh9g9eu&gW-vVKn3~jdi!Y(#*$1E5VGnW567DQ3K z8iYnW{#q83cl%^KGxD`^l@IDZ;S-6b-OM?NBLNzP-~c{8!HPRFf5PE2drx8YE{(0; zjdyKYi_rH_?4f1k6>e2=?DNUz$}{WQCirEcH=+B{)O;3~Y$XW&OW@slBW2kk`U*Pe z^T8w1yvstGHuS2-mKPw}tIV_%IKM{$!uJX{Fh1&cJ>Gff!l}&qD|Qcdp(aNcTbD8R zH=sx5tgv^z?2CU4uDhAwqXDQO5>t$i5_%03qDo{VD)vTaS-y8Uw+A*@y3|;GP#-@u z@_}A5CE9}oibQzv^foaQ5D#I51WZ(AwIeR$yXW!)*|^c> z{@2KfZ6vNOb?CNbF-j2>B#L658Slk1l@hr8tb_DeG3$N@EsN(%Er9N550O~?3pZ~Y z$GN+yhT~B;6_WhYtWMGslP4ZaP$!`3r7$q;{f-+OGP%Q)nCX!{@yZaF;+cMF{CCHZ zA+H>&jU@q(4+Pd;=s{58-C@c??!=#92WAVd^rcz9`Gj#x#tr>)f&lcQ`zC~REy8yb zbvbguiD>s}7x^`dU|?zn;$nhwmD5xsYOr+Z>erOtb5|30WMqbQ{2qIG=;8P6s0Wf3 zdPzq;l~Gyu5bY`-6|Cdf?VtA{?M6j$X(O?vrBzZOyIQttNB$7@PT{v<#j|};%Mh@T zxK)rv6Ep-LGCg0+>Kh*@a(=U*>hCadTm4ucd>J~3w;JOJG-Ltc(#;%PY*UIh_zijPxU>GA-cxlZ>$D9$P%QAPCm1zk?K z=AAoWbp0g9I_{(7n9#T^k)?Oe&I!dESRH1>WJgMC+lX!Xk@6u zW_GrGX-TSChU4iYS>Fxf%YyU>Z~CWD3)aFjlEIWSVu~;6AYP{*8(ZgN?$H-ppR;zt z!8Cj|hv&Ukv<%631I+W%+~KXeo*da;8-5-R$m;-e@u6|q$MoW4z}c_O;>Ua#7%|UM z9rD&vF-I=QmXx6qCO*_JF9&7Ica$yIcu)>MpR_t@7h`YMJbLee*CY5codqV_gRb@v zjtP?L5&Mh`Q*o$zSLGu5JJgUBI6;9Z9ymd-Fb!**Cs3f8EWPM;?Y#jF7|+Y~LnZ_# zLDmn~c1cjjo^le%3(r)g?4bo*uc_+dU`KkP$k9y>vEq!i0`V*daszT}CA3EDK~LZs zYfcGFTk9WXeo!6EEjfE;*r}wM+o)l)*JIVUeR4`zVzt!{9ydR@u4E#=gk6XMCMv~M zn5Uc+Ag*+8j>2Pow!E7mBN8^g#;m%s*4#ACTt~eDDkpg(19h)+EaHzg88!D^M}C`p z3_fWtF8IDLp%Ea$GZk53TThzn#z$taoj=r%sjF1~dqdSA^*nD%v{$D71s(y|6e1)C zaEJTG8}tEJ_?&?j<^Jrw(oDN?9B=Z7KF?SQDOraV-XPl)RVObz5T5)$%J74V;>|7tpyPsY%Dnai(dQSOf?&|wNH_S^8osnYm+-_0dVh` zIoE0-=r6P?rW0ND3I%pN+q!syB2OzDU#-P&LZad2{HafO9yqCD=-OW{=(jGFjto>C zV3$XJ^EX~e7kQ#5sEj$jP~a)l*u(u)7~3E@RUY0>G%I~;t=U;KH*C?~csA~O0pJZG zEBa2@+$cDb@Mb+eO7s`vhcCRjARF_m_}x8-wHNeg@ks)+5U*t`I1MzwZ+UV$5dDMN6_0p(s z##*?(IaYHWM_#br(hjdA%6jTMy}owk%WnIqWA`bvcLz(jvcM9?^4I-39*fMVg4K@( z!KLMQI?ttg8u+!wKH^k9to)oDQTDB;?&)mao#Tzw#4ZMWIg76cyy`RjZE;$Su@n9b z5Ba3(MCLS-PeX3u>#hFm>%m`88nw3<4vR|o0nI2PY6rp{@$(J*?}AvelJg#{I-_q_ z%`6{}&;R&#Es5x6y{vOQ%Ruhwn(6rMuH)2}R*pbDYeAO>v#*5ygo<)d9{hVB$1SUS zy*Ef;Cn)?>pLXyKK9Z{in}H$Ua=|uT6B&uq39#m|p|wnB?1JGrjcd*=&7ozq1nr#w zy?h8&Ug!GlE)ypqOtQ0YrYL3KE(^I6uV&|RQ0YM-ZNyDi_u)=?9w`P)8+PK%_+-%Z z<&F*G+#k0q1^u`s#zUdv*&`qK-wEBEuktnG}BRTiXfs8K-C!IzbrwsK*Nc3y; z)wUpPGnNPjY(N|W`*|1#sKj0KFqA!O_VM|lLF5_m2*>4)JS1FBo4hOEK4Wh6;3iJiKXJbzdU1$M+8J(s54#}LMF+RK;!iuCTpr@cuRMV!p z(MO;U2s8lUx70@&`q5ku$m*8ly6oJWIjAl%4}LofMVb^9XTv z_lw>QhacDL=zG|R(_bhWgGH0%Aopr)9kfwbwU~FXt+DcT)YPX}>_OCOfkyJ%8kBGf zG-V8G^GmqMg_~o6RVFOqnN!KPulR-Ace%yg2@1egv27{K{ry8^)5~I(#dE+aCaA9{V--!oOUB5|Q z+dZ4GO~^JJUNL|#l?o-&6rf*ysn^@=qt##+d_-%c<(+4L1*MM2T2^FwQ4f~(aq6f7 zcvE>Mt`xC_(2d&K9G>uH*NxGc`yJa#uRu}G=BsB=91MmzVlOOtP}`Fa{E4MUj-pmk z299V}$lLS;3>##5+0Xd7`*{o5iK;H2rlE+FFTBJLA1sM3Zp6Fsx+T&ug<2xRhRMfc z!#CS{E*4kb{18mLYdlb(w~z#dgQTt*-Ho`X&5&Z7fk_0hQUh$e@xIn^=Qh)|g{+e2 zGLhSkD;V-NjG$sr#oEmzn8-w&vAKyPFW7=ZH^j%z04+(YZ8Iel8*TUlyaM9A7knHAN}^u`6TDUS9f^z}hPz{`_R$@ud$bUVg<)Aw%d@D0yLNVK zG=1?h$__W)3>bScER0#xs9qmtg`#vhG^@NknV+N12VR88&7Ql_oz610xpB{T4{~xd z5saomrA=84d5cj+`=!K#WoPuwz!+obhJ__wd#{Ip;!0PVpS1JHN7a74ln> z@PYGl^O1ZHyaQ?k_S%fBkBSpCkQYBz)QyX2cb*S0z(|q|v&1I@fvPTr;@rI1I$_@> zu^07_m@Ib?(JgduHm=eR}9UJ2` zy${BpA>6weCF*^B)4=bHd7lOks0ZoS$ubmJ3iS(+h2T5nSF(=n*5PCH$Fl$4E zNydJ9xbF86e49{O(wDfpfE}am#*rJ8Wg8hubWG?q(0K!6Ee^_z>+2tS!+ z4G#aN$q#~4^muZDWSeh=pNWeix)c4wKQ^E3@jL_z*@*fD@5$9m=E<`7MapSuv*s20 zFnGh?8ycR|>t>m7O@H~e<4dja7Xhkz>7QV{>_4?6gaD8D-Rqy~mH!w>F9!qZc^X)* zV+QOQ8!(}*$#wrg(SL|KNHwrZX*8Gox|ia;>8Vjdsj>6_#`gd7XElj`>-i}wxszHl z!_FWzs8O!ERZZoC?Az`i>X9Ipt#&~~{1?41ocE9q!ra$sgU~=d%Sx=^jw<#O(1c)B zTC8*F^EbI!lBnW5(T=54>4YxicmjavBvyuXB)$xYPgdT~@%88j=wmsErNB`+aKzYy znr()xg^b92qh?1xNPR!iGok)>u=St&foyRG*S(&85W;-pBA;m#0+r~^%Fv%u_}rKq`A<*Zmd|bBxScv+Eyll~~8@ZDO_rENS&e#W>jF%k4*(I&@k2B{1xT=PVmNXG}gYmQN3xp~WXhwYH zIQCZY*BD04S@8=HvZqLX6URw(T}39P z0@BUwX=I^Pkb$k;Xej4pqEOq%1OG zU}fRFtuSLsayDztnYc&+U19S-2_ZC8ytmqi8^j)cyOH#4K{I8tYw4GzuC>EpH1HXz zR$a{T+b(95pZAh|IkGX)34r9aw-Mh4i~kK?egMBvkw^)Ca3pOsYl>dj+dkE3AYS1P zG0R5zrJlvVShgTRF>ggP1?Q?_CE8*_SnOvpY8UglYvAYA?{7w z;k-gMl?%P>wSAyZQ@5of9f4sEgkKWpt_1JqnD-PjWpU(70PcNJ%>S~Wn+Ep|<%_=P zW86AM7|c=KSyYi~9kd(2U7-D!NQ-9`b%?jIJ>+NzeBfo=l`Hd~==yC-i-4_BvAIbW z6m|GgeJj(1T z8ihlF#^S2Er7BCw9CSH~_wLs?&Mxy)#5Qe#rj_V5C!Egz37}1v73o%l?a*fxZh-i) z2Jl@r{gW`!-ZaVOfcg&M1`z&aY@%BkgRBNzf(4h*S8Tee+QYwN$ILz<{wER+2yu@7 zm_!~ss6SSL_vpiGXQ2dHPV^K*3t>bbvZRFQmD5;8Sa?hGhIfomtOOeFq9i?lGTe5f zN@79H2de!I%$0WFL0_T^2eE=d>O5B(feoCk9G_|^lk5PrkKqlI*F8@iJn8&KO5BYY zj&v3$&zGz{0T3x&VE$f&6*RLsjLmEjzoLO5-_quh`1)}l#)kEl!Kx3QG(jaG8dw!M z{UZpjb+j{?G{9u5Gsp!N&VdJh68+3p7uAWux48xFBmi9ko-&w%iTc3uJcS;}%`Cn6 zGUZw)XSZH`cf6FiFQ*>b*mC9CfVj4-$5$!)PDy#sJ(V!KJHCN0pTL7BZh~O&pl=L< ze-0eBp&p~Z3hvnSn^x5n^{`7z{dOJImo3C8V7Y#$SE-<|9)3<_22zk0NfH5gBT?B4 z^s(nP8ZKU~jy(jUbAg)aotG8S<}Rga-a}=HYL}A@h4nEn*X4LYwTi?>`qP?Uxmr)y zS=?`<$GRmgiK)(k3)`${(C6|1Z_C!z@KLIan25NE`i1c6clsI(onEw9u}p_aaIl z2aQ*fm*Hbm^+X0Fk+#i46O4ErjbzZ1f2?kXvdzAoy2=~o(1q04EFhdB1v)6~vY zGLAMH!g5@H>oeXfAlEZUp?KRo+@n>i9!d&hwr_G#)y&i-H#fI;?Fe|6tP0~z;wnF@ zje-ZfBvSxR>io2HEMzlud`eKmPF(jIl582|UY4;;AnmHM_EFC|xh3^?=NZ&-;O@>= z8Ddy~o_7N``CW%*P0T}zCl4fIfnA!@xxo^r>Gb!Xk7qp?N9(K%@z?&|5NrHn$^QUo zR96I^NIH) zOvI8q6P)k!J4x%-@_0J<>JH$DL-XV$_3>aniC$8^>7K^4;*C-bpYaIQV{Ct+WWxLRp!kNO{6ar3j9j|Cru@wub!@n=IsCBivW zbUb1yUI+l~_JmN-o6VR>v`?0<#du;A(|y1h;j2k- z0ZZaua{%Xlsoz)`f4TcqB^rZrQCKHroboIl&Q-R!*g>pnovfJRV1{N;WZgB;(X+Ae z?+q`JAnJi;nd@4NqWY^>OG3AI6hn7lJ5jhazN^56;uRo!c37g2Z=U(EJo%N1#XMQv*rVs88bD5!m} zfnDx8V64M`;Y$VKz@WugM>uFoANo8--^Wmye2fLLP<)2R;%9!uU!$KDS_dYNcxVgFtyuHGIgg_P&8j+1nKrHsp_Y%(kSV!QS zs5m9b$5?2(Hs8Y{$X5IrKN&sp3aprH-%R-du>vcD~}}FL88E}l8nW=2N#}4 zl5+Ln^ZQ49^QeyAcbVnMRG%9;h_Y5La*u-UQP0j zjQ7)?emPd|I(XWS7-5E+L}g?(l<*DrKY-@nL97RTn-ST9eVRlSE4kq8$FDv(74}6O zUR+#~UlP^S)CUj3?YbSc@7m$R)ZcTe6&Q4|OUBY_4s;oBCNTU*wYe(KobJSGC9^|A zHg0)2dbHf#4pmvsW=eRvLM#fjggRKQoe}Fx8lw?nCEa~$mu*)=tsZ_CvXL?O2+DXv zo&u3NNND?O2B{c3MT|e{E{R)%ffphYKsSZ9l5WCrK`$@U!o^1On0@akgKu)eG7v7A z+3(CbtUZFNT%r(SC4;5?NYM1#xCJAhVkI!aI!kd*=2}()-Z$zeXdssVdmrTGX>^65 z(9po8G==12f6sIE4QA@M5N6{j`8?v$0ag*hU*Y;!dqh!Y#vez9idEkw`OHL#Jj7*H zXEO<(QKeboHEMX3OZO;wZPchgN{iff9wqCje379orp*VpiU%*RP=OJH+|U`#c{SVLsZQ-~(~iu3PqF63ssvvG8CQLjSX3s`d* z_l8L;?U$&FPYag8Bn@*A)|3KE^zk{CFoq`UzlM}dJc|NEG8p^`u&Urq6G@SXMPOb! zmtU8AUbWsbvx$&?4ueH+^XK>RO!ge{I{d5MImxF5R1CeeKgy5Dpz#+wB~rDyC7Dms zytueq!)tVvN*(G@-u>Yp4J2bH4)@Pj6E+E$EW6c4ItZ#7n9@uXN_G@Idb$VmN^*@^ zp3wI4tB9^Q>1K!GzxBHBy7Df44-Ijj0K+-6=ye^NXKH@t?lG{BP4&E%ySMLa%acX% z)I*UsGw_Y*%Ltkc6TrjkWH;hsSt_KaU^R`+k*8!nVN}a#9-^UWM`!QB6Rj9;vkV`A zS4?Y1!d4#WKuiUg$nHV_c;o=yf@l+Cl^>zMOZmeo9Rr$ZV~pqKEmuWjxyg&cCg6%; z!TS&RYvR`;T|df=vyGClrRk+h3*{rfo<83!9x3^L5C@yb2z5pYt*~u*1emKJRr7vF z_E~P(^Sv?W^Mz$zt1Ya&p3Jo?6TFRt-|(Qg{+cQ)R4itSRio(cbG#?xC{YF;8tBag z*_x4E=i^b6E(G{JiQn08WFmBuj3&B&JgajZw-R&2?n(XLoS1UW=M(z0eKY0v!6WT^ zjht^JXp4>?sfW1spXFS|-TKe{%AQy>O{FMhM1b4i#my1(rwOR>WNbBL zg&F?$23cPjjH+oD%ON#Pek~o#jyDx{9Px9b1+KEL z4+~{_Nk^&NIafaBF8uPWvIfO@h_dTf;d%2rECC<4aE@<{-5dI--Fb0gNjX2%TzLKS zvl8Q7;D0j|dG;Eak70LD2DruwT=Vn)_cd8o;xY92=?=kBvyae52fRw9U@phQKOp6JZ;5lXrty-=DBs^& zT}$qP=y_LQ<=aQus7JzBHZi0MI5O5$gc0mV5AC(?5=~=VM%bMlm8AzMk|oj`WE+Be z8DxAESxMdc54to;329A=3H2A#*QIPIHm(CU@mb>>(QdR&1rS7bW0QU!x-!{5XI}jr zzz?v&yLMN=0N&ji@bDoYpwDjwX_7hv;3`${XMq8OV>)(@w^{tek19SMRRcEV|Gdc8 z4)BOZT8*25*YpbpT z7X9z*g;H!{9qA{$2RD@Y7;ps>4mz7x9%vAqL~twg?QYIFw&UMCzp3w;f)MTim6|bc zst|#*xFA4MJUW%O8z1M)ABo#N38&DjJ#9JOcR~HU*Ni_bjs}XY-SvtWA7S{UzXW8{ z3fmn8x&{70diG53A;Mjkq5V*03B3wENd<>Pt#V#*o4sYwn0}LC@?k%XKCYMg%u(0Y zQz(6j0lXD|j;AA$-YncOo9O%ZhMgsWcx%>~NryvdA(7@Z4DIbei0^sZK@k%9oT%4p zGoSqXcnEWZ8{&zpikKwX>OtQsvSq(9x?mixSyvk%ZJ<6>l{=f{OiBon&1CSUml1M%Y0g)--y^|qEc+|su*ei>R^YbZ%nB}lDJPu5nZ+;cf;GsZS8B%zR3&dLVtj7 zT{+bulop@>5?4ZHNQ+S7O8SJ#dVn|-uMNO5r+)iQuFIz~%7y1pabVv&Xh$>pKCtOq zBpn9Z$G054*uxn^HYXbq3rDPkMmj)~C`Vq<0ph|FAs9G+p1b%V`{z<3I_>gvv7So3 zx99^4sq1hGv5zgbR0j4~?o$(`tI z0l4vzkECKi-GO9vJog=Nv&eSI)xP3&w|lWvl(0*mTSyYl1gNpdUr&8zRdi#tC~vRW ze0=^C@_UUT+da$aU<{|Ym#;|y%b^Nhf@(CXI1BhFckSIHC!NsRfu5Ql?y7D2a0{hH z9~myV47%iFg(Vyq*Rf;mkSnmy0wRDl&f*(|#^x~axiNBL-uu7uFzLLGh{fLV2`81H z)Q**#9pfk~Fv$+W#;1Y{k|u;WwRh;WM6#`aA*}#dzXM*G8}J0&RTIuu$s>BIMjF{H zw*`FI2SMN93zzJOhps~b9Apn_FF{TK8+U)KiwGy}^!4yNsA>}$J7wZd3#Myy>T@sC z10B&#h_QDd8yN+Uln3y(X$a}*C?m|q;v2oO9eNz*0z zN*w$xxb_SFR-t21oW4pHx-FWQBYfH0*& zR=6LeGF{iHQsA?n2VE}+O$4{r9Xek6u#W#?H8fFCHcX%D`*4(}5}dYjfe&U9L^{H= z_$}PU1h;hOX11lf6|JZ098`dxSc9s2Xyz7r#u~`#^>N{WR!U&tWh4}Ex{<$%&IY2haX}U}1%BwDq|BYqP?>^(Q()b%(8Xpz*T|R2g3V{vr6WSEP7q0scmHYGM zSCjYH``w*wO+5d)JmlY<>OUq9T-~0SILHT-42e0b+aGBL(wf3OD87y_^KEz%6zU~8 z`k-i&r`h;v?8;2W-X;Bbg-H!k^F|C<2BaVAd41r;3jIdqFN$Ykp`-VqG3nHz^) zaMfBEt;VHUnzgK6uk{~TtuuDUC^ba*yN52PopKWNPux#XTeK5Q0AsWU#@Nm!i9?F% z;^U9RF;qE0k???|gLwe&pb~AyRJ6| z&?TGNC!83?ANyZGdHsKZ>8&2?F3+E&J4`>iV{Dm1`q}AYQY$7>!7FcaqQ=@)mt{3j z$@k|X8(F;W`cauO8t+AAMUBFgeTCoh;;t_Hr9?~8$rcf2+{pel`TTCuTOUtFQc1#} zV5Ap528$ADbLSkZx{a>%wfbeYp>-}Cv6Rat@BJ>U{4tsT7&%3o*AWs-xb{;w1eQW8 zFN1k2ejm=y@awXLo4Sa)Er)p;l~6(`>W{>Gik|@&ha#?;T^EFN`F$?ezxU*R3vvcg zQXt!n3Aog6B{lF7=3XU|;6%hB%BKePLU%z!%8K$*cM>Xr zuq))cQaCdfGkl>6K1SRz;E`yTxDeLY zE+47Db?RnxWo%ZwU14#74O##lI_6tbiR(%KZ)Xzkg4~;^j}hDG z9#+z|V;xb9Z54U37+gZw%aAs^gG}>$`B)W!v191CoQah6X$<{T9pr4ji!K+8rY`;BZi;pxRa zd@l&UT+&lKSL~5hQW1G4$_~qN^IAhSlDHPcLD@#dcNOWz+j@K*j|Fq~CVj9taE5QH zK?W^hjs8s(wfgwG?^+lnFqUWzVdzg#N(7|=(E-5)oJ6&}yl1SMWNqQO`?&qtAsfxv z-1aX6LVAFBRwyIcI>b$CAZ@0*0ZI_4G5!_9D>1HNy`is5Ec7FJ_uL#RE5%leLxj1N%*-sDh(>mflpsM@p0xd1@|E6w6slE^Msjb zZ&JM?NpuvoLv%-Tcm@z6mUlmUOt4n}O!p?lplk`PD4Oj2ci!e&$p%4y`}iU3xKgW< zi@s3N*%Qw!K8iZY87hLoA6C>n5oCaQV?Y9c-FgEDVB{3wUV7JqAd@d@;ePR}hL z38LUob%44|Rbjr98_nL4z=PL0X%*@QD~>PdDHF)>MwtcpJNHRpGsinKr#o58eC1%j zHr%LHA_KO}v9|vx?V$cu+99rE1A(JL4-s@lTLo^y^EfAFElIvu&Clf+gQuMuX84)% z*4@r+d(AO>VIEV{L!>US9fLw@wiOA2xpU^RSHI3HnuZK6x+TZ`({!Qn-rZ!_?fFJ1 z;B_TWrq;l!fhaXsBh`=e3#-1V`;<9q=hwtq%$gLz0d-)1gH^B*q^;SC&uxt#7iT(&i}RG&)LN@hl9KNc7Bk9Nru+Nc2?=1WTFs}e zz5+6Z)xFv8U2Ip~^3U5FB>?{$5=Cf!6c#BiBb;<_-jI~SV0W>+LAVld>;2SpFPpFr zT=EK+HjY?;r;79h8@UWjP@Tr4=Hqiw<%#!h~ZX(x|Mjt!L;NZNiBKzOWW);>4?72B*5mq>w$N%13s^4aHHn&a8R`#Lxve?`nYo+ ztdBR19-NKNzwon z#Sj}`@@krE_3cMbNWNmM7V>9TJxCtds!`BjFOi~Q(26U6yt{R}?I==Q1AXiG!BR1B z2(gl?z6MFQW>h2O<2iBaJqUAi{6;EoiD#qaVhpOfwphfyf#pvD_SHI)Pyyt{d@m7r zmXHvlVBqQtXy%_7x=?0J`)2`~MS4<9{0XO5n_KL%WANTQX4L&n<8E>9==>LEG%{Xf zDvqYRW22b6*n*;Q;-(u+LE@TpebQFEn)}1upZ7?V z3!h&^_{C8=9f5EQQu#Dk8#IA&Qjj22o?Nb1h_(90x3n-zesT)THw3hq4zApBZ9^5W z8^_&#@444WIYGw6J`A zsZia+q0rK4)}}LO|M_~mt1rt;=J9L-Q24=p>rHAGGg@IIg| zMIr%WiQ1TX?~!6djYJ)bnf^;xel%4a^+wX%Zn=HQIQRWVYrmhQ&Lg2g=rxh2))xx`!Y#k&v8M{F|JR5^uxcot!-scVm@ zRAO~pNX?FlSPcJ3JjOl2_F_VRyaA`LC|Wjimg2P>Mc?xy5@csr=J7jwZq}YKSi0YE z;1(0L1ynuYz}yq1+;4Jiin^qmmyQ)x7AHGL@ZKw`6+z`aG)Z8+Fg5Z5&{Ug8p%ADD zQPWsym1gwNq5EgqMd5gtK8sipZow5kiv{>0iVT4(%2q7zl{3lxJ68vHe&_ETHFxzn zZa)DWwF^`MgtPH8*rwvuF201{zM9KRu@>&HgbAh)NuBP)wa&l!p=v6$G#o@t1FsEK z+d^Gd1O=KwO;2#czHw)kX3U9$evqfJ5tXOX{HQfjglCodZsfBTcvqFkpGPmhs8mxW z+ch^a0>YQ5Pq2mLZ^-^{D+aoI*cL$tXB8v-CuX!HZCF9nmHYt^1u8yMuQ9&KcxYV5 zjU-1J$%GZPMd?a%?|$Ls5)W7p3LT)MaPY|z3zbhZH`L~mSn#2y#Us;s*HB)yhL_EcCmkKc#ljSPvLLa^wzl53Q1^GX5Fn9DycFUQznkAdO65^M zrP_}AFT(?N)$=Ij(zO)%Kd4Fpr%wTkFA)Pb)~=~ElTc!A5&}f)s?G2J^%_A>Fev^nA#gLslWlN!qM-url8FjF>Yf09vqc9b#2KT^tnPg zX-|&IBKfjZuB+j&YSNX^5%RnZLw;;H^K=BGs9~?WRB&GQknMnwtE0;e;OiLQvJhbT z6{LVI^iMXxb`zCdH}a>X{fVveAfjQS#Hi)d%;`Czc@2D3+6_$|wisH?p^Nk{u@u=b zj{0msXqc{)Yk7J=RE8(Z;8P{ZpX6QP(_^|wF0=3Jck_8TO^JPPd77}Jqeb{TC`^)F zNU+YZ5TEh7czfciHS7?27ZBE|m{!srG|cG%Q@)nTR;mpX&Ew^Dt5u4efT>@a%3Fl7 zN@$hxta9vWl4x0%eTZao*K_r4YtvOm2=+2`zCmfLRB!)F4G-J63eTIp)p!c4p-tYSP3NE;SC+lJ#DmAjZ;)xMY=?9kBxl=Br}>$dVd3lg549F~+sOssF@%w_)A>*K5I$a_ulK=jYn z!FKUsT#YyN7uxD~IioV5yM`!LR;l^GVtIZG3BH;m%NOMQi#s8#G@m>=$rVGg9N11{ zRa!Jyh=z`g=M&cB%O&(;%`f2;lM3@D@GdAF0;q@H?&#%cu%oeWjNcW z!C&ReQiqi}p|)x0P-)Bk$*Y=XG^85o)Do2|8qL2pdlBeje@D>IqMZ4EtWsCz;rz!v zP28jGjXb3hA81z2?@UXifU(8 zcHJ^hjJ0=pBkzOJgn)ehxz0TgyuL&3AWrEht`r){MlRklRx{hc#e8(uPOSEVx$M#RJ#=&^SPs-LCjA+W*&Y!{*!z>Uq<6H3e0~f>`d#K{H|La#d(lXX~#;{>0y5Lw3JeW`-|tVuSTxM z#)7gHmx2h0^In024d1a_z7kk+ zN*$UB1m96!8F44to_(3LohasJM2l$VT^**6nHc(BQR$RZme9&BI;YjVilGA=y%D>H zztf*{)Q@DIWFGxZt6aJ0P@7Er=VsvptmxyG{NJ{DBS6r`ZIOQWkzpLbGuC9xC=J>7 z_g?YTv>Im8oH)7RbLP^#4C`L?j{j(w7?DzZ^1Krw5Zj-5D(I6dR;QppWuWi}!25!iUA8&7)cp4GrJMek{Y^ z;|$woh1m5;$J0|*@};sf5PsPnKWfZh*etfxcgj z`z%M~=f=i%qP-cjMm_Pj;wXliiAy*9pI+kls!}T`#NZ=#SId8DIhxH>Ctr6FP>WbK zZpHSs@dce3b(llbBoTzmu6v%lXUn%L-A#PkOJ~^CzE_Fx-YJz(B zfTma7KZCt%ff{!*LU}LsL2(F8%;N@OALg->^&=z;srENA{~LZ zp<|cH^_(2LQHeV_rn%W(TI}w|&+%7g1-wdH=Ay!JS`~Gs-8e_B^6UazzQO3my0uE3TEYIc^Hl zp@uds7S5r*ob$k3=xE()CUMWZq5`26>^PI5D@lPU`_fcj2&YAH6A^Mdi^6Ue$Sl z0(EU{`GvVMCU9-m4j*S@lr@T+?%^WxX;{j_Vo9G2S zVI0}$?!SKQ$sAx-%4WiNKH%=uZeMxxzhyV*v{p(oMgB|;SNgyLo|?b$kN!9RCg~#F zU~IXCm-x0Y0t7?c6L}#iBh7RgYDNzB8wD!wVuf%PZ3b~al1O3F<7-J14b8|8%NB35 z%dayJ0)%Cg;b|npDk0CN`k`BS!`qrB^;*gwK~;#cX-jB~>=zmgX)JT~7;x#orM^&qY$d#gjaPKfG?kOdm9<8{hJ9Ko;5z*@1+s{;77Y@h#f zZvEdJUjM(}p#uRYoMa1tx>HXlR}sQbqKh$g3@atm`#J+V49 z|LyoruS>G3rZvzVQpoU0wgKmipYc~+olbDmz~`wR*w#XLA9Menk7wn}rXKpSfSVnr zzXuw4ZN&p1sQ(7v^lMK33-v;N&Ltha=HEFsKs-9+wCA+x!mkz7q%PdwUf~T4mQtla zJNSCJohX^ z9_U`~@CjG&;q*Qe4YwmM1J5DA7x|y_9B$xSDHg}GPF{;!8a{szx9aFGVMQ9zyoXgz z1!NyI^M|;Z>EHmR*10UGE(e@FJ&uU9RDK*2ej*ECBynG{ zNz7-okYunO8bFrGHP9qq9BxO9%5{zKTcY?TsPW>x>hRbKkKOKW;)iag$vCbMfg3hM z=H`X)aN0y~EBx#X?Sw3HEVddq(LMDkoOVP*^w_BqxdIOjsy9F{e^ z3dNYLvHy+1FLFQqdiw`wQlj^to$C42*H6cO;ws&l&U_)Dy2CwxD*UdGxzN%n z&(Y8Gp2`b1s*<}+Q@QE&qht`Nm_7bV%tX(E6BJFsG_bzwcKo$TyRrv3@zA^(lb7k+ z{%^2i|7*PV|H7;OlYlSihdbi*26cgq2GOV=$;s$DaV~kq_nsn7ZGaVwwn+y^Husmfpn24B|k`66n^%AB^`x5=gzK6r`L~8Y_FhBzw zMrAVd5!EDV$PJ_}i`3=7Lt*>zhHZPpWd{D@XFtzdgp-BkQs3iDnCiii3aAh zON-|f&Ev<7j~_VtjMujc;g>vc*h-UBeka7ZMtWI(_59dQ`A$z`N~(>) z`)Nv5>FvVwSA-^@fJVdb;Rc(yZD4u2ze)QUj@FUmwClQ6)2RHm*b3BHiDauq!`six zunOxiaCNVjf@(#!OkqxU)~je%?0}~t={cWKB!<)!$cb}s&A_rQv2Bk1Xr=b;`AOik z*e}>hI5umE9%sT%^ZT)dgb0w1Jg-$_AtTC8gFidgK3N*)>S$n0*%}_6w(3FhMPYQf zfAEqk4M(XIVES-wCP}Mi=IY|yftd8Th70jG*?;xx7teU#RZwF*miLOk2ova1YN>&aG>j3gs@ zMf7E?s8B?tNOdm-_2TIPT`vzugUOvK5(yblXVC}Z7t_QGp(I%X+kqnt?r0^w(VooA zvCMkuDz#?o?PG>#-#@f#Q#M+?8{Tgv`&5>D7&Z925Fzn9JB=aBN&_3=K4a}$iwnhy z$@ge0s5I4R$7OW(|3oad(mN8~?CP+YSkJ;`DkA`jL6J{C(kn{o& z@#Y4|=5jOg4?w&7xaa*JjKE*WNGIOuWtG2|G&zFsLfhDQg8X!VpLQGiIkmjN*>@lK z7w+T5Z-2B)Kaq2dmk#sB#I6-_`~WPiXuj@oeIR&#kS+Jj7X64jo08$MWT#oPj0c*? z_ATgET?g(t_hr-C@^}^^#M{UU8gFf~BJ+QH*~8Z4$niUdcd*SlSM0fu*kx%m*0pbV zg)>Lo@x$jD`>^U0%YzS0LNffX-gzE?HF5>zprG&+)lTCuqAdCmp1{;x^k4eQilN5( z%&XL#Ez-)_<~Bjx$Ax$wNh2wwLwUMF=m4_E3Tcb2foM~atwTJaA&l}g!7KO^-spluY z!QXa#R)m6MLAl&=icF_xTx$F0A+G*>r=4}98$9N&yKi)h)%m7E)KCQo>3_oe3?yC@ zgkNn?fSjTJ1(R-6z9iTr=H;DE|yr)osdc2ZMB+{D=?1l$rQdA`%QKq0BgTb zJSrXf#Sx*5X?tebG<{2p9XcI`F&pJG`S2u74jK6h zloD1_@@Dsspw3&08IF1?v~iSt)#3b*o?G`X=IB~QiB1Gyb6~;=>1@wYgJXB*)(<^9 zC9mAVdW5Tm??r-vbDObmE$1h} zrFKm&1%yHt917*X$-p4cn>usx5L;1ihFUkFf_tgy{<&=WsDWIUY3Nl|hb?6ch${wX zl`V4cz9gqAmN6`=4 z(T7=y_jQd~Ar%f=e}t7K+_~(1kMo172*RRr5T0$CKf)#^oF`UmNNljWXa~*K0CVpPF0rML> z?Ey5JGk{ZZE{03w7Cyl$&H%|WVX{i}%uZY%s0HS2OXzW;$|_6$30j1P6-(E@qw{{J zN0R=kIUj{zvYh_{#K2}CDHfh~a(~AI_!p$e)%D0Kiv5fFiO`Idj9`_mz^9L%DIB8Imo5c?d>#r_zLGCotyxbbi@* z&x|$tAsZx57FYOP)EZFLy(3QULNW{_op~{N5=p_m3k<}F_adkZ9D$9p7XZS@2wKjk z6=ZBXc=1xkLUL3zM-$VBAS{H;VUBUl*%=~FwI#A_WT(ywhTXl4r<^uqc{^DQ<^YKI zvZAs-EUcOEXy{AZybvf#4~xL;WWPDy8WXRu#cf?XhGOigz{%0D`UVbU|F^5SenfDu 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 literal 0 HcmV?d00001 diff --git a/docs/plans/2026-06-09-003-fix-telegram-stream-overflow-continuations-plan.md b/docs/plans/2026-06-09-003-fix-telegram-stream-overflow-continuations-plan.md new file mode 100644 index 00000000000..cd533ea49b7 --- /dev/null +++ b/docs/plans/2026-06-09-003-fix-telegram-stream-overflow-continuations-plan.md @@ -0,0 +1,240 @@ +--- +title: "fix: Prevent Telegram streamed replies from ending after first overflow chunk" +status: active +date: 2026-06-09 +type: fix +target_repo: hermes-agent +origin: user-reported Telegram topic screenshot +--- + +# fix: Prevent Telegram streamed replies from ending after first overflow chunk + +## Summary + +Fix a Telegram gateway bug where a long streamed assistant reply can appear to stop mid-answer in a topic after the first overflow chunk. The reported screenshot shows a long Hermes response in the `Nehemiah - Coding` Telegram topic ending at `- The visible tool-call summary`, followed by the user noting that the previous message did not finish streaming to that Telegram topic. + +The plan targets the streamed edit overflow path, not general model generation. A completed assistant response must either reach Telegram in full across all continuation messages or leave enough state for the gateway fallback path to deliver the remaining content instead of marking the turn complete after a partial delivery. + +--- + +## Problem Frame + +Telegram limits message text to 4096 UTF-16 code units. Hermes streams gateway responses by editing a message and, when a streamed message grows past the limit, splitting the overflow into additional Telegram messages. The adapter already has a split-and-deliver path for oversized edits, but the partial-continuation failure contract is weak: if chunk 1 is edited successfully and a later continuation fails, the adapter can still report success for the operation. The stream consumer may then mark the final response delivered even though the visible topic only contains the first part. + +This is especially visible in Telegram forum topics because a long final response can be split below tool-progress bubbles, and a missing continuation looks exactly like the stream stopped mid-answer. + +--- + +## Requirements + +- R1. Long streamed Telegram replies must preserve all final content across overflow chunks. +- R2. If any continuation chunk fails after the first overflow edit lands, the gateway must not mark the final response as fully delivered. +- R3. Continuation chunks must remain routed to the same Telegram topic/thread as the original response. +- R4. The fix must avoid duplicate full-answer sends when all overflow chunks were delivered successfully. +- R5. Tests must cover the reported failure shape: a final streamed reply that exceeds Telegram's limit, succeeds on the first edit, fails on a continuation, and must not be treated as complete. + +--- + +## Key Technical Decisions + +- Treat overflow delivery as all-or-not-complete. `_edit_overflow_split` should only return a successful final-delivery result when every planned chunk reaches Telegram. Partial delivery is a distinct outcome that downstream code can recover from. +- Carry partial-overflow metadata through `SendResult.raw_response` rather than adding a new public dataclass field unless implementation proves the existing result shape is insufficient. The stream consumer already inspects `SendResult` after adapter edits, so a small raw response contract can keep the change contained. +- Make the stream consumer responsible for final-delivery truth. The adapter knows which chunks landed, but the consumer owns `_final_response_sent`, `_final_content_delivered`, `_fallback_prefix`, and fallback final-send behaviour. +- Keep routing inside Telegram adapter helpers. Continuation sends should continue to use `_thread_kwargs_for_send(...)` with metadata-derived `message_thread_id` and reply anchors so forum topic behaviour stays consistent. + +--- + +## High-Level Technical Design + +```mermaid +sequenceDiagram + participant C as GatewayStreamConsumer + participant T as TelegramAdapter.edit_message + participant B as Telegram Bot API + + C->>T: finalize/edit long accumulated response + T->>B: edit original message with chunk 1 + loop remaining chunks + T->>B: send continuation in same topic/thread + end + alt all chunks delivered + T-->>C: success, last message id, continuation ids + C->>C: mark final response delivered + else any continuation failed + T-->>C: partial overflow failure with delivered prefix metadata + C->>C: do not mark final delivered + C->>B: fallback sends missing tail or full final response safely + end +``` + +--- + +## Implementation Units + +### U1. Add a partial-overflow contract for Telegram edit splits + +**Goal:** Make `TelegramAdapter._edit_overflow_split` distinguish complete overflow delivery from partial delivery. + +**Requirements:** R1, R2, R4 + +**Dependencies:** None + +**Files:** +- `gateway/platforms/telegram.py` +- `tests/gateway/test_telegram_send.py` or the existing Telegram adapter test module that already covers `edit_message` overflow behaviour + +**Approach:** +- Keep the successful path unchanged when every chunk is delivered: return `SendResult(success=True, message_id=, continuation_message_ids=(...))`. +- When a continuation fails after the first edit, return a result that clearly indicates partial delivery instead of plain success. Prefer `success=False`, `retryable=True`, and `raw_response` metadata such as delivered chunk count, total chunk count, last delivered message id, and the visible delivered prefix. +- Preserve logging, but do not rely on logs as the only signal. The caller must be able to tell partial delivery happened. +- Ensure the first edited chunk and all successful continuation chunks still include the existing Markdown/plain-text fallback behaviour. + +**Patterns to follow:** +- Existing overflow handling in `TelegramAdapter.edit_message` and `_edit_overflow_split`. +- Existing `SendResult` semantics in `gateway/platforms/base.py`, especially `retryable`, `raw_response`, and `continuation_message_ids`. + +**Test scenarios:** +- Oversized finalized edit where all continuations succeed returns success, the last continuation id, and all continuation ids. +- Oversized finalized edit where the first continuation send fails returns a partial-overflow failure and does not report success. +- Oversized finalized edit where one continuation succeeds and a later continuation fails reports the last delivered continuation id and delivered count in raw metadata. +- A continuation MarkdownV2 formatting failure still retries plain text before being treated as a delivery failure. + +**Verification:** Adapter tests prove complete overflow remains successful and partial overflow is observable by the caller. + +### U2. Teach the stream consumer to recover from partial overflow + +**Goal:** Ensure a partial Telegram overflow does not set `_final_response_sent` or `_final_content_delivered` unless the full response reached the user. + +**Requirements:** R1, R2, R4, R5 + +**Dependencies:** U1 + +**Files:** +- `gateway/stream_consumer.py` +- `tests/gateway/test_stream_consumer.py` or a focused new `tests/gateway/test_stream_consumer_telegram_overflow.py` + +**Approach:** +- In `_send_or_edit`, when `adapter.edit_message(...)` returns a partial-overflow failure, update consumer state to reflect the last visible prefix/message and enter fallback delivery for the missing content. +- Avoid treating `_already_sent` as final delivery. A partial visible message can be true while final delivery is false. +- Use the delivered-prefix metadata if available so `_send_fallback_final(...)` sends only the missing tail. If implementation finds the prefix is unreliable after Markdown formatting, prefer sending the complete final response as a fresh fallback message rather than silently dropping the tail. +- Keep the existing success handling for `continuation_message_ids` when the adapter delivered all chunks. + +**Patterns to follow:** +- Existing fallback mode in `GatewayStreamConsumer._send_or_edit` and `_send_fallback_final`. +- Existing comments around `_final_response_sent`, `_final_content_delivered`, and `_fallback_prefix` for prior partial-delivery regressions. + +**Test scenarios:** +- A final streamed response that overflows and receives a complete-success edit split sets final-delivery flags and does not invoke fallback. +- A final streamed response whose adapter reports partial overflow does not set final-delivery flags immediately. +- After partial overflow, fallback delivery sends the remaining tail and then marks final content delivered only if the fallback send succeeds. +- If fallback delivery also fails, the consumer leaves final-delivery false so the gateway's non-streaming final-send safety path can still run. + +**Verification:** Stream consumer tests reproduce the screenshot shape by simulating first chunk visible and continuation failure, then assert the final answer is not suppressed. + +### U3. Preserve Telegram topic/thread routing for overflow and fallback continuations + +**Goal:** Ensure overflow recovery messages land in the same Telegram forum topic or DM topic fallback context. + +**Requirements:** R3 + +**Dependencies:** U1, U2 + +**Files:** +- `gateway/platforms/telegram.py` +- `gateway/stream_consumer.py` +- `tests/gateway/test_stream_consumer_thread_routing.py` +- Relevant Telegram adapter routing tests, if existing coverage is closer there + +**Approach:** +- Keep passing `metadata` through every overflow continuation and fallback send. +- Keep reply anchors where valid, but do not let a missing reply anchor drop the `message_thread_id` for normal forum topics. +- For private DM topic fallback metadata, preserve the existing stricter anchor behaviour documented in the adapter comments. + +**Patterns to follow:** +- `TelegramAdapter._thread_kwargs_for_send(...)`. +- Existing tests around Telegram topic recovery and stream consumer thread routing. + +**Test scenarios:** +- Overflow continuations include `message_thread_id` for a forum topic. +- A continuation retry after `reply message not found` keeps forum topic routing when allowed. +- Partial-overflow fallback sends receive the same metadata passed to the original stream consumer. + +**Verification:** Thread-routing assertions inspect fake bot calls and confirm all continuation/fallback messages carry the expected topic metadata. + +### U4. Add issue evidence and PR body traceability + +**Goal:** Make the upstream issue and PR clearly trace the user-visible bug and verification evidence. + +**Requirements:** R5 + +**Dependencies:** U1, U2, U3 + +**Files:** +- GitHub issue body created via `gh issue create` +- PR body using `.github/PULL_REQUEST_TEMPLATE.md` + +**Approach:** +- Create a GitHub issue with the screenshot evidence: the long message in the `Nehemiah - Coding` Telegram topic stops at `- The visible tool-call summary`, and the user's reply says the previous message did not finish streaming to that Telegram topic. +- Reference affected component as Gateway and platform as Telegram. +- In the PR body, link the issue with `Fixes #...`, describe the split-delivery contract change, and include the screenshot or attach it if GitHub upload is available. +- Follow `CONTRIBUTING.md` and the repository PR template exactly. + +**Patterns to follow:** +- `.github/ISSUE_TEMPLATE/bug_report.yml` +- `.github/PULL_REQUEST_TEMPLATE.md` + +**Test scenarios:** +- Test expectation: none, this is tracker and PR documentation work. + +**Verification:** The GitHub issue exists with screenshot evidence or an explicit screenshot reference, and the PR body links the issue and lists the tests run. + +--- + +## Scope Boundaries + +### In Scope + +- Telegram streamed response overflow splitting and recovery. +- Stream consumer final-delivery truth for partial overflow delivery. +- Topic/thread metadata preservation for overflow and fallback continuation sends. +- Focused unit tests around adapter and stream consumer behaviour. + +### Out of Scope + +- Changing model streaming semantics in `run_agent.py`. +- Reworking Telegram draft streaming, which is DM-only and not the forum-topic path in the screenshot. +- Changing general platform message splitting for Discord, Slack, WhatsApp, or Matrix unless a shared helper must be corrected for the Telegram fix. +- Altering tool-progress display settings or terminal progress rendering. + +### Deferred to Follow-Up Work + +- Broader observability for gateway delivery completeness across all messaging platforms. +- A user-facing resend/recover command for a previous truncated response. + +--- + +## Risks & Mitigations + +- Risk: fallback recovery duplicates already-visible first chunks. Mitigation: use delivered-prefix metadata where reliable and add tests for no-duplicate complete-success behaviour. +- Risk: preserving forum topic routing while dropping invalid reply anchors is easy to regress. Mitigation: include fake bot call assertions for `message_thread_id` and reply behaviour. +- Risk: MarkdownV2 formatting can alter visible/raw prefix comparisons. Mitigation: keep fallback conservative; duplicate content is preferable to silently missing content, but tests should keep the common path tail-only. + +--- + +## Sources & Research + +- User-provided screenshot at `/root/.hermes/image_cache/img_f664e68f6ddf.jpg`. +- `gateway/stream_consumer.py` streamed edit, overflow, fallback, and final-delivery state handling. +- `gateway/platforms/telegram.py` Telegram send/edit overflow splitting and topic routing helpers. +- `gateway/platforms/base.py` `SendResult` contract and shared message chunking helper. +- `tests/gateway/test_stream_consumer.py`, `tests/gateway/test_stream_consumer_thread_routing.py`, and Telegram adapter tests for focused regression coverage. + +--- + +## Verification Strategy + +- Run focused Telegram adapter overflow tests. +- Run focused stream consumer overflow/fallback tests. +- Run topic-routing tests affected by metadata changes. +- Run the gateway test subset around Telegram send/edit, stream consumer, and run progress if touched. +- Before PR creation, ensure `git diff` contains only the plan, implementation, tests, and PR/issue-relevant documentation for this bug. diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 8a71c75a30c..b9273e7cca0 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1545,6 +1545,13 @@ class SendResult: message_id: Optional[str] = None error: Optional[str] = None raw_response: Any = None + # Adapter-specific metadata. Cross-layer contracts that affect delivery + # semantics must be documented at the producer and consumer sites. Current + # known contract: Telegram edit overflow partials set + # raw_response["partial_overflow"] with delivered_chunks, total_chunks, + # last_message_id, delivered_prefix, and continuation_message_ids so the + # stream consumer can send the missing tail instead of marking a clipped + # response complete. retryable: bool = False # True for transient connection errors — base will retry automatically # When the adapter had to split an oversized payload across multiple # platform messages (e.g. Telegram edit_message overflow split-and-deliver), diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index fa896db9d3a..051a377dc06 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -2379,6 +2379,7 @@ class TelegramAdapter(BasePlatformAdapter): # are already correctly sized). Best-effort MarkdownV2 with plain # fallback, mirroring send(). continuation_ids: list[str] = [] + delivered_chunks = [first_chunk] prev_id = message_id thread_id = self._metadata_thread_id(metadata) for chunk in chunks[1:]: @@ -2442,17 +2443,37 @@ class TelegramAdapter(BasePlatformAdapter): break if sent_msg is None: # Continuation failed — the user has chunk 1 + however many - # continuations succeeded. Report success with what we got - # so the stream consumer knows the edit landed; the - # remaining tail is lost on this attempt and the next - # streaming tick may retry. + # continuations succeeded, but NOT the full response. Do not + # report success: the stream consumer treats a successful edit + # as final delivery on got_done, which would suppress fallback + # delivery and leave the Telegram topic clipped after the last + # delivered chunk. logger.warning( "[%s] Overflow split: stopped at %d/%d chunks delivered", self.name, 1 + len(continuation_ids), len(chunks), ) - break + delivered_prefix = "".join( + re.sub(r" \(\d+/\d+\)$", "", delivered) + for delivered in delivered_chunks + ) + return SendResult( + success=False, + message_id=prev_id, + error="overflow_continuation_failed", + retryable=True, + raw_response={ + "partial_overflow": True, + "delivered_chunks": 1 + len(continuation_ids), + "total_chunks": len(chunks), + "last_message_id": prev_id, + "delivered_prefix": delivered_prefix, + "continuation_message_ids": tuple(continuation_ids), + }, + continuation_message_ids=tuple(continuation_ids), + ) new_id = str(getattr(sent_msg, "message_id", "")) or prev_id continuation_ids.append(new_id) + delivered_chunks.append(chunk) prev_id = new_id last_id = continuation_ids[-1] if continuation_ids else message_id diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index 33910c7b40b..8ebde02411b 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -149,6 +149,10 @@ class GatewayStreamConsumer: self._last_sent_text = "" # Track last-sent text to skip redundant edits self._fallback_final_send = False self._fallback_prefix = "" + # True when fallback is sending only the missing tail after a partial + # Telegram overflow delivery. In that case the already-visible prefix + # is intentional content, not a stale preview to delete. + self._fallback_preserve_partial_messages = False self._flood_strikes = 0 # Consecutive flood-control edit failures self._current_edit_interval = self.cfg.edit_interval # Adaptive backoff self._final_response_sent = False @@ -261,6 +265,7 @@ class GatewayStreamConsumer: self._last_sent_text = "" self._fallback_final_send = False self._fallback_prefix = "" + self._fallback_preserve_partial_messages = False # #29346: a tool/segment boundary means what we delivered was an interim # preamble, not the final answer — clear the flags so a premature setter # can't fool the gateway. Safe: got_done returns before any reset, and @@ -871,7 +876,11 @@ class GatewayStreamConsumer: # implement ``delete_message``, the delete fails (flood control still # active, bot lacks permission, message too old to delete), the # partial remains but at least the full answer was delivered. - if stale_message_id and stale_message_id != last_message_id: + if ( + stale_message_id + and stale_message_id != last_message_id + and not self._fallback_preserve_partial_messages + ): delete_fn = getattr(self.adapter, "delete_message", None) if delete_fn is not None: try: @@ -888,6 +897,7 @@ class GatewayStreamConsumer: self._final_content_delivered = True self._last_sent_text = chunks[-1] self._fallback_prefix = "" + self._fallback_preserve_partial_messages = False def _is_flood_error(self, result) -> bool: """Check if a SendResult failure is due to flood control / rate limiting.""" @@ -1274,6 +1284,35 @@ class GatewayStreamConsumer: self._flood_strikes = 0 return True else: + raw_response = getattr(result, "raw_response", None) + if isinstance(raw_response, dict) and raw_response.get("partial_overflow"): + # Telegram edited/sent one or more overflow chunks, + # but not the complete response. Preserve the + # visible prefix so the got_done fallback sends the + # missing tail instead of marking a clipped topic + # reply as final delivery. + self._message_id = str( + raw_response.get("last_message_id") + or result.message_id + or self._message_id + ) + delivered_prefix = raw_response.get("delivered_prefix") + if isinstance(delivered_prefix, str) and delivered_prefix: + self._last_sent_text = delivered_prefix + self._fallback_prefix = delivered_prefix + self._fallback_preserve_partial_messages = text.startswith( + delivered_prefix + ) + else: + self._fallback_prefix = self._visible_prefix() + self._fallback_preserve_partial_messages = False + self._fallback_final_send = True + self._edit_supported = False + self._already_sent = True + if getattr(result, "continuation_message_ids", ()): + self._notify_new_message() + return False + # Edit failed. If this looks like flood control / rate # limiting, use adaptive backoff: double the edit interval # and retry on the next cycle. Only permanently disable diff --git a/tests/gateway/test_telegram_overflow_partial.py b/tests/gateway/test_telegram_overflow_partial.py new file mode 100644 index 00000000000..76e4d16a617 --- /dev/null +++ b/tests/gateway/test_telegram_overflow_partial.py @@ -0,0 +1,140 @@ +"""Regression coverage for partial Telegram overflow delivery.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import PlatformConfig +from gateway.platforms.base import SendResult +from gateway.platforms.telegram import TelegramAdapter +from gateway.stream_consumer import GatewayStreamConsumer + + +def _message(message_id: int | str) -> SimpleNamespace: + return SimpleNamespace(message_id=message_id) + + +@pytest.fixture +def telegram_adapter() -> TelegramAdapter: + adapter = TelegramAdapter(PlatformConfig(enabled=True, token="fake-token")) + adapter._bot = MagicMock() + object.__setattr__(adapter, "MAX_MESSAGE_LENGTH", 160) + return adapter + + +@pytest.mark.asyncio +async def test_edit_overflow_split_reports_success_when_all_continuations_land(telegram_adapter): + """Complete overflow delivery keeps the existing successful contract.""" + content = "word " * 120 + telegram_adapter._bot.edit_message_text = AsyncMock(return_value=True) + telegram_adapter._bot.send_message = AsyncMock( + side_effect=[_message(202), _message(203), _message(204), _message(205)] + ) + + result = await telegram_adapter._edit_overflow_split( + "12345", "201", content, finalize=False, metadata={"thread_id": "77"} + ) + + assert result.success is True + assert result.message_id == result.continuation_message_ids[-1] + assert result.raw_response is None + assert telegram_adapter._bot.edit_message_text.await_count == 1 + assert telegram_adapter._bot.send_message.await_count == len(result.continuation_message_ids) + for call in telegram_adapter._bot.send_message.await_args_list: + assert call.kwargs["message_thread_id"] == 77 + + +@pytest.mark.asyncio +async def test_edit_overflow_split_reports_later_partial_failure_after_some_continuations_land(telegram_adapter): + """Partial metadata tracks the last delivered continuation before failure.""" + content = "word " * 120 + telegram_adapter._bot.edit_message_text = AsyncMock(return_value=True) + telegram_adapter._bot.send_message = AsyncMock( + side_effect=[ + _message(202), + RuntimeError("telegram send failed"), + RuntimeError("telegram send failed"), + ] + ) + + result = await telegram_adapter._edit_overflow_split( + "12345", "201", content, finalize=False, metadata={"thread_id": "77"} + ) + + assert result.success is False + assert result.message_id == "202" + assert result.raw_response["partial_overflow"] is True + assert result.raw_response["delivered_chunks"] == 2 + assert result.raw_response["last_message_id"] == "202" + assert result.continuation_message_ids == ("202",) + + +@pytest.mark.asyncio +async def test_edit_overflow_split_reports_partial_failure_when_continuation_fails(telegram_adapter): + """A failed continuation must not be reported as final delivery.""" + content = "word " * 120 + telegram_adapter._bot.edit_message_text = AsyncMock(return_value=True) + telegram_adapter._bot.send_message = AsyncMock( + side_effect=[RuntimeError("telegram send failed"), RuntimeError("telegram send failed")] + ) + + result = await telegram_adapter._edit_overflow_split( + "12345", "201", content, finalize=False, metadata={"thread_id": "77"} + ) + + assert result.success is False + assert result.retryable is True + assert result.error == "overflow_continuation_failed" + assert result.message_id == "201" + assert result.raw_response["partial_overflow"] is True + assert result.raw_response["delivered_chunks"] == 1 + assert result.raw_response["total_chunks"] > 1 + assert result.raw_response["last_message_id"] == "201" + assert result.raw_response["delivered_prefix"] + assert result.continuation_message_ids == () + + +@pytest.mark.asyncio +async def test_stream_consumer_fallback_sends_tail_after_partial_overflow(): + """A partial overflow edit enters fallback instead of marking final delivered.""" + adapter = MagicMock() + adapter.MAX_MESSAGE_LENGTH = 4096 + adapter.edit_message = AsyncMock( + return_value=SendResult( + success=False, + message_id="preview-1", + error="overflow_continuation_failed", + retryable=True, + raw_response={ + "partial_overflow": True, + "delivered_chunks": 1, + "total_chunks": 2, + "last_message_id": "preview-1", + "delivered_prefix": "hello ", + }, + ) + ) + adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="tail-1")) + adapter.delete_message = AsyncMock(return_value=True) + + consumer = GatewayStreamConsumer(adapter, "chat-1", metadata={"thread_id": "77"}) + consumer._message_id = "preview-1" + consumer._last_sent_text = "hello " + + ok = await consumer._send_or_edit("hello world", finalize=True) + + assert ok is False + assert consumer.final_response_sent is False + assert consumer.final_content_delivered is False + assert consumer._fallback_final_send is True + assert consumer._fallback_prefix == "hello " + + await consumer._send_fallback_final("hello world") + + adapter.send.assert_awaited_once() + assert adapter.send.await_args.kwargs["content"] == "world" + assert adapter.send.await_args.kwargs["metadata"] == {"thread_id": "77"} + adapter.delete_message.assert_not_awaited() + assert consumer.final_response_sent is True + assert consumer.final_content_delivered is True From da818510ec753f1c7def777eeca51bb1e4d17d1e Mon Sep 17 00:00:00 2001 From: GodsBoy Date: Wed, 10 Jun 2026 11:10:17 +0200 Subject: [PATCH 10/69] fix(gateway): finalize best-effort delivery when stream consumer is cancelled --- gateway/stream_consumer.py | 14 ++- .../test_stream_consumer_fresh_final.py | 119 ++++++++++++++++++ 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index 8ebde02411b..2bd77d8dac6 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -652,11 +652,21 @@ class GatewayStreamConsumer: await asyncio.sleep(0.05) # Small yield to not busy-loop except asyncio.CancelledError: - # Best-effort final edit on cancellation + # Best-effort final edit on cancellation. finalize=True so + # REQUIRES_EDIT_FINALIZE platforms (Telegram) apply final + # formatting — a plain edit here would leave the entire reply + # rendered as a raw streaming preview while the success flags + # below suppress the gateway's formatted re-send. + # is_turn_final=False keeps _try_fresh_final from setting + # _final_response_sent itself; this handler owns the flags. _best_effort_ok = False if self._accumulated and self._message_id: try: - _best_effort_ok = bool(await self._send_or_edit(self._accumulated)) + _best_effort_ok = bool( + await self._send_or_edit( + self._accumulated, finalize=True, is_turn_final=False, + ) + ) except Exception: pass # Only confirm final delivery if the best-effort send above diff --git a/tests/gateway/test_stream_consumer_fresh_final.py b/tests/gateway/test_stream_consumer_fresh_final.py index 2ecef4a488b..bf467781638 100644 --- a/tests/gateway/test_stream_consumer_fresh_final.py +++ b/tests/gateway/test_stream_consumer_fresh_final.py @@ -347,6 +347,125 @@ class TestSegmentBreakDoesNotMarkFinalSent: assert any("answer is 42" in t for t in self._delivered_texts(adapter)) +class TestCancelledBestEffortDeliveryFinalizes: + """Cancel-path best-effort delivery must go through the finalize path. + + The gateway cancels the consumer shortly after finish(). The + CancelledError handler re-delivers the accumulated text; previously it + did so with finalize=False, so REQUIRES_EDIT_FINALIZE platforms + (Telegram) kept the plain streaming preview — the whole final reply + rendered with raw markdown markers — while the success flags still + suppressed the gateway's formatted re-send. + """ + + @pytest.mark.asyncio + async def test_cancel_best_effort_edit_is_finalized(self): + adapter = _make_adapter() + adapter.REQUIRES_EDIT_FINALIZE = True + consumer = GatewayStreamConsumer( + adapter=adapter, + chat_id="chat", + config=StreamConsumerConfig( + edit_interval=0.01, buffer_threshold=5, cursor=" ▉", + ), + ) + consumer.on_delta("Reply with **bold** and `code` markers.") + task = asyncio.create_task(consumer.run()) + await asyncio.sleep(0.05) # preview lands; message_id set + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + finalize_edits = [ + c for c in adapter.edit_message.call_args_list + if c.kwargs.get("finalize") + ] + assert finalize_edits, ( + "cancel best-effort delivery must use finalize=True so " + "REQUIRES_EDIT_FINALIZE platforms apply final formatting" + ) + assert consumer.final_response_sent is True + assert consumer.final_content_delivered is True + + @pytest.mark.asyncio + async def test_cancel_best_effort_failure_keeps_gateway_resend_possible(self): + adapter = _make_adapter() + adapter.REQUIRES_EDIT_FINALIZE = True + consumer = GatewayStreamConsumer( + adapter=adapter, + chat_id="chat", + config=StreamConsumerConfig( + edit_interval=0.01, buffer_threshold=5, cursor=" ▉", + ), + ) + consumer.on_delta("Reply with **bold** and `code` markers.") + task = asyncio.create_task(consumer.run()) + await asyncio.sleep(0.05) + # Best-effort delivery at cancel time fails. + adapter.edit_message = AsyncMock(return_value=SimpleNamespace( + success=False, error="boom", + )) + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + assert consumer.final_response_sent is False + assert consumer.final_content_delivered is False + + @pytest.mark.asyncio + async def test_cancel_without_preview_makes_no_delivery_attempt(self): + adapter = _make_adapter() + adapter.REQUIRES_EDIT_FINALIZE = True + consumer = GatewayStreamConsumer( + adapter=adapter, + chat_id="chat", + config=StreamConsumerConfig( + edit_interval=0.01, buffer_threshold=5, cursor=" ▉", + ), + ) + task = asyncio.create_task(consumer.run()) + await asyncio.sleep(0.02) + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + adapter.edit_message.assert_not_called() + assert consumer.final_response_sent is False + assert consumer.final_content_delivered is False + + @pytest.mark.asyncio + async def test_cancel_with_fresh_final_enabled_delivers_and_flags_via_handler(self): + """With fresh_final_after_seconds enabled and an aged preview, the + finalized cancel-path delivery is eligible for fresh-final + (delete + fresh send). is_turn_final=False keeps _try_fresh_final + from setting the flags itself; the cancel handler sets them after + the successful delivery.""" + adapter = _make_adapter() + adapter.REQUIRES_EDIT_FINALIZE = True + adapter.send.side_effect = [ + SimpleNamespace(success=True, message_id="initial_preview"), + SimpleNamespace(success=True, message_id="fresh_final"), + ] + consumer = GatewayStreamConsumer( + adapter=adapter, + chat_id="chat", + config=StreamConsumerConfig( + edit_interval=0.01, buffer_threshold=5, cursor=" ▉", + fresh_final_after_seconds=0.001, + ), + ) + consumer.on_delta("Reply with **bold** and `code` markers.") + task = asyncio.create_task(consumer.run()) + await asyncio.sleep(0.05) + consumer._message_created_ts = 0.0 # force the preview stale + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + # Fresh-final engaged: a second send replaced the stale preview. + assert adapter.send.call_count == 2 + adapter.delete_message.assert_awaited_once_with("chat", "initial_preview") + # Flags were set by the cancel handler after successful delivery. + assert consumer.final_response_sent is True + assert consumer.final_content_delivered is True + + class TestStreamConsumerConfigFreshFinalField: """The dataclass field must exist and default to 0 (disabled).""" From 3b4c715e1c50cfa180934d37e8364b42b1bf158d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:32:13 -0700 Subject: [PATCH 11/69] fix(telegram): stripped-text fallbacks, re-finalize skip, and tail-only delete guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups on top of the two salvaged GodsBoy commits, all live-validated against the real Telegram Bot API: - _edit_overflow_split finalize fallbacks degrade to _strip_mdv2() clean text instead of putting raw **markdown** markers on screen (salvaged from PR #43463 minus its format-first sizing — live probes show Telegram's 4096 limit counts PARSED text, so MarkdownV2 escape inflation cannot cause MESSAGE_TOO_LONG and sizing against formatted wire length only causes premature splits and fragment messages). - Skip the redundant requires-finalize edit after a got_done edit that split-and-delivered (salvaged from PR #43463): re-finalizing re-splits the full text into the adopted continuation and duplicates chunks. - _send_fallback_final only deletes the stale partial message when the fallback re-sent the COMPLETE final text. When the prefix dedup sent only the missing tail, the partial IS the head of the answer; deleting it left users with only the second half of long responses (live- reproduced: flood-control during a long stream -> head deleted, ratio 0.54 of content visible). This is the third bug behind the 'Telegram cut messages' reports and was present on main and both PRs. --- gateway/platforms/telegram.py | 18 ++++- gateway/stream_consumer.py | 29 +++++-- tests/gateway/test_stream_consumer.py | 49 ++++++++++-- .../test_stream_consumer_fresh_final.py | 75 +++++++++++++++++++ 4 files changed, 156 insertions(+), 15 deletions(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 051a377dc06..5cba11d2ee9 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -2348,10 +2348,15 @@ class TelegramAdapter(BasePlatformAdapter): ) except Exception as fmt_err: if "not modified" not in str(fmt_err).lower(): + logger.warning( + "[%s] Overflow split: MarkdownV2 first-chunk edit " + "failed, falling back to plain text: %s", + self.name, fmt_err, + ) await self._bot.edit_message_text( chat_id=int(chat_id), message_id=int(message_id), - text=first_chunk, + text=_strip_mdv2(first_chunk), ) else: await self._bot.edit_message_text( @@ -2393,7 +2398,14 @@ class TelegramAdapter(BasePlatformAdapter): ) for use_markdown in (True, False) if finalize else (False,): try: - text = self.format_message(chunk) if use_markdown else chunk + if use_markdown: + text = self.format_message(chunk) + else: + # Plain attempt: on finalize the MarkdownV2 attempt + # failed, so degrade to clean stripped text, never + # the raw chunk (raw ** / ``` markers would render + # literally); streaming previews stay raw. + text = _strip_mdv2(chunk) if finalize else chunk sent_msg = await self._bot.send_message( chat_id=int(chat_id), text=text, @@ -2419,7 +2431,7 @@ class TelegramAdapter(BasePlatformAdapter): try: sent_msg = await self._bot.send_message( chat_id=int(chat_id), - text=chunk, + text=_strip_mdv2(chunk) if finalize else chunk, **retry_thread_kwargs, **self._link_preview_kwargs(), **self._notification_kwargs(metadata), diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index 2bd77d8dac6..53434da3c40 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -147,6 +147,9 @@ class GatewayStreamConsumer: self._edit_supported = True # Disabled when progressive edits are no longer usable self._last_edit_time = 0.0 self._last_sent_text = "" # Track last-sent text to skip redundant edits + # True when the most recent _send_or_edit split-and-delivered across + # continuation messages (the adapter adopted a new message id). + self._last_edit_overflowed = False self._fallback_final_send = False self._fallback_prefix = "" # True when fallback is sending only the missing tail after a partial @@ -586,14 +589,20 @@ class GatewayStreamConsumer: if self._accumulated: if self._fallback_final_send: await self._send_fallback_final(self._accumulated) - elif ( - current_update_visible - and not self._adapter_requires_finalize + elif current_update_visible and ( + not self._adapter_requires_finalize + or self._last_edit_overflowed ): # Mid-stream edit above already delivered the # final accumulated content. Skip the redundant - # final edit — but only for adapters that don't - # need an explicit finalize signal. + # final edit for adapters that don't need an + # explicit finalize signal, and for any adapter + # when that edit split-and-delivered across + # continuations: the split edit carried + # finalize=True itself, and re-finalizing with + # the full text would overflow-split again into + # the adopted continuation, duplicating chunks + # on screen. self._final_response_sent = True self._final_content_delivered = True elif self._message_id: @@ -882,7 +891,12 @@ class GatewayStreamConsumer: self._notify_new_message() # Remove the frozen partial message so the user only sees the - # complete fallback response. Best-effort — if the platform doesn't + # complete fallback response. ONLY safe when the fallback re-sent + # the FULL final text (continuation == final_text). When the + # prefix-based dedup above sent only the missing TAIL, the partial + # message IS the head of the answer — deleting it leaves the user + # with only the last part of the response (the "Gemini sent only + # the second half" symptom). Best-effort — if the platform doesn't # implement ``delete_message``, the delete fails (flood control still # active, bot lacks permission, message too old to delete), the # partial remains but at least the full answer was delivered. @@ -890,6 +904,7 @@ class GatewayStreamConsumer: stale_message_id and stale_message_id != last_message_id and not self._fallback_preserve_partial_messages + and continuation == final_text ): delete_fn = getattr(self.adapter, "delete_message", None) if delete_fn is not None: @@ -1228,6 +1243,7 @@ class GatewayStreamConsumer: return True # Failure already disabled drafts for this run; fall through to # the regular edit/send path below. + self._last_edit_overflowed = False try: if self._message_id is not None: if self._edit_supported: @@ -1284,6 +1300,7 @@ class GatewayStreamConsumer: and result.message_id and result.message_id != self._message_id ): + self._last_edit_overflowed = True self._message_id = str(result.message_id) self._message_created_ts = time.monotonic() self._last_sent_text = "" diff --git a/tests/gateway/test_stream_consumer.py b/tests/gateway/test_stream_consumer.py index 9a445532d0d..af012fb69a7 100644 --- a/tests/gateway/test_stream_consumer.py +++ b/tests/gateway/test_stream_consumer.py @@ -794,9 +794,11 @@ class TestSegmentBreakOnToolBoundary: ) @pytest.mark.asyncio - async def test_fallback_final_deletes_partial_after_chunks_succeed(self): - """After fallback chunks land, the frozen partial must be deleted so - the user sees only the complete response (#16668).""" + async def test_fallback_final_deletes_partial_after_full_resend(self): + """After fallback re-sends the COMPLETE response, the frozen partial + must be deleted so the user sees only the complete response (#16668). + Full resend happens when the visible prefix doesn't match the final + text (e.g. post-segment-break content, #10807).""" adapter = MagicMock() adapter.send = AsyncMock( return_value=SimpleNamespace(success=True, message_id="msg_new"), @@ -810,14 +812,49 @@ class TestSegmentBreakOnToolBoundary: config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5) consumer = GatewayStreamConsumer(adapter, "chat_123", config) - # Seed the consumer as if it already edited a partial message that - # later got stuck (flood control etc.) — _message_id is the stale id. + # The stale partial shows pre-tool text that is NOT a prefix of the + # final response — fallback re-sends the complete final text. + consumer._message_id = "msg_partial" + consumer._last_sent_text = "Let me check that for you…" + + await consumer._send_fallback_final("Working on it. Done!") + + adapter.delete_message.assert_awaited_once_with("chat_123", "msg_partial") + assert consumer._final_response_sent is True + + @pytest.mark.asyncio + async def test_fallback_final_keeps_partial_after_tail_only_send(self): + """When the fallback sends only the missing TAIL (visible prefix + matches the final text), the partial message IS the head of the + answer — deleting it would leave the user with only the last part + of the response (the 'model sent only the second half' bug).""" + adapter = MagicMock() + adapter.send = AsyncMock( + return_value=SimpleNamespace(success=True, message_id="msg_new"), + ) + adapter.edit_message = AsyncMock( + return_value=SimpleNamespace(success=True), + ) + adapter.delete_message = AsyncMock(return_value=None) + adapter.MAX_MESSAGE_LENGTH = 4096 + + config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5) + consumer = GatewayStreamConsumer(adapter, "chat_123", config) + + # Visible partial is a true prefix of the final response — the + # fallback dedup sends only the tail. consumer._message_id = "msg_partial" consumer._last_sent_text = "Working on i" await consumer._send_fallback_final("Working on it. Done!") - adapter.delete_message.assert_awaited_once_with("chat_123", "msg_partial") + # Tail was sent... + sent_contents = [ + c.kwargs.get("content", "") for c in adapter.send.call_args_list + ] + assert any("Done!" in s and "Working on i" not in s for s in sent_contents) + # ...and the head-bearing partial was NOT deleted. + adapter.delete_message.assert_not_awaited() assert consumer._final_response_sent is True @pytest.mark.asyncio diff --git a/tests/gateway/test_stream_consumer_fresh_final.py b/tests/gateway/test_stream_consumer_fresh_final.py index bf467781638..975c0ada590 100644 --- a/tests/gateway/test_stream_consumer_fresh_final.py +++ b/tests/gateway/test_stream_consumer_fresh_final.py @@ -466,6 +466,81 @@ class TestCancelledBestEffortDeliveryFinalizes: assert consumer.final_content_delivered is True +class TestGotDoneOverflowSplitNotRefinalized: + """A got_done finalize edit that split-and-delivered across continuation + messages must not be followed by the redundant requires-finalize edit. + + After a split, the consumer adopts the last continuation as the live + message and the redundant finalize edit re-submits the FULL accumulated + text against it; the adapter pre-flights that into another overflow + split, editing chunk 1 over the continuation and re-sending the rest, + so the user sees duplicated chunks. The finalize signal was already + carried by the split edit itself. + """ + + def _consumer(self, adapter): + # High interval/threshold so the only edit is the got_done finalize. + return GatewayStreamConsumer( + adapter=adapter, + chat_id="chat", + config=StreamConsumerConfig( + edit_interval=10.0, buffer_threshold=10_000, cursor=" ▉", + ), + ) + + @pytest.mark.asyncio + async def test_split_finalize_edit_is_not_refinalized(self): + adapter = _make_adapter() + adapter.REQUIRES_EDIT_FINALIZE = True + adapter.edit_message = AsyncMock(return_value=SimpleNamespace( + success=True, + message_id="cont_2", + continuation_message_ids=("cont_2",), + )) + consumer = self._consumer(adapter) + consumer.on_delta("oversize **markdown** final reply") + task = asyncio.create_task(consumer.run()) + await asyncio.sleep(0.05) # preview send lands; no interval edits + consumer.finish() + await task + + finalize_edits = [ + c for c in adapter.edit_message.call_args_list + if c.kwargs.get("finalize") + ] + assert len(finalize_edits) == 1, ( + "split finalize edit must not be re-finalized; the redundant " + "edit re-splits the full text into the adopted continuation " + "and duplicates chunks on screen" + ) + assert consumer.final_response_sent is True + assert consumer.final_content_delivered is True + + @pytest.mark.asyncio + async def test_non_split_finalize_edit_still_gets_explicit_refinalize(self): + """The narrow fix must not regress the requires-finalize contract: + a normal (non-split) got_done edit is still followed by the + explicit finalize edit (#25010 semantics unchanged).""" + adapter = _make_adapter() + adapter.REQUIRES_EDIT_FINALIZE = True + adapter.edit_message = AsyncMock(return_value=SimpleNamespace( + success=True, message_id="initial_preview", + )) + consumer = self._consumer(adapter) + consumer.on_delta("short final reply") + task = asyncio.create_task(consumer.run()) + await asyncio.sleep(0.05) + consumer.finish() + await task + + finalize_edits = [ + c for c in adapter.edit_message.call_args_list + if c.kwargs.get("finalize") + ] + assert len(finalize_edits) == 2 + assert consumer.final_response_sent is True + + class TestStreamConsumerConfigFreshFinalField: """The dataclass field must exist and default to 0 (disabled).""" From 0a593f132c41d35111b1b84f599b3a0316ebaaf8 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Wed, 10 Jun 2026 19:42:36 +0700 Subject: [PATCH 12/69] fix(skills/github): resolve .env via HERMES_HOME, not hardcoded ~/.hermes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GitHub skills' auth-detection fell back to reading GITHUB_TOKEN from a hardcoded ~/.hermes/.env. In the official Docker layout HERMES_HOME=/opt/data while tool subprocesses run with HOME=/opt/data/home, so `~/.hermes/.env` expands to /opt/data/home/.hermes/.env — a path that does not exist — while the real secrets file is /opt/data/.env. Result: the agent reports GITHUB_TOKEN as "not set" even though it is present and the dashboard Keys page shows it. Resolve the file as ${HERMES_HOME:-$HOME/.hermes}/.env (HERMES_HOME is bridged into tool subprocess env, falling back to ~/.hermes when unset) across all six auth-detection sites: github-auth (SKILL.md + scripts/gh-env.sh), github-issues, github-repo-management, github-pr-workflow, github-code-review. --- skills/github/github-auth/SKILL.md | 4 ++-- skills/github/github-auth/scripts/gh-env.sh | 4 ++-- skills/github/github-code-review/SKILL.md | 4 ++-- skills/github/github-issues/SKILL.md | 4 ++-- skills/github/github-pr-workflow/SKILL.md | 4 ++-- skills/github/github-repo-management/SKILL.md | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/skills/github/github-auth/SKILL.md b/skills/github/github-auth/SKILL.md index 6b929a408d5..95606d36709 100644 --- a/skills/github/github-auth/SKILL.md +++ b/skills/github/github-auth/SKILL.md @@ -220,8 +220,8 @@ if command -v gh &>/dev/null && gh auth status &>/dev/null; then echo "AUTH_METHOD=gh" elif [ -n "$GITHUB_TOKEN" ]; then echo "AUTH_METHOD=curl" -elif [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then - export GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r') +elif _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && grep -q "^GITHUB_TOKEN=" "$_hermes_env"; then + export GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$_hermes_env" | head -1 | cut -d= -f2 | tr -d '\n\r') echo "AUTH_METHOD=curl" elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then export GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') diff --git a/skills/github/github-auth/scripts/gh-env.sh b/skills/github/github-auth/scripts/gh-env.sh index 043c6b5551b..47b3ff98c5c 100755 --- a/skills/github/github-auth/scripts/gh-env.sh +++ b/skills/github/github-auth/scripts/gh-env.sh @@ -23,8 +23,8 @@ if command -v gh &>/dev/null && gh auth status &>/dev/null 2>&1; then GH_USER=$(gh api user --jq '.login' 2>/dev/null) elif [ -n "$GITHUB_TOKEN" ]; then GH_AUTH_METHOD="curl" -elif [ -f "$HOME/.hermes/.env" ] && grep -q "^GITHUB_TOKEN=" "$HOME/.hermes/.env" 2>/dev/null; then - GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$HOME/.hermes/.env" | head -1 | cut -d= -f2 | tr -d '\n\r') +elif _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && grep -q "^GITHUB_TOKEN=" "$_hermes_env" 2>/dev/null; then + GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$_hermes_env" | head -1 | cut -d= -f2 | tr -d '\n\r') if [ -n "$GITHUB_TOKEN" ]; then GH_AUTH_METHOD="curl" fi diff --git a/skills/github/github-code-review/SKILL.md b/skills/github/github-code-review/SKILL.md index 3b50ac45279..b5830923512 100644 --- a/skills/github/github-code-review/SKILL.md +++ b/skills/github/github-code-review/SKILL.md @@ -28,8 +28,8 @@ if command -v gh &>/dev/null && gh auth status &>/dev/null; then else AUTH="git" if [ -z "$GITHUB_TOKEN" ]; then - if [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then - GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r') + if _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && grep -q "^GITHUB_TOKEN=" "$_hermes_env"; then + GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$_hermes_env" | head -1 | cut -d= -f2 | tr -d '\n\r') elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') fi diff --git a/skills/github/github-issues/SKILL.md b/skills/github/github-issues/SKILL.md index 338074f885c..bded118a10a 100644 --- a/skills/github/github-issues/SKILL.md +++ b/skills/github/github-issues/SKILL.md @@ -28,8 +28,8 @@ if command -v gh &>/dev/null && gh auth status &>/dev/null; then else AUTH="git" if [ -z "$GITHUB_TOKEN" ]; then - if [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then - GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r') + if _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && grep -q "^GITHUB_TOKEN=" "$_hermes_env"; then + GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$_hermes_env" | head -1 | cut -d= -f2 | tr -d '\n\r') elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') fi diff --git a/skills/github/github-pr-workflow/SKILL.md b/skills/github/github-pr-workflow/SKILL.md index 0b02eca3d1e..69eb21183d3 100644 --- a/skills/github/github-pr-workflow/SKILL.md +++ b/skills/github/github-pr-workflow/SKILL.md @@ -30,8 +30,8 @@ else AUTH="git" # Ensure we have a token for API calls if [ -z "$GITHUB_TOKEN" ]; then - if [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then - GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r') + if _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && grep -q "^GITHUB_TOKEN=" "$_hermes_env"; then + GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$_hermes_env" | head -1 | cut -d= -f2 | tr -d '\n\r') elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') fi diff --git a/skills/github/github-repo-management/SKILL.md b/skills/github/github-repo-management/SKILL.md index 0ba049e2787..1026ce36e49 100644 --- a/skills/github/github-repo-management/SKILL.md +++ b/skills/github/github-repo-management/SKILL.md @@ -27,8 +27,8 @@ if command -v gh &>/dev/null && gh auth status &>/dev/null; then else AUTH="git" if [ -z "$GITHUB_TOKEN" ]; then - if [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then - GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r') + if _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && grep -q "^GITHUB_TOKEN=" "$_hermes_env"; then + GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$_hermes_env" | head -1 | cut -d= -f2 | tr -d '\n\r') elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') fi From d1383a6b1450c6c139720b1b01f8b99cc130453f Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:46:21 -0700 Subject: [PATCH 13/69] fix(skills): widen HERMES_HOME-aware .env resolution to all sibling skills Follow-up to the GitHub-skills fix: the same hardcoded ~/.hermes/.env pattern existed across other bundled and optional skills. Under the official Docker setup (HERMES_HOME=/opt/data, subprocess HOME=/opt/data/home) those paths point at a nonexistent file. - kanban-video-orchestrator setup.sh.tmpl + docs: resolve via ${HERMES_HOME:-$HOME/.hermes}/.env in check_key() - telephony.py / canvas_api.py / hyperliquid_client.py: error and save messages now report the real resolved env path instead of a hardcoded literal (path resolution itself was already correct) - godmode SKILL.md: load_dotenv snippet resolves via HERMES_HOME - watch_github.py + ~20 SKILL.md prose mentions: document the env file as ${HERMES_HOME:-~/.hermes}/.env so Docker users edit the right file --- .../blockchain/hyperliquid/SKILL.md | 4 +-- .../hyperliquid/scripts/hyperliquid_client.py | 2 +- .../kanban-video-orchestrator/SKILL.md | 2 +- .../assets/setup.sh.tmpl | 7 +++-- .../references/kanban-setup.md | 10 ++++--- .../references/tool-matrix.md | 4 +-- optional-skills/devops/watchers/SKILL.md | 2 +- .../devops/watchers/scripts/watch_github.py | 3 +- optional-skills/productivity/canvas/SKILL.md | 2 +- .../productivity/canvas/scripts/canvas_api.py | 5 +++- optional-skills/productivity/shopify/SKILL.md | 2 +- optional-skills/productivity/siyuan/SKILL.md | 2 +- .../productivity/telephony/SKILL.md | 8 ++--- .../telephony/scripts/telephony.py | 30 +++++++++---------- optional-skills/security/1password/SKILL.md | 2 +- optional-skills/security/godmode/SKILL.md | 2 +- .../rest-graphql-debug/SKILL.md | 2 +- .../hermes-agent/SKILL.md | 4 +-- .../hermes-agent/references/webhooks.md | 2 +- skills/media/gif-search/SKILL.md | 2 +- skills/note-taking/obsidian/SKILL.md | 2 +- skills/productivity/airtable/SKILL.md | 4 +-- skills/productivity/notion/SKILL.md | 4 +-- .../teams-meeting-pipeline/SKILL.md | 2 +- skills/research/llm-wiki/SKILL.md | 2 +- 25 files changed, 59 insertions(+), 52 deletions(-) diff --git a/optional-skills/blockchain/hyperliquid/SKILL.md b/optional-skills/blockchain/hyperliquid/SKILL.md index ec0671e0508..51843bbf1b3 100644 --- a/optional-skills/blockchain/hyperliquid/SKILL.md +++ b/optional-skills/blockchain/hyperliquid/SKILL.md @@ -36,7 +36,7 @@ Read-only — no API key, no signing, no order placement. Stdlib only — no external packages, no API key. -The script reads `~/.hermes/.env` for two optional defaults: +The script reads `${HERMES_HOME:-~/.hermes}/.env` for two optional defaults: - `HYPERLIQUID_API_URL` — defaults to `https://api.hyperliquid.xyz`. Set to `https://api.hyperliquid-testnet.xyz` for testnet. @@ -80,7 +80,7 @@ hyperliquid_client.py export [--interval 1h] [--hours N] [--output PATH] ``` For `state`, `spot-balances`, `fills`, `orders`, and `review`, the address is -optional when `HYPERLIQUID_USER_ADDRESS` is set in `~/.hermes/.env`. +optional when `HYPERLIQUID_USER_ADDRESS` is set in `${HERMES_HOME:-~/.hermes}/.env`. --- diff --git a/optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py b/optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py index 1079f6b6267..be2a95d5f99 100644 --- a/optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py +++ b/optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py @@ -115,7 +115,7 @@ def _resolve_user(user: Optional[str]) -> str: sys.exit( "Missing Hyperliquid address. Pass

explicitly or set " - f"{DEFAULT_USER_ENV} in your environment or ~/.hermes/.env." + f"{DEFAULT_USER_ENV} in your environment or {_hermes_home() / '.env'}." ) diff --git a/optional-skills/creative/kanban-video-orchestrator/SKILL.md b/optional-skills/creative/kanban-video-orchestrator/SKILL.md index f06972abd5f..c5ac2a8c96e 100644 --- a/optional-skills/creative/kanban-video-orchestrator/SKILL.md +++ b/optional-skills/creative/kanban-video-orchestrator/SKILL.md @@ -182,7 +182,7 @@ task graphs. See **[references/examples.md](references/examples.md)**. right human-review gates. 8. **Verify API keys BEFORE firing.** External APIs (TTS, image-gen, - image-to-video) need keys in `~/.hermes/.env` or the user's secret store. + image-to-video) need keys in `${HERMES_HOME:-~/.hermes}/.env` or the user's secret store. A worker that hits a missing-key error wastes a task slot. The setup script's `check_key` helper aborts cleanly if a required key is missing. diff --git a/optional-skills/creative/kanban-video-orchestrator/assets/setup.sh.tmpl b/optional-skills/creative/kanban-video-orchestrator/assets/setup.sh.tmpl index 01d836def8d..3f7629d6293 100644 --- a/optional-skills/creative/kanban-video-orchestrator/assets/setup.sh.tmpl +++ b/optional-skills/creative/kanban-video-orchestrator/assets/setup.sh.tmpl @@ -23,8 +23,9 @@ check_key() { local var="$1" local kc_account="${2:-hermes}" local kc_service="${3:-$1}" - if grep -q "^${var}=" "$HOME/.hermes/.env" 2>/dev/null && \ - [ -n "$(grep "^${var}=" "$HOME/.hermes/.env" | cut -d= -f2-)" ]; then + local _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env" + if grep -q "^${var}=" "$_hermes_env" 2>/dev/null && \ + [ -n "$(grep "^${var}=" "$_hermes_env" | cut -d= -f2-)" ]; then echo " ✓ ${var} (env)" return 0 fi @@ -33,7 +34,7 @@ check_key() { echo " ✓ ${var} (Keychain ${kc_account}/${kc_service})" return 0 fi - echo " ✗ ${var} not set in ~/.hermes/.env or Keychain (${kc_account}/${kc_service})" + echo " ✗ ${var} not set in ${_hermes_env} or Keychain (${kc_account}/${kc_service})" return 1 } diff --git a/optional-skills/creative/kanban-video-orchestrator/references/kanban-setup.md b/optional-skills/creative/kanban-video-orchestrator/references/kanban-setup.md index ab449a0b0a4..53e4f269997 100644 --- a/optional-skills/creative/kanban-video-orchestrator/references/kanban-setup.md +++ b/optional-skills/creative/kanban-video-orchestrator/references/kanban-setup.md @@ -218,22 +218,24 @@ The director turns this into actual `kanban_create` calls. ## API-key prerequisites check Before firing the kanban, verify required keys are available. Check both -`~/.hermes/.env` and macOS Keychain (if on macOS): +the Hermes `.env` (`${HERMES_HOME:-$HOME/.hermes}/.env`) and macOS Keychain +(if on macOS): ```bash check_key() { local var="$1" local kc_account="$2" local kc_service="$3" - if grep -q "^${var}=" ~/.hermes/.env 2>/dev/null && \ - [ -n "$(grep "^${var}=" ~/.hermes/.env | cut -d= -f2-)" ]; then + local _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env" + if grep -q "^${var}=" "$_hermes_env" 2>/dev/null && \ + [ -n "$(grep "^${var}=" "$_hermes_env" | cut -d= -f2-)" ]; then return 0 fi if command -v security >/dev/null 2>&1 && \ security find-generic-password -a "${kc_account}" -s "${kc_service}" -w >/dev/null 2>&1; then return 0 fi - echo "ERROR: ${var} not set in ~/.hermes/.env or Keychain (${kc_account}/${kc_service})" + echo "ERROR: ${var} not set in ${_hermes_env} or Keychain (${kc_account}/${kc_service})" return 1 } diff --git a/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md b/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md index 5a52d15ddd0..b5e59c31478 100644 --- a/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md +++ b/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md @@ -284,7 +284,7 @@ skills: ## API key requirements Track these in the project setup. The setup script should verify each required -key is present in `~/.hermes/.env` (or macOS Keychain) before firing the kanban. +key is present in `${HERMES_HOME:-~/.hermes}/.env` (or macOS Keychain) before firing the kanban. | Service | Env var | Used by | |---------|---------|---------| @@ -301,7 +301,7 @@ key is present in `~/.hermes/.env` (or macOS Keychain) before firing the kanban. | Anthropic | `ANTHROPIC_API_KEY` | every Hermes profile (Claude) | If a key is missing, prompt the user to add it. Storage methods, in order of -preference: macOS Keychain → `~/.hermes/.env` → environment variable. +preference: macOS Keychain → `${HERMES_HOME:-~/.hermes}/.env` → environment variable. ## Skill version pinning diff --git a/optional-skills/devops/watchers/SKILL.md b/optional-skills/devops/watchers/SKILL.md index 628f340b4c8..7c326ae7e4b 100644 --- a/optional-skills/devops/watchers/SKILL.md +++ b/optional-skills/devops/watchers/SKILL.md @@ -62,7 +62,7 @@ python $HERMES_HOME/skills/devops/watchers/scripts/watch_rss.py \ --name hn --url https://news.ycombinator.com/rss --max 5 ``` -Watch a GitHub repo (set `GITHUB_TOKEN` in `~/.hermes/.env` to avoid the 60 req/hr anonymous rate limit): +Watch a GitHub repo (set `GITHUB_TOKEN` in `${HERMES_HOME:-~/.hermes}/.env` to avoid the 60 req/hr anonymous rate limit): ```bash python $HERMES_HOME/skills/devops/watchers/scripts/watch_github.py \ diff --git a/optional-skills/devops/watchers/scripts/watch_github.py b/optional-skills/devops/watchers/scripts/watch_github.py index bb4a3ca6f30..4b42d4ed3ee 100755 --- a/optional-skills/devops/watchers/scripts/watch_github.py +++ b/optional-skills/devops/watchers/scripts/watch_github.py @@ -8,7 +8,8 @@ Usage (via cron with --no-agent): --script "$HERMES_HOME/skills/devops/watchers/scripts/watch_github.py" \\ --script-args "--name hermes-issues --repo NousResearch/hermes-agent --scope issues" -Set GITHUB_TOKEN (or GH_TOKEN) in ~/.hermes/.env to avoid the 60 req/hr +Set GITHUB_TOKEN (or GH_TOKEN) in the Hermes .env file +(``${HERMES_HOME:-~/.hermes}/.env``) to avoid the 60 req/hr anonymous rate limit. Scopes: issues | pulls | releases | commits. Or pass --search QUERY to diff --git a/optional-skills/productivity/canvas/SKILL.md b/optional-skills/productivity/canvas/SKILL.md index fbcfec5853a..68d6402e554 100644 --- a/optional-skills/productivity/canvas/SKILL.md +++ b/optional-skills/productivity/canvas/SKILL.md @@ -26,7 +26,7 @@ Read-only access to Canvas LMS for listing courses and assignments. 2. Go to **Account → Settings** (click your profile icon, then Settings) 3. Scroll to **Approved Integrations** and click **+ New Access Token** 4. Name the token (e.g., "Hermes Agent"), set an optional expiry, and click **Generate Token** -5. Copy the token and add to `~/.hermes/.env`: +5. Copy the token and add to `${HERMES_HOME:-~/.hermes}/.env`: ``` CANVAS_API_TOKEN=your_token_here diff --git a/optional-skills/productivity/canvas/scripts/canvas_api.py b/optional-skills/productivity/canvas/scripts/canvas_api.py index 13599c57556..2390d5ff513 100644 --- a/optional-skills/productivity/canvas/scripts/canvas_api.py +++ b/optional-skills/productivity/canvas/scripts/canvas_api.py @@ -28,9 +28,12 @@ def _check_config(): if not CANVAS_BASE_URL: missing.append("CANVAS_BASE_URL") if missing: + hermes_env = os.path.join( + os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")), ".env" + ) print( f"Missing required environment variables: {', '.join(missing)}\n" - "Set them in ~/.hermes/.env or export them in your shell.\n" + f"Set them in {hermes_env} or export them in your shell.\n" "See the canvas skill SKILL.md for setup instructions.", file=sys.stderr, ) diff --git a/optional-skills/productivity/shopify/SKILL.md b/optional-skills/productivity/shopify/SKILL.md index 0062674069a..4dc8dc93ad8 100644 --- a/optional-skills/productivity/shopify/SKILL.md +++ b/optional-skills/productivity/shopify/SKILL.md @@ -36,7 +36,7 @@ The REST Admin API is legacy since 2024-04 and only receives security fixes. **U 1. In Shopify admin: **Settings → Apps and sales channels → Develop apps → Create an app**. 2. Click **Configure Admin API scopes**, select what you need (examples below), save. 3. **Install app** → the Admin API access token appears ONCE. Copy it immediately — Shopify will never show it again. Tokens start with `shpat_`. -4. Save to `~/.hermes/.env`: +4. Save to `${HERMES_HOME:-~/.hermes}/.env`: ``` SHOPIFY_ACCESS_TOKEN=shpat_xxxxxxxxxxxxxxxxxxxx SHOPIFY_STORE_DOMAIN=my-store.myshopify.com diff --git a/optional-skills/productivity/siyuan/SKILL.md b/optional-skills/productivity/siyuan/SKILL.md index 0417ba6c4c5..3f199776438 100644 --- a/optional-skills/productivity/siyuan/SKILL.md +++ b/optional-skills/productivity/siyuan/SKILL.md @@ -30,7 +30,7 @@ Use the [SiYuan](https://github.com/siyuan-note/siyuan) kernel API via curl to s 1. Install and run SiYuan (desktop or Docker) 2. Get your API token: **Settings > About > API token** -3. Store it in `~/.hermes/.env`: +3. Store it in `${HERMES_HOME:-~/.hermes}/.env`: ``` SIYUAN_TOKEN=your_token_here SIYUAN_URL=http://127.0.0.1:6806 diff --git a/optional-skills/productivity/telephony/SKILL.md b/optional-skills/productivity/telephony/SKILL.md index b3d1d5884eb..f0d28614912 100644 --- a/optional-skills/productivity/telephony/SKILL.md +++ b/optional-skills/productivity/telephony/SKILL.md @@ -17,7 +17,7 @@ metadata: This optional skill gives Hermes practical phone capabilities while keeping telephony out of the core tool list. It ships with a helper script, `scripts/telephony.py`, that can: -- save provider credentials into `~/.hermes/.env` +- save provider credentials into `${HERMES_HOME:-~/.hermes}/.env` - search for and buy a Twilio phone number - remember that owned number for later sessions - send SMS / MMS from the owned number @@ -104,7 +104,7 @@ Why: The skill persists telephony state in two places: -### `~/.hermes/.env` +### `${HERMES_HOME:-~/.hermes}/.env` Used for long-lived provider credentials and owned-number IDs, for example: - `TWILIO_ACCOUNT_SID` - `TWILIO_AUTH_TOKEN` @@ -241,7 +241,7 @@ python3 "$SCRIPT" save-twilio AC... auth_token_here python3 "$SCRIPT" twilio-search --country US --area-code 702 --limit 10 ``` -3. Buy it and save it into `~/.hermes/.env` + state: +3. Buy it and save it into `${HERMES_HOME:-~/.hermes}/.env` + state: ```bash python3 "$SCRIPT" twilio-buy "+17025551234" --save-env ``` @@ -403,7 +403,7 @@ After setup, you should be able to do all of the following with just this skill: 1. `diagnose` shows provider readiness and remembered state 2. search and buy a Twilio number -3. persist that number to `~/.hermes/.env` +3. persist that number to `${HERMES_HOME:-~/.hermes}/.env` 4. send an SMS from the owned number 5. poll inbound texts for the owned number later 6. place a direct Twilio call diff --git a/optional-skills/productivity/telephony/scripts/telephony.py b/optional-skills/productivity/telephony/scripts/telephony.py index 188b6be2ad9..291fd8629ab 100644 --- a/optional-skills/productivity/telephony/scripts/telephony.py +++ b/optional-skills/productivity/telephony/scripts/telephony.py @@ -2,7 +2,7 @@ """Telephony helper for the Hermes optional telephony skill. Capabilities: -- Persist telephony provider credentials to ~/.hermes/.env +- Persist telephony provider credentials to the Hermes .env file ($HERMES_HOME/.env) - Search for, buy, and remember Twilio phone numbers - Make direct Twilio calls (TwiML or ) - Send SMS / MMS via Twilio @@ -286,7 +286,7 @@ def _twilio_creds() -> tuple[str, str]: if not sid or not token: raise TelephonyError( "Twilio credentials are not configured. Use 'save-twilio' or set " - "TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN in ~/.hermes/.env." + f"TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN in {_env_path()}." ) return sid, token @@ -420,7 +420,7 @@ def _resolve_twilio_number(identifier: str | None = None) -> OwnedTwilioNumber: raise TelephonyError( "No default Twilio phone number is set. Use 'twilio-buy --save-env', " - "'twilio-set-default', or set TWILIO_PHONE_NUMBER in ~/.hermes/.env." + f"'twilio-set-default', or set TWILIO_PHONE_NUMBER in {_env_path()}." ) @@ -756,7 +756,7 @@ def _vapi_import_twilio_number( api_key = _vapi_api_key() if not api_key: raise TelephonyError( - "Vapi is not configured. Use 'save-vapi' or set VAPI_API_KEY in ~/.hermes/.env first." + f"Vapi is not configured. Use 'save-vapi' or set VAPI_API_KEY in {_env_path()} first." ) owned = _resolve_twilio_number(phone_identifier) sid, token = _twilio_creds() @@ -803,7 +803,7 @@ def _bland_call( api_key = _bland_api_key() if not api_key: raise TelephonyError( - "Bland.ai is not configured. Use 'save-bland' or set BLAND_API_KEY in ~/.hermes/.env." + f"Bland.ai is not configured. Use 'save-bland' or set BLAND_API_KEY in {_env_path()}." ) normalized = _normalize_phone(phone_number) if voice is None: @@ -881,13 +881,13 @@ def _vapi_call( api_key = _vapi_api_key() if not api_key: raise TelephonyError( - "Vapi is not configured. Use 'save-vapi' or set VAPI_API_KEY in ~/.hermes/.env." + f"Vapi is not configured. Use 'save-vapi' or set VAPI_API_KEY in {_env_path()}." ) phone_number_id = _vapi_phone_number_id() if not phone_number_id: raise TelephonyError( "No Vapi phone number id is configured. Import an owned Twilio number with " - "'vapi-import-twilio --save-env' or set VAPI_PHONE_NUMBER_ID in ~/.hermes/.env." + f"'vapi-import-twilio --save-env' or set VAPI_PHONE_NUMBER_ID in {_env_path()}." ) normalized = _normalize_phone(phone_number) voice_provider = _env_or_config( @@ -1091,7 +1091,7 @@ def save_twilio(account_sid: str, auth_token: str, phone_number: str = "", phone "provider": "twilio", "saved_env_keys": sorted(updates), "env_path": str(env_file), - "message": "Twilio credentials saved to ~/.hermes/.env.", + "message": f"Twilio credentials saved to {env_file}.", } if phone_number: result.update(_remember_twilio_number(phone_number=updates["TWILIO_PHONE_NUMBER"], phone_sid=phone_sid.strip(), save_env=False)) @@ -1111,7 +1111,7 @@ def save_bland(api_key: str, voice: str = BLAND_DEFAULT_VOICE) -> dict[str, Any] "provider": "bland", "saved_env_keys": ["BLAND_API_KEY", "BLAND_DEFAULT_VOICE", "PHONE_PROVIDER"], "env_path": str(env_file), - "message": "Bland.ai configuration saved to ~/.hermes/.env.", + "message": f"Bland.ai configuration saved to {env_file}.", } @@ -1138,7 +1138,7 @@ def save_vapi( "provider": "vapi", "saved_env_keys": sorted(updates), "env_path": str(env_file), - "message": "Vapi configuration saved to ~/.hermes/.env.", + "message": f"Vapi configuration saved to {env_file}.", } if phone_number_id: result.update(_remember_vapi_number(phone_number_id=phone_number_id.strip(), save_env=False)) @@ -1151,17 +1151,17 @@ def _build_parser() -> argparse.ArgumentParser: sub.add_parser("diagnose", help="Show saved telephony state and provider readiness") - p = sub.add_parser("save-twilio", help="Save Twilio credentials to ~/.hermes/.env") + p = sub.add_parser("save-twilio", help="Save Twilio credentials to the Hermes .env file") p.add_argument("account_sid") p.add_argument("auth_token") p.add_argument("--phone-number", default="") p.add_argument("--phone-sid", default="") - p = sub.add_parser("save-bland", help="Save Bland.ai settings to ~/.hermes/.env") + p = sub.add_parser("save-bland", help="Save Bland.ai settings to the Hermes .env file") p.add_argument("api_key") p.add_argument("--voice", default=BLAND_DEFAULT_VOICE) - p = sub.add_parser("save-vapi", help="Save Vapi settings to ~/.hermes/.env") + p = sub.add_parser("save-vapi", help="Save Vapi settings to the Hermes .env file") p.add_argument("api_key") p.add_argument("--phone-number-id", default="") p.add_argument("--voice-provider", default=VAPI_DEFAULT_VOICE_PROVIDER) @@ -1312,7 +1312,7 @@ def _dispatch(args: argparse.Namespace) -> dict[str, Any]: ) raise TelephonyError( f"Unsupported AI call provider '{provider}'. Use --provider bland or --provider vapi, " - "or set PHONE_PROVIDER in ~/.hermes/.env." + f"or set PHONE_PROVIDER in {_env_path()}." ) if cmd == "ai-status": provider = (args.provider or _ai_provider()).lower().strip() @@ -1322,7 +1322,7 @@ def _dispatch(args: argparse.Namespace) -> dict[str, Any]: return _bland_status(args.call_id, analyze=args.analyze or None) raise TelephonyError( f"Unsupported AI call provider '{provider}'. Use --provider bland or --provider vapi, " - "or set PHONE_PROVIDER in ~/.hermes/.env." + f"or set PHONE_PROVIDER in {_env_path()}." ) raise TelephonyError(f"Unknown command: {cmd}") diff --git a/optional-skills/security/1password/SKILL.md b/optional-skills/security/1password/SKILL.md index 2a6cc8e18b0..152cd13e60b 100644 --- a/optional-skills/security/1password/SKILL.md +++ b/optional-skills/security/1password/SKILL.md @@ -41,7 +41,7 @@ Use this skill when the user wants secrets managed through 1Password instead of ### Service Account (recommended for Hermes) -Set `OP_SERVICE_ACCOUNT_TOKEN` in `~/.hermes/.env` (the skill will prompt for this on first load). +Set `OP_SERVICE_ACCOUNT_TOKEN` in `${HERMES_HOME:-~/.hermes}/.env` (the skill will prompt for this on first load). No desktop app needed. Supports `op read`, `op inject`, `op run`. ```bash diff --git a/optional-skills/security/godmode/SKILL.md b/optional-skills/security/godmode/SKILL.md index 27751e93ecb..deda3471785 100644 --- a/optional-skills/security/godmode/SKILL.md +++ b/optional-skills/security/godmode/SKILL.md @@ -400,4 +400,4 @@ Claude Sonnet 4 is robust against all current techniques for clearly harmful con 9. **Always use `load_godmode.py` in execute_code** — The individual scripts (`parseltongue.py`, `godmode_race.py`, `auto_jailbreak.py`) have argparse CLI entry points with `if __name__ == '__main__'` blocks. When loaded via `exec()` in execute_code, `__name__` is `'__main__'` and argparse fires, crashing the script. The `load_godmode.py` loader handles this by setting `__name__` to a non-main value and managing sys.argv. 10. **boundary_inversion is model-version specific** — Works on Claude 3.5 Sonnet but NOT Claude Sonnet 4 or Claude 4.6. The strategy order in auto_jailbreak tries it first for Claude models, but falls through to refusal_inversion when it fails. Update the strategy order if you know the model version. 11. **Gray-area vs hard queries** — Jailbreak techniques work much better on "dual-use" queries (lock picking, security tools, chemistry) than on overtly harmful ones (phishing templates, malware). For hard queries, skip directly to ULTRAPLINIAN or use Hermes/Grok models that don't refuse. -12. **execute_code sandbox has no env vars** — When Hermes runs auto_jailbreak via execute_code, the sandbox doesn't inherit `~/.hermes/.env`. Load dotenv explicitly: `from dotenv import load_dotenv; load_dotenv(os.path.expanduser("~/.hermes/.env"))` +12. **execute_code sandbox has no env vars** — When Hermes runs auto_jailbreak via execute_code, the sandbox doesn't inherit the Hermes `.env`. Load dotenv explicitly: `import os; from dotenv import load_dotenv; load_dotenv(os.path.join(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")), ".env"))` diff --git a/optional-skills/software-development/rest-graphql-debug/SKILL.md b/optional-skills/software-development/rest-graphql-debug/SKILL.md index 78f90f2a91f..64b96b3cdd3 100644 --- a/optional-skills/software-development/rest-graphql-debug/SKILL.md +++ b/optional-skills/software-development/rest-graphql-debug/SKILL.md @@ -397,7 +397,7 @@ class TestAPISmoke: ### Token handling - Never log full tokens. Redact: `Bearer `. -- Never hardcode tokens in scripts. Read from env (`os.environ["API_TOKEN"]`) or `~/.hermes/.env`. +- Never hardcode tokens in scripts. Read from env (`os.environ["API_TOKEN"]`) or `${HERMES_HOME:-~/.hermes}/.env`. - Rotate immediately if a token surfaces in logs, error messages, or git history. ### Safe logging diff --git a/skills/autonomous-ai-agents/hermes-agent/SKILL.md b/skills/autonomous-ai-agents/hermes-agent/SKILL.md index 08a4fd2b43a..d02ac7933cb 100644 --- a/skills/autonomous-ai-agents/hermes-agent/SKILL.md +++ b/skills/autonomous-ai-agents/hermes-agent/SKILL.md @@ -343,7 +343,7 @@ The registry of record is `hermes_cli/commands.py` — every consumer ``` ~/.hermes/config.yaml Main configuration -~/.hermes/.env API keys and secrets +~/.hermes/.env API keys and secrets (under $HERMES_HOME if set) $HERMES_HOME/skills/ Installed skills ~/.hermes/sessions/ Gateway routing index, request dumps, *.jsonl transcripts (and optional per-session JSON snapshots when sessions.write_json_snapshots: true) ~/.hermes/state.db Canonical session store (SQLite + FTS5) @@ -908,7 +908,7 @@ hermes-agent/ └── website/ # Docusaurus docs site ``` -Config: `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (API keys). +Config: `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (API keys) — both under `$HERMES_HOME` when it is set. ### Adding a Tool (3 files) diff --git a/skills/autonomous-ai-agents/hermes-agent/references/webhooks.md b/skills/autonomous-ai-agents/hermes-agent/references/webhooks.md index a1758d64f09..0af935ea23c 100644 --- a/skills/autonomous-ai-agents/hermes-agent/references/webhooks.md +++ b/skills/autonomous-ai-agents/hermes-agent/references/webhooks.md @@ -30,7 +30,7 @@ platforms: ``` ### Option 3: Environment variables -Add to `~/.hermes/.env`: +Add to `${HERMES_HOME:-~/.hermes}/.env`: ```bash WEBHOOK_ENABLED=true WEBHOOK_PORT=8644 diff --git a/skills/media/gif-search/SKILL.md b/skills/media/gif-search/SKILL.md index 1a28b8b293d..5416290a9d0 100644 --- a/skills/media/gif-search/SKILL.md +++ b/skills/media/gif-search/SKILL.md @@ -23,7 +23,7 @@ Useful for finding reaction GIFs, creating visual content, and sending GIFs in c ## Setup -Set your Tenor API key in your environment (add to `~/.hermes/.env`): +Set your Tenor API key in your environment (add to `${HERMES_HOME:-~/.hermes}/.env`): ```bash TENOR_API_KEY=your_key_here diff --git a/skills/note-taking/obsidian/SKILL.md b/skills/note-taking/obsidian/SKILL.md index 15810900889..e3a9872309a 100644 --- a/skills/note-taking/obsidian/SKILL.md +++ b/skills/note-taking/obsidian/SKILL.md @@ -12,7 +12,7 @@ Use this skill for filesystem-first Obsidian vault work: reading notes, listing Use a known or resolved vault path before calling file tools. -The documented vault-path convention is the `OBSIDIAN_VAULT_PATH` environment variable, for example from `~/.hermes/.env`. If it is unset, use `~/Documents/Obsidian Vault`. +The documented vault-path convention is the `OBSIDIAN_VAULT_PATH` environment variable, for example from `${HERMES_HOME:-~/.hermes}/.env`. If it is unset, use `~/Documents/Obsidian Vault`. File tools do not expand shell variables. Do not pass paths containing `$OBSIDIAN_VAULT_PATH` to `read_file`, `write_file`, `patch`, or `search_files`; resolve the vault path first and pass a concrete absolute path. Vault paths may contain spaces, which is another reason to prefer file tools over shell commands. diff --git a/skills/productivity/airtable/SKILL.md b/skills/productivity/airtable/SKILL.md index 547e2a14b73..3fa1b0ab973 100644 --- a/skills/productivity/airtable/SKILL.md +++ b/skills/productivity/airtable/SKILL.md @@ -26,7 +26,7 @@ Work with Airtable's REST API directly via `curl` using the `terminal` tool. No - `data.records:write` — create / update / delete rows - `schema.bases:read` — list bases and tables 3. **Important:** in the same token UI, add each base you want to access to the token's **Access** list. PATs are scoped per-base — a valid token on the wrong base returns `403`. -4. Store the token in `~/.hermes/.env` (or via `hermes setup`): +4. Store the token in `${HERMES_HOME:-~/.hermes}/.env` (or via `hermes setup`): ``` AIRTABLE_API_KEY=pat_your_token_here ``` @@ -222,7 +222,7 @@ done ## Important Notes for Hermes - **Always use the `terminal` tool with `curl`.** Do NOT use `web_extract` (it can't send auth headers) or `browser_navigate` (needs UI auth and is slow). -- **`AIRTABLE_API_KEY` flows from `~/.hermes/.env` into the subprocess automatically** when this skill is loaded — no need to re-export it before each `curl` call. +- **`AIRTABLE_API_KEY` flows from `${HERMES_HOME:-~/.hermes}/.env` into the subprocess automatically** when this skill is loaded — no need to re-export it before each `curl` call. - **Escape curly braces in formulas carefully.** In a heredoc body, `{Status}` is literal. In a shell argument, `{Status}` is safe outside `{...}` brace-expansion context — but pass dynamic strings through `python3 urllib.parse.quote` before splicing into a URL. - **Pretty-print with `python3 -m json.tool`** (always present) rather than `jq` (optional). Only reach for `jq` when you need filtering/projection. - **Pagination is per-page, not global.** Airtable's 100-record cap is a hard limit; there is no way to bump it. Loop with `offset` until the field is absent. diff --git a/skills/productivity/notion/SKILL.md b/skills/productivity/notion/SKILL.md index 83222ffd938..22010c6241d 100644 --- a/skills/productivity/notion/SKILL.md +++ b/skills/productivity/notion/SKILL.md @@ -26,7 +26,7 @@ Talk to Notion two ways. Same integration token works for both — pick by what' 1. Create an integration at https://notion.so/my-integrations 2. Copy the API key (starts with `ntn_` or `secret_`) -3. Store in `~/.hermes/.env`: +3. Store in `${HERMES_HOME:-~/.hermes}/.env`: ``` NOTION_API_KEY=ntn_your_key_here ``` @@ -50,7 +50,7 @@ export NOTION_API_TOKEN=$NOTION_API_KEY # ntn reads NOTION_API_TOKEN export NOTION_KEYRING=0 # don't try to use the OS keychain ``` -Add those exports to your shell profile (or to `~/.hermes/.env`) so every session inherits them. +Add those exports to your shell profile (or to `${HERMES_HOME:-~/.hermes}/.env`) so every session inherits them. ### 3. Choose path at runtime diff --git a/skills/productivity/teams-meeting-pipeline/SKILL.md b/skills/productivity/teams-meeting-pipeline/SKILL.md index 4ad37c4758a..11960aa3201 100644 --- a/skills/productivity/teams-meeting-pipeline/SKILL.md +++ b/skills/productivity/teams-meeting-pipeline/SKILL.md @@ -39,7 +39,7 @@ Multilingual trigger examples (not exhaustive): ## Prerequisites -Before using the pipeline, verify these are set in `~/.hermes/.env`: +Before using the pipeline, verify these are set in `${HERMES_HOME:-~/.hermes}/.env`: ```bash MSGRAPH_TENANT_ID=... diff --git a/skills/research/llm-wiki/SKILL.md b/skills/research/llm-wiki/SKILL.md index 839c2f682a0..7dc708c9a5f 100644 --- a/skills/research/llm-wiki/SKILL.md +++ b/skills/research/llm-wiki/SKILL.md @@ -35,7 +35,7 @@ Use this skill when the user: ## Wiki Location -**Location:** Set via `WIKI_PATH` environment variable (e.g. in `~/.hermes/.env`). +**Location:** Set via `WIKI_PATH` environment variable (e.g. in `${HERMES_HOME:-~/.hermes}/.env`). If unset, defaults to `~/wiki`. From e96ca1a0d35a8661ab5315c25c369073ae64022e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:22:27 -0700 Subject: [PATCH 14/69] feat(sessions): drop empty sessions on CLI exit and session rotation Port from google-gemini/gemini-cli#27770: starting the CLI and immediately quitting (or rotating with /new, /clear) left an empty untitled session row behind. These ghost rows pile up in /resume, `hermes sessions list`, and the in-chat recent-sessions browser. - SessionDB.delete_session_if_empty(): transactional check-and-delete that only removes rows with no messages, no title, and no child sessions (delegate subagent parents are preserved). Also removes on-disk transcript files via the existing _remove_session_files. - HermesCLI._discard_session_if_empty(): thin wrapper, wired into the cli_close shutdown path and the new_session() rotation path. Skipped when /exit --delete already handles removal. Unlike the one-shot prune_empty_ghost_sessions migration (TUI-only, 24h-old rows), this prevents new ghost rows from accumulating at the moment they would be created. --- cli.py | 35 +++++++ hermes_state.py | 42 ++++++++ tests/test_empty_session_hygiene.py | 148 ++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 tests/test_empty_session_hygiene.py diff --git a/cli.py b/cli.py index 641c200ad3d..2cc1a8f16db 100644 --- a/cli.py +++ b/cli.py @@ -5821,6 +5821,29 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): except Exception: pass + def _discard_session_if_empty(self, session_id: Optional[str]) -> bool: + """Drop a just-ended session row when it never gained content. + + Starting the CLI and immediately quitting (or rotating with /new, + /clear) used to leave an empty untitled row behind that clutters + ``/resume`` and ``hermes sessions list``. Delegates the + check-and-delete to ``SessionDB.delete_session_if_empty``, which + only removes rows with no messages, no title, and no child + sessions. Ported from google-gemini/gemini-cli#27770. + """ + if not self._session_db or not session_id: + return False + try: + from hermes_constants import get_hermes_home as _ghh + return self._session_db.delete_session_if_empty( + session_id, sessions_dir=_ghh() / "sessions" + ) + except Exception: + logger.debug( + "Could not prune empty session %s", session_id, exc_info=True + ) + return False + def new_session(self, silent=False, title=None): """Start a fresh session with a new session ID and cleared agent state.""" if self.agent and self.conversation_history: @@ -5837,6 +5860,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._session_db.end_session(old_session_id, "new_session") except Exception: pass + # Don't let immediately-rotated empty sessions pile up in + # /resume and `hermes sessions list` (gemini-cli#27770 port). + self._discard_session_if_empty(old_session_id) self.session_start = datetime.now() timestamp_str = self.session_start.strftime("%Y%m%d_%H%M%S") @@ -13074,6 +13100,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._session_db.end_session(self.agent.session_id, "cli_close") except (Exception, KeyboardInterrupt) as e: logger.debug("Could not close session in DB: %s", e) + # Started-and-immediately-quit sessions never gained content; + # drop the empty row so /resume and `hermes sessions list` + # stay clean (gemini-cli#27770 port). No-op for resumed or + # titled sessions and anything with messages or children. + if not getattr(self, '_delete_session_on_exit', False): + try: + self._discard_session_if_empty(self.agent.session_id) + except (Exception, KeyboardInterrupt) as e: + logger.debug("Could not prune empty session: %s", e) # /exit --delete: also remove the current session's transcripts # and SQLite history. Ported from google-gemini/gemini-cli#19332. if getattr(self, '_delete_session_on_exit', False): diff --git a/hermes_state.py b/hermes_state.py index bda6eeacd62..40341a69733 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -3658,6 +3658,48 @@ class SessionDB: self._remove_session_files(sessions_dir, session_id) return deleted + def delete_session_if_empty( + self, + session_id: str, + sessions_dir: Optional[Path] = None, + ) -> bool: + """Delete *session_id* only when it never gained resumable content. + + A session is considered empty when it has no messages and no + user-assigned title. Used by CLI exit / session-rotation paths so + immediately-started-and-quit sessions don't pile up in ``/resume`` + and ``hermes sessions list`` output. (Pattern ported from + google-gemini/gemini-cli#27770.) + + The emptiness check and delete run in one transaction, so a message + flushed concurrently by another writer can't be lost. Sessions with + children (delegate subagent runs) are preserved — a parent that + spawned work is not "empty" even if its own transcript never + flushed. Returns True if the session was deleted. + """ + def _do(conn): + cursor = conn.execute( + """ + DELETE FROM sessions + WHERE id = ? + AND title IS NULL + AND NOT EXISTS ( + SELECT 1 FROM messages WHERE messages.session_id = sessions.id + ) + AND NOT EXISTS ( + SELECT 1 FROM sessions child + WHERE child.parent_session_id = sessions.id + ) + """, + (session_id,), + ) + return cursor.rowcount > 0 + + deleted = self._execute_write(_do) + if deleted: + self._remove_session_files(sessions_dir, session_id) + return bool(deleted) + def delete_sessions( self, session_ids: List[str], diff --git a/tests/test_empty_session_hygiene.py b/tests/test_empty_session_hygiene.py new file mode 100644 index 00000000000..7166ce936e1 --- /dev/null +++ b/tests/test_empty_session_hygiene.py @@ -0,0 +1,148 @@ +"""Tests for empty-session hygiene — gemini-cli#27770 port. + +Starting the CLI and immediately quitting (or rotating sessions with /new) +used to leave empty untitled rows in the session DB that clutter /resume +and `hermes sessions list`. ``SessionDB.delete_session_if_empty`` removes +a just-ended session row only when it never gained resumable content: +no messages, no title, and no child sessions. +""" + +import pytest + +from hermes_state import SessionDB + + +@pytest.fixture() +def db(tmp_path): + session_db = SessionDB(db_path=tmp_path / "state.db") + yield session_db + session_db.close() + + +class TestDeleteSessionIfEmpty: + def test_deletes_empty_untitled_session(self, db): + db.create_session(session_id="empty", source="cli", model="test") + db.end_session("empty", "cli_close") + + assert db.delete_session_if_empty("empty") is True + assert db.get_session("empty") is None + + def test_keeps_session_with_messages(self, db): + db.create_session(session_id="busy", source="cli", model="test") + db.append_message("busy", role="user", content="hello") + db.end_session("busy", "cli_close") + + assert db.delete_session_if_empty("busy") is False + assert db.get_session("busy") is not None + + def test_keeps_titled_session(self, db): + """A user-assigned title is resumable content even without messages.""" + db.create_session(session_id="titled", source="cli", model="test") + db.set_session_title("titled", "Important plans") + db.end_session("titled", "cli_close") + + assert db.delete_session_if_empty("titled") is False + assert db.get_session("titled") is not None + + def test_keeps_session_with_children(self, db): + """A parent that spawned delegate subagent runs is not empty.""" + db.create_session(session_id="parent", source="cli", model="test") + db.create_session( + session_id="child", + source="tool", + model="test", + parent_session_id="parent", + ) + db.end_session("parent", "cli_close") + + assert db.delete_session_if_empty("parent") is False + assert db.get_session("parent") is not None + assert db.get_session("child") is not None + + def test_unknown_session_returns_false(self, db): + assert db.delete_session_if_empty("nope") is False + + def test_removes_on_disk_transcripts(self, db, tmp_path): + sessions_dir = tmp_path / "sessions" + sessions_dir.mkdir() + (sessions_dir / "empty.json").write_text("{}", encoding="utf-8") + (sessions_dir / "empty.jsonl").write_text("", encoding="utf-8") + + db.create_session(session_id="empty", source="cli", model="test") + db.end_session("empty", "cli_close") + + assert db.delete_session_if_empty("empty", sessions_dir=sessions_dir) + assert not (sessions_dir / "empty.json").exists() + assert not (sessions_dir / "empty.jsonl").exists() + + def test_no_file_cleanup_when_kept(self, db, tmp_path): + sessions_dir = tmp_path / "sessions" + sessions_dir.mkdir() + (sessions_dir / "busy.json").write_text("{}", encoding="utf-8") + + db.create_session(session_id="busy", source="cli", model="test") + db.append_message("busy", role="user", content="hello") + + assert not db.delete_session_if_empty("busy", sessions_dir=sessions_dir) + assert (sessions_dir / "busy.json").exists() + + def test_empty_session_disappears_from_listing(self, db): + """The user-facing symptom: empty rows polluting session lists.""" + db.create_session(session_id="real", source="cli", model="test") + db.append_message("real", role="user", content="do the thing") + db.end_session("real", "cli_close") + + db.create_session(session_id="ghost", source="cli", model="test") + db.end_session("ghost", "cli_close") + + ids_before = {s["id"] for s in db.list_sessions_rich(source="cli")} + assert {"real", "ghost"} <= ids_before + + db.delete_session_if_empty("ghost") + + ids_after = {s["id"] for s in db.list_sessions_rich(source="cli")} + assert "real" in ids_after + assert "ghost" not in ids_after + + +class TestCLIDiscardSessionIfEmpty: + """Wiring tests for HermesCLI._discard_session_if_empty.""" + + def _make_cli(self, db): + from cli import HermesCLI + + cli = HermesCLI.__new__(HermesCLI) + cli._session_db = db + return cli + + def test_discards_empty(self, db): + db.create_session(session_id="empty", source="cli", model="test") + db.end_session("empty", "cli_close") + + cli = self._make_cli(db) + assert cli._discard_session_if_empty("empty") is True + assert db.get_session("empty") is None + + def test_keeps_nonempty(self, db): + db.create_session(session_id="busy", source="cli", model="test") + db.append_message("busy", role="user", content="hi") + + cli = self._make_cli(db) + assert cli._discard_session_if_empty("busy") is False + assert db.get_session("busy") is not None + + def test_no_db_is_noop(self): + cli = self._make_cli(None) + assert cli._discard_session_if_empty("anything") is False + + def test_none_session_id_is_noop(self, db): + cli = self._make_cli(db) + assert cli._discard_session_if_empty(None) is False + + def test_db_error_swallowed(self, db): + class Boom: + def delete_session_if_empty(self, *a, **k): + raise RuntimeError("locked") + + cli = self._make_cli(Boom()) + assert cli._discard_session_if_empty("x") is False From 4490c7cf8de4aef8abb61e5e4cb748bbd960e5c2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:37:34 -0700 Subject: [PATCH 15/69] fix: in-memory transcript blocks empty-session prune CI caught tests/cli/test_cli_new_session.py asserting that /new keeps the old session row when conversation history exists in memory. The live transcript is authoritative: a session whose messages haven't flushed to the DB yet (or whose flush failed) must not be pruned. Guard _discard_session_if_empty on self.conversation_history and pin the behavior with a test. --- cli.py | 6 ++++++ tests/test_empty_session_hygiene.py | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/cli.py b/cli.py index 2cc1a8f16db..126d0a1038a 100644 --- a/cli.py +++ b/cli.py @@ -5833,6 +5833,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): """ if not self._session_db or not session_id: return False + # In-memory transcript is authoritative: if this CLI object holds + # conversation messages (flushed to the DB or not), the session is + # not empty. Protects against pruning a real conversation whose DB + # flush failed or hasn't happened yet. + if getattr(self, "conversation_history", None): + return False try: from hermes_constants import get_hermes_home as _ghh return self._session_db.delete_session_if_empty( diff --git a/tests/test_empty_session_hygiene.py b/tests/test_empty_session_hygiene.py index 7166ce936e1..3576e7dce72 100644 --- a/tests/test_empty_session_hygiene.py +++ b/tests/test_empty_session_hygiene.py @@ -113,6 +113,7 @@ class TestCLIDiscardSessionIfEmpty: cli = HermesCLI.__new__(HermesCLI) cli._session_db = db + cli.conversation_history = [] return cli def test_discards_empty(self, db): @@ -146,3 +147,15 @@ class TestCLIDiscardSessionIfEmpty: cli = self._make_cli(Boom()) assert cli._discard_session_if_empty("x") is False + + def test_in_memory_history_blocks_prune(self, db): + """The live transcript is authoritative: even if the DB row has no + flushed messages yet, a CLI holding conversation history must not + prune the session (covers flush-failed / not-yet-flushed turns).""" + db.create_session(session_id="unflushed", source="cli", model="test") + db.end_session("unflushed", "new_session") + + cli = self._make_cli(db) + cli.conversation_history = [{"role": "user", "content": "hello"}] + assert cli._discard_session_if_empty("unflushed") is False + assert db.get_session("unflushed") is not None From 9dd9ef0ec99a87f078f7272b4323df5440b4b3f9 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Wed, 10 Jun 2026 20:43:22 -0400 Subject: [PATCH 16/69] fix(web): profiles page modal (#43858) * fix(web): profiles page modal * chore: drop unrelated package-lock.json changes Co-authored-by: Cursor --------- Co-authored-by: Cursor --- web/src/pages/ProfilesPage.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/web/src/pages/ProfilesPage.tsx b/web/src/pages/ProfilesPage.tsx index 463e5a4120b..bda2515528f 100644 --- a/web/src/pages/ProfilesPage.tsx +++ b/web/src/pages/ProfilesPage.tsx @@ -794,7 +794,7 @@ export default function ProfilesPage() {
- )) - )} -
- - ) -} diff --git a/apps/desktop/src/app/chat/composer/text-utils.test.ts b/apps/desktop/src/app/chat/composer/text-utils.test.ts index 5ef677f4d0f..f80e6db4385 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.test.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.test.ts @@ -22,6 +22,33 @@ describe('detectTrigger', () => { it('returns null for plain text', () => { expect(detectTrigger('hello there')).toBeNull() }) + + it('keeps the slash trigger live while typing args', () => { + expect(detectTrigger('/personality ')).toEqual({ + kind: '/', + query: 'personality ', + tokenLength: 13 + }) + expect(detectTrigger('/personality alic')).toEqual({ + kind: '/', + query: 'personality alic', + tokenLength: 17 + }) + expect(detectTrigger('/tools enable foo')).toEqual({ + kind: '/', + query: 'tools enable foo', + tokenLength: 17 + }) + }) + + it('does not treat file-style paths as slash triggers', () => { + expect(detectTrigger('src/foo/bar')).toBeNull() + expect(detectTrigger('/path/to/file')).toBeNull() + }) + + it('still anchors at-mention triggers strictly at the token edge', () => { + expect(detectTrigger('@file:path with space')).toBeNull() + }) }) describe('extractClipboardImageBlobs', () => { diff --git a/apps/desktop/src/app/chat/composer/text-utils.ts b/apps/desktop/src/app/chat/composer/text-utils.ts index e9a8fb6aaee..4535d6963c3 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.ts @@ -6,7 +6,13 @@ export interface TriggerState { tokenLength: number } -const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/ +// `@` triggers stop at the first whitespace — `@file:path` and `@diff` are +// single tokens. `/` triggers keep going so the popover stays live while the +// user types args (`/personality alic` → arg completer suggests `alice`). +// Restricting the slash command name to `[a-zA-Z][\w-]*` avoids matching file +// paths like `src/foo/bar`. +const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@/]*)$/ +const SLASH_TRIGGER_RE = /(?:^|[\s])(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/ /** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */ export function blobDedupeKey(blob: Blob): string { @@ -97,11 +103,17 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null { } export function detectTrigger(textBefore: string): TriggerState | null { - const match = TRIGGER_RE.exec(textBefore) + const slash = SLASH_TRIGGER_RE.exec(textBefore) - if (!match) { - return null + if (slash) { + return { kind: '/', query: slash[2], tokenLength: 1 + slash[2].length } } - return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length } + const at = AT_TRIGGER_RE.exec(textBefore) + + if (at) { + return { kind: '@', query: at[2], tokenLength: 1 + at[2].length } + } + + return null } diff --git a/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx b/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx index 9acc43f7f19..3aefbfee0a5 100644 --- a/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx +++ b/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx @@ -34,9 +34,17 @@ describe('ComposerTriggerPopover i18n', () => { }) it('renders localized loading copy for slash commands', () => { - const { container } = renderPopover('/', true) + renderPopover('/', true) + // While loading the popover shows only the spinner + loading copy — the + // `/help` empty-state hint is reserved for the resolved (not-loading) state. expect(screen.getByText('查找中…')).toBeTruthy() + }) + + it('renders the slash empty-state hint when not loading', () => { + const { container } = renderPopover('/') + + expect(screen.getByText('没有匹配项。')).toBeTruthy() expect(container.textContent).toContain('/help') }) }) diff --git a/apps/desktop/src/app/chat/composer/trigger-popover.tsx b/apps/desktop/src/app/chat/composer/trigger-popover.tsx index a09190dd6b3..1099c0748ba 100644 --- a/apps/desktop/src/app/chat/composer/trigger-popover.tsx +++ b/apps/desktop/src/app/chat/composer/trigger-popover.tsx @@ -1,5 +1,7 @@ import type { Unstable_TriggerItem } from '@assistant-ui/core' +import { Fragment } from 'react' +import { BrailleSpinner } from '@/components/ui/braille-spinner' import { Codicon } from '@/components/ui/codicon' import { useI18n } from '@/i18n' import { cn } from '@/lib/utils' @@ -7,7 +9,6 @@ import { cn } from '@/lib/utils' import { COMPLETION_DRAWER_BELOW_CLASS, COMPLETION_DRAWER_CLASS, - COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty } from './completion-drawer' @@ -23,11 +24,7 @@ const AT_ICON_BY_TYPE: Record = { url: 'globe' } -function completionIcon(kind: '@' | '/', item: Unstable_TriggerItem) { - if (kind === '/') { - return 'terminal' - } - +function atIcon(item: Unstable_TriggerItem) { const meta = item.metadata as { rawText?: string } | undefined const raw = meta?.rawText || item.label @@ -42,6 +39,18 @@ function completionIcon(kind: '@' | '/', item: Unstable_TriggerItem) { return AT_ICON_BY_TYPE[item.type] || AT_ICON_BY_TYPE.simple } +interface RowMeta { + display?: string + group?: string + meta?: string +} + +const ROW_BASE_CLASS = [ + 'relative flex w-full cursor-default select-none rounded-md px-2 py-1 text-left', + 'outline-hidden transition-colors hover:bg-(--ui-bg-tertiary)', + 'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground' +].join(' ') + interface ComposerTriggerPopoverProps { activeIndex: number items: readonly Unstable_TriggerItem[] @@ -63,6 +72,9 @@ export function ComposerTriggerPopover({ }: ComposerTriggerPopoverProps) { const { t } = useI18n() const copy = t.composer + const isSlash = kind === '/' + + let lastGroup: string | undefined return (
{items.length === 0 ? ( - - {kind === '@' ? ( - <> - {copy.lookupTry} @file: {copy.lookupOr}{' '} - @folder:. - - ) : ( - <> - {copy.lookupTry} /help. - - )} - + loading ? ( +
+ + {copy.lookupLoading} +
+ ) : ( + + {kind === '@' ? ( + <> + {copy.lookupTry} @file: {copy.lookupOr}{' '} + @folder:. + + ) : ( + <> + {copy.lookupTry} /help. + + )} + + ) ) : ( items.map((item, index) => { - const meta = item.metadata as { display?: string; meta?: string } | undefined - const display = meta?.display ?? (kind === '/' ? `/${item.label}` : item.label) + const meta = item.metadata as RowMeta | undefined + const display = meta?.display ?? (isSlash ? `/${item.label}` : item.label) const description = meta?.meta || item.description + const group = meta?.group?.trim() + const showHeader = isSlash && Boolean(group) && group !== lastGroup + const isFirstHeader = lastGroup === undefined + lastGroup = group || lastGroup + const active = index === activeIndex return ( - + + ) }) )} diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index ab4f3f0eb0e..0da26639544 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -98,6 +98,7 @@ import { RightSidebarPane } from './right-sidebar' import { $terminalTakeover } from './right-sidebar/store' import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent' import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes' +import { SessionPickerOverlay } from './session-picker-overlay' import { SessionSwitcher } from './session-switcher' import { useContextSuggestions } from './session/hooks/use-context-suggestions' import { useCwdActions } from './session/hooks/use-cwd-actions' @@ -694,6 +695,7 @@ export function DesktopController() { handleSkinCommand, refreshSessions, requestGateway, + resumeStoredSession: resumeSession, selectedStoredSessionIdRef, startFreshSessionDraft, sttEnabled, @@ -829,6 +831,7 @@ export function DesktopController() { /> )} + diff --git a/apps/desktop/src/app/session-picker-overlay.tsx b/apps/desktop/src/app/session-picker-overlay.tsx new file mode 100644 index 00000000000..65344fcac26 --- /dev/null +++ b/apps/desktop/src/app/session-picker-overlay.tsx @@ -0,0 +1,32 @@ +import { useStore } from '@nanostores/react' + +import { SessionPickerDialog } from '@/components/session-picker' +import { $gatewayState, $selectedStoredSessionId, $sessionPickerOpen, setSessionPickerOpen } from '@/store/session' + +interface SessionPickerOverlayProps { + onResume: (storedSessionId: string) => void +} + +/** + * Mounts the session picker that `/resume` (and `/sessions`, `/switch`) opens — + * the desktop equivalent of the TUI's sessions overlay. Resuming runs through + * the same `resumeSession` path the sidebar uses. + */ +export function SessionPickerOverlay({ onResume }: SessionPickerOverlayProps) { + const open = useStore($sessionPickerOpen) + const gatewayOpen = useStore($gatewayState) === 'open' + const activeStoredSessionId = useStore($selectedStoredSessionId) + + if (!gatewayOpen) { + return null + } + + return ( + + ) +} diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx index 96af1e8400e..3418e0bad80 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx @@ -1,6 +1,6 @@ import { cleanup, render, waitFor } from '@testing-library/react' import type { MutableRefObject } from 'react' -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { $composerAttachments, type ComposerAttachment } from '@/store/composer' @@ -55,6 +55,7 @@ function Harness({ onSeedState, refreshSessions, requestGateway, + resumeStoredSession, storedSessionId }: { busyRef?: MutableRefObject @@ -62,6 +63,7 @@ function Harness({ onSeedState?: (state: Record) => void refreshSessions: () => Promise requestGateway: (method: string, params?: Record) => Promise + resumeStoredSession?: (storedSessionId: string) => Promise | void storedSessionId?: null | string }) { const activeSessionIdRef: MutableRefObject = { current: RUNTIME_SESSION_ID } @@ -69,6 +71,12 @@ function Harness({ current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId } const localBusyRef = busyRef ?? { current: false } + const stateRef = useRef({ + messages: [], + busy: false, + awaitingResponse: false, + interrupted: true + } as never) const actions = usePromptActions({ activeSessionId: RUNTIME_SESSION_ID, @@ -79,17 +87,14 @@ function Harness({ handleSkinCommand: () => '', refreshSessions, requestGateway, + resumeStoredSession: resumeStoredSession ?? (() => undefined), selectedStoredSessionIdRef, startFreshSessionDraft: () => undefined, sttEnabled: false, updateSessionState: (_sessionId, updater) => { // Seed with interrupted:true so we can prove a fresh submit clears it. - const next = updater({ - messages: [], - busy: false, - awaitingResponse: false, - interrupted: true - } as never) as unknown as Record + const next = updater(stateRef.current) as unknown as Record + stateRef.current = next as never onSeedState?.(next) return next as never @@ -190,6 +195,68 @@ describe('usePromptActions /title', () => { }) }) +describe('usePromptActions desktop slash pickers', () => { + beforeEach(() => { + setSessions(() => [sessionInfo({ id: '20260610_120000_abcdef', title: 'Loaded session' })]) + }) + + afterEach(() => { + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('resumes an exact session id even when it is not in the loaded sidebar cache', async () => { + const resumeStoredSession = vi.fn(async () => undefined) + const requestGateway = vi.fn(async () => ({}) as never) + + let handle: HarnessHandle | null = null + render( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + resumeStoredSession={resumeStoredSession} + /> + ) + + await handle!.submitText('/resume 20260610_130000_123abc') + + expect(resumeStoredSession).toHaveBeenCalledWith('20260610_130000_123abc') + expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything()) + }) + + it('marks a timed-out handoff as failed so the next attempt can retry', async () => { + vi.useFakeTimers() + const calls: { method: string; params?: Record }[] = [] + const requestGateway = vi.fn(async (method: string, params?: Record) => { + calls.push({ method, params }) + + if (method === 'handoff.state') { + return { state: 'pending' } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + render( (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />) + + const result = handle!.submitText('/handoff telegram') + await vi.advanceTimersByTimeAsync(61_000) + await result + + expect(calls.some(call => call.method === 'handoff.request')).toBe(true) + expect(calls).toContainEqual({ + method: 'handoff.fail', + params: { + error: expect.stringContaining('Timed out'), + session_id: RUNTIME_SESSION_ID + } + }) + }) +}) + describe('usePromptActions submit / queue drain semantics', () => { afterEach(() => { cleanup() diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index 167f0d3224f..15831bb4189 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -4,20 +4,24 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react' import { getProfiles, transcribeAudio } from '@/hermes' import { translateNow, type Translations, useI18n } from '@/i18n' +import { stripAnsi } from '@/lib/ansi' import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages' import { optimisticAttachmentRef, parseCommandDispatch, parseSlashCommand, pathLabel, + sessionTitle, SLASH_COMMAND_RE } from '@/lib/chat-runtime' import { type CommandsCatalogLike, + type DesktopActionId, + type DesktopPickerId, desktopSlashUnavailableMessage, filterDesktopCommandsCatalog, isDesktopSlashCommand, - isModelPickerCommand + resolveDesktopCommand } from '@/lib/desktop-slash-commands' import { triggerHaptic } from '@/lib/haptics' import { setMutableRef } from '@/lib/mutable-ref' @@ -38,11 +42,13 @@ import { $busy, $connection, $messages, + $sessions, $yoloActive, setAwaitingResponse, setBusy, setMessages, setModelPickerOpen, + setSessionPickerOpen, setSessions, setYoloActive } from '@/store/session' @@ -50,12 +56,30 @@ import { import type { ClientSessionState, FileAttachResponse, + HandoffFailResponse, + HandoffRequestResponse, + HandoffStateResponse, ImageAttachResponse, SessionSteerResponse, SessionTitleResponse, SlashExecResponse } from '../../types' +interface HandoffResult { + ok: boolean + error?: string +} + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +function isSessionIdCandidate(value: string): boolean { + const trimmed = value.trim() + + return /^\d{8}_\d{6}_[A-Fa-f0-9]{6}$/.test(trimmed) || /^[A-Fa-f0-9]{32}$/.test(trimmed) +} + function blobToDataUrl(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() @@ -245,6 +269,7 @@ interface PromptActionsOptions { handleSkinCommand: (arg: string) => string refreshSessions: () => Promise requestGateway: (method: string, params?: Record) => Promise + resumeStoredSession: (storedSessionId: string) => Promise | void selectedStoredSessionIdRef: MutableRefObject startFreshSessionDraft: () => void sttEnabled: boolean @@ -260,6 +285,15 @@ interface SubmitTextOptions { fromQueue?: boolean } +/** Everything a slash handler needs about the invocation it's serving. */ +interface SlashActionCtx { + arg: string + command: string + name: string + recordInput: boolean + sessionHint?: string +} + function renderCommandsCatalog(catalog: CommandsCatalogLike, copy: Translations['desktop']): string { const desktopCatalog = filterDesktopCommandsCatalog(catalog) @@ -310,6 +344,7 @@ export function usePromptActions({ handleSkinCommand, refreshSessions, requestGateway, + resumeStoredSession, selectedStoredSessionIdRef, startFreshSessionDraft, sttEnabled, @@ -320,7 +355,11 @@ export function usePromptActions({ const appendSessionTextMessage = useCallback( (sessionId: string, role: ChatMessage['role'], text: string) => { - const body = text.trim() + // Strip ANSI: slash-command output from the backend worker carries SGR + // color codes (e.g. "Unknown command" in red). The ESC byte is invisible + // in the chat panel, so without this the `[1;31m…[0m` payload leaks as + // literal text. + const body = stripAnsi(text).trim() if (!body) { return @@ -696,230 +735,124 @@ export function usePromptActions({ ] ) + // Queue a handoff of this session to a messaging platform and watch it to + // a terminal state. We only write the request through the gateway; the + // separate `hermes gateway` process performs the actual transfer, so we + // poll `handoff.state` (mirror of the CLI's block-poll) for the result. + const handoffSession = useCallback( + async ( + platform: string, + options?: { onProgress?: (state: string) => void; sessionId?: string } + ): Promise => { + const sid = options?.sessionId || activeSessionIdRef.current + + if (!sid) { + return { error: copy.sessionUnavailable, ok: false } + } + + const target = platform.trim().toLowerCase() + + if (!target) { + return { error: copy.handoff.failed(''), ok: false } + } + + try { + options?.onProgress?.('pending') + await requestGateway('handoff.request', { + platform: target, + session_id: sid + }) + } catch (err) { + return { error: inlineErrorMessage(err, copy.handoff.failed(target)), ok: false } + } + + const deadline = Date.now() + 60_000 + let lastState = 'pending' + + while (Date.now() < deadline) { + await delay(800) + + let record: HandoffStateResponse + + try { + record = await requestGateway('handoff.state', { session_id: sid }) + } catch { + continue + } + + const state = record.state || 'pending' + + if (state !== lastState) { + options?.onProgress?.(state) + lastState = state + } + + if (state === 'completed') { + appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target)) + notify({ kind: 'success', message: copy.handoff.success(target) }) + + return { ok: true } + } + + if (state === 'failed') { + return { error: record.error || copy.handoff.failed(target), ok: false } + } + } + + const cleanup = await requestGateway('handoff.fail', { + error: copy.handoff.timedOut, + session_id: sid + }).catch(() => null) + + if (cleanup?.state === 'completed') { + appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target)) + notify({ kind: 'success', message: copy.handoff.success(target) }) + + return { ok: true } + } + + return { error: copy.handoff.timedOut, ok: false } + }, + [activeSessionIdRef, appendSessionTextMessage, copy, requestGateway] + ) + const executeSlashCommand = useCallback( async (rawCommand: string, options?: { sessionId?: string; recordInput?: boolean }) => { - const runSlash = async (commandText: string, sessionHint?: string, recordInput = true): Promise => { - const command = commandText.trim() - const { name, arg } = parseSlashCommand(command) - const normalizedName = name.toLowerCase() + const ensureSessionId = async (sessionHint?: string) => + sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend()) - if (!name) { - const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend()) - - if (sessionId) { - appendSessionTextMessage(sessionId, 'system', copy.emptySlashCommand) - } - - return - } - - if (normalizedName === 'new' || normalizedName === 'reset') { - startFreshSessionDraft() - - return - } - - if (normalizedName === 'branch' || normalizedName === 'fork') { - await branchCurrentSession() - - return - } - - // /yolo maps to the status-bar YOLO control — a per-session approval - // bypass, same scope as the TUI's Shift+Tab. With no session yet we arm - // it locally; the session-create path applies it on the first message. - if (normalizedName === 'yolo') { - const sid = sessionHint || activeSessionIdRef.current - const next = !$yoloActive.get() - - if (!sid) { - setYoloActive(next) - notify({ kind: 'success', message: next ? copy.yoloArmed : copy.yoloOff }) - - return - } - - try { - const active = await setSessionYolo(requestGateway, sid, next) - appendSessionTextMessage(sid, 'system', copy.yoloSystem(active)) - } catch { - notify({ kind: 'error', title: copy.yoloTitle, message: copy.yoloToggleFailed }) - } - - return - } - - // /model opens the desktop model picker overlay — the same full - // provider+model picker reachable from the status-bar model button — - // instead of the headless prompt_toolkit modal the slash worker can't - // render. With explicit args (`/model [--provider ...]`) run the - // switch directly through slash.exec so power users can still type it. - if (isModelPickerCommand(`/${normalizedName}`)) { - if (!arg.trim()) { - setModelPickerOpen(true) - - return - } - - const sid = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend()) - - if (!sid) { - notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' }) - - return - } - - try { - const result = await requestGateway('slash.exec', { - session_id: sid, - command: command.replace(/^\/+/, '') - }) - - const body = result?.output || `/${name}: model switched` - appendSessionTextMessage( - sid, - 'system', - recordInput ? slashStatusText(command, body) : body - ) - } catch (err) { - appendSessionTextMessage( - sid, - 'system', - `error: ${err instanceof Error ? err.message : String(err)}` - ) - } - - return - } - - if (normalizedName === 'skin' && !sessionHint && !activeSessionIdRef.current) { - notify({ kind: 'success', message: handleSkinCommand(arg) }) - - return - } - - // /profile selects which profile new chats open in — no app relaunch. - // A profile is per-session now, so an existing thread can't change its - // profile mid-stream; `/profile ` instead points the next new chat - // (and the current empty draft) at that profile's backend. - if (normalizedName === 'profile') { - const target = arg.trim() - const current = normalizeProfileKey($activeGatewayProfile.get()) - - if (!target) { - notify({ - kind: 'success', - message: copy.profileStatus(current) - }) - - return - } - - try { - const { profiles } = await getProfiles() - const match = profiles.find(profile => profile.name === target) - - if (!match) { - notify({ - kind: 'error', - title: copy.unknownProfile, - message: copy.noProfileNamed(target, profiles.map(profile => profile.name).join(', ')) - }) - - return - } - - const key = normalizeProfileKey(match.name) - - $newChatProfile.set(key) - // Swap the live gateway now so an empty draft sends into this - // profile immediately; an existing thread keeps its own profile. - await ensureGatewayProfile(key) - notify({ kind: 'success', message: copy.newChatsProfile(match.name) }) - } catch (err) { - notifyError(err, copy.setProfileFailed) - } - - return - } - - const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend()) + // Resolve the target session plus a writer for inline slash output, or + // notify + return null when none can be created. Folds the ensure / bail / + // build-renderSlashOutput boilerplate every exec-style handler repeats. + const withSlashOutput = async ( + ctx: SlashActionCtx + ): Promise<{ render: (text: string) => void; sessionId: string } | null> => { + const sessionId = await ensureSessionId(ctx.sessionHint) if (!sessionId) { - notify({ - kind: 'error', - title: copy.sessionUnavailable, - message: copy.createSessionFailed - }) + notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed }) + return null + } + + const render = (text: string) => + appendSessionTextMessage(sessionId, 'system', ctx.recordInput ? slashStatusText(ctx.command, text) : text) + + return { render, sessionId } + } + + // `exec` commands (and unknown skill / quick commands the backend owns) + // run on the gateway and render their text output inline. This is the only + // path that talks to slash.exec / command.dispatch. + async function runExec(ctx: SlashActionCtx): Promise { + const { arg, command, name } = ctx + const resolved = await withSlashOutput(ctx) + + if (!resolved) { return } - const renderSlashOutput = (text: string) => - appendSessionTextMessage(sessionId, 'system', recordInput ? slashStatusText(command, text) : text) - - // /title renames the session. Route through the gateway's - // `session.title` RPC — the same path the TUI uses — NOT the REST - // renameSession endpoint and NOT the slash worker. - // - // Why not the slash worker: it's a separate HermesCLI subprocess whose - // SQLite write to the shared state.db can silently fail (notably on - // Windows), and it never refreshes the sidebar. - // - // Why not REST renameSession: `sessionId` here is the *runtime* session - // id returned by session.create — it is NOT the stored DB `sessions.id`, - // and session.create deliberately does not persist a DB row until the - // first turn. The REST PATCH endpoint resolves against the sessions - // table, so a runtime id (or a brand-new, not-yet-persisted session) - // 404s with "Session not found" on every platform. See #38508 / #38576. - // - // session.title maps the runtime id to the in-memory session, writes - // through the gateway's own DB connection, and QUEUES the title - // (`pending: true`) when the row isn't persisted yet — so it works for a - // fresh chat too. refreshSessions() then pulls the authoritative title - // back into the sidebar. A bare `/title` (no arg) still falls through to - // the worker to display the current title. - if (normalizedName === 'title' && arg) { - try { - const result = await requestGateway('session.title', { - session_id: sessionId, - title: arg - }) - - const finalTitle = (result?.title || arg).trim() - const queued = result?.pending === true - - setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s))) - await refreshSessions().catch(() => undefined) - renderSlashOutput( - finalTitle - ? `Session title set: ${finalTitle}${queued ? ' (queued while session initializes)' : ''}` - : 'Session title cleared.' - ) - } catch (err) { - renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) - } - - return - } - - if (normalizedName === 'skin') { - renderSlashOutput(handleSkinCommand(arg)) - - return - } - - if (name === 'help' || name === 'commands') { - try { - const catalog = await requestGateway('commands.catalog', { session_id: sessionId }) - - renderSlashOutput(renderCommandsCatalog(catalog, copy)) - } catch (err) { - renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) - } - - return - } + const { render: renderSlashOutput, sessionId } = resolved if (!isDesktopSlashCommand(name)) { renderSlashOutput(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`) @@ -943,11 +876,7 @@ export function usePromptActions({ try { const dispatch = parseCommandDispatch( - await requestGateway('command.dispatch', { - session_id: sessionId, - name, - arg - }) + await requestGateway('command.dispatch', { session_id: sessionId, name, arg }) ) if (!dispatch) { @@ -994,6 +923,261 @@ export function usePromptActions({ } } + // One handler per `action` command. Adding a desktop-native command is a + // registry row in desktop-slash-commands.ts plus an entry here — never a + // new branch in a dispatch ladder. + const actionHandlers: Record Promise> = { + new: async () => { + startFreshSessionDraft() + }, + branch: async () => { + await branchCurrentSession() + }, + // /yolo maps to the status-bar YOLO control — a per-session approval + // bypass, same scope as the TUI's Shift+Tab. With no session yet we arm + // it locally; the session-create path applies it on the first message. + yolo: async ({ sessionHint }) => { + const sid = sessionHint || activeSessionIdRef.current + const next = !$yoloActive.get() + + if (!sid) { + setYoloActive(next) + notify({ kind: 'success', message: next ? copy.yoloArmed : copy.yoloOff }) + + return + } + + try { + const active = await setSessionYolo(requestGateway, sid, next) + appendSessionTextMessage(sid, 'system', copy.yoloSystem(active)) + } catch { + notify({ kind: 'error', title: copy.yoloTitle, message: copy.yoloToggleFailed }) + } + }, + // /handoff hands this session to a messaging platform. The platform is + // completed inline in the slash popover (backend _handoff_completions), + // so there is no overlay: `/handoff ` runs the desktop's own + // handoff RPC. cli_only on the backend, so it must not reach slash.exec. + handoff: async ({ arg, command, recordInput, sessionHint }) => { + const platform = arg.trim() + + if (!platform) { + notify({ kind: 'success', message: copy.handoff.pickPlatform }) + + return + } + + const sid = sessionHint || activeSessionIdRef.current + + if (!sid) { + notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed }) + + return + } + + const result = await handoffSession(platform, { sessionId: sid }) + + if (!result.ok && result.error) { + appendSessionTextMessage(sid, 'system', recordInput ? slashStatusText(command, result.error) : result.error) + } + }, + // /profile selects which profile new chats open in — no app relaunch. + // A profile is per-session now, so an existing thread can't change its + // profile mid-stream; `/profile ` points the next new chat (and + // the current empty draft) at that profile's backend. + profile: async ({ arg }) => { + const target = arg.trim() + const current = normalizeProfileKey($activeGatewayProfile.get()) + + if (!target) { + notify({ kind: 'success', message: copy.profileStatus(current) }) + + return + } + + try { + const { profiles } = await getProfiles() + const match = profiles.find(profile => profile.name === target) + + if (!match) { + notify({ + kind: 'error', + title: copy.unknownProfile, + message: copy.noProfileNamed(target, profiles.map(profile => profile.name).join(', ')) + }) + + return + } + + const key = normalizeProfileKey(match.name) + + $newChatProfile.set(key) + await ensureGatewayProfile(key) + notify({ kind: 'success', message: copy.newChatsProfile(match.name) }) + } catch (err) { + notifyError(err, copy.setProfileFailed) + } + }, + skin: async ({ arg, command, recordInput, sessionHint }) => { + const sid = sessionHint || activeSessionIdRef.current + const message = handleSkinCommand(arg) + + // No session to print into yet — surface it as a toast instead of + // spinning up a backend session just to change the theme. + if (!sid) { + notify({ kind: 'success', message }) + + return + } + + appendSessionTextMessage(sid, 'system', recordInput ? slashStatusText(command, message) : message) + }, + // /title renames via the gateway's session.title RPC — the same + // path the TUI uses, NOT REST renameSession (which 404s on runtime ids) + // nor the slash worker (whose DB write can silently fail). Bare /title + // shows the current title, which the worker owns, so delegate to exec. + title: async ctx => { + if (!ctx.arg) { + await runExec(ctx) + + return + } + + const resolved = await withSlashOutput(ctx) + + if (!resolved) { + return + } + + const { render: renderSlashOutput, sessionId } = resolved + const { arg } = ctx + + try { + const result = await requestGateway('session.title', { + session_id: sessionId, + title: arg + }) + + const finalTitle = (result?.title || arg).trim() + const queued = result?.pending === true + + setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s))) + await refreshSessions().catch(() => undefined) + renderSlashOutput( + finalTitle + ? `Session title set: ${finalTitle}${queued ? ' (queued while session initializes)' : ''}` + : 'Session title cleared.' + ) + } catch (err) { + renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) + } + }, + help: async ctx => { + const resolved = await withSlashOutput(ctx) + + if (!resolved) { + return + } + + const { render: renderSlashOutput, sessionId } = resolved + + try { + const catalog = await requestGateway('commands.catalog', { session_id: sessionId }) + + renderSlashOutput(renderCommandsCatalog(catalog, copy)) + } catch (err) { + renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) + } + } + } + + // Picker commands open a desktop overlay; a typed arg is resolved by that + // picker so the command never dead-ends or falls through to the backend. + const openPicker = async (pickerId: DesktopPickerId, ctx: SlashActionCtx): Promise => { + if (pickerId === 'model') { + if (!ctx.arg.trim()) { + setModelPickerOpen(true) + + return + } + + // Power users can still type `/model ` — run it on the backend. + await runExec(ctx) + + return + } + + // session picker — /resume, /sessions, /switch + const query = ctx.arg.trim() + + if (!query) { + setSessionPickerOpen(true) + + return + } + + const sessions = $sessions.get() + const lower = query.toLowerCase() + + const match = + sessions.find(session => session.id === query) || + sessions.find(session => sessionTitle(session).toLowerCase().includes(lower)) || + sessions.find(session => (session.preview ?? '').toLowerCase().includes(lower)) + + if (!match) { + if (isSessionIdCandidate(query)) { + await resumeStoredSession(query) + + return + } + + notify({ kind: 'error', message: copy.resumeFailed }) + + return + } + + await resumeStoredSession(match.id) + } + + // The whole dispatcher: resolve the command's desktop surface, then act on + // its kind. No per-command ladder — behavior lives in the registry. + async function runSlash(commandText: string, sessionHint?: string, recordInput = true): Promise { + const command = commandText.trim() + const { name, arg } = parseSlashCommand(command) + + if (!name) { + const sessionId = await ensureSessionId(sessionHint) + + if (sessionId) { + appendSessionTextMessage(sessionId, 'system', copy.emptySlashCommand) + } + + return + } + + const ctx: SlashActionCtx = { arg, command, name, recordInput, sessionHint } + const surface = resolveDesktopCommand(`/${name}`)?.surface + + switch (surface?.kind) { + case 'unavailable': { + const resolved = await withSlashOutput(ctx) + resolved?.render(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`) + + return + } + + case 'picker': + return openPicker(surface.picker, ctx) + + case 'action': + return actionHandlers[surface.action](ctx) + + default: + // exec spec, or an unknown skill / quick command the backend owns. + return runExec(ctx) + } + } + await runSlash(rawCommand, options?.sessionId, options?.recordInput ?? true) }, [ @@ -1004,8 +1188,10 @@ export function usePromptActions({ copy, createBackendSessionForSend, handleSkinCommand, + handoffSession, refreshSessions, requestGateway, + resumeStoredSession, startFreshSessionDraft, submitPromptText ] @@ -1314,6 +1500,7 @@ export function usePromptActions({ cancelRun, editMessage, handleThreadMessagesChange, + handoffSession, reloadFromMessage, steerPrompt, submitText, diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts index 672beb9a089..01694dc8220 100644 --- a/apps/desktop/src/app/types.ts +++ b/apps/desktop/src/app/types.ts @@ -61,6 +61,26 @@ export interface SessionTitleResponse { session_key?: string } +export interface HandoffRequestResponse { + queued?: boolean + session_key?: string + platform?: string + // Human-readable home channel name for the destination platform. + home_name?: string +} + +export interface HandoffStateResponse { + // '' | 'pending' | 'running' | 'completed' | 'failed' + state?: string + platform?: string + error?: string +} + +export interface HandoffFailResponse { + failed?: boolean + state?: string +} + export interface ExecCommandDispatchResponse { type: 'exec' | 'plugin' output?: string diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx index 79f772d450f..b870913b012 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.tsx +++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx @@ -63,7 +63,7 @@ export function directiveIconSvg(type: string) { return `${inner}` } -export function directiveIconElement(type: string) { +function iconElementFromPaths(paths: string[]) { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') svg.setAttribute('class', 'size-3 shrink-0 opacity-80') svg.setAttribute('fill', 'none') @@ -74,7 +74,7 @@ export function directiveIconElement(type: string) { svg.setAttribute('viewBox', '0 0 24 24') svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg') - for (const d of iconPathsFor(type)) { + for (const d of paths) { const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') path.setAttribute('d', d) svg.append(path) @@ -83,6 +83,46 @@ export function directiveIconElement(type: string) { return svg } +export function directiveIconElement(type: string) { + return iconElementFromPaths(iconPathsFor(type)) +} + +/** Per-type slash-command pill styling. The composer inserts these chips when a + * command is picked; the kind drives a theme-aware accent so commands, skills, + * and themes read distinctly (Cursor-style). */ +export type SlashChipKind = 'command' | 'skill' | 'theme' + +const SLASH_ICON_PATHS: Record = { + command: ['M5 7l5 5l-5 5', 'M12 19l7 0'], + skill: ['M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11'], + theme: [ + 'M3 21v-4a4 4 0 1 1 4 4h-4', + 'M21 3a16 16 0 0 0 -12.8 10.2', + 'M21 3a16 16 0 0 1 -10.2 12.8', + 'M10.6 9a9 9 0 0 1 4.4 4.4' + ] +} + +const SLASH_CHIP_VARIANT: Record = { + command: + 'bg-[color-mix(in_srgb,var(--ui-accent)_14%,transparent)] text-[color-mix(in_srgb,var(--ui-accent)_82%,var(--foreground))]', + skill: + 'bg-[color-mix(in_srgb,var(--ui-warm)_18%,transparent)] text-[color-mix(in_srgb,var(--ui-warm)_82%,var(--foreground))]', + theme: + 'bg-[color-mix(in_srgb,var(--ui-accent-secondary)_16%,transparent)] text-[color-mix(in_srgb,var(--ui-accent-secondary)_82%,var(--foreground))]' +} + +export const SLASH_CHIP_BASE_CLASS = + 'mx-0.5 inline-flex max-w-64 items-center gap-1 rounded px-1.5 py-0.5 align-middle text-[0.86em] font-medium leading-none' + +export function slashChipClass(kind: SlashChipKind): string { + return `${SLASH_CHIP_BASE_CLASS} ${SLASH_CHIP_VARIANT[kind]}` +} + +export function slashIconElement(kind: SlashChipKind) { + return iconElementFromPaths(SLASH_ICON_PATHS[kind]) +} + const DirectiveIcon: FC<{ type: string }> = ({ type }) => ( { const slashStatus = text.match(SLASH_STATUS_RE) if (slashStatus?.groups) { + const output = slashStatus.groups.output.trim() + // Single-line status (e.g. "model → x") reads best centered inline; padded + // multiline output (catalogs, usage tables) needs left-aligned, wider room + // or the column alignment breaks. + const multiline = output.includes('\n') + return ( {slashStatus.groups.command} - · - + {multiline ? ( + + ) : ( + <> + · + + + )} ) } + const multiline = text.includes('\n') + return ( diff --git a/apps/desktop/src/components/session-picker.tsx b/apps/desktop/src/components/session-picker.tsx new file mode 100644 index 00000000000..048fa32a208 --- /dev/null +++ b/apps/desktop/src/components/session-picker.tsx @@ -0,0 +1,108 @@ +import { useQuery } from '@tanstack/react-query' +import { Dialog as DialogPrimitive } from 'radix-ui' +import { useEffect, useMemo, useState } from 'react' + +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' +import { listSessions } from '@/hermes' +import { useI18n } from '@/i18n' +import { sessionTitle } from '@/lib/chat-runtime' +import { Check, MessageCircle } from '@/lib/icons' +import { cn } from '@/lib/utils' + +interface SessionPickerDialogProps { + /** Stored id of the session currently open, so it can be flagged in the list. */ + activeStoredSessionId?: string | null + onOpenChange: (open: boolean) => void + onResume: (storedSessionId: string) => void + open: boolean +} + +/** + * Desktop equivalent of the TUI's sessions overlay (`/resume`, `/sessions`, + * `/switch`): a focused, type-to-filter list of recent sessions that resumes + * the picked one. Mirrors the command palette's cmdk surface but scoped to + * sessions only, so `/resume` feels first-class instead of falling through to + * the headless slash worker (which can't render the picker). + */ +export function SessionPickerDialog({ + activeStoredSessionId, + onOpenChange, + onResume, + open +}: SessionPickerDialogProps) { + const { t } = useI18n() + const [search, setSearch] = useState('') + + const sessionsQuery = useQuery({ + enabled: open, + queryFn: () => listSessions(200, 1, 'exclude'), + queryKey: ['session-picker', 'sessions'] + }) + + useEffect(() => { + if (!open) { + setSearch('') + } + }, [open]) + + const sessions = useMemo(() => sessionsQuery.data?.sessions ?? [], [sessionsQuery.data]) + + return ( + + + + + {t.commandCenter.sections.sessions} + + + + {t.commandCenter.noResults} + + {sessions.map(session => { + const title = sessionTitle(session) + const preview = session.preview?.trim() + + return ( + { + onResume(session.id) + onOpenChange(false) + }} + value={`${title} ${preview ?? ''} ${session.id}`} + > + + + {title} + {preview ? ( + {preview} + ) : null} + + + + ) + })} + + + + + + + ) +} diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 5aaf090d7e6..5a18d47efa9 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1778,7 +1778,14 @@ export const en: Translations = { clipboard: 'Clipboard', noClipboardImage: 'No image found in clipboard', clipboardPasteFailed: 'Clipboard paste failed', - dropFiles: 'Drop files' + dropFiles: 'Drop files', + handoff: { + pickPlatform: 'Choose a destination', + success: platform => `Handed off to ${platform}. Resume here anytime.`, + systemNote: platform => `↻ Handed off to ${platform} — resume here anytime.`, + failed: error => `Handoff failed: ${error}`, + timedOut: 'Timed out waiting for the gateway. Is `hermes gateway` running?' + } }, errors: { diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 956788067ed..36634a6c025 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1914,7 +1914,14 @@ export const ja = defineLocale({ clipboard: 'クリップボード', noClipboardImage: 'クリップボードに画像が見つかりません', clipboardPasteFailed: 'クリップボードからの貼り付けに失敗しました', - dropFiles: 'ファイルをドロップ' + dropFiles: 'ファイルをドロップ', + handoff: { + pickPlatform: '送信先を選択', + success: platform => `${platform} に引き継ぎました。いつでもここで再開できます。`, + systemNote: platform => `↻ ${platform} に引き継ぎました — いつでもここで再開できます。`, + failed: error => `引き継ぎに失敗しました: ${error}`, + timedOut: 'ゲートウェイの待機がタイムアウトしました。`hermes gateway` は起動していますか?' + } }, errors: { diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 77424e426ac..7a10e5f3d1c 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1437,6 +1437,13 @@ export interface Translations { noClipboardImage: string clipboardPasteFailed: string dropFiles: string + handoff: { + pickPlatform: string + success: (platform: string) => string + systemNote: (platform: string) => string + failed: (error: string) => string + timedOut: string + } } errors: { diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 9f045c4d022..830dc475134 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1873,7 +1873,14 @@ export const zhHant = defineLocale({ clipboard: '剪貼簿', noClipboardImage: '剪貼簿中沒有圖片', clipboardPasteFailed: '剪貼簿貼上失敗', - dropFiles: '拖曳檔案' + dropFiles: '拖曳檔案', + handoff: { + pickPlatform: '選擇目標平台', + success: platform => `已移交到 ${platform}。隨時可在此處恢復。`, + systemNote: platform => `↻ 已移交到 ${platform} — 隨時可在此處恢復。`, + failed: error => `移交失敗:${error}`, + timedOut: '等待閘道逾時。`hermes gateway` 是否正在執行?' + } }, errors: { diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index f6b119a2777..dbad00cf5d1 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1956,7 +1956,14 @@ export const zh: Translations = { clipboard: '剪贴板', noClipboardImage: '剪贴板中没有图片', clipboardPasteFailed: '粘贴剪贴板失败', - dropFiles: '拖放文件' + dropFiles: '拖放文件', + handoff: { + pickPlatform: '选择目标平台', + success: platform => `已移交到 ${platform}。随时可在此处恢复。`, + systemNote: platform => `↻ 已移交到 ${platform} — 随时可在此处恢复。`, + failed: error => `移交失败:${error}`, + timedOut: '等待网关超时。`hermes gateway` 是否正在运行?' + } }, errors: { diff --git a/apps/desktop/src/lib/ansi.ts b/apps/desktop/src/lib/ansi.ts index f30987ec605..c7770e8b777 100644 --- a/apps/desktop/src/lib/ansi.ts +++ b/apps/desktop/src/lib/ansi.ts @@ -173,3 +173,14 @@ export function hasAnsiCodes(input: string): boolean { // eslint-disable-next-line no-control-regex return /\x1b\[/.test(input) } + +/** Remove all ANSI escape sequences, returning plain text. Use when output is + * rendered as text (e.g. chat system messages) rather than styled segments — + * otherwise the ESC byte is invisible and the `[1;31m…` payload leaks through. */ +export function stripAnsi(input: string): string { + if (!input) { + return input + } + + return input.replace(OTHER_ESCAPE_RE, '').replace(CSI_RE, '') +} diff --git a/apps/desktop/src/lib/desktop-slash-commands.test.ts b/apps/desktop/src/lib/desktop-slash-commands.test.ts index de0e72ec28b..d37738173ce 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.test.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.test.ts @@ -7,7 +7,9 @@ import { filterDesktopCommandsCatalog, isDesktopSlashCommand, isDesktopSlashSuggestion, - isModelPickerCommand + isModelPickerCommand, + isPickerCommand, + resolveDesktopCommand } from './desktop-slash-commands' describe('desktop slash command curation', () => { @@ -38,6 +40,18 @@ describe('desktop slash command curation', () => { expect(isDesktopSlashSuggestion('/curator')).toBe(false) }) + it('surfaces /tools, /save, and /personality on the desktop', () => { + expect(isDesktopSlashSuggestion('/tools')).toBe(true) + expect(isDesktopSlashSuggestion('/save')).toBe(true) + expect(isDesktopSlashSuggestion('/personality')).toBe(true) + expect(isDesktopSlashCommand('/tools')).toBe(true) + expect(isDesktopSlashCommand('/save')).toBe(true) + expect(isDesktopSlashCommand('/personality')).toBe(true) + expect(desktopSlashUnavailableMessage('/tools')).toBeNull() + expect(desktopSlashUnavailableMessage('/save')).toBeNull() + expect(desktopSlashUnavailableMessage('/personality')).toBeNull() + }) + it('allows aliases to execute without cluttering the popover', () => { expect(isDesktopSlashSuggestion('/reset')).toBe(false) expect(isDesktopSlashCommand('/reset')).toBe(true) @@ -74,6 +88,24 @@ describe('desktop slash command curation', () => { ['/new', 'Start a new desktop chat'], ['/ship-it', 'Run release checklist'] ]) + // skill_count is recomputed from the filtered output (only /ship-it is an + // extension command — /new is a built-in) so the /help footer matches what + // the user actually sees rather than echoing the unfiltered backend total. + expect(filtered.skill_count).toBe(1) + }) + + it('recomputes skill_count to reflect only extensions surfaced on desktop', () => { + const filtered = filterDesktopCommandsCatalog({ + pairs: [ + ['/new', 'Start a new session'], + ['/clear', 'Clear terminal screen'], + ['/gif-search', 'Search for a gif'], + ['/ship-it', 'Run release checklist'] + ], + skill_count: 12 + }) + + expect(filtered.pairs?.map(([cmd]) => cmd)).toEqual(['/new', '/gif-search', '/ship-it']) expect(filtered.skill_count).toBe(2) }) @@ -123,4 +155,26 @@ describe('desktop slash command curation', () => { expect(isModelPickerCommand('/new')).toBe(false) expect(isModelPickerCommand('/skills')).toBe(false) }) + + it('gives /resume (and its aliases) a first-class session picker surface', () => { + expect(isPickerCommand('/resume', 'session')).toBe(true) + expect(isPickerCommand('/sessions', 'session')).toBe(true) + expect(isPickerCommand('/switch', 'session')).toBe(true) + // Unlike /model, /resume shows in the popover; its aliases stay hidden. + expect(isDesktopSlashSuggestion('/resume')).toBe(true) + expect(isDesktopSlashSuggestion('/sessions')).toBe(false) + expect(isDesktopSlashCommand('/switch')).toBe(true) + // The session picker is distinct from the model picker. + expect(isModelPickerCommand('/resume')).toBe(false) + }) + + it('resolves commands and aliases to their declared surface', () => { + expect(resolveDesktopCommand('/new')?.surface).toEqual({ kind: 'action', action: 'new' }) + expect(resolveDesktopCommand('/reset')?.surface).toEqual({ kind: 'action', action: 'new' }) + expect(resolveDesktopCommand('/resume')?.surface).toEqual({ kind: 'picker', picker: 'session' }) + expect(resolveDesktopCommand('/usage')?.surface).toEqual({ kind: 'exec' }) + expect(resolveDesktopCommand('/clear')?.surface).toEqual({ kind: 'unavailable', reason: 'terminal' }) + // Skill / quick commands aren't in the registry. + expect(resolveDesktopCommand('/gif-search')).toBeNull() + }) }) diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index e373ac94317..d898a6c83f1 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -22,110 +22,161 @@ export interface DesktopThemeCommandOption { name: string } -const DESKTOP_COMMAND_META = [ - ['/agents', 'Show active desktop sessions and running tasks'], - ['/background', 'Run a prompt in the background'], - ['/branch', 'Branch the latest message into a new chat'], - ['/compress', 'Compress this conversation context'], - ['/debug', 'Create a debug report'], - ['/goal', 'Manage the standing goal for this session'], - ['/help', 'Show desktop slash commands'], - ['/new', 'Start a new desktop chat'], - ['/profile', 'Switch the active Hermes profile'], - ['/queue', 'Queue a prompt for the next turn'], - ['/resume', 'Resume a saved session'], - ['/retry', 'Retry the last user message'], - ['/rollback', 'List or restore filesystem checkpoints'], - ['/skin', 'Switch desktop theme or cycle to the next one'], - ['/status', 'Show current session status'], - ['/steer', 'Steer the current run after the next tool call'], - ['/stop', 'Stop running background processes'], - ['/title', 'Rename the current session'], - ['/undo', 'Remove the last user/assistant exchange'], - ['/usage', 'Show token usage for this session'], - ['/version', 'Show Hermes Agent version'], - ['/yolo', 'Toggle YOLO — auto-approve dangerous commands'] -] as const +/** + * Local client action a command resolves to. Each id maps to exactly one + * handler in the dispatcher (`use-prompt-actions`), so adding a command never + * means adding a branch to a switch ladder — you add a row here + a handler + * keyed by the id. + */ +export type DesktopActionId = + | 'branch' + | 'handoff' + | 'help' + | 'new' + | 'profile' + | 'skin' + | 'title' + | 'yolo' -const DESKTOP_COMMANDS: ReadonlySet = new Set(DESKTOP_COMMAND_META.map(([command]) => command)) +/** A command fulfilled by opening a desktop overlay picker. */ +export type DesktopPickerId = 'model' | 'session' -const DESKTOP_ALIASES = new Map([ - ['/bg', '/background'], - ['/btw', '/background'], - ['/fork', '/branch'], - ['/q', '/queue'], - ['/reload_mcp', '/reload-mcp'], - ['/reload_skills', '/reload-skills'], - ['/reset', '/new'], - ['/tasks', '/agents'] -]) +/** Why a known Hermes command has no desktop UI surface. */ +export type DesktopUnavailableReason = 'advanced' | 'messaging' | 'settings' | 'terminal' -const DESKTOP_COMMAND_DESCRIPTIONS: ReadonlyMap = new Map(DESKTOP_COMMAND_META) +/** + * How the desktop fulfils a command. This is the single discriminator the + * dispatcher, popover, pills, and pickers all read — no parallel block-lists. + * + * - `action` → handled by a local client handler (new chat, branch, …) + * - `picker` → opens an overlay (`/model`, `/resume`); a typed arg is + * resolved by that picker instead of falling through + * - `exec` → runs on the backend via slash.exec / command.dispatch and + * renders its text output inline + * - `unavailable`→ a known command with genuinely no desktop UI (terminal-only, + * messaging-only, …); shows a reason instead of executing + */ +export type DesktopCommandSurface = + | { kind: 'action'; action: DesktopActionId } + | { kind: 'picker'; picker: DesktopPickerId } + | { kind: 'exec' } + | { kind: 'unavailable'; reason: DesktopUnavailableReason } -const PICKER_OWNED_COMMANDS = new Set(['/model']) +export interface DesktopCommandSpec { + /** Canonical command, leading slash included (e.g. `/resume`). */ + name: string + /** Popover/help label; omitted for unavailable commands (never surfaced). */ + description?: string + aliases?: string[] + surface: DesktopCommandSurface + /** + * Hide from the slash popover / completions while still letting it execute. + * Used for picker commands reachable from chrome (the model picker lives on + * the status bar), so the popover doesn't dead-end on inline completion. + */ + hidden?: boolean + /** + * The command has an inline options "screen" (theme / personality / session / + * platform / toolset list). Picking the bare command in the popover expands to + * that argument step instead of committing — mirroring typing `/ ` by hand. + */ + args?: boolean +} -const TERMINAL_ONLY_COMMANDS = new Set([ - '/browser', - '/busy', - '/clear', - '/commands', - '/compact', - '/config', - '/copy', - '/cron', - '/details', - '/exit', - '/footer', - '/gateway', - '/gquota', - '/history', - '/image', - '/indicator', - '/logs', - '/mouse', - '/paste', - '/platforms', - '/plugins', - '/quit', - '/redraw', - '/reload', - '/restart', - '/save', - '/sb', - '/set-home', - '/sethome', - '/snap', - '/snapshot', - '/statusbar', - '/toolsets', - '/tools', - '/update', - '/verbose' -]) +const exec = (): DesktopCommandSurface => ({ kind: 'exec' }) +const action = (id: DesktopActionId): DesktopCommandSurface => ({ kind: 'action', action: id }) +const picker = (id: DesktopPickerId): DesktopCommandSurface => ({ kind: 'picker', picker: id }) +const unavailable = (reason: DesktopUnavailableReason): DesktopCommandSurface => ({ kind: 'unavailable', reason }) -const MESSAGING_ONLY_COMMANDS = new Set(['/approve', '/deny']) +/** + * THE source of truth for desktop slash commands. Everything below — execution + * gating, popover suggestions, catalog filtering, pill grouping, and the + * dispatcher's behavior — derives from this one table. + */ +const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ + // Local client actions + { name: '/new', description: 'Start a new desktop chat', aliases: ['/reset'], surface: action('new') }, + { name: '/branch', description: 'Branch the latest message into a new chat', aliases: ['/fork'], surface: action('branch') }, + { name: '/yolo', description: 'Toggle YOLO — auto-approve dangerous commands', surface: action('yolo') }, + { name: '/handoff', description: 'Hand off this session to a messaging platform', surface: action('handoff'), args: true }, + { name: '/profile', description: 'Switch the active Hermes profile', surface: action('profile') }, + { name: '/skin', description: 'Switch desktop theme or cycle to the next one', surface: action('skin'), args: true }, + { name: '/title', description: 'Rename the current session', surface: action('title') }, + { name: '/help', description: 'Show desktop slash commands', aliases: ['/commands'], surface: action('help') }, -const SETTINGS_OWNED_COMMANDS = new Set(['/skills']) + // Overlay pickers + { name: '/model', description: 'Switch the model for this session', surface: picker('model'), hidden: true }, + { + name: '/resume', + description: 'Resume a saved session', + aliases: ['/sessions', '/switch'], + surface: picker('session'), + args: true + }, -const ADVANCED_COMMANDS = new Set([ - '/curator', - '/fast', - '/insights', - '/kanban', - '/personality', - '/reasoning', - '/reload-mcp', - '/reload-skills', - '/voice' -]) + // Backend-executed commands that render useful inline output + { name: '/agents', description: 'Show active desktop sessions and running tasks', aliases: ['/tasks'], surface: exec() }, + { name: '/background', description: 'Run a prompt in the background', aliases: ['/bg', '/btw'], surface: exec() }, + { name: '/compress', description: 'Compress this conversation context', surface: exec() }, + { name: '/debug', description: 'Create a debug report', surface: exec() }, + { name: '/goal', description: 'Manage the standing goal for this session', surface: exec() }, + { name: '/personality', description: 'Switch personality for this session', surface: exec(), args: true }, + { name: '/queue', description: 'Queue a prompt for the next turn', aliases: ['/q'], surface: exec() }, + { name: '/retry', description: 'Retry the last user message', surface: exec() }, + { name: '/rollback', description: 'List or restore filesystem checkpoints', surface: exec() }, + { name: '/save', description: 'Save the current transcript to JSON', surface: exec() }, + { name: '/status', description: 'Show current session status', surface: exec() }, + { name: '/steer', description: 'Steer the current run after the next tool call', surface: exec() }, + { name: '/stop', description: 'Stop running background processes', surface: exec() }, + { name: '/tools', description: 'List or toggle tools available to the agent', surface: exec(), args: true }, + { name: '/undo', description: 'Remove the last user/assistant exchange', surface: exec() }, + { name: '/usage', description: 'Show token usage for this session', surface: exec() }, + { name: '/version', description: 'Show Hermes Agent version', surface: exec() }, -const BLOCKED_COMMANDS = new Set([ - ...PICKER_OWNED_COMMANDS, - ...TERMINAL_ONLY_COMMANDS, - ...MESSAGING_ONLY_COMMANDS, - ...SETTINGS_OWNED_COMMANDS, - ...ADVANCED_COMMANDS -]) + // No desktop surface, but carry an alias (underscore spelling variants). + { name: '/reload-mcp', aliases: ['/reload_mcp'], surface: unavailable('advanced') }, + { name: '/reload-skills', aliases: ['/reload_skills'], surface: unavailable('advanced') } +] + +// Known commands with no desktop surface (and no alias) — a flat name list +// per reason beats 40 identical object literals. +const NO_DESKTOP_SURFACE: Record = { + terminal: [ + '/browser', '/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details', + '/exit', '/footer', '/gateway', '/gquota', '/history', '/image', '/indicator', '/logs', + '/mouse', '/paste', '/platforms', '/plugins', '/quit', '/redraw', '/reload', '/restart', + '/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose' + ], + messaging: ['/approve', '/deny'], + settings: ['/skills'], + advanced: ['/curator', '/fast', '/insights', '/kanban', '/reasoning', '/voice'] +} + +const ALL_SPECS: readonly DesktopCommandSpec[] = [ + ...DESKTOP_COMMAND_SPECS, + ...(Object.entries(NO_DESKTOP_SURFACE) as [DesktopUnavailableReason, readonly string[]][]).flatMap( + ([reason, names]) => names.map(name => ({ name, surface: unavailable(reason) })) + ) +] + +const SPEC_BY_NAME = new Map(ALL_SPECS.map(spec => [spec.name, spec])) + +const ALIAS_TO_CANONICAL = new Map( + ALL_SPECS.flatMap(spec => (spec.aliases ?? []).map(alias => [alias, spec.name] as const)) +) + +const UNAVAILABLE_MESSAGE: Record string> = { + advanced: command => + `${command} is not shown in the desktop slash palette. Use the relevant desktop control or terminal interface instead.`, + messaging: command => `${command} is only used from messaging platforms.`, + settings: command => `${command} is managed from the desktop sidebar.`, + terminal: command => `${command} is only available in the terminal interface.` +} + +const PICKER_UNAVAILABLE_MESSAGE: Record string> = { + model: command => `${command} uses the desktop model picker instead of a slash command.`, + session: command => `${command} uses the desktop session picker instead of a slash command.` +} function normalizeCommand(command: string): string { const trimmed = command.trim() @@ -137,27 +188,25 @@ function normalizeCommand(command: string): string { export function canonicalDesktopSlashCommand(command: string): string { const normalized = normalizeCommand(command) - return DESKTOP_ALIASES.get(normalized) || normalized + return ALIAS_TO_CANONICAL.get(normalized) || normalized } -export function isDesktopSlashCommand(command: string): boolean { +/** Resolve a command (or alias) to its desktop spec, or null for unknown/extension commands. */ +export function resolveDesktopCommand(command: string): DesktopCommandSpec | null { + return SPEC_BY_NAME.get(canonicalDesktopSlashCommand(command)) ?? null +} + +function isKnownHermesSlashCommand(command: string): boolean { const normalized = normalizeCommand(command) - const canonical = canonicalDesktopSlashCommand(normalized) - if (BLOCKED_COMMANDS.has(normalized) || BLOCKED_COMMANDS.has(canonical)) { - return false - } - - return DESKTOP_COMMANDS.has(canonical) || !isKnownHermesSlashCommand(normalized) + return SPEC_BY_NAME.has(normalized) || ALIAS_TO_CANONICAL.has(normalized) } /** * An "extension" command is anything the backend surfaces that is NOT one of * Hermes' built-in slash commands — i.e. skill commands (`/gif-search`, * `/codex`, …) and user-defined quick commands. These are user-activated, so - * they should appear in the desktop slash palette even though they aren't in - * the curated `DESKTOP_COMMANDS` allow-list. This mirrors the predicate in - * `isDesktopSlashCommand` that already lets them EXECUTE when typed. + * they appear in the desktop slash palette and execute when typed. */ export function isDesktopSlashExtensionCommand(command: string): boolean { const normalized = normalizeCommand(command) @@ -169,63 +218,85 @@ export function isDesktopSlashExtensionCommand(command: string): boolean { return !isKnownHermesSlashCommand(normalized) } -export function isDesktopSlashSuggestion(command: string): boolean { - const normalized = normalizeCommand(command) - const canonical = canonicalDesktopSlashCommand(normalized) +/** Gates execution: true unless the command is a known no-desktop-surface command. */ +export function isDesktopSlashCommand(command: string): boolean { + const spec = resolveDesktopCommand(command) - // Surface skill / quick commands (extensions the backend provides) alongside - // the curated built-ins. Built-in aliases stay hidden so the popover isn't - // cluttered with duplicates. - if (isDesktopSlashExtensionCommand(normalized)) { - return true + if (spec) { + return spec.surface.kind !== 'unavailable' } - return DESKTOP_COMMANDS.has(canonical) && !DESKTOP_ALIASES.has(normalized) + return isDesktopSlashExtensionCommand(command) +} + +/** Gates discovery in the popover/completions. */ +export function isDesktopSlashSuggestion(command: string): boolean { + const normalized = normalizeCommand(command) + + // Aliases stay hidden so the popover isn't cluttered with duplicates. + if (ALIAS_TO_CANONICAL.has(normalized)) { + return false + } + + const spec = SPEC_BY_NAME.get(normalized) + + if (spec) { + return spec.surface.kind !== 'unavailable' && !spec.hidden + } + + // Skill / quick commands the backend provides. + return isDesktopSlashExtensionCommand(normalized) } /** - * True for commands the desktop fulfils by opening the model picker overlay - * (e.g. `/model`) rather than executing a slash command. The caller opens the - * picker UI instead of printing the "uses the desktop model picker" notice. + * True for commands the desktop fulfils by opening an overlay picker + * (`/model`, `/resume`/`/sessions`/`/switch`). Optionally pin to one picker. */ -export function isModelPickerCommand(command: string): boolean { - const normalized = normalizeCommand(command) - const canonical = canonicalDesktopSlashCommand(normalized) +export function isPickerCommand(command: string, picker?: DesktopPickerId): boolean { + const surface = resolveDesktopCommand(command)?.surface - return PICKER_OWNED_COMMANDS.has(canonical) + if (surface?.kind !== 'picker') { + return false + } + + return picker ? surface.picker === picker : true +} + +/** Back-compat shim for the model picker check. */ +export function isModelPickerCommand(command: string): boolean { + return isPickerCommand(command, 'model') } export function desktopSlashUnavailableMessage(command: string): string | null { - const normalized = normalizeCommand(command) - const canonical = canonicalDesktopSlashCommand(normalized) + const canonical = canonicalDesktopSlashCommand(command) + const surface = SPEC_BY_NAME.get(canonical)?.surface - if (PICKER_OWNED_COMMANDS.has(canonical)) { - return `/${canonical.slice(1)} uses the desktop model picker instead of a slash command.` + if (!surface) { + return null } - if (SETTINGS_OWNED_COMMANDS.has(canonical)) { - return `/${canonical.slice(1)} is managed from the desktop sidebar.` + if (surface.kind === 'unavailable') { + return UNAVAILABLE_MESSAGE[surface.reason](canonical) } - if (MESSAGING_ONLY_COMMANDS.has(canonical)) { - return `/${canonical.slice(1)} is only used from messaging platforms.` - } - - if (ADVANCED_COMMANDS.has(canonical)) { - return `/${canonical.slice(1)} is not shown in the desktop slash palette. Use the relevant desktop control or terminal interface instead.` - } - - if (TERMINAL_ONLY_COMMANDS.has(normalized) || TERMINAL_ONLY_COMMANDS.has(canonical)) { - return `/${canonical.slice(1)} is only available in the terminal interface.` + if (surface.kind === 'picker') { + return PICKER_UNAVAILABLE_MESSAGE[surface.picker](canonical) } return null } export function desktopSlashDescription(command: string, fallback = ''): string { - const canonical = canonicalDesktopSlashCommand(command) + return SPEC_BY_NAME.get(canonicalDesktopSlashCommand(command))?.description || fallback +} - return DESKTOP_COMMAND_DESCRIPTIONS.get(canonical) || fallback +/** + * True when picking the bare command should expand to its inline argument + * options (theme / personality / session / platform / toolset) rather than + * committing immediately. Lets the popover act as a two-step picker. + */ +export function desktopSlashCommandTakesArgs(command: string): boolean { + return resolveDesktopCommand(command)?.args ?? false } export function desktopSkinSlashCompletions( @@ -274,13 +345,36 @@ export function filterDesktopCommandsCatalog(catalog: CommandsCatalogLike): Comm ?.filter(([command]) => isDesktopSlashSuggestion(command)) .map(([command, description]) => [command, desktopSlashDescription(command, description)] as [string, string]) + // Recount skill commands from the filtered output so /help's footer reflects + // what the user actually sees. Backend's skill_count includes commands the + // desktop hides (terminal-only, picker-owned, advanced), producing a footer + // like "60 skill commands available" while only ~29 appear in the list. + const filteredCommands = new Set() + + for (const section of categories ?? []) { + for (const [command] of section.pairs) { + filteredCommands.add(canonicalDesktopSlashCommand(command)) + } + } + + for (const [command] of pairs ?? []) { + filteredCommands.add(canonicalDesktopSlashCommand(command)) + } + + let skillCount = 0 + + for (const command of filteredCommands) { + if (isDesktopSlashExtensionCommand(command)) { + skillCount += 1 + } + } + + const hasSkillCount = catalog.skill_count !== undefined || skillCount > 0 + return { ...catalog, ...(categories ? { categories } : {}), - ...(pairs ? { pairs } : {}) + ...(pairs ? { pairs } : {}), + ...(hasSkillCount ? { skill_count: skillCount } : {}) } } - -function isKnownHermesSlashCommand(command: string): boolean { - return DESKTOP_COMMANDS.has(command) || DESKTOP_ALIASES.has(command) || BLOCKED_COMMANDS.has(command) -} diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 6df96946bf1..4139915cea2 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -200,6 +200,7 @@ export const $availablePersonalities = atom([]) export const $introSeed = atom(0) export const $contextSuggestions = atom([]) export const $modelPickerOpen = atom(false) +export const $sessionPickerOpen = atom(false) export const setConnection = (next: Updater) => updateAtom($connection, next) export const setGatewayState = (next: Updater) => updateAtom($gatewayState, next) @@ -249,6 +250,7 @@ export const setAvailablePersonalities = (next: Updater) => updateAtom export const setIntroSeed = (next: Updater) => updateAtom($introSeed, next) export const setContextSuggestions = (next: Updater) => updateAtom($contextSuggestions, next) export const setModelPickerOpen = (next: Updater) => updateAtom($modelPickerOpen, next) +export const setSessionPickerOpen = (next: Updater) => updateAtom($sessionPickerOpen, next) // Watchdog tracking — when does a "working" session count as stuck? // Long-running tool calls (LLM inference, long shell commands, web fetches) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index f23d1960da7..aded4d41d81 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -1544,12 +1544,140 @@ class SlashCommandCompleter(Completer): except Exception: pass + @staticmethod + def _tools_completions(sub_text: str, sub_lower: str): + """Yield completions for /tools — subcommand + toolset/MCP-server name. + + Handles both ``/tools `` (suggesting ``list|disable|enable``) and + ``/tools enable `` / ``/tools disable `` (suggesting toolset + keys and MCP server prefixes, filtered by current enable state so the + user only sees actionable options). + """ + SUBS = ("list", "disable", "enable") + parts = sub_text.split() + trailing_space = sub_text.endswith(" ") + + # Subcommand stage: zero words typed, or completing the first word. + if len(parts) == 0 or (len(parts) == 1 and not trailing_space): + partial = sub_text if not trailing_space else "" + for sub in SUBS: + if sub.startswith(partial.lower()) and sub != partial.lower(): + yield Completion(sub, start_position=-len(partial), display=sub) + return + + subcommand = parts[0].lower() + if subcommand not in ("enable", "disable"): + return + + partial = "" if trailing_space else parts[-1] + partial_lower = partial.lower() + already = set(parts[1:] if trailing_space else parts[1:-1]) + + try: + from hermes_cli.config import load_config + from hermes_cli.tools_config import ( + CONFIGURABLE_TOOLSETS, + _get_platform_tools, + _get_plugin_toolset_keys, + ) + + config = load_config() + enabled = _get_platform_tools(config, "cli", include_default_mcp_servers=False) + + for ts_key, label, _desc in CONFIGURABLE_TOOLSETS: + if ts_key in already or not ts_key.startswith(partial_lower): + continue + is_on = ts_key in enabled + if subcommand == "enable" and is_on: + continue + if subcommand == "disable" and not is_on: + continue + yield Completion( + ts_key, + start_position=-len(partial), + display=ts_key, + display_meta=label, + ) + + for ts_key in sorted(_get_plugin_toolset_keys()): + if ts_key in already or not ts_key.startswith(partial_lower): + continue + is_on = ts_key in enabled + if subcommand == "enable" and is_on: + continue + if subcommand == "disable" and not is_on: + continue + yield Completion( + ts_key, + start_position=-len(partial), + display=ts_key, + display_meta="plugin toolset", + ) + + mcp_servers = config.get("mcp_servers") or {} + if isinstance(mcp_servers, dict): + for server in sorted(mcp_servers): + prefix = f"{server}:" + if prefix in already or not prefix.startswith(partial_lower): + continue + yield Completion( + prefix, + start_position=-len(partial), + display=prefix, + display_meta=f"MCP server '{server}'", + ) + except Exception: + return + + @staticmethod + def _handoff_completions(sub_text: str, sub_lower: str): + """Yield platform completions for /handoff. + + Offers connected (enabled + configured) gateway platforms. A recorded + home channel is NOT required to list a platform — it's often learned at + runtime — so the meta hints whether one is set yet. Completes only the + first arg (the platform); once one is chosen, stop. + """ + parts = sub_text.split() + trailing_space = sub_text.endswith(" ") + if len(parts) > 1 or (len(parts) == 1 and trailing_space): + return + partial = "" if (not parts or trailing_space) else parts[-1] + partial_lower = partial.lower() + try: + from gateway.config import load_gateway_config + + gw = load_gateway_config() + platforms = gw.get_connected_platforms() + except Exception: + return + for platform in platforms: + name = platform.value + if not name.startswith(partial_lower): + continue + try: + home = gw.get_home_channel(platform) + except Exception: + home = None + meta = f"→ {home.name}" if home and getattr(home, "name", None) else "send this session here" + yield Completion( + name, + start_position=-len(partial), + display=name, + display_meta=meta, + ) + @staticmethod def _personality_completions(sub_text: str, sub_lower: str): """Yield completions for /personality from configured personalities.""" try: - from hermes_cli.config import load_config - personalities = load_config().get("agent", {}).get("personalities", {}) + # Resolve from the same source the runtime applies personalities — + # agent.personalities via the CLI config (which ships the built-ins). + # load_config()'s schema has no agent.personalities, so the completer + # used to come back empty even with personalities available. + from cli import load_cli_config + + personalities = (load_cli_config().get("agent") or {}).get("personalities", {}) or {} if "none".startswith(sub_lower) and "none" != sub_lower: yield Completion( "none", @@ -1602,6 +1730,17 @@ class SlashCommandCompleter(Completer): yield from self._personality_completions(sub_text, sub_lower) return + # /tools needs multi-word completion (subcommand + toolset name) + # so it handles both stages itself, bypassing the single-word + # SUBCOMMANDS branch below. + if base_cmd == "/tools": + yield from self._tools_completions(sub_text, sub_lower) + return + + if base_cmd == "/handoff": + yield from self._handoff_completions(sub_text, sub_lower) + return + # Static subcommand completions if " " not in sub_text and base_cmd in SUBCOMMANDS and self._command_allowed(base_cmd): for sub in SUBCOMMANDS[base_cmd]: diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 6a63ebe73e5..62c2be4ab79 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -691,6 +691,169 @@ class TestSubcommandCompletion: completions = _completions(SlashCommandCompleter(), "/help ") assert completions == [] + def test_tools_subcommand_completion(self): + """`/tools ` should suggest list, disable, enable.""" + completions = _completions(SlashCommandCompleter(), "/tools ") + texts = {c.text for c in completions} + assert texts == {"list", "disable", "enable"} + + def test_tools_subcommand_prefix_filters(self): + completions = _completions(SlashCommandCompleter(), "/tools en") + texts = {c.text for c in completions} + assert texts == {"enable"} + + def test_tools_enable_completes_toolset_names(self, monkeypatch): + """`/tools enable ` should suggest currently-disabled toolsets.""" + from hermes_cli import commands as commands_mod + + # `web` is enabled, `spotify` is disabled — enabling should only offer + # the disabled ones. + monkeypatch.setattr( + "hermes_cli.tools_config._get_platform_tools", + lambda *_a, **_k: {"web", "file"}, + ) + monkeypatch.setattr("hermes_cli.config.load_config", lambda: {}) + monkeypatch.setattr( + "hermes_cli.tools_config._get_plugin_toolset_keys", + lambda: set(), + ) + + completions = _completions(SlashCommandCompleter(), "/tools enable ") + texts = {c.text for c in completions} + # Should include disabled toolsets, exclude already-enabled ones. + assert "web" not in texts + assert "file" not in texts + assert "spotify" in texts + + def test_tools_disable_completes_enabled_toolsets_only(self, monkeypatch): + monkeypatch.setattr( + "hermes_cli.tools_config._get_platform_tools", + lambda *_a, **_k: {"web", "file"}, + ) + monkeypatch.setattr("hermes_cli.config.load_config", lambda: {}) + monkeypatch.setattr( + "hermes_cli.tools_config._get_plugin_toolset_keys", + lambda: set(), + ) + + completions = _completions(SlashCommandCompleter(), "/tools disable ") + texts = {c.text for c in completions} + # Should include enabled toolsets, exclude disabled ones. + assert texts == {"web", "file"} + + def test_tools_enable_partial_filters(self, monkeypatch): + monkeypatch.setattr( + "hermes_cli.tools_config._get_platform_tools", + lambda *_a, **_k: set(), + ) + monkeypatch.setattr("hermes_cli.config.load_config", lambda: {}) + monkeypatch.setattr( + "hermes_cli.tools_config._get_plugin_toolset_keys", + lambda: set(), + ) + + completions = _completions(SlashCommandCompleter(), "/tools enable sp") + texts = {c.text for c in completions} + assert texts == {"spotify"} + + def test_tools_enable_skips_already_listed(self, monkeypatch): + """If the user already typed a name, don't suggest it again.""" + monkeypatch.setattr( + "hermes_cli.tools_config._get_platform_tools", + lambda *_a, **_k: set(), + ) + monkeypatch.setattr("hermes_cli.config.load_config", lambda: {}) + monkeypatch.setattr( + "hermes_cli.tools_config._get_plugin_toolset_keys", + lambda: set(), + ) + + completions = _completions(SlashCommandCompleter(), "/tools enable spotify ") + texts = {c.text for c in completions} + assert "spotify" not in texts + + def test_tools_suggests_mcp_server_prefixes(self, monkeypatch): + monkeypatch.setattr( + "hermes_cli.tools_config._get_platform_tools", + lambda *_a, **_k: set(), + ) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"mcp_servers": {"github": {}, "linear": {}}}, + ) + monkeypatch.setattr( + "hermes_cli.tools_config._get_plugin_toolset_keys", + lambda: set(), + ) + + completions = _completions(SlashCommandCompleter(), "/tools enable git") + texts = {c.text for c in completions} + assert "github:" in texts + + def _fake_gateway(self, monkeypatch, platforms): + """Patch load_gateway_config with a fake whose connected platforms are + the keys of `platforms` (name -> home as None or a (chat_id, name) tuple). + """ + from types import SimpleNamespace + + enums = {name: SimpleNamespace(value=name) for name in platforms} + homes = { + name: (None if home is None else SimpleNamespace(chat_id=home[0], name=home[1])) + for name, home in platforms.items() + } + fake = SimpleNamespace( + get_connected_platforms=lambda: list(enums.values()), + get_home_channel=lambda p: homes[p.value], + ) + monkeypatch.setattr("gateway.config.load_gateway_config", lambda: fake) + + def test_handoff_completes_connected_platforms(self, monkeypatch): + """`/handoff ` offers connected platforms, with or without a home channel.""" + self._fake_gateway( + monkeypatch, + { + "telegram": ("123", "Me"), + "discord": None, # no home channel yet -> still listed + }, + ) + + texts = {c.text for c in _completions(SlashCommandCompleter(), "/handoff ")} + assert texts == {"telegram", "discord"} + + def test_handoff_filters_by_prefix(self, monkeypatch): + self._fake_gateway( + monkeypatch, + { + "telegram": ("1", "H"), + "signal": ("2", "H"), + }, + ) + + texts = {c.text for c in _completions(SlashCommandCompleter(), "/handoff te")} + assert texts == {"telegram"} + + def test_handoff_no_completion_after_platform_chosen(self, monkeypatch): + self._fake_gateway(monkeypatch, {"telegram": ("1", "H")}) + assert _completions(SlashCommandCompleter(), "/handoff telegram ") == [] + + def test_handoff_completion_swallows_config_errors(self, monkeypatch): + def _boom(): + raise RuntimeError("no gateway config") + + monkeypatch.setattr("gateway.config.load_gateway_config", _boom) + assert _completions(SlashCommandCompleter(), "/handoff ") == [] + + def test_personality_completes_configured_personalities(self): + """`/personality ` lists real personalities, not just `none`. + + Regression: the completer read load_config().agent.personalities, a path + that never exists, so it always came back empty. It must resolve from the + CLI config the runtime actually applies (which ships built-ins). + """ + texts = {c.text for c in _completions(SlashCommandCompleter(), "/personality ")} + assert "none" in texts + assert len(texts) > 1 + # ── Ghost text (SlashCommandAutoSuggest) ──────────────────────────────── diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 3b95b8dceb8..c510c4ef230 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -86,6 +86,47 @@ def test_session_context_uses_session_cwd(monkeypatch, tmp_path): server._sessions.pop(sid, None) +def test_handoff_fail_marks_only_inflight_rows(monkeypatch): + class DbContext: + def __init__(self, db): + self.db = db + + def __enter__(self): + return self.db + + def __exit__(self, *_args): + return False + + class FakeDb: + def __init__(self, state): + self.state = state + self.failed_with = None + + def get_handoff_state(self, _key): + return {"state": self.state, "platform": "telegram", "error": None} + + def fail_handoff(self, _key, error): + self.failed_with = error + self.state = "failed" + + sid = "rt-handoff" + server._sessions[sid] = {"session_key": "stored-handoff"} + try: + pending = FakeDb("pending") + monkeypatch.setattr(server, "_session_db", lambda _session: DbContext(pending)) + result = server._methods["handoff.fail"]("r1", {"session_id": sid, "error": "timed out"}) + assert result["result"] == {"failed": True, "state": "failed"} + assert pending.failed_with == "timed out" + + completed = FakeDb("completed") + monkeypatch.setattr(server, "_session_db", lambda _session: DbContext(completed)) + result = server._methods["handoff.fail"]("r2", {"session_id": sid, "error": "late timeout"}) + assert result["result"] == {"failed": False, "state": "completed"} + assert completed.failed_with is None + finally: + server._sessions.pop(sid, None) + + def test_session_context_explicit_cwd_for_ephemeral_task(monkeypatch, tmp_path): """Background/preview tasks use ephemeral ids absent from `_sessions`, so the parent workspace is passed explicitly; it must pin instead of clearing back diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 390c31b092e..5af8530abc5 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1,5 +1,6 @@ import atexit import concurrent.futures +import contextlib import contextvars import copy import inspect @@ -1171,6 +1172,34 @@ def _ensure_session_db_row(session: dict) -> None: pass +@contextlib.contextmanager +def _session_db(session: dict): + """Yield the SessionDB that owns this session's row (profile-aware). + + Mirrors :func:`_ensure_session_db_row`: a remote/profile session persists + into its own profile's ``state.db`` (a fresh handle we close on exit); + everything else borrows the shared ``_get_db()`` handle (left open). Yields + None when the db is unavailable. + """ + db, close_db = None, False + profile_home = session.get("profile_home") + if profile_home: + from hermes_state import SessionDB + + try: + db, close_db = SessionDB(db_path=Path(profile_home) / "state.db"), True + except Exception: + logger.debug("failed to open profile db for session", exc_info=True) + else: + db = _get_db() + try: + yield db + finally: + if close_db and db is not None: + with contextlib.suppress(Exception): + db.close() + + def _set_session_cwd(session: dict, cwd: str) -> str: resolved = os.path.abspath(os.path.expanduser(str(cwd))) if not os.path.isdir(resolved): @@ -4193,6 +4222,145 @@ def _(rid, params: dict) -> dict: return _err(rid, 5007, str(e)) +@method("handoff.request") +def _(rid, params: dict) -> dict: + """Queue a handoff of this session to a messaging platform. + + Desktop parity with the CLI ``/handoff`` command: we only write + ``handoff_state='pending'`` onto the persisted session row. The actual + transfer is performed by the separate ``hermes gateway`` process, whose + ``_handoff_watcher`` claims the row, re-binds the session to the platform's + home channel, and forges a synthetic turn. The desktop then polls + ``handoff.state`` for the terminal result. + """ + session, err = _sess_nowait(params, rid) + if err: + return err + if session.get("running"): + return _err( + rid, + 4009, + "session busy — wait for the current turn to finish, then retry the handoff", + ) + + platform_name = (params.get("platform", "") or "").strip().lower() + if not platform_name: + return _err(rid, 4023, "platform required") + + # Validate against the live gateway config — an unconfigured platform or a + # missing home channel would leave the handoff pending forever, so reject + # up front with a clear, actionable message (mirrors cli.py). + try: + from gateway.config import Platform, load_gateway_config + except Exception as e: # pragma: no cover — gateway pkg always ships + return _err(rid, 5021, f"could not load gateway config: {e}") + try: + platform = Platform(platform_name) + except (ValueError, KeyError): + return _err(rid, 4024, f"unknown platform '{platform_name}'") + try: + gw_config = load_gateway_config() + except Exception as e: + return _err(rid, 5021, f"could not load gateway config: {e}") + pcfg = gw_config.platforms.get(platform) + if not pcfg or not pcfg.enabled: + return _err( + rid, + 4025, + f"platform '{platform_name}' is not configured/enabled in the gateway", + ) + home = gw_config.get_home_channel(platform) + if not home or not home.chat_id: + return _err( + rid, + 4026, + f"no home channel configured for {platform_name} — set one with " + "/sethome on the destination chat first", + ) + + # The watcher transfers a persisted DB row, so make sure one exists even + # for a brand-new empty chat (mirrors the CLI's set_session_title stub). + _ensure_session_db_row(session) + + with _session_db(session) as db: + if db is None: + return _db_unavailable_error(rid, code=5007) + key = session["session_key"] + try: + if not db.get_session(key): + db.set_session_title(key, f"handoff-{key[:8]}") + ok = db.request_handoff(key, platform_name) + except Exception as e: + return _err(rid, 5007, str(e)) + + if not ok: + return _err( + rid, + 4027, + "session is already in flight for handoff — wait for it to settle, then retry", + ) + return _ok( + rid, + { + "queued": True, + "session_key": key, + "platform": platform_name, + "home_name": home.name, + }, + ) + + +@method("handoff.state") +def _(rid, params: dict) -> dict: + """Poll the handoff state for a session. + + Returns ``{state, platform, error}`` where ``state`` is one of + ``pending|running|completed|failed`` (or empty when no handoff record + exists). Desktop polls this after ``handoff.request``. + """ + session, err = _sess_nowait(params, rid) + if err: + return err + with _session_db(session) as db: + if db is None: + return _db_unavailable_error(rid, code=5007) + record = db.get_handoff_state(session["session_key"]) + + record = record or {} + return _ok( + rid, + { + "state": record.get("state") or "", + "platform": record.get("platform") or "", + "error": record.get("error") or "", + }, + ) + + +@method("handoff.fail") +def _(rid, params: dict) -> dict: + """Mark an in-flight handoff as failed so the user can retry. + + Desktop calls this when its bounded poll times out. Only pending/running + rows are changed so a late success from the gateway watcher is not clobbered. + """ + session, err = _sess_nowait(params, rid) + if err: + return err + reason = str(params.get("error") or "handoff failed").strip()[:500] + with _session_db(session) as db: + if db is None: + return _db_unavailable_error(rid, code=5007) + key = session["session_key"] + record = db.get_handoff_state(key) or {} + state = record.get("state") or "" + if state in {"pending", "running"}: + db.fail_handoff(key, reason) + return _ok(rid, {"failed": True, "state": "failed"}) + + return _ok(rid, {"failed": False, "state": state}) + + @method("session.usage") def _(rid, params: dict) -> dict: session, err = _sess_nowait(params, rid) From fe54960142d1e6edc9e43299c0e8889964f4e837 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Wed, 10 Jun 2026 21:35:38 -0500 Subject: [PATCH 19/69] desktop: un-truncate the active slash/@ row so long descriptions stay readable (#43926) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #42351. Slash command rows render the command label and description with `truncate`, so skill commands and longer blurbs were clipped with no way to read the full text. Rather than add a floating tooltip (which overlaps the popover and only helps the mouse), the active row — the one reached by keyboard arrows or hover, since onMouseEnter already sets activeIndex — now drops truncation and wraps inline (whitespace-normal break-words). Idle rows stay single-line/truncated so the list reads compact. --- .../src/app/chat/composer/trigger-popover.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/trigger-popover.tsx b/apps/desktop/src/app/chat/composer/trigger-popover.tsx index 1099c0748ba..dffa1ae7745 100644 --- a/apps/desktop/src/app/chat/composer/trigger-popover.tsx +++ b/apps/desktop/src/app/chat/composer/trigger-popover.tsx @@ -136,9 +136,24 @@ export function ComposerTriggerPopover({ > {isSlash ? ( <> - {display} + {/* Active row (keyboard nav or hover) un-truncates inline so + long command names / descriptions stay readable without a + floating tooltip. */} + + {display} + {description && ( - + {description} )} From e0e25717116c818d22f05a1875fe44960b189ad7 Mon Sep 17 00:00:00 2001 From: Matt Harris Date: Fri, 29 May 2026 17:51:52 -0400 Subject: [PATCH 20/69] =?UTF-8?q?feat(web):=20Parallel-backed=20web=20sear?= =?UTF-8?q?ch=20&=20extract=20=E2=80=94=20free=20Search=20MCP=20when=20key?= =?UTF-8?q?less,=20v1=20REST=20when=20keyed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make Parallel the web search/extract backend with a zero-setup free tier: - Keyless (no PARALLEL_API_KEY): web_search/web_extract work out of the box via Parallel's free hosted Search MCP (search.parallel.ai/mcp), and parallel becomes the default backend when no other web credentials are configured (ahead of ddgs, which is search-only). A small hand-rolled Streamable-HTTP JSON-RPC client speaks the MCP's web_search/web_fetch tools; the existing web_search/web_extract tools are the only tools registered. - Keyed (PARALLEL_API_KEY set): uses the Parallel v1 REST endpoints (client.search / client.extract with advanced_settings.full_content) — no beta. Bumps parallel-web 0.4.2 -> 0.6.0. - Attribution: on the free path only, results carry provider/attribution and the CLI tool line reads "Parallel search" / "Parallel fetch"; the paid path is unbranded. - Selection/registration: web tools register unconditionally (free MCP backstop) while check_web_api_key remains a real usability probe; explicit per-capability backends are honored (so misconfig surfaces) rather than masked by the fallback. Tested: live web_search/web_extract against search.parallel.ai in keyless and keyed modes; unit suites for the MCP client, backend selection, and display labeling; full agent run shows the "Parallel search" label on the free path. --- agent/display.py | 22 +- hermes_cli/tools_config.py | 9 +- plugins/web/parallel/provider.py | 470 ++++++++++++++++-- pyproject.toml | 2 +- tests/agent/test_display.py | 41 ++ tests/hermes_cli/test_tools_config.py | 13 + .../plugins/web/test_parallel_keyless_mcp.py | 382 ++++++++++++++ .../web/test_web_search_provider_plugins.py | 27 +- tests/tools/test_web_providers.py | 73 ++- tests/tools/test_web_providers_ddgs.py | 8 +- tests/tools/test_web_providers_searxng.py | 7 +- tests/tools/test_web_tools_config.py | 98 +++- tools/lazy_deps.py | 2 +- tools/web_tools.py | 142 +++++- uv.lock | 8 +- 15 files changed, 1206 insertions(+), 98 deletions(-) create mode 100644 tests/plugins/web/test_parallel_keyless_mcp.py diff --git a/agent/display.py b/agent/display.py index 8514279888e..84c8509faed 100644 --- a/agent/display.py +++ b/agent/display.py @@ -858,6 +858,20 @@ def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str] return False, "" +def _used_free_parallel(result: str | None) -> bool: + """True when a web result came from Parallel's free Search MCP. + + Only the keyless Parallel path tags its result with ``provider="parallel"``; + the paid REST path and every other provider omit it. Used to label the tool + line "Parallel search" / "Parallel fetch" exactly when the free MCP served + the call. + """ + if not isinstance(result, str) or '"provider"' not in result: + return False + data = safe_json_loads(result) + return isinstance(data, dict) and str(data.get("provider", "")).lower() == "parallel" + + def get_cute_tool_message( tool_name: str, args: dict, duration: float, result: str | None = None, ) -> str: @@ -895,15 +909,17 @@ def get_cute_tool_message( return f"{line}{failure_suffix}" if tool_name == "web_search": - return _wrap(f"┊ 🔍 search {_trunc(args.get('query', ''), 42)} {dur}") + verb = "Parallel search" if _used_free_parallel(result) else "search" + return _wrap(f"┊ 🔍 {verb:<9} {_trunc(args.get('query', ''), 42)} {dur}") if tool_name == "web_extract": + verb = "Parallel fetch" if _used_free_parallel(result) else "fetch" urls = args.get("urls", []) if urls: url = urls[0] if isinstance(urls, list) else str(urls) domain = url.replace("https://", "").replace("http://", "").split("/")[0] extra = f" +{len(urls)-1}" if len(urls) > 1 else "" - return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}") - return _wrap(f"┊ 📄 fetch pages {dur}") + return _wrap(f"┊ 📄 {verb:<9} {_trunc(domain, 35)}{extra} {dur}") + return _wrap(f"┊ 📄 {verb:<9} pages {dur}") if tool_name == "terminal": return _wrap(f"┊ 💻 $ {_trunc(args.get('command', ''), 42)} {dur}") if tool_name == "process": diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index ae97dbf54a2..01d4ba72793 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -2178,8 +2178,13 @@ def _toolset_needs_configuration_prompt( tts_cfg = config.get("tts", {}) return not isinstance(tts_cfg, dict) or "provider" not in tts_cfg if ts_key == "web": - web_cfg = config.get("web", {}) - return not isinstance(web_cfg, dict) or "backend" not in web_cfg + # Web works out of the box via Parallel's free Search MCP (no key), so + # don't force setup just because ``web.backend`` is unset — only prompt + # when web isn't actually usable (e.g. an explicit backend configured + # without its credentials). Lazy import: web_tools is heavy and most + # tools_config callers don't need it. + from tools.web_tools import check_web_api_key + return not check_web_api_key() if ts_key == "browser": browser_cfg = config.get("browser", {}) return not isinstance(browser_cfg, dict) or "cloud_provider" not in browser_cfg diff --git a/plugins/web/parallel/provider.py b/plugins/web/parallel/provider.py index 38578e6b52c..20c4291d77b 100644 --- a/plugins/web/parallel/provider.py +++ b/plugins/web/parallel/provider.py @@ -1,14 +1,20 @@ """Parallel.ai web search + content extraction — plugin form. -Subclasses :class:`agent.web_search_provider.WebSearchProvider`. Uses two -distinct Parallel SDK clients: +Subclasses :class:`agent.web_search_provider.WebSearchProvider`. -- ``Parallel`` (sync) — for :meth:`search` -- ``AsyncParallel`` (async) — for :meth:`extract` +Search runs on one of two transports, picked by credential: -This is the first plugin to exercise the **async-extract** code path in -the ABC: :meth:`extract` is declared ``async def``, and the dispatcher -in :func:`tools.web_tools.web_extract_tool` detects coroutines via +- **No key →** the free hosted Search MCP at ``https://search.parallel.ai/mcp`` + (anonymous Streamable-HTTP JSON-RPC). This makes ``web_search`` work out of + the box with zero setup, which is why ``parallel`` is the keyless default + backend in :func:`tools.web_tools._get_backend`. +- **``PARALLEL_API_KEY`` →** the ``parallel`` SDK's v1 ``search`` / ``extract`` + REST endpoints (objective-tuned, mode-selectable, higher rate limits). + +Extract mirrors search: keyed uses the async SDK (``AsyncParallel``) v1 +``extract``; keyless uses the free MCP's ``web_fetch``. :meth:`extract` is +declared ``async def`` and the dispatcher in +:func:`tools.web_tools.web_extract_tool` detects coroutines via :func:`inspect.iscoroutinefunction` and awaits. Config keys this provider responds to:: @@ -17,25 +23,63 @@ Config keys this provider responds to:: search_backend: "parallel" # explicit per-capability extract_backend: "parallel" # explicit per-capability backend: "parallel" # shared fallback - # Optional: search mode (default "agentic"; also "fast" or "one-shot") - # via the PARALLEL_SEARCH_MODE env var. + # Optional: search mode (default "advanced"; also "basic") + # via the PARALLEL_SEARCH_MODE env var. REST path only. Env vars:: - PARALLEL_API_KEY=... # https://parallel.ai (required) - PARALLEL_SEARCH_MODE=agentic # optional: agentic|fast|one-shot + PARALLEL_API_KEY=... # https://parallel.ai (optional — unlocks + # the v1 REST Search API; without it, + # search and extract use the free MCP) + PARALLEL_SEARCH_MODE=advanced # optional: basic|advanced (legacy + # fast/one-shot map to basic, agentic to + # advanced). REST path only. """ from __future__ import annotations +import asyncio +import json import logging import os +import uuid from typing import Any, Dict, List +import httpx + from agent.web_search_provider import WebSearchProvider logger = logging.getLogger(__name__) +# Free hosted Search MCP — anonymous-friendly, used when no PARALLEL_API_KEY is +# configured. Docs: https://docs.parallel.ai/integrations/mcp/search-mcp +_MCP_SEARCH_URL = "https://search.parallel.ai/mcp" +_MCP_PROTOCOL_VERSION = "2025-06-18" +_MCP_CLIENT_NAME = "hermes-agent" +_MCP_CLIENT_VERSION = "1.0.0" +# Identify free-tier traffic at the HTTP layer. Without this, httpx sends a +# generic ``python-httpx/`` User-Agent and hermes usage is only visible +# via the JSON-RPC ``clientInfo`` payload. +_MCP_USER_AGENT = f"{_MCP_CLIENT_NAME}/{_MCP_CLIENT_VERSION}" +_MCP_TIMEOUT_SECONDS = 30.0 + +# Free-tier attribution. The hosted Search MCP is free to use; surfacing this +# on keyless results credits Parallel and matches the free-tier terms +# (https://parallel.ai/customer-terms). +_FREE_MCP_ATTRIBUTION = ( + "Search powered by the free Parallel Web Search MCP (https://parallel.ai)." +) + + +def _new_session_id() -> str: + """Mint a fresh Parallel ``session_id`` for a single tool call. + + Per-call rather than process-global: one process serves many unrelated + chats in the gateway/batch runners, and a shared id would pool their + searches into one Parallel session. + """ + return f"hermes-agent-{uuid.uuid4().hex}" + # Module-level note: the canonical cache slots ``_parallel_client`` and # ``_async_parallel_client`` live on :mod:`tools.web_tools` so tests that do # ``tools.web_tools._parallel_client = None`` between cases see fresh state. @@ -133,11 +177,319 @@ _get_async_parallel_client = _get_async_client def _resolve_search_mode() -> str: - """Return the validated PARALLEL_SEARCH_MODE value (default "agentic").""" - mode = os.getenv("PARALLEL_SEARCH_MODE", "agentic").lower().strip() - if mode not in {"fast", "one-shot", "agentic"}: - mode = "agentic" - return mode + """Return the validated v1 search mode (default "advanced"). + + V1 collapses the three Beta modes into two. We accept the v1 values + directly and map the legacy Beta values for back-compat with anyone who + still sets ``PARALLEL_SEARCH_MODE=fast|one-shot|agentic``: + + - ``fast`` / ``one-shot`` → ``basic`` (lower latency) + - ``agentic`` → ``advanced`` (higher quality, the v1 default) + """ + mode = os.getenv("PARALLEL_SEARCH_MODE", "advanced").lower().strip() + if mode == "basic" or mode in {"fast", "one-shot"}: + return "basic" + # advanced, legacy "agentic", and anything unrecognized → the v1 default. + return "advanced" + + +# --------------------------------------------------------------------------- +# Free Search MCP transport (keyless path) +# --------------------------------------------------------------------------- +# +# A small hand-rolled Streamable-HTTP JSON-RPC client for the hosted Search +# MCP, rather than the full MCP-client subsystem: we only call two tools +# (``web_search`` / ``web_fetch``), so keeping it inline lets web_search and +# web_extract stay ordinary tools with the MCP endpoint as just their wire +# protocol. + + +def _mcp_headers( + session_id: str | None, + api_key: str | None, + protocol_version: str | None = None, +) -> Dict[str, str]: + """Headers for an MCP request. + + A Bearer token is attached only when we actually hold a key — the free + endpoint is anonymous, and sending an empty/garbage token would make it + 401 instead of serving the anonymous tier. After ``initialize`` the + Streamable-HTTP spec expects the negotiated ``MCP-Protocol-Version`` on + every follow-up request, so we echo it once known. + """ + headers = { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "User-Agent": _MCP_USER_AGENT, + } + if session_id: + headers["Mcp-Session-Id"] = session_id + if protocol_version: + headers["MCP-Protocol-Version"] = protocol_version + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + return headers + + +def _iter_mcp_messages(text: str): + """Yield JSON-RPC message dicts from a plain-JSON or SSE response body. + + Handles ``application/json`` (a single object) and ``text/event-stream`` + (SSE: events separated by blank lines; an event's one-or-more ``data:`` + lines concatenate into a single JSON payload). Unparseable chunks and + non-``data`` SSE fields (``event:``/``id:``/comments) are skipped. + """ + def _emit(payload): + # Streamable HTTP allows batching responses/notifications into a JSON + # array — flatten so callers always see individual message dicts. + if isinstance(payload, list): + yield from payload + elif payload is not None: + yield payload + + body = (text or "").strip() + if not body: + return + if body.startswith("{") or body.startswith("["): + try: + parsed = json.loads(body) + except json.JSONDecodeError: + return + yield from _emit(parsed) + return + + data_lines: List[str] = [] + + def _flush(): + if not data_lines: + return None + try: + return json.loads("\n".join(data_lines)) + except json.JSONDecodeError: + return None + + for raw in body.split("\n"): + line = raw.rstrip("\r") + if line.startswith("data:"): + data_lines.append(line[len("data:"):].lstrip()) + elif line.strip() == "": # event boundary + yield from _emit(_flush()) + data_lines = [] + yield from _emit(_flush()) + + +def _mcp_response_envelope(text: str, request_id: str) -> Dict[str, Any]: + """Select the JSON-RPC response for *request_id* from an MCP response body. + + Streamable-HTTP servers may emit progress/log notifications before the + final result, so we scan the whole stream and return the result/error + message whose ``id`` matches our request. Falls back to the last + result/error-bearing message if no id matches; ``{}`` if none is present. + """ + fallback: Dict[str, Any] = {} + for msg in _iter_mcp_messages(text): + if not isinstance(msg, dict) or not ("result" in msg or "error" in msg): + continue + if msg.get("id") == request_id: + return msg + fallback = msg + return fallback + + +def _mcp_payload(envelope: Dict[str, Any]) -> Dict[str, Any]: + """Extract the tool result payload from a ``tools/call`` envelope. + + Prefers ``structuredContent`` (authoritative machine-readable form); + otherwise scans text blocks for the first JSON-parseable one. Raises on a + JSON-RPC error or a tool-level ``isError``. + """ + if "error" in envelope: + raise RuntimeError(f"Parallel MCP error: {str(envelope['error'])[:500]}") + result = envelope.get("result") or {} + if result.get("isError"): + raise RuntimeError(f"Parallel MCP tool error: {str(result)[:500]}") + + structured = result.get("structuredContent") + if isinstance(structured, dict): + return structured + + for block in result.get("content", []) or []: + if isinstance(block, dict) and block.get("type") == "text": + text = str(block.get("text") or "") + if not text: + continue + try: + return json.loads(text) + except json.JSONDecodeError: + continue + raise RuntimeError( + f"Parallel MCP returned no parseable content: {str(result)[:500]}" + ) + + +def _mcp_call( + tool_name: str, arguments: Dict[str, Any], api_key: str | None +) -> Dict[str, Any]: + """Run the MCP handshake then a single ``tools/call`` and return its payload. + + initialize → (capture ``Mcp-Session-Id``) → notifications/initialized → + tools/call ``tool_name``. Returns the parsed tool payload dict (see + :func:`_mcp_payload`). A Bearer token is attached only when *api_key* is set. + """ + with httpx.Client(timeout=_MCP_TIMEOUT_SECONDS) as client: + # 1. initialize — capture the server-assigned MCP session id. + init_id = str(uuid.uuid4()) + init = client.post( + _MCP_SEARCH_URL, + headers=_mcp_headers(None, api_key), + json={ + "jsonrpc": "2.0", + "id": init_id, + "method": "initialize", + "params": { + "protocolVersion": _MCP_PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": { + "name": _MCP_CLIENT_NAME, + "version": _MCP_CLIENT_VERSION, + }, + }, + }, + ) + init.raise_for_status() + # Only echo a session id the server actually issued. Stateless + # Streamable-HTTP servers may omit it; inventing one and sending it on + # follow-up requests can get those requests rejected (the server never + # created that session). When absent, the Mcp-Session-Id header is simply + # omitted (see _mcp_headers). This is separate from the tool-arg + # ``session_id`` below, which is a client-minted rate-limit/grouping id. + mcp_session_id = init.headers.get("mcp-session-id") + init_env = _mcp_response_envelope(init.text, init_id) + # Echo the negotiated protocol version on every post-init request, per + # the Streamable-HTTP spec (servers may enforce it). + negotiated_version = ( + (init_env.get("result") or {}).get("protocolVersion") + or _MCP_PROTOCOL_VERSION + ) + + # 2. notifications/initialized — required handshake ack. + client.post( + _MCP_SEARCH_URL, + headers=_mcp_headers(mcp_session_id, api_key, negotiated_version), + json={"jsonrpc": "2.0", "method": "notifications/initialized"}, + ) + + # 3. tools/call. + call_id = str(uuid.uuid4()) + call = client.post( + _MCP_SEARCH_URL, + headers=_mcp_headers(mcp_session_id, api_key, negotiated_version), + json={ + "jsonrpc": "2.0", + "id": call_id, + "method": "tools/call", + "params": {"name": tool_name, "arguments": arguments}, + }, + ) + call.raise_for_status() + return _mcp_payload(_mcp_response_envelope(call.text, call_id)) + + +def _mcp_web_search(query: str, limit: int, api_key: str | None) -> Dict[str, Any]: + """Run a ``web_search`` tool call against the hosted Search MCP. + + Returns the standard provider search shape + (``{"success": True, "data": {"web": [...]}}``). The MCP serves a fixed + result count, so ``limit`` is applied client-side. The MCP requires + ``objective`` (REST treats it as optional), so we mirror the query. + """ + payload = _mcp_call( + "web_search", + { + "objective": query, + "search_queries": [query], + "session_id": _new_session_id(), + }, + api_key, + ) + + web_results: List[Dict[str, Any]] = [] + for i, result in enumerate((payload.get("results") or [])[: max(limit, 1)]): + if not isinstance(result, dict): + continue + excerpts = result.get("excerpts") or [] + web_results.append( + { + "url": result.get("url") or "", + "title": result.get("title") or "", + "description": " ".join(excerpts) if excerpts else "", + "position": i + 1, + } + ) + + # Credit the free tier (anonymous path only — keyed search uses REST and + # carries no attribution). + return { + "success": True, + "data": {"web": web_results}, + "provider": "parallel", + "attribution": _FREE_MCP_ATTRIBUTION, + } + + +def _mcp_web_fetch(urls: List[str], api_key: str | None) -> List[Dict[str, Any]]: + """Run a ``web_fetch`` tool call against the hosted Search MCP. + + Returns the per-URL extract shape that + :func:`tools.web_tools.web_extract_tool` expects — exactly one row per input + URL, in request order (including duplicates). We pass ``full_content=True`` + so the page body comes back as markdown (matching the keyed SDK path and + what extract callers/summarizers expect), falling back to excerpts only when + full content is absent. Any input the MCP didn't return is emitted as a + per-URL error row. + """ + payload = _mcp_call( + "web_fetch", + {"urls": list(urls), "full_content": True, "session_id": _new_session_id()}, + api_key, + ) + + # Index the response by URL, then emit one row per *input* URL in order so + # duplicates and positional alignment with the request list are preserved. + by_url: Dict[str, Dict[str, Any]] = {} + for item in payload.get("results") or []: + if isinstance(item, dict) and item.get("url"): + by_url.setdefault(item["url"], item) + + results: List[Dict[str, Any]] = [] + for url in urls: + item = by_url.get(url) + if item is None: + results.append( + { + "url": url, + "title": "", + "content": "", + "error": "extraction failed (no content returned)", + "metadata": {"sourceURL": url}, + } + ) + continue + title = item.get("title") or "" + # Prefer the full page body; fall back to joined excerpts (mirrors the + # keyed SDK extract path). + content = item.get("full_content") or "\n\n".join(item.get("excerpts") or []) + results.append( + { + "url": url, + "title": title, + "content": content, + "raw_content": content, + "metadata": {"sourceURL": url, "title": title}, + } + ) + + return results class ParallelWebSearchProvider(WebSearchProvider): @@ -152,7 +504,14 @@ class ParallelWebSearchProvider(WebSearchProvider): return "Parallel" def is_available(self) -> bool: - """Return True when ``PARALLEL_API_KEY`` is set to a non-empty value.""" + """Return True when ``PARALLEL_API_KEY`` is set. + + Deliberately key-based: this gates the registry's active-provider walk + and the ``hermes tools`` picker (auto-selecting Parallel for a user who + hasn't named it), so it must not claim availability on the keyless path. + The keyless free-MCP path is reached independently via + :func:`tools.web_tools._get_backend`'s ``parallel`` terminal default. + """ return bool(os.getenv("PARALLEL_API_KEY", "").strip()) def supports_search(self) -> bool: @@ -164,9 +523,11 @@ class ParallelWebSearchProvider(WebSearchProvider): def search(self, query: str, limit: int = 5) -> Dict[str, Any]: """Execute a Parallel search (sync). - Uses the ``beta.search`` endpoint with the configured mode - (``PARALLEL_SEARCH_MODE`` env var, default "agentic"). Limit is - capped at 20 server-side. + With ``PARALLEL_API_KEY`` set, uses the v1 ``search`` REST endpoint with + the configured mode (``PARALLEL_SEARCH_MODE`` env var, default + "advanced"; limit requested via advanced_settings.max_results, capped at + 20). Without a key, falls back to the free hosted Search MCP so search + still works with zero setup. """ try: from tools.interrupt import is_interrupted @@ -174,19 +535,31 @@ class ParallelWebSearchProvider(WebSearchProvider): if is_interrupted(): return {"success": False, "error": "Interrupted"} + api_key = os.getenv("PARALLEL_API_KEY", "").strip() + if not api_key: + logger.info( + "Parallel search (free MCP): '%s' (limit=%d)", query, limit + ) + return _mcp_web_search(query, limit, api_key=None) + mode = _resolve_search_mode() logger.info( - "Parallel search: '%s' (mode=%s, limit=%d)", query, mode, limit + "Parallel search (v1 REST): '%s' (mode=%s, limit=%d)", + query, mode, limit, ) - response = _get_sync_client().beta.search( + # v1 Search API. Request the caller's limit via max_results (capped + # at 20) so we don't rely on the API default — the slice below can + # only trim, not ask for more. + response = _get_sync_client().search( search_queries=[query], objective=query, mode=mode, - max_results=min(limit, 20), + session_id=_new_session_id(), + advanced_settings={"max_results": min(max(limit, 1), 20)}, ) web_results = [] - for i, result in enumerate(response.results or []): + for i, result in enumerate((response.results or [])[: max(limit, 1)]): excerpts = result.excerpts or [] web_results.append( { @@ -197,6 +570,8 @@ class ParallelWebSearchProvider(WebSearchProvider): } ) + # Paid/REST path: no attribution and no "[Parallel]" label — the + # branding is specifically for the free Search MCP tier. return {"success": True, "data": {"web": web_results}} except ValueError as exc: return {"success": False, "error": str(exc)} @@ -212,7 +587,12 @@ class ParallelWebSearchProvider(WebSearchProvider): async def extract( self, urls: List[str], **kwargs: Any ) -> List[Dict[str, Any]]: - """Extract content from one or more URLs via the async SDK. + """Extract content from one or more URLs. + + With ``PARALLEL_API_KEY`` set, uses the async SDK's v1 ``extract`` for + full page content. Without a key, falls back to the free hosted Search + MCP's ``web_fetch`` tool so extraction works with zero setup, mirroring + the keyless search path. Returns the legacy list-of-results shape that :func:`tools.web_tools.web_extract_tool` expects: one entry per @@ -227,10 +607,21 @@ class ParallelWebSearchProvider(WebSearchProvider): {"url": u, "error": "Interrupted", "title": ""} for u in urls ] - logger.info("Parallel extract: %d URL(s)", len(urls)) - response = await _get_async_client().beta.extract( + api_key = os.getenv("PARALLEL_API_KEY", "").strip() + if not api_key: + logger.info( + "Parallel extract (free MCP web_fetch): %d URL(s)", len(urls) + ) + # _mcp_web_fetch is sync httpx; run off the event loop. + return await asyncio.to_thread(_mcp_web_fetch, list(urls), None) + + logger.info("Parallel extract (v1 REST): %d URL(s)", len(urls)) + # v1 Extract API (client.extract, /v1/extract); full_content is set + # via advanced_settings. + response = await _get_async_client().extract( urls=urls, - full_content=True, + advanced_settings={"full_content": True}, + session_id=_new_session_id(), ) results: List[Dict[str, Any]] = [] @@ -251,13 +642,20 @@ class ParallelWebSearchProvider(WebSearchProvider): ) for error in response.errors or []: + err_url = getattr(error, "url", "") or "" + err_msg = ( + getattr(error, "message", None) + or getattr(error, "content", None) + or getattr(error, "error_type", None) + or "extraction failed" + ) results.append( { - "url": error.url or "", + "url": err_url, "title": "", "content": "", - "error": error.content or error.error_type or "extraction failed", - "metadata": {"sourceURL": error.url or ""}, + "error": err_msg, + "metadata": {"sourceURL": err_url}, } ) @@ -279,12 +677,16 @@ class ParallelWebSearchProvider(WebSearchProvider): def get_setup_schema(self) -> Dict[str, Any]: return { "name": "Parallel", - "badge": "paid", - "tag": "Objective-tuned search + parallel page extraction.", + "badge": "free", + "tag": ( + "Free web search + extraction via Parallel's hosted Search MCP " + "— no key needed. Add PARALLEL_API_KEY for the v1 REST Search " + "API (richer modes, higher limits)." + ), "env_vars": [ { "key": "PARALLEL_API_KEY", - "prompt": "Parallel API key", + "prompt": "Parallel API key (optional — unlocks the v1 REST Search API)", "url": "https://parallel.ai", }, ], diff --git a/pyproject.toml b/pyproject.toml index b2a486aefd0..e5bf882d87a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,7 +122,7 @@ anthropic = ["anthropic==0.87.0"] # CVE-2026-34450, CVE-2026-34452 # search provider (configured via `hermes tools` or config.yaml). exa = ["exa-py==2.10.2"] firecrawl = ["firecrawl-py==4.17.0"] -parallel-web = ["parallel-web==0.4.2"] +parallel-web = ["parallel-web==0.6.0"] # Image generation backends fal = ["fal-client==0.13.1"] # Edge TTS — default TTS provider but still optional (users can pick diff --git a/tests/agent/test_display.py b/tests/agent/test_display.py index 994aae28648..0203e38b3cd 100644 --- a/tests/agent/test_display.py +++ b/tests/agent/test_display.py @@ -12,6 +12,7 @@ from agent.display import ( set_tool_preview_max_len, _render_inline_unified_diff, _summarize_rendered_diff_sections, + _used_free_parallel, render_edit_diff_with_delta, ) @@ -171,6 +172,46 @@ class TestCuteToolMessagePreviewLength: assert "[error]" not in line +class TestWebProviderLabel: + """The free-path "Parallel search"/"Parallel fetch" verb labeling.""" + + def test_free_search_verb_is_parallel(self): + result = json.dumps({"success": True, "data": {"web": []}, "provider": "parallel"}) + line = get_cute_tool_message("web_search", {"query": "hello"}, 0.1, result=result) + assert "Parallel search" in line + assert "hello" in line + + def test_paid_search_verb_is_plain(self): + result = json.dumps({"success": True, "data": {"web": [{"url": "u"}]}}) + line = get_cute_tool_message("web_search", {"query": "hi"}, 0.1, result=result) + assert "Parallel" not in line + assert "search" in line + + def test_missing_result_verb_is_plain(self): + line = get_cute_tool_message("web_search", {"query": "hello"}, 0.1) + assert "Parallel" not in line + assert "search" in line + + def test_helper_is_parallel_free_specific(self): + # Only Parallel's free MCP path marks results; nothing else does. + assert _used_free_parallel(json.dumps({"provider": "parallel"})) is True + assert _used_free_parallel(json.dumps({"provider": "exa"})) is False + assert _used_free_parallel(json.dumps({"provider": "firecrawl"})) is False + assert _used_free_parallel(json.dumps({"success": True, "data": {}})) is False + assert _used_free_parallel('not json') is False + assert _used_free_parallel(None) is False + + def test_free_extract_verb_is_parallel(self): + result = json.dumps({"results": [{"url": "u", "content": "x"}], "provider": "parallel"}) + line = get_cute_tool_message("web_extract", {"urls": ["https://a.test"]}, 0.1, result=result) + assert "Parallel fetch" in line + + def test_paid_extract_verb_is_plain(self): + result = json.dumps({"results": [{"url": "u", "content": "x"}]}) + line = get_cute_tool_message("web_extract", {"urls": ["https://a.test"]}, 0.1, result=result) + assert "Parallel" not in line + + class TestEditDiffPreview: def test_extract_edit_diff_for_patch(self): diff = extract_edit_diff("patch", '{"success": true, "diff": "--- a/x\\n+++ b/x\\n"}') diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index 5b24d2b6ebd..3a68c975897 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -975,6 +975,19 @@ def test_toolset_has_keys_treats_no_key_providers_as_configured(): assert _toolset_has_keys("computer_use", config) is True +def test_web_no_prompt_when_usable_keyless(): + """Fresh install: web works via the free Parallel MCP, so enabling the web + toolset should not force provider setup.""" + with patch("tools.web_tools.check_web_api_key", return_value=True): + assert _toolset_needs_configuration_prompt("web", {}) is False + + +def test_web_no_prompt_when_extract_backend_is_extract_capable(): + with patch("tools.web_tools.check_web_api_key", return_value=True): + cfg = {"web": {"extract_backend": "parallel"}} + assert _toolset_needs_configuration_prompt("web", cfg) is False + + def test_computer_use_needs_configuration_when_cua_driver_post_setup_pending(): """No-key providers can still need setup when their post_setup is unsatisfied. diff --git a/tests/plugins/web/test_parallel_keyless_mcp.py b/tests/plugins/web/test_parallel_keyless_mcp.py new file mode 100644 index 00000000000..49975c47f2f --- /dev/null +++ b/tests/plugins/web/test_parallel_keyless_mcp.py @@ -0,0 +1,382 @@ +"""Keyless Parallel search via the free hosted Search MCP. + +Covers the transport added in ``plugins/web/parallel/provider.py`` that lets +``web_search`` work with no ``PARALLEL_API_KEY``: + +- ``_mcp_headers`` — Bearer attached only when a key is held +- ``_decode_mcp_envelope`` — plain-JSON and SSE (``data:``) response bodies +- ``_mcp_payload`` — structuredContent preferred, text-block JSON fallback, errors +- ``_mcp_web_search`` — full handshake (mocked transport) → standard search shape +- ``ParallelWebSearchProvider.search`` — keyless path routes to the MCP +""" + +from __future__ import annotations + +import asyncio +import json +from unittest.mock import patch + +import pytest + +import plugins.web.parallel.provider as pp + + +# ─── _mcp_headers ────────────────────────────────────────────────────────── + +class TestMcpHeaders: + def test_anonymous_has_no_authorization(self): + h = pp._mcp_headers(session_id=None, api_key=None) + assert "Authorization" not in h + assert h["Accept"] == "application/json, text/event-stream" + assert "Mcp-Session-Id" not in h + + def test_identifies_hermes_via_user_agent(self): + # Free-tier traffic is attributable at the HTTP layer (not just via the + # JSON-RPC clientInfo payload), on both the anonymous and keyed paths. + assert pp._mcp_headers(session_id=None, api_key=None)["User-Agent"].startswith( + "hermes-agent/" + ) + assert pp._mcp_headers(session_id="sid", api_key="pk-live")["User-Agent"].startswith( + "hermes-agent/" + ) + + def test_session_id_and_bearer_when_present(self): + h = pp._mcp_headers(session_id="sid-123", api_key="pk-live") + assert h["Mcp-Session-Id"] == "sid-123" + assert h["Authorization"] == "Bearer pk-live" + + +# ─── SSE / JSON-RPC parsing ────────────────────────────────────────────────── + +class TestMcpResponseParsing: + def test_plain_json_matched_by_id(self): + body = '{"jsonrpc":"2.0","id":"abc","result":{"ok":true}}' + assert pp._mcp_response_envelope(body, "abc")["result"]["ok"] is True + + def test_sse_selects_response_for_request_id_skipping_notifications(self): + # A progress notification (no id) precedes the real result; an unrelated + # response id is also present. We must pick the one matching our id. + body = ( + 'event: message\ndata: {"jsonrpc":"2.0","method":"notifications/progress","params":{"p":1}}\n\n' + 'event: message\ndata: {"jsonrpc":"2.0","id":"other","result":{"ok":false}}\n\n' + 'event: message\ndata: {"jsonrpc":"2.0","id":"req-1","result":{"ok":true}}\n\n' + ) + env = pp._mcp_response_envelope(body, "req-1") + assert env["result"]["ok"] is True + + def test_sse_multiline_data_concatenated(self): + body = 'data: {"jsonrpc":"2.0","id":"x",\ndata: "result":{"n":42}}\n\n' + assert pp._mcp_response_envelope(body, "x")["result"]["n"] == 42 + + def test_falls_back_to_last_result_when_id_absent(self): + body = '{"jsonrpc":"2.0","id":"server-chose","result":{"ok":true}}' + # request id doesn't match, but there's a single result → use it + assert pp._mcp_response_envelope(body, "mismatch")["result"]["ok"] is True + + def test_empty_body(self): + assert pp._mcp_response_envelope("", "x") == {} + assert pp._mcp_response_envelope(" ", "x") == {} + + def test_batched_json_array_flattened(self): + # Streamable HTTP may batch messages into a JSON array. + body = ('[{"jsonrpc":"2.0","method":"notifications/progress"},' + '{"jsonrpc":"2.0","id":"req-9","result":{"ok":true}}]') + assert pp._mcp_response_envelope(body, "req-9")["result"]["ok"] is True + + def test_batched_sse_data_array_flattened(self): + body = 'data: [{"jsonrpc":"2.0","id":"a","result":{"n":1}}]\n\n' + assert pp._mcp_response_envelope(body, "a")["result"]["n"] == 1 + + +# ─── _mcp_payload ──────────────────────────────────────────────────────────── + +class TestMcpPayload: + def test_prefers_structured_content(self): + env = {"result": {"structuredContent": {"results": [{"url": "u"}]}, + "content": [{"type": "text", "text": "ignored"}]}} + assert pp._mcp_payload(env) == {"results": [{"url": "u"}]} + + def test_parses_text_block_json(self): + inner = {"search_id": "s1", "results": [{"url": "u", "title": "t"}]} + env = {"result": {"content": [{"type": "text", "text": json.dumps(inner)}]}} + assert pp._mcp_payload(env)["search_id"] == "s1" + + def test_raises_on_jsonrpc_error(self): + with pytest.raises(RuntimeError, match="Parallel MCP error"): + pp._mcp_payload({"error": {"code": -32000, "message": "boom"}}) + + def test_raises_on_tool_iserror(self): + with pytest.raises(RuntimeError, match="Parallel MCP tool error"): + pp._mcp_payload({"result": {"isError": True, "content": []}}) + + +# ─── _mcp_web_search (mocked transport) ────────────────────────────────────── + +class _FakeResponse: + def __init__(self, *, text="", headers=None): + self.text = text + self.headers = headers or {} + + def raise_for_status(self): + return None + + +class _FakeClient: + """Stands in for httpx.Client: replays init → ack → tools/call.""" + + def __init__(self, search_payload, init_session_id="server-sid"): + self._search_payload = search_payload + self._init_session_id = init_session_id + self.calls = [] + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def post(self, url, headers=None, json=None): + self.calls.append({"headers": headers, "json": json}) + req = json or {} + method = req.get("method") + req_id = req.get("id") + if method == "initialize": + # Echo the request id, as the real server does. + return _FakeResponse( + text=json_dumps({"jsonrpc": "2.0", "id": req_id, + "result": {"protocolVersion": "2099-01-01"}}), + headers=( + {"mcp-session-id": self._init_session_id} + if self._init_session_id is not None + else {} + ), + ) + if method == "notifications/initialized": + return _FakeResponse(text="") + # tools/call + envelope = {"jsonrpc": "2.0", "id": req_id, "result": { + "content": [{"type": "text", "text": json_dumps(self._search_payload)}], + }} + return _FakeResponse(text=json_dumps(envelope)) + + +def json_dumps(obj): + return json.dumps(obj) + + +class TestMcpWebSearch: + def _payload(self, n): + return {"search_id": "s", "results": [ + {"url": f"https://ex/{i}", "title": f"t{i}", + "excerpts": [f"a{i}", f"b{i}"]} + for i in range(n) + ]} + + def test_returns_standard_shape_and_handshake(self): + fake = _FakeClient(self._payload(3)) + with patch.object(pp.httpx, "Client", return_value=fake): + out = pp._mcp_web_search("hello", limit=5, api_key=None) + + assert out["success"] is True + # Free-tier results credit Parallel. + assert "Parallel" in out["attribution"] + web = out["data"]["web"] + assert [r["position"] for r in web] == [1, 2, 3] + assert web[0]["url"] == "https://ex/0" + assert web[0]["description"] == "a0 b0" # excerpts joined + # handshake order + methods = [c["json"].get("method") for c in fake.calls] + assert methods == ["initialize", "notifications/initialized", "tools/call"] + # session id from the initialize response header is reused + assert fake.calls[-1]["headers"]["Mcp-Session-Id"] == "server-sid" + + def test_stateless_server_no_session_header_not_invented(self): + # A stateless Streamable-HTTP server may omit mcp-session-id on + # initialize; we must NOT invent one (sending an unissued session id can + # get follow-up requests rejected). The follow-ups carry no header. + fake = _FakeClient(self._payload(1), init_session_id=None) + with patch.object(pp.httpx, "Client", return_value=fake): + out = pp._mcp_web_search("hello", limit=5, api_key=None) + assert out["success"] is True + follow_ups = [c for c in fake.calls if c["json"].get("method") != "initialize"] + assert follow_ups, "expected notifications/initialized + tools/call" + assert all("Mcp-Session-Id" not in c["headers"] for c in follow_ups) + # anonymous → no Authorization on any call + assert all("Authorization" not in c["headers"] for c in fake.calls) + # tools/call mirrors query into objective + search_queries + args = fake.calls[-1]["json"]["params"]["arguments"] + assert args["objective"] == "hello" + assert args["search_queries"] == ["hello"] + + def test_limit_is_applied_client_side(self): + fake = _FakeClient(self._payload(10)) + with patch.object(pp.httpx, "Client", return_value=fake): + out = pp._mcp_web_search("q", limit=2, api_key=None) + assert len(out["data"]["web"]) == 2 + + def test_bearer_attached_when_key_present(self): + fake = _FakeClient(self._payload(1)) + with patch.object(pp.httpx, "Client", return_value=fake): + pp._mcp_web_search("q", limit=1, api_key="pk-live") + assert all(c["headers"]["Authorization"] == "Bearer pk-live" for c in fake.calls) + + def test_negotiated_protocol_version_echoed_post_init(self): + fake = _FakeClient(self._payload(1)) + with patch.object(pp.httpx, "Client", return_value=fake): + pp._mcp_web_search("q", limit=1, api_key=None) + # initialize request doesn't carry the (not-yet-negotiated) version... + assert "MCP-Protocol-Version" not in fake.calls[0]["headers"] + # ...but notifications/initialized and tools/call echo the negotiated one. + assert fake.calls[1]["headers"]["MCP-Protocol-Version"] == "2099-01-01" + assert fake.calls[-1]["headers"]["MCP-Protocol-Version"] == "2099-01-01" + + +# ─── provider.search keyless routing ───────────────────────────────────────── + +class TestProviderKeylessSearch: + def test_search_without_key_uses_mcp(self, monkeypatch): + monkeypatch.delenv("PARALLEL_API_KEY", raising=False) + captured = {} + + def _fake(query, limit, api_key): + captured.update(query=query, limit=limit, api_key=api_key) + return {"success": True, "data": {"web": []}} + + monkeypatch.setattr(pp, "_mcp_web_search", _fake) + out = pp.ParallelWebSearchProvider().search("kittens", limit=4) + assert out["success"] is True + assert captured == {"query": "kittens", "limit": 4, "api_key": None} + + def test_is_available_reflects_key(self, monkeypatch): + # is_available() gates the registry's active-provider walk + picker, so + # it's key-based (keyless dispatch is handled by _get_backend, not this). + monkeypatch.delenv("PARALLEL_API_KEY", raising=False) + assert pp.ParallelWebSearchProvider().is_available() is False + monkeypatch.setenv("PARALLEL_API_KEY", "k") + assert pp.ParallelWebSearchProvider().is_available() is True + + +# ─── web_fetch (keyless extract) ───────────────────────────────────────────── + +class TestMcpWebFetch: + def _payload(self, urls): + return {"extract_id": "e1", "results": [ + {"url": u, "title": f"T{i}", "publish_date": None, + "excerpts": [f"chunk-a-{i}", f"chunk-b-{i}"]} + for i, u in enumerate(urls) + ]} + + def test_maps_to_extract_shape(self): + urls = ["https://a.test", "https://b.test"] + fake = _FakeClient(self._payload(urls)) + with patch.object(pp.httpx, "Client", return_value=fake): + out = pp._mcp_web_fetch(urls, api_key=None) + assert [r["url"] for r in out] == urls + assert out[0]["content"] == "chunk-a-0\n\nchunk-b-0" + assert out[0]["raw_content"] == out[0]["content"] + assert out[0]["metadata"] == {"sourceURL": "https://a.test", "title": "T0"} + # tools/call targeted web_fetch, requesting full page bodies. + args = fake.calls[-1]["json"]["params"] + assert args["name"] == "web_fetch" + assert args["arguments"]["urls"] == urls + assert args["arguments"]["full_content"] is True + assert args["arguments"]["session_id"].startswith("hermes-agent-") + + def test_prefers_full_content_over_excerpts(self): + payload = {"results": [ + {"url": "https://a.test", "title": "T", + "excerpts": ["snippet"], "full_content": "the entire page body"}, + ]} + fake = _FakeClient(payload) + with patch.object(pp.httpx, "Client", return_value=fake): + out = pp._mcp_web_fetch(["https://a.test"], api_key=None) + assert out[0]["content"] == "the entire page body" + + def test_missing_url_becomes_error_entry(self): + # Server returns only one of the two requested URLs. + fake = _FakeClient(self._payload(["https://a.test"])) + with patch.object(pp.httpx, "Client", return_value=fake): + out = pp._mcp_web_fetch(["https://a.test", "https://missing.test"], api_key=None) + assert len(out) == 2 + missing = [r for r in out if r["url"] == "https://missing.test"][0] + assert "error" in missing + assert missing["content"] == "" + + def test_preserves_order_and_duplicate_inputs(self): + # MCP returns each unique URL once; output must still be one row per + # input, in order, including the duplicate. + fake = _FakeClient(self._payload(["https://a.test", "https://b.test"])) + urls = ["https://b.test", "https://a.test", "https://b.test"] + with patch.object(pp.httpx, "Client", return_value=fake): + out = pp._mcp_web_fetch(urls, api_key=None) + assert [r["url"] for r in out] == urls # one row per input, in order + assert all("error" not in r for r in out) # all three resolved + + def test_extract_without_key_uses_web_fetch(self, monkeypatch): + monkeypatch.delenv("PARALLEL_API_KEY", raising=False) + captured = {} + + def _fake(urls, api_key): + captured.update(urls=list(urls), api_key=api_key) + return [{"url": urls[0], "title": "", "content": "x", + "raw_content": "x", "metadata": {}}] + + monkeypatch.setattr(pp, "_mcp_web_fetch", _fake) + out = asyncio.run(pp.ParallelWebSearchProvider().extract(["https://x.test"])) + assert out[0]["content"] == "x" + assert captured == {"urls": ["https://x.test"], "api_key": None} + + +# ─── keyed v1 REST search ──────────────────────────────────────────────────── + +class TestKeyedV1Search: + def test_passes_max_results_and_omits_branding(self, monkeypatch): + monkeypatch.setenv("PARALLEL_API_KEY", "pk-live") + monkeypatch.delenv("PARALLEL_SEARCH_MODE", raising=False) + captured = {} + + class _Res: + def __init__(self, url): + self.url, self.title, self.excerpts = url, "T", ["x"] + + class _Resp: + results = [_Res(f"https://r/{i}") for i in range(10)] + + class _Client: + def search(self, **kw): + captured.update(kw) + return _Resp() + + monkeypatch.setattr(pp, "_get_sync_client", lambda: _Client()) + out = pp.ParallelWebSearchProvider().search("q", limit=7) + + assert out["success"] is True + # honors the caller's limit via advanced_settings.max_results + assert captured["advanced_settings"] == {"max_results": 7} + assert captured["mode"] == "advanced" # v1 default + assert captured["session_id"].startswith("hermes-agent-") # per-call id + assert len(out["data"]["web"]) == 7 # client-side slice + # paid path: no free-tier attribution, no [Parallel] label signal + assert "attribution" not in out + assert "provider" not in out + + +# ─── v1 search mode mapping ────────────────────────────────────────────────── + +class TestResolveSearchMode: + @pytest.mark.parametrize("env,expected", [ + (None, "advanced"), # default + ("advanced", "advanced"), + ("basic", "basic"), + ("fast", "basic"), # legacy → basic + ("one-shot", "basic"), # legacy → basic + ("agentic", "advanced"), # legacy → advanced + ("garbage", "advanced"), # invalid → default + ("BASIC", "basic"), # case-insensitive + ]) + def test_mode_mapping(self, monkeypatch, env, expected): + if env is None: + monkeypatch.delenv("PARALLEL_SEARCH_MODE", raising=False) + else: + monkeypatch.setenv("PARALLEL_SEARCH_MODE", env) + assert pp._resolve_search_mode() == expected diff --git a/tests/plugins/web/test_web_search_provider_plugins.py b/tests/plugins/web/test_web_search_provider_plugins.py index 2177d875c4b..2d74b2a1813 100644 --- a/tests/plugins/web/test_web_search_provider_plugins.py +++ b/tests/plugins/web/test_web_search_provider_plugins.py @@ -193,11 +193,16 @@ class TestIsAvailable: assert p.is_available() is True def test_parallel_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + """is_available() is key-based — it gates the registry's active-provider + walk/picker. (Keyless search/extract still work via the free MCP through + _get_backend's terminal default, independent of this flag.) + """ _ensure_plugins_loaded() from agent.web_search_registry import get_provider p = get_provider("parallel") assert p is not None + monkeypatch.delenv("PARALLEL_API_KEY", raising=False) assert p.is_available() is False monkeypatch.setenv("PARALLEL_API_KEY", "real") assert p.is_available() is True @@ -422,17 +427,33 @@ class TestErrorResponseShapes: assert result.get("success") is False assert "error" in result - def test_parallel_extract_returns_per_url_errors_when_unconfigured(self) -> None: + def test_parallel_extract_keyless_uses_mcp_web_fetch( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Without a key, extract routes to the free MCP web_fetch tool rather + than erroring. The MCP transport is mocked so the test stays offline.""" _ensure_plugins_loaded() from agent.web_search_registry import get_provider + import plugins.web.parallel.provider as parallel_provider + + monkeypatch.delenv("PARALLEL_API_KEY", raising=False) + captured = {} + + def _fake_fetch(urls, api_key): + captured["urls"] = list(urls) + captured["api_key"] = api_key + return [{"url": urls[0], "title": "Example", "content": "body", + "raw_content": "body", "metadata": {"sourceURL": urls[0]}}] + + monkeypatch.setattr(parallel_provider, "_mcp_web_fetch", _fake_fetch) p = get_provider("parallel") assert p is not None result = asyncio.run(p.extract(["https://example.com"])) assert isinstance(result, list) - assert len(result) == 1 - assert "error" in result[0] assert result[0]["url"] == "https://example.com" + assert result[0]["content"] == "body" + assert captured == {"urls": ["https://example.com"], "api_key": None} def test_firecrawl_extract_returns_per_url_errors_when_unconfigured(self) -> None: _ensure_plugins_loaded() diff --git a/tests/tools/test_web_providers.py b/tests/tools/test_web_providers.py index bd3cce8754a..230f909b5fe 100644 --- a/tests/tools/test_web_providers.py +++ b/tests/tools/test_web_providers.py @@ -167,6 +167,21 @@ class TestPerCapabilityBackendSelection: monkeypatch.setenv("TAVILY_API_KEY", "test-key") assert web_tools._get_search_backend() == "tavily" + def test_explicit_extract_backend_honored_when_unavailable(self, monkeypatch): + """An explicit per-capability backend is honored even with no creds, so + its setup error surfaces instead of silently rerouting to the keyless + Parallel default (which would send user URLs to a different provider).""" + from tools import web_tools + + monkeypatch.setattr(web_tools, "_load_web_config", lambda: { + "extract_backend": "firecrawl", + }) + for key in ("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "FIRECRAWL_GATEWAY_URL"): + monkeypatch.delenv(key, raising=False) + monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False, raising=False) + # Resolves to firecrawl (not parallel) despite firecrawl being unavailable. + assert web_tools._get_extract_backend() == "firecrawl" + def test_falls_back_to_generic_backend_when_extract_backend_empty(self, monkeypatch): from tools import web_tools @@ -177,7 +192,7 @@ class TestPerCapabilityBackendSelection: monkeypatch.setenv("PARALLEL_API_KEY", "test-key") assert web_tools._get_extract_backend() == "parallel" - def test_search_backend_ignored_when_not_available(self, monkeypatch): + def test_explicit_search_backend_honored_when_unavailable(self, monkeypatch): from tools import web_tools monkeypatch.setattr(web_tools, "_load_web_config", lambda: { @@ -186,8 +201,10 @@ class TestPerCapabilityBackendSelection: }) monkeypatch.delenv("EXA_API_KEY", raising=False) monkeypatch.setenv("FIRECRAWL_API_KEY", "fc-key") - # Should fall back to firecrawl since exa isn't configured - assert web_tools._get_search_backend() == "firecrawl" + # The explicit per-capability choice (exa) is honored even though it's + # unavailable, so its setup error surfaces — we don't silently reroute + # to the shared backend (or the keyless Parallel default). + assert web_tools._get_search_backend() == "exa" def test_fully_backward_compatible_with_web_backend_only(self, monkeypatch): from tools import web_tools @@ -291,25 +308,55 @@ class TestUnconfiguredErrorEnvelopeParity: ): monkeypatch.delenv(k, raising=False) - def test_unconfigured_search_emits_top_level_error(self, monkeypatch): - """``web_search_tool`` with no creds returns ``{"error": "Error searching web: ..."}`` - — matching main's ``tool_error()`` envelope, not a per-result shape. + def test_extract_empty_urls_does_not_raise(self, monkeypatch): + """Regression: empty (or fully SSRF-blocked) URL sets skip the dispatch + branch; the free-Parallel flag must still be initialized so the tool + returns an error envelope instead of UnboundLocalError.""" + import asyncio + from tools import web_tools + self._clear_web_creds(monkeypatch) + monkeypatch.setattr(web_tools, "_load_web_config", lambda: {}) + out = asyncio.run(web_tools.web_extract_tool([], "markdown")) + # The key assertion is that it returns a normal error envelope (a + # string) rather than raising UnboundLocalError. + assert isinstance(out, str) + result = json.loads(out) + assert "error" in result + + def test_unconfigured_search_falls_back_to_free_parallel(self, monkeypatch): + """``web_search_tool`` with no creds routes to Parallel's free Search + MCP rather than erroring. The MCP transport is mocked so the test + stays offline; we assert dispatch landed on parallel and returned the + standard search envelope. """ from tools import web_tools + import plugins.web.parallel.provider as parallel_provider self._clear_web_creds(monkeypatch) - # Reset firecrawl client cache so the unconfigured state is re-evaluated monkeypatch.setattr(web_tools, "_firecrawl_client", None, raising=False) monkeypatch.setattr(web_tools, "_firecrawl_client_config", None, raising=False) monkeypatch.setattr(web_tools, "_load_web_config", lambda: {}) + captured = {} + + def _fake_mcp(query, limit, api_key): + captured["query"] = query + captured["api_key"] = api_key + return { + "success": True, + "data": {"web": [ + {"url": "https://example.com", "title": "Example", + "description": "hit", "position": 1}, + ]}, + } + + monkeypatch.setattr(parallel_provider, "_mcp_web_search", _fake_mcp) + result = json.loads(web_tools.web_search_tool("hello world", limit=3)) - assert "error" in result, f"expected top-level 'error' key, got {result}" - # ``Error searching web:`` prefix comes from web_tools' top-level except handler - assert "Error searching web:" in result["error"] - assert "FIRECRAWL_API_KEY" in result["error"] - # No per-result burying - assert "results" not in result + assert result.get("success") is True, f"expected success, got {result}" + assert result["data"]["web"][0]["url"] == "https://example.com" + # Keyless path: dispatched to parallel with no Bearer token. + assert captured == {"query": "hello world", "api_key": None} class TestDispatchersTriggerPluginDiscovery: diff --git a/tests/tools/test_web_providers_ddgs.py b/tests/tools/test_web_providers_ddgs.py index 283a25f0a1b..1050a4e554a 100644 --- a/tests/tools/test_web_providers_ddgs.py +++ b/tests/tools/test_web_providers_ddgs.py @@ -190,7 +190,11 @@ class TestDDGSBackendWiring: monkeypatch.setattr(web_tools, "_ddgs_package_importable", lambda: True) assert web_tools._get_backend() == "exa" - def test_auto_detect_picks_ddgs_as_last_resort(self, monkeypatch): + def test_auto_detect_prefers_keyless_parallel_over_ddgs(self, monkeypatch): + # With no credentials, keyless Parallel is the auto-detect default even + # when the ddgs package is installed — ddgs is search-only (can't + # extract), so Parallel is preferred so both search and extract work. + # ddgs remains reachable via an explicit web.backend=ddgs. from tools import web_tools monkeypatch.setattr(web_tools, "_load_web_config", lambda: {}) for key in ("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "PARALLEL_API_KEY", @@ -198,7 +202,7 @@ class TestDDGSBackendWiring: monkeypatch.delenv(key, raising=False) monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False) monkeypatch.setattr(web_tools, "_ddgs_package_importable", lambda: True) - assert web_tools._get_backend() == "ddgs" + assert web_tools._get_backend() == "parallel" def test_check_web_api_key_true_when_ddgs_configured(self, monkeypatch): from tools import web_tools diff --git a/tests/tools/test_web_providers_searxng.py b/tests/tools/test_web_providers_searxng.py index e093532bf37..2877d56b868 100644 --- a/tests/tools/test_web_providers_searxng.py +++ b/tests/tools/test_web_providers_searxng.py @@ -313,7 +313,9 @@ class TestCheckWebApiKey: ) assert web_tools.check_web_api_key() is True - def test_no_credentials_fails(self, monkeypatch): + def test_no_credentials_usable_via_free_parallel(self, monkeypatch): + """No credentials → check_web_api_key True: the keyless Parallel free MCP + services calls, so web is usable out of the box.""" from tools import web_tools monkeypatch.setattr(web_tools, "_load_web_config", lambda: {}) monkeypatch.delenv("FIRECRAWL_API_KEY", raising=False) @@ -324,7 +326,8 @@ class TestCheckWebApiKey: monkeypatch.delenv("SEARXNG_URL", raising=False) monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False) monkeypatch.setattr(web_tools, "check_firecrawl_api_key", lambda: False) - assert web_tools.check_web_api_key() is False + monkeypatch.setattr(web_tools, "_ddgs_package_importable", lambda: False) + assert web_tools.check_web_api_key() is True # --------------------------------------------------------------------------- diff --git a/tests/tools/test_web_tools_config.py b/tests/tools/test_web_tools_config.py index 28323122aca..c9a6b3b31a3 100644 --- a/tests/tools/test_web_tools_config.py +++ b/tests/tools/test_web_tools_config.py @@ -384,11 +384,14 @@ class TestBackendSelection: patch.dict(os.environ, {"FIRECRAWL_API_KEY": "fc-test"}): assert _get_backend() == "firecrawl" - def test_fallback_no_keys_defaults_to_firecrawl(self): - """No keys, no config → 'firecrawl' (will fail at client init).""" + def test_fallback_no_keys_defaults_to_parallel(self): + """No credentials, no config → 'parallel' (free Search MCP works + keyless). Selection is purely credential-based.""" from tools.web_tools import _get_backend - with patch("tools.web_tools._load_web_config", return_value={}): - assert _get_backend() == "firecrawl" + with patch("tools.web_tools._load_web_config", return_value={}), \ + patch("tools.web_tools._is_tool_gateway_ready", return_value=False), \ + patch("tools.web_tools._ddgs_package_importable", return_value=False): + assert _get_backend() == "parallel" def test_invalid_config_falls_through_to_fallback(self): """web.backend=invalid → ignored, uses key-based fallback.""" @@ -623,9 +626,74 @@ class TestCheckWebApiKey: from tools.web_tools import check_web_api_key assert check_web_api_key() is True - def test_no_keys_returns_false(self): + def test_no_keys_usable_via_free_parallel(self): + """No credentials → check_web_api_key True: selection resolves to the + keyless Parallel free MCP, which genuinely services calls (web works out + of the box). check_web_api_key is a usability probe, not a key check.""" from tools.web_tools import check_web_api_key - assert check_web_api_key() is False + with patch("tools.web_tools._load_web_config", return_value={}), \ + patch("tools.web_tools._is_tool_gateway_ready", return_value=False), \ + patch("tools.web_tools._ddgs_package_importable", return_value=False), \ + patch.dict(os.environ, {}, clear=False): + for k in ("PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", + "TAVILY_API_KEY", "EXA_API_KEY", "SEARXNG_URL", "BRAVE_SEARCH_API_KEY"): + os.environ.pop(k, None) + assert check_web_api_key() is True + + def test_typo_extract_backend_not_masked_by_parallel(self): + """A typo'd per-capability backend is honored (so dispatch errors) + rather than silently falling through to keyless Parallel.""" + from tools.web_tools import _get_extract_backend, check_web_api_key + with patch("tools.web_tools._load_web_config", + return_value={"extract_backend": "parrallel"}): + assert _get_extract_backend() == "parrallel" # not "parallel" + assert check_web_api_key() is False # unknown → unusable + + def test_keyless_parallel_unusable_when_provider_disabled(self): + """If the bundled web-parallel provider is disabled/unregistered, the + keyless free-MCP path must NOT report web as usable — otherwise setup is + skipped but web tools fail at runtime with no provider.""" + from tools.web_tools import check_web_api_key + with patch("tools.web_tools._load_web_config", return_value={}), \ + patch("tools.web_tools._parallel_provider_registered", return_value=False), \ + patch("tools.web_tools._is_tool_gateway_ready", return_value=False), \ + patch("tools.web_tools.check_firecrawl_api_key", return_value=False), \ + patch("tools.web_tools._ddgs_package_importable", return_value=False), \ + patch.dict(os.environ, {}, clear=False): + for var in ( + "PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", + "TAVILY_API_KEY", "EXA_API_KEY", "BRAVE_SEARCH_API_KEY", "SEARXNG_URL", + ): + os.environ.pop(var, None) + assert check_web_api_key() is False + + def test_extract_autodetect_skips_search_only_for_keyless_parallel(self): + """A search-only env credential (SEARXNG_URL) must not shadow the keyless + Parallel free-MCP extract fallback: extract auto-detect skips search-only + backends, so _get_extract_backend resolves to parallel (which can fetch), + while search auto-detect still prefers the configured searxng.""" + from tools.web_tools import _get_extract_backend, _get_search_backend + with patch("tools.web_tools._load_web_config", return_value={}), \ + patch.dict(os.environ, {}, clear=False): + for var in ( + "PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", + "TAVILY_API_KEY", "EXA_API_KEY", "BRAVE_SEARCH_API_KEY", + ): + os.environ.pop(var, None) + os.environ["SEARXNG_URL"] = "http://localhost:8080" + with patch("tools.web_tools._is_tool_gateway_ready", return_value=False): + assert _get_search_backend() == "searxng" + assert _get_extract_backend() == "parallel" + + def test_configured_but_unavailable_backend_reports_unusable(self): + """An explicitly configured backend with no creds (exa, no key) → + check_web_api_key False so diagnostics flag the misconfiguration — + even though the tools stay registered.""" + from tools.web_tools import check_web_api_key + with patch("tools.web_tools._load_web_config", return_value={"backend": "exa"}), \ + patch.dict(os.environ, {}, clear=False): + os.environ.pop("EXA_API_KEY", None) + assert check_web_api_key() is False def test_both_keys_returns_true(self): with patch.dict(os.environ, { @@ -688,12 +756,18 @@ class TestCheckWebApiKey: assert refresh_calls == [] - def test_configured_backend_must_match_available_provider(self): - with patch("tools.web_tools._load_web_config", return_value={"backend": "parallel"}): - with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"): - with patch.dict(os.environ, {"FIRECRAWL_GATEWAY_URL": "http://127.0.0.1:3002"}, clear=False): - from tools.web_tools import check_web_api_key - assert check_web_api_key() is False + def test_web_tools_registered_even_when_configured_backend_unavailable(self): + # Registration is unconditional (web_tools_registered) so an explicitly + # configured but unavailable backend (exa without EXA_API_KEY) keeps the + # tools registered to surface exa's setup error at call time — while the + # readiness probe (check_web_api_key) honestly reports not-configured. + from tools.web_tools import web_tools_registered, check_web_api_key + assert web_tools_registered() is True + with patch("tools.web_tools._load_web_config", return_value={"backend": "exa"}), \ + patch.dict(os.environ, {}, clear=False): + os.environ.pop("EXA_API_KEY", None) + assert web_tools_registered() is True + assert check_web_api_key() is False def test_configured_firecrawl_backend_accepts_managed_gateway(self): with patch("tools.web_tools._load_web_config", return_value={"backend": "firecrawl"}): diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index e4b0a9a57f0..76f146c7869 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -90,7 +90,7 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = { # ─── Web search backends ─────────────────────────────────────────────── "search.exa": ("exa-py==2.10.2",), "search.firecrawl": ("firecrawl-py==4.17.0",), - "search.parallel": ("parallel-web==0.4.2",), + "search.parallel": ("parallel-web==0.6.0",), # ─── TTS providers ───────────────────────────────────────────────────── # Pinned to exact versions to match pyproject.toml's no-ranges policy diff --git a/tools/web_tools.py b/tools/web_tools.py index 133489b0a89..6bf522f33ec 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -141,15 +141,35 @@ def _load_web_config() -> dict: except (ImportError, Exception): return {} -def _get_backend() -> str: +# Recognized web backend names (config values accepted in ``web.backend`` / +# ``web.search_backend`` / ``web.extract_backend``). Kept as a single source of +# truth for config validation across the selection helpers. +_KNOWN_WEB_BACKENDS = frozenset( + {"parallel", "firecrawl", "tavily", "exa", "searxng", "brave-free", "ddgs", "xai"} +) + +# Backends that only service web_search (their provider's ``supports_extract()`` +# is False). They are skipped during *extract* auto-detect so a search-only +# credential (e.g. SEARXNG_URL) does not shadow the keyless Parallel free-MCP +# fallback, which would otherwise leave web_extract broken on a no-key install. +_SEARCH_ONLY_BACKENDS = frozenset({"searxng", "brave-free", "ddgs", "xai"}) + + +def _get_backend(capability: str = "search") -> str: """Determine which web backend to use (shared fallback). Reads ``web.backend`` from config.yaml (set by ``hermes tools``). Falls back to whichever API key is present for users who configured keys manually without running setup. + + ``capability`` ("search" | "extract") only affects auto-detect: for + ``extract`` we skip search-only backends (``_SEARCH_ONLY_BACKENDS``) so a + search-only credential never shadows the keyless Parallel free-MCP extract + fallback. An explicit ``web.backend`` value is honored as-is (explicit wins, + surfacing that backend's own search-only error rather than rerouting). """ configured = (_load_web_config().get("backend") or "").lower().strip() - if configured in {"parallel", "firecrawl", "tavily", "exa", "searxng", "brave-free", "ddgs", "xai"}: + if configured in _KNOWN_WEB_BACKENDS: return configured # Fallback for manual / legacy config — pick the highest-priority @@ -158,7 +178,8 @@ def _get_backend() -> str: # pre-empted by a Nous OAuth token whose subscription tier may not # actually grant web-search access (the gateway then fails at runtime # with "no subscription" and the tool returns an error to the agent - # without falling back). Free-tier backends trail the paid ones. + # without falling back). Free-tier backends (searxng / brave-free / + # keyless parallel / ddgs) trail the keyed ones. backend_candidates = ( ("tavily", _has_env("TAVILY_API_KEY")), ("exa", _has_env("EXA_API_KEY")), @@ -167,13 +188,24 @@ def _get_backend() -> str: ("firecrawl", _is_tool_gateway_ready()), ("searxng", _has_env("SEARXNG_URL")), ("brave-free", _has_env("BRAVE_SEARCH_API_KEY")), + # Keyless Parallel free MCP — always available, the intended no-key + # default for both search and extract. Ahead of ddgs (search-only, so it + # can't service web_extract); ddgs stays reachable via web.backend=ddgs. + ("parallel", True), ("ddgs", _ddgs_package_importable()), ) for backend, available in backend_candidates: - if available: - return backend + if not available: + continue + # For extract, skip search-only backends so the keyless Parallel + # free-MCP fallback (which can fetch URLs) is reached instead. + if capability == "extract" and backend in _SEARCH_ONLY_BACKENDS: + continue + return backend - return "firecrawl" # default (backward compat) + # Defensive terminal (the keyless ``parallel`` candidate above is always + # available, so this is effectively unreachable). + return "parallel" def _get_search_backend() -> str: @@ -204,14 +236,19 @@ def _get_extract_backend() -> str: def _get_capability_backend(capability: str) -> str: """Shared helper for per-capability backend selection. - Reads ``web.{capability}_backend`` from config; if set and available, - uses it. Otherwise falls through to the shared ``_get_backend()``. + Reads ``web.{capability}_backend`` from config. Any explicit value is + honored **regardless of availability** — including unrecognized typos like + ``parrallel`` — so the dispatcher surfaces that backend's own setup/config + error rather than silently rerouting to the keyless Parallel default (which + would send user queries to a different provider and hide the + misconfiguration). This matches ``web_search_registry``'s "explicit config + wins" rule. Only an *unset* value falls through to ``_get_backend()``. """ cfg = _load_web_config() specific = (cfg.get(f"{capability}_backend") or "").lower().strip() - if specific and _is_backend_available(specific): + if specific: return specific - return _get_backend() + return _get_backend(capability) def _is_backend_available(backend: str) -> bool: @@ -219,6 +256,8 @@ def _is_backend_available(backend: str) -> bool: if backend == "exa": return _has_env("EXA_API_KEY") if backend == "parallel": + # Credential probe: True only with a real key. The keyless free-MCP + # fallback is handled by _get_backend()'s terminal default, not here. return _has_env("PARALLEL_API_KEY") if backend == "firecrawl": return check_firecrawl_api_key() @@ -972,11 +1011,19 @@ async def web_extract_tool( else: safe_urls.append(url) + # Tracks the free-tier Parallel extract path (no key → web_fetch via the + # hosted Search MCP) so we can credit Parallel in the output/UI. Bound + # here so empty/all-blocked inputs (which skip dispatch) stay defined. + _free_parallel_extract = False + # Dispatch only safe URLs to the configured backend if not safe_urls: results = [] else: backend = _get_extract_backend() + _free_parallel_extract = ( + backend == "parallel" and not _has_env("PARALLEL_API_KEY") + ) # All seven providers (brave-free, ddgs, searxng, exa, parallel, # tavily, firecrawl) now live as plugins. The dispatcher is a @@ -1150,6 +1197,14 @@ async def web_extract_tool( for r in response.get("results", []) ] trimmed_response = {"results": trimmed_results} + if _free_parallel_extract: + # Credit Parallel's free Search MCP (drives the "[Parallel]" UI tag + # + lets the model cite the source). Free tier only. + trimmed_response["provider"] = "parallel" + trimmed_response["attribution"] = ( + "Extraction powered by the free Parallel Web Search MCP " + "(https://parallel.ai)." + ) if trimmed_response.get("results") == []: result_json = tool_error("Content was inaccessible or not found") @@ -1181,16 +1236,61 @@ async def web_extract_tool( return tool_error(error_msg) -# Convenience function to check Firecrawl credentials +def web_tools_registered() -> bool: + """Whether the web tools should be registered. Always True. + + Registration is decoupled from credential readiness: with no credentials, + search/extract fall back to Parallel's free hosted Search MCP, and an + explicitly configured-but-unavailable backend must stay registered so + dispatch surfaces that backend's own setup error rather than the tool + silently vanishing. For "is web actually configured?" use + :func:`check_web_api_key`. + """ + return True + + +def _parallel_provider_registered() -> bool: + """True when the bundled ``web-parallel`` provider is registered/enabled. + + Plugin discovery skips disabled plugins, so a disabled (``plugins.disabled``) + or otherwise-unregistered parallel provider yields ``None`` here. + """ + _ensure_web_plugins_loaded() + try: + from agent.web_search_registry import get_provider + + return get_provider("parallel") is not None + except Exception: # noqa: BLE001 + return False + + +def _backend_usable(backend: str) -> bool: + """True when *backend* can service calls. Keyless Parallel counts (free MCP). + + Unknown/typo'd backend names are not usable (so an explicit typo is reported + as a config problem rather than masked by the keyless fallback). + """ + if backend == "parallel" and not _has_env("PARALLEL_API_KEY"): + # Keyless Parallel is only genuinely usable when its provider is actually + # registered/enabled. If web-parallel is disabled or discovery failed, + # report unusable so setup is not skipped and the user is not left with + # web tools that fail at runtime ("No web search provider configured"). + return _parallel_provider_registered() + return _is_backend_available(backend) + + def check_web_api_key() -> bool: - """Check whether the configured web backend is available.""" - configured = _load_web_config().get("backend", "").lower().strip() - if configured in {"exa", "parallel", "firecrawl", "tavily", "searxng", "brave-free", "ddgs", "xai"}: - return _is_backend_available(configured) - return any( - _is_backend_available(backend) - for backend in ("exa", "parallel", "firecrawl", "tavily", "searxng", "brave-free", "ddgs", "xai") - ) + """Usability probe: True when the selected web backends can service calls. + + Probes the backends that :func:`_get_search_backend` / + :func:`_get_extract_backend` actually select (not just shared + ``web.backend``), so an explicit per-capability backend with missing + credentials — or a typo'd name — reports unusable instead of being masked by + the keyless Parallel fallback. Keyless Parallel itself genuinely services + calls, so a zero-setup install reports usable. Distinct from + :func:`web_tools_registered` (always True — whether the tool is offered). + """ + return _backend_usable(_get_search_backend()) and _backend_usable(_get_extract_backend()) def check_auxiliary_model() -> bool: @@ -1358,7 +1458,7 @@ registry.register( toolset="web", schema=WEB_SEARCH_SCHEMA, handler=lambda args, **kw: web_search_tool(args.get("query", ""), limit=args.get("limit", 5)), - check_fn=check_web_api_key, + check_fn=web_tools_registered, requires_env=_web_requires_env(), emoji="🔍", max_result_size_chars=100_000, @@ -1369,7 +1469,7 @@ registry.register( schema=WEB_EXTRACT_SCHEMA, handler=lambda args, **kw: web_extract_tool( args.get("urls", [])[:5] if isinstance(args.get("urls"), list) else [], "markdown"), - check_fn=check_web_api_key, + check_fn=web_tools_registered, requires_env=_web_requires_env(), is_async=True, emoji="📄", diff --git a/uv.lock b/uv.lock index 55d7da4a4a8..f90a3a4270c 100644 --- a/uv.lock +++ b/uv.lock @@ -1653,7 +1653,7 @@ requires-dist = [ { name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" }, { name = "openai", specifier = "==2.24.0" }, { name = "packaging", specifier = "==26.0" }, - { name = "parallel-web", marker = "extra == 'parallel-web'", specifier = "==0.4.2" }, + { name = "parallel-web", marker = "extra == 'parallel-web'", specifier = "==0.6.0" }, { name = "pathspec", specifier = "==1.1.1" }, { name = "pillow", specifier = "==12.2.0" }, { name = "prompt-toolkit", specifier = "==3.0.52" }, @@ -2690,7 +2690,7 @@ wheels = [ [[package]] name = "parallel-web" -version = "0.4.2" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2700,9 +2700,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/24/50/fb9b28a679e01682006b5259abff96de3d16e114e9447a7793fec31715de/parallel_web-0.4.2.tar.gz", hash = "sha256:599b5a8f387dc35c7dc8c81e372eadf6958a40acacea58bf170dfc663c003da7", size = 140026, upload-time = "2026-03-09T22:24:35.448Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/81/101c961fe6665212df01fb39a70ebb379dc33529c7bc9210675c0f525139/parallel_web-0.6.0.tar.gz", hash = "sha256:f8aecd3f1958090090c4516881cefea4f55c40948ba3bb99217ca9a6d4263225", size = 173149, upload-time = "2026-05-06T19:13:09.782Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/3e/2218fa29637781b8e7ac35a928108ff2614ddd40879389d3af2caa725af5/parallel_web-0.4.2-py3-none-any.whl", hash = "sha256:aa3a4a9aecc08972c5ce9303271d4917903373dff4dd277d9a3e30f9cff53346", size = 144012, upload-time = "2026-03-09T22:24:33.979Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7c/7e8b63a0e90efaf567a818fca86c6ad3a85711f8995d2657b51b0cae2351/parallel_web-0.6.0-py3-none-any.whl", hash = "sha256:dc5342ef7262bd2e9f85eb7eace32833bd3d7e3af0bf5fbd780d1ea8c8d9ceb0", size = 199217, upload-time = "2026-05-06T19:13:08.316Z" }, ] [[package]] From 0a5762c78d11f4d6626dbf99da5f62cc34cfe2c4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:13:42 -0700 Subject: [PATCH 21/69] fix(web): genericize free-MCP client identity per telemetry policy Replace the hermes-identifying clientInfo/User-Agent/session-id prefix on the keyless Parallel Search MCP path with a neutral 'mcp-web-client' identity. Project policy forbids third-party usage attribution without an explicit user opt-in (see telemetry PR policy); MCP requires a clientInfo, so a generic one satisfies the spec without attributing traffic. Also adds the contributor AUTHOR_MAP entry and refreshes uv.lock against current main (parallel-web 0.6.0). --- plugins/web/parallel/provider.py | 15 +++++++----- scripts/release.py | 1 + .../plugins/web/test_parallel_keyless_mcp.py | 23 ++++++++++--------- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/plugins/web/parallel/provider.py b/plugins/web/parallel/provider.py index 20c4291d77b..7a15b3d3f80 100644 --- a/plugins/web/parallel/provider.py +++ b/plugins/web/parallel/provider.py @@ -55,11 +55,13 @@ logger = logging.getLogger(__name__) # configured. Docs: https://docs.parallel.ai/integrations/mcp/search-mcp _MCP_SEARCH_URL = "https://search.parallel.ai/mcp" _MCP_PROTOCOL_VERSION = "2025-06-18" -_MCP_CLIENT_NAME = "hermes-agent" +# Deliberately generic client identity. Project policy (see the telemetry PR +# policy in AGENTS.md) forbids third-party usage attribution without an +# explicit user opt-in, so neither clientInfo nor the User-Agent names +# hermes. MCP requires *a* clientInfo; a neutral one satisfies the spec +# without attributing traffic. +_MCP_CLIENT_NAME = "mcp-web-client" _MCP_CLIENT_VERSION = "1.0.0" -# Identify free-tier traffic at the HTTP layer. Without this, httpx sends a -# generic ``python-httpx/`` User-Agent and hermes usage is only visible -# via the JSON-RPC ``clientInfo`` payload. _MCP_USER_AGENT = f"{_MCP_CLIENT_NAME}/{_MCP_CLIENT_VERSION}" _MCP_TIMEOUT_SECONDS = 30.0 @@ -76,9 +78,10 @@ def _new_session_id() -> str: Per-call rather than process-global: one process serves many unrelated chats in the gateway/batch runners, and a shared id would pool their - searches into one Parallel session. + searches into one Parallel session. The prefix is deliberately generic + (no hermes attribution — telemetry policy). """ - return f"hermes-agent-{uuid.uuid4().hex}" + return f"{_MCP_CLIENT_NAME}-{uuid.uuid4().hex}" # Module-level note: the canonical cache slots ``_parallel_client`` and # ``_async_parallel_client`` live on :mod:`tools.web_tools` so tests that do diff --git a/scripts/release.py b/scripts/release.py index cd0fe475d5d..68ad134d6cd 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -75,6 +75,7 @@ AUTHOR_MAP = { "129007007+HeLLGURD@users.noreply.github.com": "HeLLGURD", "290859878+synapsesx@users.noreply.github.com": "synapsesx", "dirtyren@users.noreply.github.com": "dirtyren", + "mharris@parallel.ai": "NormallyGaussian", "ted.malone@outlook.com": "temalo", "adityamalik2833@gmail.com": "alarcritty", "islam666@users.noreply.github.com": "islam666", diff --git a/tests/plugins/web/test_parallel_keyless_mcp.py b/tests/plugins/web/test_parallel_keyless_mcp.py index 49975c47f2f..8495df144b4 100644 --- a/tests/plugins/web/test_parallel_keyless_mcp.py +++ b/tests/plugins/web/test_parallel_keyless_mcp.py @@ -30,15 +30,16 @@ class TestMcpHeaders: assert h["Accept"] == "application/json, text/event-stream" assert "Mcp-Session-Id" not in h - def test_identifies_hermes_via_user_agent(self): - # Free-tier traffic is attributable at the HTTP layer (not just via the - # JSON-RPC clientInfo payload), on both the anonymous and keyed paths. - assert pp._mcp_headers(session_id=None, api_key=None)["User-Agent"].startswith( - "hermes-agent/" - ) - assert pp._mcp_headers(session_id="sid", api_key="pk-live")["User-Agent"].startswith( - "hermes-agent/" - ) + def test_user_agent_is_generic_not_hermes(self): + # Telemetry policy: no third-party usage attribution without opt-in. + # The UA must be set (not python-httpx default) but must not name + # hermes, on both the anonymous and keyed paths. + for ua in ( + pp._mcp_headers(session_id=None, api_key=None)["User-Agent"], + pp._mcp_headers(session_id="sid", api_key="pk-live")["User-Agent"], + ): + assert ua == f"{pp._MCP_CLIENT_NAME}/{pp._MCP_CLIENT_VERSION}" + assert "hermes" not in ua.lower() def test_session_id_and_bearer_when_present(self): h = pp._mcp_headers(session_id="sid-123", api_key="pk-live") @@ -280,7 +281,7 @@ class TestMcpWebFetch: assert args["name"] == "web_fetch" assert args["arguments"]["urls"] == urls assert args["arguments"]["full_content"] is True - assert args["arguments"]["session_id"].startswith("hermes-agent-") + assert args["arguments"]["session_id"].startswith(f"{pp._MCP_CLIENT_NAME}-") def test_prefers_full_content_over_excerpts(self): payload = {"results": [ @@ -354,7 +355,7 @@ class TestKeyedV1Search: # honors the caller's limit via advanced_settings.max_results assert captured["advanced_settings"] == {"max_results": 7} assert captured["mode"] == "advanced" # v1 default - assert captured["session_id"].startswith("hermes-agent-") # per-call id + assert captured["session_id"].startswith(f"{pp._MCP_CLIENT_NAME}-") # per-call id assert len(out["data"]["web"]) == 7 # client-side slice # paid path: no free-tier attribution, no [Parallel] label signal assert "attribution" not in out From acd7932c0fc5f98331f01af6073115e6cf3ccf9d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 10 Jun 2026 19:54:44 -0700 Subject: [PATCH 22/69] docs: cross-link write-approval gate from skills, configuration, and slash-command docs (#43801) The memory/skill write-approval gate (#38199, #43354, #43452) was only documented inside features/memory.md. Surface it everywhere users will actually look: - features/skills.md: new 'Gating agent skill writes' section under skill_manage, with the staging semantics, review commands, and the distinction from skills.guard_agent_created - configuration.md: memory.write_approval added to the Memory Configuration block; new 'Write approval for skill writes' subsection next to the guard_agent_created scanner - reference/slash-commands.md: /memory and /skills review subcommands in both the CLI and messaging tables; Notes updated since /skills pending/approve/reject/diff/approval now works on the gateway - features/memory.md: cross-link to the new skills section --- website/docs/reference/slash-commands.md | 8 +++-- website/docs/user-guide/configuration.md | 14 ++++++++ website/docs/user-guide/features/memory.md | 1 + website/docs/user-guide/features/skills.md | 37 ++++++++++++++++++++++ 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 44a9a303a62..a9951263d7f 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -86,7 +86,8 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/tools [list\|disable\|enable] [name...]` | Manage tools: list available tools, or disable/enable specific tools for the current session. Disabling a tool removes it from the agent's toolset and triggers a session reset. | | `/toolsets` | List available toolsets | | `/browser [connect\|disconnect\|status]` | Manage a local Chromium-family CDP connection. `connect` attaches browser tools to a running Chrome, Brave, Chromium, or Edge instance (default: `http://127.0.0.1:9222`). `disconnect` detaches. `status` shows current connection. Auto-launches a supported Chromium-family browser if no debugger is detected. | -| `/skills` | Search, install, inspect, or manage skills from online registries | +| `/skills` | Search, install, inspect, or manage skills from online registries. Also the review surface for the skill write-approval gate: `/skills pending`, `/skills diff `, `/skills approve `, `/skills reject `, `/skills approval on\|off`. See [Gating agent skill writes](/user-guide/features/skills#gating-agent-skill-writes-skillswrite_approval). | +| `/memory [pending\|approve\|reject\|approval]` | Review pending memory writes staged by the write-approval gate (`memory.write_approval`) and toggle the gate. See [Controlling memory writes](/user-guide/features/memory#controlling-memory-writes-write_approval). | | `/bundles` | List configured skill bundles — `/` slash aliases that preload several skills at once. Configure under `bundles:` in `~/.hermes/config.yaml`. See [Skill Bundles](/user-guide/features/skills#skill-bundles). | | `/cron` | Manage scheduled tasks (list, add/create, edit, pause, resume, run, remove) | | `/curator` | Background skill maintenance — `status`, `run`, `pin`, `archive`. See [Curator](/user-guide/features/curator). | @@ -222,6 +223,8 @@ The messaging gateway supports the following built-in commands inside Telegram, | `/goal ` | Set a standing goal Hermes works toward across turns — our take on the Ralph loop. A judge model checks after each turn; if not done, Hermes auto-continues until it is, you pause/clear it, or the turn budget (default 20) is hit. Subcommands: `/goal status`, `/goal pause`, `/goal resume`, `/goal clear`. Safe to run mid-agent for status/pause/clear; setting a new goal requires `/stop` first. See [Persistent Goals](/user-guide/features/goals). | | `/footer [on\|off\|status]` | Toggle the runtime-metadata footer on final replies (shows model, context %, and cwd). | | `/curator [status\|run\|pin\|archive]` | Background skill maintenance controls. | +| `/memory [pending\|approve\|reject\|approval]` | Review pending memory writes staged by the write-approval gate (`memory.write_approval`) — approve or reject them right in chat — and toggle the gate with `/memory approval on\|off`. See [Controlling memory writes](/user-guide/features/memory#controlling-memory-writes-write_approval). | +| `/skills [pending\|approve\|reject\|diff\|approval]` | Review pending **skill** writes staged by the write-approval gate (`skills.write_approval`). Shows a one-line gist per staged write; `/skills diff ` is truncated for chat — read the full diff on the CLI or in `~/.hermes/pending/skills/.json`. Only appears when the gate is on (or staged writes remain); search/install stay CLI-only. | | `/kanban ` | Drive the multi-profile, multi-project collaboration board from chat — identical argument surface to the CLI. Bypasses the running-agent guard, so `/kanban unblock t_abc`, `/kanban comment t_abc "…"`, `/kanban list --mine`, `/kanban boards switch `, etc. work mid-turn. `/kanban create …` auto-subscribes the originating chat to the new task's terminal events. See [Kanban slash command](/user-guide/features/kanban#kanban-slash-command). | | `/reload-mcp` (alias: `/reload_mcp`) | Reload MCP servers from config. | | `/yolo` | Toggle YOLO mode — skip all dangerous command approval prompts. | @@ -236,7 +239,8 @@ The messaging gateway supports the following built-in commands inside Telegram, ## Notes -- `/skin`, `/snapshot`, `/gquota`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/skills`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/plugins`, `/busy`, `/indicator`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, and `/quit` are **CLI-only** commands. +- `/skin`, `/snapshot`, `/gquota`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/plugins`, `/busy`, `/indicator`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, and `/quit` are **CLI-only** commands. +- `/skills` is **CLI-only for search/browse/install**; its write-approval review subcommands (`pending`, `approve`, `reject`, `diff`, `approval`) also work on messaging platforms when `skills.write_approval` is on. `/memory` works on **both** surfaces. - `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config. - `/sethome`, `/update`, `/restart`, `/approve`, `/deny`, `/topic`, and `/commands` are **messaging-only** commands. - `/status`, `/version`, `/background`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/debug`, `/fast`, `/footer`, `/curator`, `/kanban`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway. diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 4b2d2c40e93..062eb53d344 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -533,6 +533,17 @@ skills: When on, any flagged `skill_manage` write surfaces as an approval prompt with the scanner's rationale. Accepted writes land; denied writes return an explanatory error to the agent. +### Write approval for skill writes + +Independent of the content scanner above, `skills.write_approval` gates **every** agent skill write (create / edit / patch / delete / supporting files) behind your explicit approval — the same approve/deny mechanism as dangerous commands: + +```yaml +skills: + write_approval: false # false = write freely (default) | true = stage every write for review +``` + +When on, skill writes are staged under `~/.hermes/pending/skills/` and reviewed with `/skills pending`, `/skills diff `, `/skills approve `, `/skills reject ` — from the CLI or any messaging platform. Toggle at runtime with `/skills approval on|off`. Memory has the same gate (`memory.write_approval`, below). Full walkthrough: [Gating agent skill writes](/user-guide/features/skills#gating-agent-skill-writes-skillswrite_approval). + ## Memory Configuration ```yaml @@ -541,8 +552,11 @@ memory: user_profile_enabled: true memory_char_limit: 2200 # ~800 tokens user_char_limit: 1375 # ~500 tokens + write_approval: false # true = require approval before any memory write ``` +With `memory.write_approval: true`, memory writes need your approval before they land: interactive CLI turns prompt inline; messaging sessions and the background self-improvement review stage the write for `/memory pending` → `/memory approve ` / `/memory reject ` review. Toggle at runtime with `/memory approval on|off`. See [Controlling memory writes](/user-guide/features/memory#controlling-memory-writes-write_approval). + ## File Read Safety Controls how much content a single `read_file` call can return. Reads that exceed the limit are rejected with an error telling the agent to use `offset` and `limit` for a smaller range. This prevents a single read of a minified JS bundle or large data file from flooding the context window. diff --git a/website/docs/user-guide/features/memory.md b/website/docs/user-guide/features/memory.md index b4814d2a178..1f0ee16942f 100644 --- a/website/docs/user-guide/features/memory.md +++ b/website/docs/user-guide/features/memory.md @@ -270,6 +270,7 @@ inline, but the full diff stays out-of-band: On a messaging platform, approve a skill from its gist + metadata, or open `/skills diff` on the CLI / dashboard / the staged file under `~/.hermes/pending/skills/.json` when you want to read the whole change. +Full details in [Gating agent skill writes](/user-guide/features/skills#gating-agent-skill-writes-skillswrite_approval). ## External Memory Providers diff --git a/website/docs/user-guide/features/skills.md b/website/docs/user-guide/features/skills.md index 8a5209c6944..6cfbafee3c3 100644 --- a/website/docs/user-guide/features/skills.md +++ b/website/docs/user-guide/features/skills.md @@ -401,6 +401,43 @@ The agent can create, update, and delete its own skills via the `skill_manage` t The `patch` action is preferred for updates — it's more token-efficient than `edit` because only the changed text appears in the tool call. ::: +### Gating agent skill writes (`skills.write_approval`) + +By default the agent writes skills freely — including from the [background +self-improvement review](/user-guide/features/memory#controlling-memory-writes-write_approval) +that runs after a turn. If you'd rather approve every skill write first +(small models that misjudge what they learned, secure environments, or just +wanting eyes on the self-improvement loop), turn on the write-approval gate: + +```yaml +skills: + write_approval: false # false = write freely (default) | true = require approval +``` + +When `write_approval: true`, every `skill_manage` write (create / edit / +patch / delete / write_file / remove_file) is **staged** instead of committed — +a SKILL.md is too large to review inline, so staging applies regardless of +whether the write came from a foreground turn or the background review. +Staged writes survive restarts under `~/.hermes/pending/skills/` and are +reviewed with the same familiar approve/deny flow as dangerous commands: + +``` +/skills pending # list staged skill writes + a one-line gist each +/skills diff # full unified diff (best viewed in CLI or dashboard) +/skills approve # apply it (or 'all') +/skills reject # drop it (or 'all') +/skills approval on # turn the gate on (or 'off') and persist it +``` + +The review surface works in the interactive CLI and on messaging platforms +(diff output is truncated for chat bubbles — read the full diff on the CLI or +in the pending JSON file). Memory writes have the same gate under +`memory.write_approval` — see [Controlling memory writes](/user-guide/features/memory#controlling-memory-writes-write_approval). + +> The separate `skills.guard_agent_created` setting is a content scanner +> (dangerous-pattern heuristics), not an approval gate — the two are +> independent. See [Guard on agent-created skill writes](/user-guide/configuration#guard-on-agent-created-skill-writes). + ## Skills Hub Browse, search, install, and manage skills from online registries, `skills.sh`, direct well-known skill endpoints, and official optional skills. From 18d61bd06e062049f524d6b1d3a51956db2cf1ea Mon Sep 17 00:00:00 2001 From: Roger Date: Wed, 10 Jun 2026 13:00:45 -0400 Subject: [PATCH 23/69] fix(desktop): persist composer drafts across reloads Save in-progress composer text to browser localStorage per chat session and restore it when the desktop composer remounts. Keep the draft when submit is rejected or throws, and clear it only after the prompt is accepted. --- apps/desktop/src/app/chat/composer/index.tsx | 54 ++++++++++++++++- apps/desktop/src/store/composer.test.ts | 42 ++++++++++++- apps/desktop/src/store/composer.ts | 62 ++++++++++++++++++++ 3 files changed, 154 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index bf948834837..05fa4a451bc 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -24,7 +24,14 @@ import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' -import { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer' +import { + $composerAttachments, + clearComposerAttachments, + clearPersistedComposerDraft, + type ComposerAttachment, + readPersistedComposerDraft, + writePersistedComposerDraft +} from '@/store/composer' import { browseBackward, browseForward, @@ -160,6 +167,7 @@ export function ChatBar({ const scrolledUp = useStore($threadScrolledUp) const sessionMessages = useStore($messages) const activeQueueSessionKey = queueSessionKey || sessionId || null + const draftPersistenceScope = activeQueueSessionKey || null const queuedPrompts = useMemo( () => (activeQueueSessionKey ? (queuedPromptsBySession[activeQueueSessionKey] ?? []) : []), @@ -171,6 +179,7 @@ export function ChatBar({ const editorRef = useRef(null) const draftRef = useRef(draft) const previousBusyRef = useRef(busy) + const skipNextDraftPersistScopeRef = useRef(null) const drainingQueueRef = useRef(false) const urlInputRef = useRef(null) @@ -1097,6 +1106,22 @@ export function ChatBar({ } } + useEffect(() => { + const persisted = readPersistedComposerDraft(draftPersistenceScope) + skipNextDraftPersistScopeRef.current = draftPersistenceScope + loadIntoComposer(persisted, []) + }, [draftPersistenceScope]) // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (skipNextDraftPersistScopeRef.current === draftPersistenceScope) { + skipNextDraftPersistScopeRef.current = null + + return + } + + writePersistedComposerDraft(draftPersistenceScope, draft) + }, [draft, draftPersistenceScope]) + const beginQueuedEdit = (entry: QueuedPromptEntry) => { if (!activeQueueSessionKey || queueEdit) { return @@ -1323,8 +1348,10 @@ export function ChatBar({ // input event; refresh it from the editor once more to also cover an // in-flight keystroke that hasn't fired its input event yet. const editor = editorRef.current + if (editor) { const domText = composerPlainText(editor) + if (domText !== draftRef.current) { draftRef.current = domText aui.composer().setText(domText) @@ -1348,7 +1375,17 @@ export function ChatBar({ const submitted = text triggerHaptic('submit') clearDraft() - void onSubmit(submitted) + void Promise.resolve(onSubmit(submitted)).then(accepted => { + if (accepted === false) { + loadIntoComposer(submitted, []) + writePersistedComposerDraft(draftPersistenceScope, submitted) + } else { + clearPersistedComposerDraft(draftPersistenceScope) + } + }).catch(() => { + loadIntoComposer(submitted, []) + writePersistedComposerDraft(draftPersistenceScope, submitted) + }) } else if (payloadPresent) { queueCurrentDraft() } else { @@ -1361,11 +1398,22 @@ export function ChatBar({ void drainNextQueued() } else if (payloadPresent) { const submitted = text + const submittedAttachments = cloneAttachments(attachments) triggerHaptic('submit') resetBrowseState(sessionId) clearDraft() clearComposerAttachments() - void onSubmit(submitted, { attachments }) + void Promise.resolve(onSubmit(submitted, { attachments: submittedAttachments })).then(accepted => { + if (accepted === false) { + loadIntoComposer(submitted, submittedAttachments) + writePersistedComposerDraft(draftPersistenceScope, submitted) + } else { + clearPersistedComposerDraft(draftPersistenceScope) + } + }).catch(() => { + loadIntoComposer(submitted, submittedAttachments) + writePersistedComposerDraft(draftPersistenceScope, submitted) + }) } focusInput() diff --git a/apps/desktop/src/store/composer.test.ts b/apps/desktop/src/store/composer.test.ts index 83f0a3feb96..7bb44c74bd0 100644 --- a/apps/desktop/src/store/composer.test.ts +++ b/apps/desktop/src/store/composer.test.ts @@ -3,9 +3,13 @@ import { afterEach, describe, expect, it } from 'vitest' import { $composerAttachments, addComposerAttachment, + clearPersistedComposerDraft, type ComposerAttachment, + composerDraftStorageKey, + readPersistedComposerDraft, removeComposerAttachment, - updateComposerAttachment + updateComposerAttachment, + writePersistedComposerDraft } from './composer' function attachment(overrides: Partial & Pick): ComposerAttachment { @@ -41,3 +45,39 @@ describe('updateComposerAttachment', () => { expect($composerAttachments.get()).toHaveLength(0) }) }) + +describe('persisted composer drafts', () => { + afterEach(() => { + window.localStorage.clear() + }) + + it('stores and restores text drafts per session scope', () => { + writePersistedComposerDraft('session-a', 'almost submitted prompt') + writePersistedComposerDraft('session-b', 'other draft') + + expect(readPersistedComposerDraft('session-a')).toBe('almost submitted prompt') + expect(readPersistedComposerDraft('session-b')).toBe('other draft') + }) + + it('uses a stable new-session key when no session id exists yet', () => { + writePersistedComposerDraft(null, 'first prompt draft') + + expect(window.localStorage.getItem(composerDraftStorageKey(null))).toBe('first prompt draft') + expect(readPersistedComposerDraft(undefined)).toBe('first prompt draft') + }) + + it('removes empty drafts instead of leaving stale text behind', () => { + writePersistedComposerDraft('session-a', 'saved') + writePersistedComposerDraft('session-a', '') + + expect(readPersistedComposerDraft('session-a')).toBe('') + expect(window.localStorage.getItem(composerDraftStorageKey('session-a'))).toBeNull() + }) + + it('can explicitly clear a saved draft after submit', () => { + writePersistedComposerDraft('session-a', 'saved') + clearPersistedComposerDraft('session-a') + + expect(readPersistedComposerDraft('session-a')).toBe('') + }) +}) diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts index 6b2b58ccb8d..5af98a49e3b 100644 --- a/apps/desktop/src/store/composer.ts +++ b/apps/desktop/src/store/composer.ts @@ -21,6 +21,68 @@ export const $composerDraft = atom('') export const $composerAttachments = atom([]) export const $composerTerminalSelections = atom>({}) +const COMPOSER_DRAFT_STORAGE_PREFIX = 'hermes:composer-draft:v1:' +const NEW_SESSION_DRAFT_SCOPE = '__new__' + +function storageScope(scope: string | null | undefined): string { + const trimmed = scope?.trim() + + return trimmed || NEW_SESSION_DRAFT_SCOPE +} + +function browserStorage(): Storage | null { + if (typeof window === 'undefined') { + return null + } + + try { + return window.localStorage + } catch { + return null + } +} + +export function composerDraftStorageKey(scope: string | null | undefined): string { + return `${COMPOSER_DRAFT_STORAGE_PREFIX}${encodeURIComponent(storageScope(scope))}` +} + +export function readPersistedComposerDraft(scope: string | null | undefined): string { + try { + return browserStorage()?.getItem(composerDraftStorageKey(scope)) ?? '' + } catch { + return '' + } +} + +export function writePersistedComposerDraft(scope: string | null | undefined, value: string) { + try { + const storage = browserStorage() + + if (!storage) { + return + } + + const key = composerDraftStorageKey(scope) + + if (value.length === 0) { + storage.removeItem(key) + } else { + storage.setItem(key, value) + } + } catch { + // Draft persistence is a safety net only; storage quota/private-mode errors + // must never break typing or submission. + } +} + +export function clearPersistedComposerDraft(scope: string | null | undefined) { + try { + browserStorage()?.removeItem(composerDraftStorageKey(scope)) + } catch { + // Best-effort only. + } +} + export function setComposerDraft(value: string) { $composerDraft.set(value) } From 3d14f01fd674ab2add062d3a302c30a6b457b791 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 10 Jun 2026 19:57:09 -0700 Subject: [PATCH 24/69] fix(desktop): debounce per-keystroke draft persistence writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The salvaged draft-persistence effect wrote to localStorage on every keystroke — the composer's per-keystroke path was deliberately slimmed down previously, so debounce the write (400ms) and flush pending text on scope change/unmount so a fast session switch can't drop trailing keystrokes. Also add AUTHOR_MAP entry for the salvaged commit. --- apps/desktop/src/app/chat/composer/index.tsx | 33 +++++++++++++++++++- scripts/release.py | 1 + 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 05fa4a451bc..5530ffb60ae 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -137,6 +137,10 @@ interface QueueEditState { const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a })) +// How long the composer waits after the last keystroke before persisting the +// draft to localStorage. Scope-change/unmount flushes bypass the delay. +const DRAFT_PERSIST_DEBOUNCE_MS = 400 + export function ChatBar({ busy, cwd, @@ -180,6 +184,7 @@ export function ChatBar({ const draftRef = useRef(draft) const previousBusyRef = useRef(busy) const skipNextDraftPersistScopeRef = useRef(null) + const pendingDraftPersistRef = useRef<{ scope: string | null; value: string } | null>(null) const drainingQueueRef = useRef(false) const urlInputRef = useRef(null) @@ -1119,9 +1124,35 @@ export function ChatBar({ return } - writePersistedComposerDraft(draftPersistenceScope, draft) + // Debounce the localStorage write: the composer's per-keystroke path was + // deliberately slimmed down (see the draftRef sync comment above), so we + // don't touch storage on every keypress. The pending ref below is flushed + // on scope change / unmount so a fast session switch can't drop the + // trailing keystrokes. + pendingDraftPersistRef.current = { scope: draftPersistenceScope, value: draft } + + const handle = window.setTimeout(() => { + pendingDraftPersistRef.current = null + writePersistedComposerDraft(draftPersistenceScope, draft) + }, DRAFT_PERSIST_DEBOUNCE_MS) + + return () => window.clearTimeout(handle) }, [draft, draftPersistenceScope]) + // Flush any pending debounced draft write when leaving a session scope or + // unmounting, so the departing session's latest text is always persisted. + useEffect( + () => () => { + const pending = pendingDraftPersistRef.current + + if (pending) { + pendingDraftPersistRef.current = null + writePersistedComposerDraft(pending.scope, pending.value) + } + }, + [draftPersistenceScope] + ) + const beginQueuedEdit = (entry: QueuedPromptEntry) => { if (!activeQueueSessionKey || queueEdit) { return diff --git a/scripts/release.py b/scripts/release.py index 68ad134d6cd..10ed7b658a2 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -76,6 +76,7 @@ AUTHOR_MAP = { "290859878+synapsesx@users.noreply.github.com": "synapsesx", "dirtyren@users.noreply.github.com": "dirtyren", "mharris@parallel.ai": "NormallyGaussian", + "roger@roger.local": "mollusk", "ted.malone@outlook.com": "temalo", "adityamalik2833@gmail.com": "alarcritty", "islam666@users.noreply.github.com": "islam666", From 914befa9aaae46f2709373bc3d7352e813a18c54 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:36:51 -0700 Subject: [PATCH 25/69] feat(dashboard): profile-scoped skills & toolsets management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Set as active' on the Profiles page only flips the sticky active_profile file (future CLI/gateway runs) — it never retargets the running dashboard process. The skills/toolsets endpoints called bare load_config()/ save_config(), so after 'activating' a profile in the web UI, deactivating a skill silently wrote into the dashboard's own profile and the activated profile was untouched. Backend: - _profile_scope() context manager on the skills/toolsets endpoints: context-local HERMES_HOME override for call-time config resolution + cron-style locked swap of tools.skills_tool's import-time SKILLS_DIR - profile param on /api/skills, /api/skills/toggle, /api/tools/toolsets* (list/toggle/config/provider/env), hub sources/search installed-state - hub install/uninstall/update spawn 'hermes -p skills ...' so the child rebinds skills_hub.SKILLS_DIR at import (the override cannot reach import-time globals); profile validated -> 404/400 before spawn Frontend: - Skills page: profile selector (deep-linkable /skills?profile=), amber banner naming the managed profile, threaded through skill toggles, toolset drawer, and hub browser - Profiles page: 'Manage skills & tools' action per card; 'Set as active' toast now says it applies to new CLI/gateway runs only Omitted profile keeps legacy behavior (dashboard's own profile). --- hermes_cli/web_server.py | 327 ++++++++++++------ .../test_web_server_skills_profiles.py | 210 +++++++++++ web/src/components/ToolsetConfigDrawer.tsx | 15 +- web/src/i18n/en.ts | 4 + web/src/i18n/types.ts | 6 + web/src/lib/api.ts | 66 ++-- web/src/pages/ProfilesPage.tsx | 34 +- web/src/pages/SkillsPage.tsx | 152 +++++++- 8 files changed, 662 insertions(+), 152 deletions(-) create mode 100644 tests/hermes_cli/test_web_server_skills_profiles.py diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index e1f1c62051d..f7cc695874a 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -9,7 +9,7 @@ Usage: python -m hermes_cli.main web --port 8080 """ -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, contextmanager import asyncio import base64 @@ -7373,6 +7373,24 @@ async def prune_checkpoints(): class SkillInstallRequest(BaseModel): identifier: str + profile: Optional[str] = None + + +def _profile_cli_args(profile: Optional[str]) -> List[str]: + """Return ``["-p", ]`` for a validated non-default profile. + + Hub install/uninstall/update run in a fresh ``hermes`` subprocess, and + ``_apply_profile_override()`` reads ``-p`` from argv in the child — the + only mechanism that reaches import-time-bound globals like + ``skills_hub.SKILLS_DIR``. Empty/"current" means the dashboard's own + profile (no args, legacy behavior). + """ + requested = (profile or "").strip() + if not requested or requested.lower() == "current": + return [] + from hermes_cli import profiles as profiles_mod + _resolve_profile_dir(requested) + return ["-p", profiles_mod.normalize_profile_name(requested)] @app.post("/api/skills/hub/install") @@ -7381,7 +7399,12 @@ async def install_skill_hub(body: SkillInstallRequest): if not identifier: raise HTTPException(status_code=400, detail="identifier is required") try: - proc = _spawn_hermes_action(["skills", "install", identifier], "skills-install") + proc = _spawn_hermes_action( + _profile_cli_args(body.profile) + ["skills", "install", identifier], + "skills-install", + ) + except HTTPException: + raise except Exception as exc: _log.exception("Failed to spawn skills install") raise HTTPException(status_code=500, detail=f"Failed to install skill: {exc}") @@ -7390,6 +7413,7 @@ async def install_skill_hub(body: SkillInstallRequest): class SkillUninstallRequest(BaseModel): name: str + profile: Optional[str] = None @app.post("/api/skills/hub/uninstall") @@ -7398,17 +7422,31 @@ async def uninstall_skill_hub(body: SkillUninstallRequest): if not name: raise HTTPException(status_code=400, detail="name is required") try: - proc = _spawn_hermes_action(["skills", "uninstall", name, "--yes"], "skills-uninstall") + proc = _spawn_hermes_action( + _profile_cli_args(body.profile) + ["skills", "uninstall", name, "--yes"], + "skills-uninstall", + ) + except HTTPException: + raise except Exception as exc: _log.exception("Failed to spawn skills uninstall") raise HTTPException(status_code=500, detail=f"Failed to uninstall skill: {exc}") return {"ok": True, "pid": proc.pid, "name": "skills-uninstall"} +class SkillsUpdateRequest(BaseModel): + profile: Optional[str] = None + + @app.post("/api/skills/hub/update") -async def update_skills_hub(): +async def update_skills_hub(body: Optional[SkillsUpdateRequest] = None): try: - proc = _spawn_hermes_action(["skills", "update"], "skills-update") + profile = body.profile if body else None + proc = _spawn_hermes_action( + _profile_cli_args(profile) + ["skills", "update"], "skills-update" + ) + except HTTPException: + raise except Exception as exc: _log.exception("Failed to spawn skills update") raise HTTPException(status_code=500, detail=f"Failed to update skills: {exc}") @@ -7443,17 +7481,25 @@ def _skill_meta_to_payload(m) -> dict: } -def _installed_hub_identifiers() -> dict: +def _installed_hub_identifiers(profile: Optional[str] = None) -> dict: """Map identifier -> installed lock entry for hub-installed skills. - Lets the UI mark search results that are already installed. Best-effort: - returns an empty dict if the lock file can't be read. + Lets the UI mark search results that are already installed. Scoped to + ``profile``'s skills/.hub/lock.json when provided (HubLockFile takes an + explicit path, sidestepping the import-time LOCK_FILE binding). + Best-effort: returns an empty dict if the lock file can't be read. """ try: from tools.skills_hub import HubLockFile + requested = (profile or "").strip() + if requested and requested.lower() != "current": + profile_dir = _resolve_profile_dir(requested) + lock = HubLockFile(profile_dir / "skills" / ".hub" / "lock.json") + else: + lock = HubLockFile() out = {} - for entry in HubLockFile().list_installed(): + for entry in lock.list_installed(): ident = entry.get("identifier") if ident: out[ident] = { @@ -7467,13 +7513,14 @@ def _installed_hub_identifiers() -> dict: @app.get("/api/skills/hub/sources") -async def list_skills_hub_sources(): +async def list_skills_hub_sources(profile: Optional[str] = None): """List the configured skill-hub sources and installed-skill provenance. Gives the dashboard something to show BEFORE a search runs — which hubs are wired up, their trust tier, and a set of featured skills pulled from the centralized index (zero extra API calls). Without this the Browse-hub tab is a blank page with no indication it's even connected to anything. + ``profile`` scopes the installed-skill provenance to that profile. """ def _run(): @@ -7514,18 +7561,22 @@ async def list_skills_hub_sources(): "sources": out, "index_available": index_available, "featured": featured, - "installed": _installed_hub_identifiers(), + "installed": _installed_hub_identifiers(profile), } try: return await asyncio.to_thread(_run) + except HTTPException: + raise except Exception as exc: _log.exception("skills hub sources listing failed") raise HTTPException(status_code=502, detail=f"Hub sources failed: {exc}") @app.get("/api/skills/hub/search") -async def search_skills_hub(q: str = "", source: str = "all", limit: int = 20): +async def search_skills_hub( + q: str = "", source: str = "all", limit: int = 20, profile: Optional[str] = None +): """Search the skill hub across all configured sources. Network-bound (parallel source search); runs in a thread so the FastAPI @@ -7560,11 +7611,13 @@ async def search_skills_hub(q: str = "", source: str = "all", limit: int = 20): "results": [_skill_meta_to_payload(m) for m in deduped], "source_counts": source_counts, "timed_out": timed_out, - "installed": _installed_hub_identifiers(), + "installed": _installed_hub_identifiers(profile), } try: return await asyncio.to_thread(_run) + except HTTPException: + raise except Exception as exc: _log.exception("skills hub search failed") raise HTTPException(status_code=502, detail=f"Hub search failed: {exc}") @@ -8333,21 +8386,75 @@ async def describe_profile_auto_endpoint(name: str, body: ProfileDescribeAuto): # --------------------------------------------------------------------------- # Skills & Tools endpoints +# +# Every read/write below accepts an optional ``profile`` query param so the +# dashboard can manage ANY profile's skills/toolsets, not just the profile +# the dashboard process happens to be running under. Without this, "Set as +# active" on the Profiles page (which only flips the sticky ``active_profile`` +# file for FUTURE CLI/gateway invocations) misled users into thinking skill +# toggles would land in the activated profile — they silently wrote into the +# dashboard's own config instead. See _profile_scope() for the mechanism. # --------------------------------------------------------------------------- +_SKILLS_PROFILE_LOCK = threading.RLock() + + +@contextmanager +def _profile_scope(profile: Optional[str]): + """Scope config + skill-directory resolution to ``profile`` for one request. + + Two seams must be redirected for skills/toolsets endpoints: + + 1. ``load_config``/``save_config`` resolve ``get_hermes_home()`` at call + time — the context-local override from ``set_hermes_home_override`` + reaches them (same pattern as ``_write_profile_model``). + 2. ``tools.skills_tool`` binds ``SKILLS_DIR`` at import time, so the + override CANNOT reach it. Like ``_call_cron_for_profile`` does for + cron's module globals, temporarily retarget it under a lock and + restore it immediately after. + + ``profile`` of None/""/"current" means "the dashboard's own profile" — + a no-op scope, preserving existing behavior for old clients. + """ + requested = (profile or "").strip() + if not requested or requested.lower() == "current": + yield None + return + + profile_dir = _resolve_profile_dir(requested) + + from hermes_constants import set_hermes_home_override, reset_hermes_home_override + from tools import skills_tool as _skills_tool + + token = set_hermes_home_override(str(profile_dir)) + with _SKILLS_PROFILE_LOCK: + old_home = _skills_tool.HERMES_HOME + old_skills_dir = _skills_tool.SKILLS_DIR + _skills_tool.HERMES_HOME = profile_dir + _skills_tool.SKILLS_DIR = profile_dir / "skills" + try: + yield profile_dir + finally: + _skills_tool.HERMES_HOME = old_home + _skills_tool.SKILLS_DIR = old_skills_dir + reset_hermes_home_override(token) + + class SkillToggle(BaseModel): name: str enabled: bool + profile: Optional[str] = None @app.get("/api/skills") -async def get_skills(): +async def get_skills(profile: Optional[str] = None): from tools.skills_tool import _find_all_skills from hermes_cli.skills_config import get_disabled_skills - config = load_config() - disabled = get_disabled_skills(config) - skills = _find_all_skills(skip_disabled=True) + with _profile_scope(profile): + config = load_config() + disabled = get_disabled_skills(config) + skills = _find_all_skills(skip_disabled=True) for s in skills: s["enabled"] = s["name"] not in disabled return skills @@ -8356,18 +8463,19 @@ async def get_skills(): @app.put("/api/skills/toggle") async def toggle_skill(body: SkillToggle): from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills - config = load_config() - disabled = get_disabled_skills(config) - if body.enabled: - disabled.discard(body.name) - else: - disabled.add(body.name) - save_disabled_skills(config, disabled) + with _profile_scope(body.profile): + config = load_config() + disabled = get_disabled_skills(config) + if body.enabled: + disabled.discard(body.name) + else: + disabled.add(body.name) + save_disabled_skills(config, disabled) return {"ok": True, "name": body.name, "enabled": body.enabled} @app.get("/api/tools/toolsets") -async def get_toolsets(): +async def get_toolsets(profile: Optional[str] = None): from hermes_cli.tools_config import ( _get_effective_configurable_toolsets, _get_platform_tools, @@ -8376,12 +8484,13 @@ async def get_toolsets(): ) from toolsets import resolve_toolset - config = load_config() - enabled_toolsets = _get_platform_tools( - config, - "cli", - include_default_mcp_servers=False, - ) + with _profile_scope(profile): + config = load_config() + enabled_toolsets = _get_platform_tools( + config, + "cli", + include_default_mcp_servers=False, + ) result = [] for name, label, desc in _get_effective_configurable_toolsets(): try: @@ -8403,6 +8512,7 @@ async def get_toolsets(): class ToolsetToggle(BaseModel): enabled: bool + profile: Optional[str] = None @app.put("/api/tools/toolsets/{name}") @@ -8411,7 +8521,8 @@ async def toggle_toolset(name: str, body: ToolsetToggle): Persists to ``platform_toolsets.cli`` via the same ``_save_platform_tools`` helper the CLI ``hermes tools`` picker uses, so the GUI and CLI stay in - lockstep. Returns 400 for unknown toolset keys. + lockstep. Scoped to ``body.profile`` when provided. Returns 400 for + unknown toolset keys. """ from hermes_cli.tools_config import ( _get_effective_configurable_toolsets, @@ -8423,20 +8534,21 @@ async def toggle_toolset(name: str, body: ToolsetToggle): if name not in valid: raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}") - config = load_config() - enabled = set( - _get_platform_tools(config, "cli", include_default_mcp_servers=False) - ) - if body.enabled: - enabled.add(name) - else: - enabled.discard(name) - _save_platform_tools(config, "cli", enabled) + with _profile_scope(body.profile): + config = load_config() + enabled = set( + _get_platform_tools(config, "cli", include_default_mcp_servers=False) + ) + if body.enabled: + enabled.add(name) + else: + enabled.discard(name) + _save_platform_tools(config, "cli", enabled) return {"ok": True, "name": name, "enabled": body.enabled} @app.get("/api/tools/toolsets/{name}/config") -async def get_toolset_config(name: str): +async def get_toolset_config(name: str, profile: Optional[str] = None): """Return the provider matrix + key status for a toolset's config panel. Surfaces the same provider rows the CLI ``hermes tools`` picker shows @@ -8457,38 +8569,39 @@ async def get_toolset_config(name: str): if name not in valid: raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}") - config = load_config() - cat = TOOL_CATEGORIES.get(name) - providers = [] - active_provider = None - if cat: - for prov in _visible_providers(cat, config, force_fresh=True): - env_vars = [ - { - "key": e["key"], - "prompt": e.get("prompt", e["key"]), - "url": e.get("url"), - "default": e.get("default"), - "is_set": bool(get_env_value(e["key"])), - } - for e in prov.get("env_vars", []) - ] - # Surface the same active-provider determination the CLI picker - # uses (``_is_provider_active``) so the GUI highlights the provider - # actually written to config (e.g. web.backend), not just the first - # keyless one in the list. - is_active = _is_provider_active(prov, config, force_fresh=True) - if is_active and active_provider is None: - active_provider = prov["name"] - providers.append({ - "name": prov["name"], - "badge": prov.get("badge", ""), - "tag": prov.get("tag", ""), - "env_vars": env_vars, - "post_setup": prov.get("post_setup"), - "requires_nous_auth": bool(prov.get("requires_nous_auth")), - "is_active": is_active, - }) + with _profile_scope(profile): + config = load_config() + cat = TOOL_CATEGORIES.get(name) + providers = [] + active_provider = None + if cat: + for prov in _visible_providers(cat, config, force_fresh=True): + env_vars = [ + { + "key": e["key"], + "prompt": e.get("prompt", e["key"]), + "url": e.get("url"), + "default": e.get("default"), + "is_set": bool(get_env_value(e["key"])), + } + for e in prov.get("env_vars", []) + ] + # Surface the same active-provider determination the CLI picker + # uses (``_is_provider_active``) so the GUI highlights the provider + # actually written to config (e.g. web.backend), not just the first + # keyless one in the list. + is_active = _is_provider_active(prov, config, force_fresh=True) + if is_active and active_provider is None: + active_provider = prov["name"] + providers.append({ + "name": prov["name"], + "badge": prov.get("badge", ""), + "tag": prov.get("tag", ""), + "env_vars": env_vars, + "post_setup": prov.get("post_setup"), + "requires_nous_auth": bool(prov.get("requires_nous_auth")), + "is_active": is_active, + }) return { "name": name, "has_category": cat is not None, @@ -8499,6 +8612,7 @@ async def get_toolset_config(name: str): class ToolsetProviderSelect(BaseModel): provider: str + profile: Optional[str] = None @app.put("/api/tools/toolsets/{name}/provider") @@ -8520,17 +8634,19 @@ async def select_toolset_provider(name: str, body: ToolsetProviderSelect): if name not in valid: raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}") - config = load_config() - try: - apply_provider_selection(name, body.provider, config) - except KeyError as exc: - raise HTTPException(status_code=400, detail=str(exc).strip('"')) - save_config(config) + with _profile_scope(body.profile): + config = load_config() + try: + apply_provider_selection(name, body.provider, config) + except KeyError as exc: + raise HTTPException(status_code=400, detail=str(exc).strip('"')) + save_config(config) return {"ok": True, "name": name, "provider": body.provider} class ToolsetEnvUpdate(BaseModel): env: Dict[str, str] + profile: Optional[str] = None @app.put("/api/tools/toolsets/{name}/env") @@ -8556,34 +8672,35 @@ async def save_toolset_env(name: str, body: ToolsetEnvUpdate): if name not in valid_ts: raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}") - config = load_config() - cat = TOOL_CATEGORIES.get(name) - allowed: set[str] = set() - if cat: - for prov in _visible_providers(cat, config, force_fresh=True): - for e in prov.get("env_vars", []): - allowed.add(e["key"]) + with _profile_scope(body.profile): + config = load_config() + cat = TOOL_CATEGORIES.get(name) + allowed: set[str] = set() + if cat: + for prov in _visible_providers(cat, config, force_fresh=True): + for e in prov.get("env_vars", []): + allowed.add(e["key"]) - unknown = [k for k in body.env if k not in allowed] - if unknown: - raise HTTPException( - status_code=400, - detail=f"Unknown env var(s) for toolset {name}: {', '.join(sorted(unknown))}", - ) + unknown = [k for k in body.env if k not in allowed] + if unknown: + raise HTTPException( + status_code=400, + detail=f"Unknown env var(s) for toolset {name}: {', '.join(sorted(unknown))}", + ) - saved: List[str] = [] - skipped: List[str] = [] - for key, value in body.env.items(): - if value and value.strip(): - try: - save_env_value(key, value.strip()) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) - saved.append(key) - else: - skipped.append(key) + saved: List[str] = [] + skipped: List[str] = [] + for key, value in body.env.items(): + if value and value.strip(): + try: + save_env_value(key, value.strip()) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + saved.append(key) + else: + skipped.append(key) - status = {k: bool(get_env_value(k)) for k in allowed} + status = {k: bool(get_env_value(k)) for k in allowed} return {"ok": True, "name": name, "saved": saved, "skipped": skipped, "is_set": status} diff --git a/tests/hermes_cli/test_web_server_skills_profiles.py b/tests/hermes_cli/test_web_server_skills_profiles.py new file mode 100644 index 00000000000..9a131bbb246 --- /dev/null +++ b/tests/hermes_cli/test_web_server_skills_profiles.py @@ -0,0 +1,210 @@ +"""Regression tests for dashboard profile-scoped skills/toolsets management. + +"Set as active" on the Profiles page only flips the sticky ``active_profile`` +file (future CLI/gateway runs) — it never retargets the running dashboard +process. Before the ``profile`` parameter existed, toggling a skill after +"activating" a profile silently wrote into the dashboard's own config. +These tests pin the new behavior: reads and writes land in the REQUESTED +profile's HERMES_HOME, and the dashboard's own profile stays untouched. +""" +import pytest +import yaml + + +def _write_skill(skills_dir, name, description="test skill"): + d = skills_dir / name + d.mkdir(parents=True, exist_ok=True) + (d / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n", + encoding="utf-8", + ) + + +@pytest.fixture +def isolated_profiles(tmp_path, monkeypatch, _isolate_hermes_home): + """Isolated default home + one named profile, each with its own skills.""" + from hermes_constants import get_hermes_home + from hermes_cli import profiles + + default_home = get_hermes_home() + profiles_root = default_home / "profiles" + worker_home = profiles_root / "worker_alpha" + for home in (default_home, worker_home): + (home / "skills").mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text("{}\n", encoding="utf-8") + + _write_skill(default_home / "skills", "dashboard-skill") + _write_skill(worker_home / "skills", "worker-skill") + + monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home) + monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root) + return {"default": default_home, "worker_alpha": worker_home} + + +@pytest.fixture +def client(monkeypatch, isolated_profiles): + try: + from starlette.testclient import TestClient + except ImportError: + pytest.skip("fastapi/starlette not installed") + + import hermes_state + from hermes_constants import get_hermes_home + from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN + + monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db") + c = TestClient(app) + c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN + return c + + +def _load_cfg(home): + return yaml.safe_load((home / "config.yaml").read_text()) or {} + + +class TestProfileScopedSkills: + def test_skills_list_scopes_to_requested_profile(self, client, isolated_profiles): + resp = client.get("/api/skills", params={"profile": "worker_alpha"}) + assert resp.status_code == 200 + names = {s["name"] for s in resp.json()} + assert "worker-skill" in names + assert "dashboard-skill" not in names + + def test_skills_list_without_profile_uses_dashboard_home( + self, client, isolated_profiles + ): + resp = client.get("/api/skills") + assert resp.status_code == 200 + names = {s["name"] for s in resp.json()} + assert "dashboard-skill" in names + assert "worker-skill" not in names + + def test_toggle_writes_into_target_profile_only(self, client, isolated_profiles): + resp = client.put( + "/api/skills/toggle", + json={"name": "worker-skill", "enabled": False, "profile": "worker_alpha"}, + ) + assert resp.status_code == 200 + assert resp.json() == {"ok": True, "name": "worker-skill", "enabled": False} + + worker_cfg = _load_cfg(isolated_profiles["worker_alpha"]) + assert "worker-skill" in worker_cfg.get("skills", {}).get("disabled", []) + # The dashboard's own config must stay untouched — this was the bug. + default_cfg = _load_cfg(isolated_profiles["default"]) + assert "worker-skill" not in default_cfg.get("skills", {}).get("disabled", []) + + def test_toggle_reenable_round_trip(self, client, isolated_profiles): + for enabled in (False, True): + client.put( + "/api/skills/toggle", + json={ + "name": "worker-skill", + "enabled": enabled, + "profile": "worker_alpha", + }, + ) + worker_cfg = _load_cfg(isolated_profiles["worker_alpha"]) + assert "worker-skill" not in worker_cfg.get("skills", {}).get("disabled", []) + + def test_unknown_profile_returns_404(self, client, isolated_profiles): + resp = client.get("/api/skills", params={"profile": "no_such_profile"}) + assert resp.status_code == 404 + + def test_invalid_profile_name_returns_400(self, client, isolated_profiles): + resp = client.get("/api/skills", params={"profile": "Bad Name!"}) + assert resp.status_code == 400 + + def test_scope_restores_module_globals(self, client, isolated_profiles): + """The SKILLS_DIR swap is per-request; the module global must be + restored even after a scoped call (cron-style locked swap).""" + import tools.skills_tool as skills_tool + + before = skills_tool.SKILLS_DIR + client.get("/api/skills", params={"profile": "worker_alpha"}) + assert skills_tool.SKILLS_DIR == before + + +class TestProfileScopedToolsets: + def test_toolset_toggle_scopes_to_profile(self, client, isolated_profiles): + resp = client.put( + "/api/tools/toolsets/x_search", + json={"enabled": True, "profile": "worker_alpha"}, + ) + assert resp.status_code == 200 + + worker_cfg = _load_cfg(isolated_profiles["worker_alpha"]) + assert "x_search" in worker_cfg.get("platform_toolsets", {}).get("cli", []) + default_cfg = _load_cfg(isolated_profiles["default"]) + assert "x_search" not in default_cfg.get("platform_toolsets", {}).get("cli", []) + + listing = client.get( + "/api/tools/toolsets", params={"profile": "worker_alpha"} + ).json() + assert {t["name"]: t for t in listing}["x_search"]["enabled"] is True + # Unscoped listing reflects the dashboard's own (untouched) config. + listing = client.get("/api/tools/toolsets").json() + assert {t["name"]: t for t in listing}["x_search"]["enabled"] is False + + def test_toolset_toggle_unknown_profile_404(self, client, isolated_profiles): + resp = client.put( + "/api/tools/toolsets/x_search", + json={"enabled": True, "profile": "ghost"}, + ) + assert resp.status_code == 404 + + +class TestProfileScopedHubActions: + def test_hub_install_spawns_with_profile_flag( + self, client, isolated_profiles, monkeypatch + ): + """Hub installs must go through a fresh ``hermes -p `` + subprocess — the in-process scope can't reach skills_hub's + import-time SKILLS_DIR binding.""" + import hermes_cli.web_server as web_server + + calls = [] + + class _FakeProc: + pid = 4242 + + def _fake_spawn(subcommand, name): + calls.append((list(subcommand), name)) + return _FakeProc() + + monkeypatch.setattr(web_server, "_spawn_hermes_action", _fake_spawn) + resp = client.post( + "/api/skills/hub/install", + json={"identifier": "official/demo", "profile": "worker_alpha"}, + ) + assert resp.status_code == 200 + assert calls == [ + (["-p", "worker_alpha", "skills", "install", "official/demo"], "skills-install") + ] + + def test_hub_install_without_profile_keeps_legacy_argv( + self, client, isolated_profiles, monkeypatch + ): + import hermes_cli.web_server as web_server + + calls = [] + + class _FakeProc: + pid = 4242 + + monkeypatch.setattr( + web_server, + "_spawn_hermes_action", + lambda subcommand, name: calls.append(list(subcommand)) or _FakeProc(), + ) + resp = client.post( + "/api/skills/hub/install", json={"identifier": "official/demo"} + ) + assert resp.status_code == 200 + assert calls == [["skills", "install", "official/demo"]] + + def test_hub_install_unknown_profile_404(self, client, isolated_profiles): + resp = client.post( + "/api/skills/hub/install", + json={"identifier": "official/demo", "profile": "ghost"}, + ) + assert resp.status_code == 404 diff --git a/web/src/components/ToolsetConfigDrawer.tsx b/web/src/components/ToolsetConfigDrawer.tsx index 42e58d589f5..5bbcba61866 100644 --- a/web/src/components/ToolsetConfigDrawer.tsx +++ b/web/src/components/ToolsetConfigDrawer.tsx @@ -20,6 +20,9 @@ import { cn, themedBody } from "@/lib/utils"; interface Props { /** The toolset whose backends are being configured. */ toolset: ToolsetInfo; + /** Optional profile to scope config reads/writes to (Skills page profile + * selector). Omitted = the dashboard process's own profile. */ + profile?: string; onClose: () => void; /** Called after a toggle/provider/key change so the parent grid refreshes. */ onChanged: () => void; @@ -31,7 +34,7 @@ interface Props { * the toolset on/off, pick a provider, enter API keys, and run a provider's * post-setup install hook (npm/pip/binary) with a live log tail. */ -export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) { +export function ToolsetConfigDrawer({ toolset, profile, onClose, onChanged }: Props) { const { toast, showToast } = useToast(); const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); @@ -60,7 +63,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) { // react-hooks/set-state-in-effect — setState only fires inside the // async .then/.catch/.finally callbacks. return api - .getToolsetConfig(toolset.name) + .getToolsetConfig(toolset.name, profile) .then((cfg) => { setConfig(cfg); setActiveProvider(cfg.active_provider); @@ -72,7 +75,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) { }) .catch(() => showToast("Failed to load toolset config", "error")) .finally(() => setLoading(false)); - }, [toolset.name, showToast]); + }, [toolset.name, profile, showToast]); useEffect(() => { void loadConfig(); @@ -121,7 +124,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) { const handleToggle = async (next: boolean) => { setToggling(true); try { - await api.toggleToolset(toolset.name, next); + await api.toggleToolset(toolset.name, next, profile); setEnabled(next); showToast( `${toolset.label || toolset.name} ${next ? "enabled" : "disabled"}`, @@ -138,7 +141,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) { const handleSelectProvider = async (provider: ToolsetProvider) => { setSelecting(provider.name); try { - await api.selectToolsetProvider(toolset.name, provider.name); + await api.selectToolsetProvider(toolset.name, provider.name, profile); setActiveProvider(provider.name); showToast(`Provider set to ${provider.name}`, "success"); onChanged(); @@ -164,7 +167,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) { } setSavingProvider(provider.name); try { - const res = await api.saveToolsetEnv(toolset.name, env); + const res = await api.saveToolsetEnv(toolset.name, env, profile); setIsSet((prev) => ({ ...prev, ...res.is_set })); // Clear saved drafts so the inputs reset to the "saved" placeholder. setDrafts((prev) => { diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 4f593487fd3..39cf80d6995 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -408,6 +408,10 @@ export const en: Translations = { setupNeeded: "Setup needed", disabledForCli: "Disabled for CLI", more: "+{count} more", + profileSelector: "Profile", + currentProfile: "current ({name})", + managingProfile: + "Managing profile \u201c{name}\u201d — toggles apply to that profile, not this dashboard\u2019s.", }, config: { diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index 14bc41f2d08..cac5688bdc6 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -404,6 +404,8 @@ export interface Translations { modelSaved?: string; modelSelect?: string; actions?: string; + manageSkills?: string; + activeSetHint?: string; }; // ── Skills page ── @@ -425,6 +427,10 @@ export interface Translations { setupNeeded: string; disabledForCli: string; more: string; + /** Optional — fall back to English literals until translated. */ + profileSelector?: string; + currentProfile?: string; + managingProfile?: string; }; // ── Config page ── diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 37a8f15eba2..7cde6eb78f9 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -249,6 +249,14 @@ export async function buildWsUrl( return `${proto}//${window.location.host}${BASE}${path}?${qs}`; } +/** Build a ``?profile=`` query suffix, or "" when unset. + * + * Used by the skills/toolsets endpoints so the dashboard can manage a + * profile other than the one the server process runs under. */ +function profileQuery(profile?: string): string { + return profile ? `?profile=${encodeURIComponent(profile)}` : ""; +} + export const api = { getStatus: () => fetchJSON("/api/status"), /** @@ -542,43 +550,49 @@ export const api = { ), // Skills & Toolsets - getSkills: () => fetchJSON("/api/skills"), - toggleSkill: (name: string, enabled: boolean) => + // + // All calls accept an optional ``profile`` so the Skills page can manage + // any profile's skills/toolsets — not just the one the dashboard process + // runs under. Omitted/empty profile = the dashboard's own profile. + getSkills: (profile?: string) => + fetchJSON(`/api/skills${profileQuery(profile)}`), + toggleSkill: (name: string, enabled: boolean, profile?: string) => fetchJSON<{ ok: boolean }>("/api/skills/toggle", { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name, enabled }), + body: JSON.stringify({ name, enabled, profile: profile || undefined }), }), - getToolsets: () => fetchJSON("/api/tools/toolsets"), - toggleToolset: (name: string, enabled: boolean) => + getToolsets: (profile?: string) => + fetchJSON(`/api/tools/toolsets${profileQuery(profile)}`), + toggleToolset: (name: string, enabled: boolean, profile?: string) => fetchJSON<{ ok: boolean; name: string; enabled: boolean }>( `/api/tools/toolsets/${encodeURIComponent(name)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enabled }), + body: JSON.stringify({ enabled, profile: profile || undefined }), }, ), - getToolsetConfig: (name: string) => + getToolsetConfig: (name: string, profile?: string) => fetchJSON( - `/api/tools/toolsets/${encodeURIComponent(name)}/config`, + `/api/tools/toolsets/${encodeURIComponent(name)}/config${profileQuery(profile)}`, ), - selectToolsetProvider: (name: string, provider: string) => + selectToolsetProvider: (name: string, provider: string, profile?: string) => fetchJSON<{ ok: boolean; name: string; provider: string }>( `/api/tools/toolsets/${encodeURIComponent(name)}/provider`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider }), + body: JSON.stringify({ provider, profile: profile || undefined }), }, ), - saveToolsetEnv: (name: string, env: Record) => + saveToolsetEnv: (name: string, env: Record, profile?: string) => fetchJSON( `/api/tools/toolsets/${encodeURIComponent(name)}/env`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ env }), + body: JSON.stringify({ env, profile: profile || undefined }), }, ), runToolsetPostSetup: (name: string, key: string) => @@ -986,26 +1000,34 @@ export const api = { fetchJSON("/api/ops/checkpoints/prune", { method: "POST" }), // ── Admin: Skills hub ─────────────────────────────────────────────── - installSkillFromHub: (identifier: string) => + // ``profile`` scopes install/uninstall/update and the installed-state + // annotations to that profile (omitted = the dashboard's own profile). + installSkillFromHub: (identifier: string, profile?: string) => fetchJSON("/api/skills/hub/install", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ identifier }), + body: JSON.stringify({ identifier, profile: profile || undefined }), }), - uninstallSkillFromHub: (name: string) => + uninstallSkillFromHub: (name: string, profile?: string) => fetchJSON("/api/skills/hub/uninstall", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name }), + body: JSON.stringify({ name, profile: profile || undefined }), }), - updateSkillsFromHub: () => - fetchJSON("/api/skills/hub/update", { method: "POST" }), - searchSkillsHub: (q: string, source = "all", limit = 20) => + updateSkillsFromHub: (profile?: string) => + fetchJSON("/api/skills/hub/update", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile: profile || undefined }), + }), + searchSkillsHub: (q: string, source = "all", limit = 20, profile?: string) => fetchJSON( - `/api/skills/hub/search?q=${encodeURIComponent(q)}&source=${encodeURIComponent(source)}&limit=${limit}`, + `/api/skills/hub/search?q=${encodeURIComponent(q)}&source=${encodeURIComponent(source)}&limit=${limit}${profile ? `&profile=${encodeURIComponent(profile)}` : ""}`, + ), + getSkillHubSources: (profile?: string) => + fetchJSON( + `/api/skills/hub/sources${profileQuery(profile)}`, ), - getSkillHubSources: () => - fetchJSON("/api/skills/hub/sources"), previewSkillFromHub: (identifier: string) => fetchJSON( `/api/skills/hub/preview?identifier=${encodeURIComponent(identifier)}`, diff --git a/web/src/pages/ProfilesPage.tsx b/web/src/pages/ProfilesPage.tsx index bda2515528f..a1e69bfbcc8 100644 --- a/web/src/pages/ProfilesPage.tsx +++ b/web/src/pages/ProfilesPage.tsx @@ -22,6 +22,7 @@ import { X, } from "lucide-react"; import spinners from "unicode-animations"; +import { useNavigate } from "react-router-dom"; import { H2 } from "@nous-research/ui/ui/components/typography/h2"; import { api } from "@/lib/api"; import type { ActiveProfileInfo, ProfileInfo } from "@/lib/api"; @@ -96,6 +97,7 @@ function ProfileActionsMenu({ onEditDescription, onEditModel, onEditSoul, + onManageSkills, onRename, onSetActive, }: ProfileActionsMenuProps) { @@ -201,6 +203,16 @@ function ProfileActionsMenu({ {labels.editSoul} + + + + + )} + + {restartMessage && !restartNeeded && ( + + + + {restartMessage} + + + )} + + {restartNeeded && ( + + +
+ + + {restartError ?? + "Webhooks are enabled, but the gateway still needs a restart before the receiver can come online."}
+
)} @@ -400,8 +530,8 @@ export default function WebhooksPage() {

- Disabled webhooks reject incoming events; the gateway hot-reloads - changes (no restart needed). + Subscription changes hot-reload once the webhook receiver is running. + Disabled subscriptions reject incoming events.

{subscriptions.length === 0 && ( From 0b5b7ddfd27e65d0fc5ff4fd4429f49be19c77b9 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Mon, 30 Mar 2026 23:46:32 -0700 Subject: [PATCH 42/69] fix(cli): show quick commands in /help output User-defined quick_commands from config.yaml now appear in the /help output under a "Quick Commands" section, between skill commands and tips. Fixes https://github.com/NousResearch/hermes-agent/issues/4090 Co-Authored-By: Claude Opus 4.6 (1M context) --- cli.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cli.py b/cli.py index 126d0a1038a..b2d0316c1f7 100644 --- a/cli.py +++ b/cli.py @@ -5552,6 +5552,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): f"{_escape(desc)} [dim]({skill_count} skills)[/]" ) + quick_commands = self.config.get("quick_commands", {}) + if quick_commands: + _cprint(f"\n ⚡ {_BOLD}Quick Commands{_RST} ({len(quick_commands)} configured):") + for name, qcmd in sorted(quick_commands.items()): + desc = qcmd.get("description", qcmd.get("type", "")) + ChatConsole().print( + f" [bold {_accent_hex()}]{('/' + name):<22}[/] [dim]-[/] {_escape(desc)}" + ) + _cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}") _cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}") _cprint(f" {_DIM}Draft editor: Ctrl+G (Alt+G in VSCode/Cursor){_RST}") From 2450fd7066dd90302e6c19ed53db6b60723bf72c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:56:17 -0700 Subject: [PATCH 43/69] chore: add mvanhorn to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index cd0fe475d5d..dc3521d41a2 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -75,6 +75,7 @@ AUTHOR_MAP = { "129007007+HeLLGURD@users.noreply.github.com": "HeLLGURD", "290859878+synapsesx@users.noreply.github.com": "synapsesx", "dirtyren@users.noreply.github.com": "dirtyren", + "mvanhorn@MacBook-Pro.local": "mvanhorn", "ted.malone@outlook.com": "temalo", "adityamalik2833@gmail.com": "alarcritty", "islam666@users.noreply.github.com": "islam666", From f38f7a387013a3191b0eab37ac47f96ee07ee7b3 Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:45:08 +0530 Subject: [PATCH 44/69] fix(desktop): recover stale session before stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop already recovers from a stale runtime session id when `prompt.submit` returns `session not found` after a gateway restart or sleep/wake. The stop path did not have the same recovery: `cancelRun` called `session.interrupt` once with the stale runtime id, then surfaced `Stop failed / session not found`. This makes stop/cancel mirror the prompt recovery path. If `session.interrupt` reports `session not found` and the selected stored session id is available, Desktop resumes that durable session, updates the active runtime ref with the recovered id, and retries `session.interrupt` once against the recovered runtime id. Salvaged from #43941 — rebased onto current main, dropping the unrelated `package-lock.json` (@types/node 24.13.1->24.13.2) and `nix/lib.nix` hash churn. That bump is a local npm 11 re-resolution artifact, not a CI requirement: repo CI runs node 22 (npm 10) and main is green at @types/node 24.13.1, so the lockfile and nix hash do not need to change. Co-authored-by: helix4u <4317663+helix4u@users.noreply.github.com> --- .../session/hooks/use-prompt-actions.test.tsx | 47 +++++++++++++++++-- .../app/session/hooks/use-prompt-actions.ts | 42 +++++++++++++++-- 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx index 3418e0bad80..e7dfe9d7da5 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx @@ -42,6 +42,7 @@ function sessionInfo(overrides: Partial = {}): SessionInfo { } interface HarnessHandle { + cancelRun: () => Promise steerPrompt: (text: string) => Promise submitText: ( text: string, @@ -102,8 +103,12 @@ function Harness({ }) useEffect(() => { - onReady({ steerPrompt: actions.steerPrompt, submitText: actions.submitText }) - }, [actions.steerPrompt, actions.submitText, onReady]) + onReady({ + cancelRun: actions.cancelRun, + steerPrompt: actions.steerPrompt, + submitText: actions.submitText + }) + }, [actions.cancelRun, actions.steerPrompt, actions.submitText, onReady]) return null } @@ -629,6 +634,43 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' }) }) + it('resumes the stored session and retries once when session.interrupt reports "session not found"', async () => { + const calls: { method: string; params?: Record }[] = [] + let interruptAttempts = 0 + const requestGateway = vi.fn(async (method: string, params?: Record) => { + calls.push({ method, params }) + if (method === 'session.interrupt') { + interruptAttempts += 1 + if (interruptAttempts === 1) { + throw new Error('session not found') + } + return {} as never + } + if (method === 'session.resume') { + return { session_id: RECOVERED_SESSION_ID } as never + } + return {} as never + }) + + let handle: HarnessHandle | null = null + render( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + storedSessionId={STORED_SESSION_ID} + /> + ) + await waitFor(() => expect(handle).not.toBeNull()) + + await handle!.cancelRun() + + expect(calls.map(c => c.method)).toEqual(['session.interrupt', 'session.resume', 'session.interrupt']) + expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID }) + expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID }) + expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID }) + }) + it('surfaces the original error (no resume) when the failure is not "session not found"', async () => { const calls: string[] = [] const states: Record[] = [] @@ -818,4 +860,3 @@ describe('uploadComposerAttachment remote read failures', () => { ).rejects.toThrow('ENOENT: no such file') }) }) - diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index 15831bb4189..b09d86ffd10 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -108,6 +108,12 @@ function inlineErrorMessage(error: unknown, fallback: string): string { return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim() } +function isSessionNotFoundError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + + return /session not found/i.test(message) +} + function base64FromDataUrl(dataUrl: string): string { const comma = dataUrl.indexOf(',') @@ -661,9 +667,7 @@ export function usePromptActions({ try { await requestGateway('prompt.submit', { session_id: sessionId, text }) } catch (firstErr) { - const firstMsg = firstErr instanceof Error ? firstErr.message : String(firstErr) - - if (/session not found/i.test(firstMsg) && selectedStoredSessionIdRef.current) { + if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) { // Re-register the session in the gateway and get a fresh live ID. const resumed = await requestGateway<{ session_id: string }>('session.resume', { session_id: selectedStoredSessionIdRef.current @@ -1273,11 +1277,39 @@ export function usePromptActions({ try { await requestGateway('session.interrupt', { session_id: sessionId }) } catch (err) { + let stopError = err + + if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) { + try { + const resumed = await requestGateway<{ session_id: string }>('session.resume', { + session_id: selectedStoredSessionIdRef.current + }) + const recoveredId = resumed?.session_id + + if (recoveredId) { + activeSessionIdRef.current = recoveredId + await requestGateway('session.interrupt', { session_id: recoveredId }) + + return + } + } catch (resumeErr) { + stopError = resumeErr + } + } + setMutableRef(busyRef, false) setBusy(false) - notifyError(err, copy.stopFailed) + notifyError(stopError, copy.stopFailed) } - }, [activeSessionId, activeSessionIdRef, busyRef, copy.stopFailed, requestGateway, updateSessionState]) + }, [ + activeSessionId, + activeSessionIdRef, + busyRef, + copy.stopFailed, + requestGateway, + selectedStoredSessionIdRef, + updateSessionState + ]) // Steer = nudge the live turn without interrupting: the gateway appends the // text to the next tool result so the model reads it on its next iteration From 264ac72b676b634c0f63b26af53c90205121bffd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BC=AC=E5=90=9B=E5=A4=8F=E7=BA=AA?= <470766206@qq.com> Date: Sun, 7 Jun 2026 20:49:36 +0800 Subject: [PATCH 45/69] fix(gateway,windows): preserve restart watcher env --- gateway/run.py | 72 +++++++++++++++++++++++++++++ tests/gateway/test_restart_drain.py | 64 +++++++++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index 708106ad8c8..ff82fe43582 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -32,6 +32,7 @@ import logging import os import re import shlex +import site import sys import signal import tempfile @@ -135,6 +136,60 @@ _GATEWAY_SECRET_PATTERNS = ( ) +def _ensure_windows_gateway_venv_imports() -> None: + """Make detached Windows gateway runs see the Hermes venv packages. + + Some Windows restart paths run the gateway under uv's base ``pythonw.exe`` + to avoid the venv launcher respawning a visible console interpreter. That + mode can import the source tree via cwd/PYTHONPATH but still miss optional + packages installed only in ``venv/Lib/site-packages`` (notably the MCP SDK). + Patch the live process before MCP discovery so tool injection does not + depend on every launcher preserving PYTHONPATH perfectly. + """ + if sys.platform != "win32": + return + + project_root = Path(__file__).resolve().parent.parent + candidates: list[Path] = [] + if os.environ.get("VIRTUAL_ENV"): + candidates.append(Path(os.environ["VIRTUAL_ENV"])) + candidates.append(project_root / "venv") + + seen: set[str] = set() + for venv_dir in candidates: + try: + resolved_venv = venv_dir.resolve() + except OSError: + resolved_venv = venv_dir + venv_key = str(resolved_venv).lower() + if venv_key in seen: + continue + seen.add(venv_key) + + site_packages = resolved_venv / "Lib" / "site-packages" + if not site_packages.exists(): + continue + + project_entry = str(project_root) + site_entry = str(site_packages) + if project_entry not in sys.path: + sys.path.insert(0, project_entry) + # addsitepackages() semantics matter here: pywin32, used by the MCP + # SDK on Windows, relies on .pth processing to expose pywintypes. + site.addsitedir(site_entry) + if site_entry in sys.path: + sys.path.remove(site_entry) + insert_at = 1 if sys.path and sys.path[0] == project_entry else 0 + sys.path.insert(insert_at, site_entry) + + os.environ["VIRTUAL_ENV"] = str(resolved_venv) + pythonpath = [project_entry, site_entry] + if os.environ.get("PYTHONPATH"): + pythonpath.append(os.environ["PYTHONPATH"]) + os.environ["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(pythonpath)) + return + + def _gateway_platform_value(platform: Any) -> str: """Return a normalized gateway platform value for enums or raw strings.""" return str(getattr(platform, "value", platform) or "").strip().lower() @@ -4255,10 +4310,25 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ) """ ).strip() + watcher_env = os.environ.copy() + # This watcher is intentionally outside the running gateway. If it + # inherits the gateway marker, `hermes gateway restart` refuses to + # run as a self-restart loop guard and the gateway stays stopped. + watcher_env.pop("_HERMES_GATEWAY", None) + project_root = Path(__file__).resolve().parent.parent + venv_dir = Path(watcher_env.get("VIRTUAL_ENV") or project_root / "venv") + site_packages = venv_dir / "Lib" / "site-packages" + if site_packages.exists(): + watcher_env["VIRTUAL_ENV"] = str(venv_dir) + pythonpath = [str(project_root), str(site_packages)] + if watcher_env.get("PYTHONPATH"): + pythonpath.append(watcher_env["PYTHONPATH"]) + watcher_env["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(pythonpath)) subprocess.Popen( [sys.executable, "-c", watcher, str(current_pid), *cmd_argv], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + env=watcher_env, **windows_detach_popen_kwargs(), ) return @@ -15910,6 +15980,8 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = atexit.register(remove_pid_file) atexit.register(release_gateway_runtime_lock) + _ensure_windows_gateway_venv_imports() + # MCP tool discovery — run in an executor so the asyncio event loop # stays responsive even when a configured MCP server is slow or # unreachable. discover_mcp_tools() uses a blocking 120s wait diff --git a/tests/gateway/test_restart_drain.py b/tests/gateway/test_restart_drain.py index a48e5f73781..32710fdb897 100644 --- a/tests/gateway/test_restart_drain.py +++ b/tests/gateway/test_restart_drain.py @@ -197,6 +197,7 @@ async def test_launch_detached_restart_command_uses_setsid(monkeypatch): runner, _adapter = make_restart_runner() popen_calls = [] + monkeypatch.setattr(gateway_run.sys, "platform", "linux") monkeypatch.setattr(gateway_run, "_resolve_hermes_bin", lambda: ["/usr/bin/hermes"]) monkeypatch.setattr(gateway_run.os, "getpid", lambda: 321) monkeypatch.setattr(shutil, "which", lambda cmd: "/usr/bin/setsid" if cmd == "setsid" else None) @@ -219,6 +220,69 @@ async def test_launch_detached_restart_command_uses_setsid(monkeypatch): assert kwargs["stderr"] is subprocess.DEVNULL +def test_windows_gateway_venv_imports_add_site_packages(monkeypatch, tmp_path): + venv_dir = tmp_path / "venv" + site_packages = venv_dir / "Lib" / "site-packages" + pth_extra = tmp_path / "pywin32_system32" + site_packages.mkdir(parents=True) + pth_extra.mkdir() + (site_packages / "pywin32.pth").write_text(str(pth_extra), encoding="utf-8") + project_root = str(gateway_run.Path(gateway_run.__file__).resolve().parent.parent) + + monkeypatch.setattr(gateway_run.sys, "platform", "win32") + monkeypatch.setattr(gateway_run.sys, "path", ["existing"]) + monkeypatch.setenv("VIRTUAL_ENV", str(venv_dir)) + monkeypatch.setenv("PYTHONPATH", "already-there") + + gateway_run._ensure_windows_gateway_venv_imports() + + assert gateway_run.sys.path[:2] == [project_root, str(site_packages)] + assert str(pth_extra) in gateway_run.sys.path + assert gateway_run.os.environ["VIRTUAL_ENV"] == str(venv_dir.resolve()) + pythonpath = gateway_run.os.environ["PYTHONPATH"].split(gateway_run.os.pathsep) + assert pythonpath[:3] == [project_root, str(site_packages), "already-there"] + + +@pytest.mark.asyncio +async def test_windows_detached_restart_scrubs_gateway_marker(monkeypatch, tmp_path): + runner, _adapter = make_restart_runner() + popen_calls = [] + venv_dir = tmp_path / "venv" + site_packages = venv_dir / "Lib" / "site-packages" + site_packages.mkdir(parents=True) + + monkeypatch.setattr(gateway_run.sys, "platform", "win32") + monkeypatch.setattr(gateway_run, "_resolve_hermes_bin", lambda: ["hermes"]) + monkeypatch.setattr(gateway_run.os, "getpid", lambda: 321) + monkeypatch.setenv("_HERMES_GATEWAY", "1") + monkeypatch.setenv("VIRTUAL_ENV", str(venv_dir)) + + import hermes_cli._subprocess_compat as subprocess_compat + + monkeypatch.setattr( + subprocess_compat, + "windows_detach_popen_kwargs", + lambda: {}, + ) + + def fake_popen(cmd, **kwargs): + popen_calls.append((cmd, kwargs)) + return MagicMock() + + monkeypatch.setattr(subprocess, "Popen", fake_popen) + + await runner._launch_detached_restart_command() + + assert len(popen_calls) == 1 + cmd, kwargs = popen_calls[0] + assert cmd[-3:] == ["hermes", "gateway", "restart"] + assert kwargs["env"].get("_HERMES_GATEWAY") is None + assert kwargs["env"]["VIRTUAL_ENV"] == str(venv_dir) + assert str(site_packages) in kwargs["env"]["PYTHONPATH"].split(gateway_run.os.pathsep) + assert kwargs["stdout"] is subprocess.DEVNULL + assert kwargs["stderr"] is subprocess.DEVNULL + + # ── Shutdown notification tests ────────────────────────────────────── From cb2c13055ed9c3f467be61dbef1f3a7a5dcdc5f6 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:54:32 -0700 Subject: [PATCH 46/69] fix(gateway): scrub _HERMES_GATEWAY from POSIX detached restart watcher too Follow-up to the salvaged #41264 (Windows watcher): the setsid/bash detached restart watcher on Linux/macOS inherits _HERMES_GATEWAY=1 the same way, so the CLI's self-restart loop guard silently refuses 'hermes gateway restart' and the gateway never comes back. Scrub the marker from the watcher env on the POSIX branch as well, and extend the setsid test to assert it. --- gateway/run.py | 9 +++++++++ scripts/release.py | 1 + tests/gateway/test_restart_drain.py | 4 ++++ 3 files changed, 14 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index ff82fe43582..897cb85f652 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -4338,12 +4338,20 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew f"while kill -0 {current_pid} 2>/dev/null; do sleep 0.2; done; " f"{cmd} gateway restart" ) + # Same marker scrub as the Windows watcher above: this watcher runs + # `hermes gateway restart` from outside the gateway, but it inherits + # _HERMES_GATEWAY=1 from us, and the CLI's self-restart loop guard + # refuses to run when that marker is set — silently (DEVNULL), so the + # gateway stops and never comes back. + watcher_env = os.environ.copy() + watcher_env.pop("_HERMES_GATEWAY", None) setsid_bin = shutil.which("setsid") if setsid_bin: subprocess.Popen( [setsid_bin, "bash", "-lc", shell_cmd], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + env=watcher_env, start_new_session=True, ) else: @@ -4351,6 +4359,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ["bash", "-lc", shell_cmd], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + env=watcher_env, start_new_session=True, ) diff --git a/scripts/release.py b/scripts/release.py index 16b8bd7cdb8..ff4f4bc6da7 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -75,6 +75,7 @@ AUTHOR_MAP = { "129007007+HeLLGURD@users.noreply.github.com": "HeLLGURD", "290859878+synapsesx@users.noreply.github.com": "synapsesx", "dirtyren@users.noreply.github.com": "dirtyren", + "470766206@qq.com": "youjunxiaji", "mharris@parallel.ai": "NormallyGaussian", "roger@roger.local": "mollusk", "ted.malone@outlook.com": "temalo", diff --git a/tests/gateway/test_restart_drain.py b/tests/gateway/test_restart_drain.py index 32710fdb897..15b948a4f79 100644 --- a/tests/gateway/test_restart_drain.py +++ b/tests/gateway/test_restart_drain.py @@ -200,6 +200,7 @@ async def test_launch_detached_restart_command_uses_setsid(monkeypatch): monkeypatch.setattr(gateway_run.sys, "platform", "linux") monkeypatch.setattr(gateway_run, "_resolve_hermes_bin", lambda: ["/usr/bin/hermes"]) monkeypatch.setattr(gateway_run.os, "getpid", lambda: 321) + monkeypatch.setenv("_HERMES_GATEWAY", "1") monkeypatch.setattr(shutil, "which", lambda cmd: "/usr/bin/setsid" if cmd == "setsid" else None) def fake_popen(cmd, **kwargs): @@ -218,6 +219,9 @@ async def test_launch_detached_restart_command_uses_setsid(monkeypatch): assert kwargs["start_new_session"] is True assert kwargs["stdout"] is subprocess.DEVNULL assert kwargs["stderr"] is subprocess.DEVNULL + # The watcher must NOT inherit the gateway marker, or the CLI's + # self-restart loop guard refuses to run `hermes gateway restart`. + assert kwargs["env"].get("_HERMES_GATEWAY") is None def test_windows_gateway_venv_imports_add_site_packages(monkeypatch, tmp_path): From 04b3f195380f5e3ba30dd8cede29215cff8d0042 Mon Sep 17 00:00:00 2001 From: Dan Schnurbusch Date: Mon, 8 Jun 2026 13:31:53 -0500 Subject: [PATCH 47/69] fix(sessions): archive compressed conversation lineages --- .../app/session/hooks/use-session-actions.ts | 6 +++ hermes_state.py | 46 +++++++++++++++-- tests/hermes_state/test_session_archiving.py | 51 +++++++++++++++++++ 3 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 tests/hermes_state/test_session_archiving.py diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index 5077226aade..313a5357004 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -866,6 +866,12 @@ export function useSessionActions({ try { await setSessionArchived(storedSessionId, true, archived?.profile) + // A sidebar refresh can race the optimistic removal while the PATCH is + // in flight and briefly reinsert the still-unarchived backend row. Win + // that race after the mutation succeeds so right-click → Archive does + // not appear to do nothing until the next full refresh. + setSessions(prev => prev.filter(s => s.id !== storedSessionId)) + $pinnedSessionIds.set($pinnedSessionIds.get().filter(id => id !== storedSessionId && id !== archivedPinId)) notify({ durationMs: 2_000, kind: 'success', message: copy.archived }) } catch (err) { if (archived) { diff --git a/hermes_state.py b/hermes_state.py index bda6eeacd62..41070e15d18 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -1715,15 +1715,51 @@ class SessionDB: """Archive or unarchive a session. Archived sessions are hidden from the default session list but keep all - their messages — this is a soft hide, not a delete. Returns True when a - row was updated. + their messages — this is a soft hide, not a delete. For compression + chains, archive the whole logical conversation. Desktop lists compression + roots projected forward to their latest continuation; updating only the + displayed tip lets the still-unarchived root resurrect it on refresh. + Returns True when at least one row was updated. """ def _do(conn): cursor = conn.execute( - "UPDATE sessions SET archived = ? WHERE id = ?", - (1 if archived else 0, session_id), + """ + WITH RECURSIVE + ancestors(id) AS ( + SELECT ? + UNION + SELECT parent.id + FROM ancestors a + JOIN sessions child ON child.id = a.id + JOIN sessions parent ON parent.id = child.parent_session_id + WHERE parent.end_reason = 'compression' + AND child.started_at >= parent.ended_at + ), + descendants(id) AS ( + SELECT ? + UNION + SELECT child.id + FROM descendants d + JOIN sessions parent ON parent.id = d.id + JOIN sessions child ON child.parent_session_id = parent.id + WHERE parent.end_reason = 'compression' + AND child.started_at >= parent.ended_at + ), + lineage(id) AS ( + SELECT id FROM ancestors + UNION + SELECT id FROM descendants + ) + UPDATE sessions + SET archived = ? + WHERE id IN (SELECT id FROM lineage) + """, + (session_id, session_id, 1 if archived else 0), ) - return cursor.rowcount + rowcount = cursor.rowcount + if rowcount is None or rowcount < 0: + rowcount = conn.execute("SELECT changes()").fetchone()[0] + return rowcount rowcount = self._execute_write(_do) return rowcount > 0 diff --git a/tests/hermes_state/test_session_archiving.py b/tests/hermes_state/test_session_archiving.py new file mode 100644 index 00000000000..36ecb95a17b --- /dev/null +++ b/tests/hermes_state/test_session_archiving.py @@ -0,0 +1,51 @@ +import time + +import pytest + +from hermes_state import SessionDB + + +@pytest.fixture +def db(tmp_path): + database = SessionDB(tmp_path / "state.db") + try: + yield database + finally: + database.close() + + +def _compression_pair(db: SessionDB): + base = time.time() - 100 + db.create_session("root", source="cli") + db.create_session("tip", source="cli", parent_session_id="root") + db._conn.execute( + "UPDATE sessions SET started_at = ?, ended_at = ?, end_reason = 'compression', message_count = 1 WHERE id = 'root'", + (base, base + 10), + ) + db._conn.execute( + "UPDATE sessions SET started_at = ?, message_count = 1 WHERE id = 'tip'", + (base + 20,), + ) + db._conn.commit() + + +def test_archiving_compression_tip_archives_projected_root(db): + _compression_pair(db) + + assert db.set_session_archived("tip", True) is True + + assert db.get_session("root")["archived"] == 1 + assert db.get_session("tip")["archived"] == 1 + assert [s["id"] for s in db.list_sessions_rich(order_by_last_active=True)] == [] + assert [s["id"] for s in db.list_sessions_rich(order_by_last_active=True, archived_only=True)] == ["tip"] + + +def test_unarchiving_compression_tip_unarchives_projected_root(db): + _compression_pair(db) + db.set_session_archived("tip", True) + + assert db.set_session_archived("tip", False) is True + + assert db.get_session("root")["archived"] == 0 + assert db.get_session("tip")["archived"] == 0 + assert [s["id"] for s in db.list_sessions_rich(order_by_last_active=True)] == ["tip"] From 5e81113d0982978a30eeace942bb3524e05b7a8d Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:34:12 +0530 Subject: [PATCH 48/69] chore: map dschnurbusch contributor email for attribution --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index ff4f4bc6da7..dd2f4b70893 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1515,6 +1515,7 @@ AUTHOR_MAP = { "josephjohnson.joel@gmail.com": "JoelJJohnson", # PR #39913 salvage (Windows ConPTY dashboard chat bridge) "andreas@schwarz-ketsch.de": "Nea74", # PR #40022 co-author credit (same Windows ConPTY bridge design) "chanhokyim@gmail.com": "joel611", # PR #33958 salvage (DISCORD_ALLOWED_ROLES role_authorized gateway flag) + "desg38@gmail.com": "dschnurbusch", # PR #42373 salvage (archive compressed conversation lineages) } From dd40600e0a40ea120d267256a938326ce8f7f561 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Sat, 30 May 2026 22:08:04 +0800 Subject: [PATCH 49/69] fix(backup): stage SQLite snapshots alongside output zip and stop excluding nested hermes-agent skill dirs Two bugs in the backup routine: 1. SQLite safe-copy used tempfile.NamedTemporaryFile() which defaults to the system temp directory (/tmp). When /tmp is a small tmpfs and the database is large, the copy silently fails and the resulting zip is missing state.db, kanban.db, and response_store.db. Fix: pass dir=out_path.parent so the temp file is staged alongside the output zip on the same filesystem. 2. _EXCLUDED_DIRS contained "hermes-agent" which matched at ANY path depth, accidentally excluding the Hermes Agent skill directory at skills/autonomous-ai-agents/hermes-agent/. Fix: special-case "hermes-agent" to only match when it is the first path component (the root-level code checkout). All other excluded dir names continue to match at any depth. Regression tests added for both fixes. --- hermes_cli/backup.py | 27 +++++++++++++++++++----- tests/hermes_cli/test_backup.py | 37 +++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py index 0c6bf8692fc..d9bb12d62e1 100644 --- a/hermes_cli/backup.py +++ b/hermes_cli/backup.py @@ -31,6 +31,9 @@ logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Directory names to skip entirely (matched against each path component) +# ``hermes-agent`` is special-cased to root level only in ``_should_exclude`` +# so that skill directories like ``skills/autonomous-ai-agents/hermes-agent/`` +# are not accidentally excluded. _EXCLUDED_DIRS = { "hermes-agent", # the codebase repo — re-clone instead "__pycache__", # bytecode caches — regenerated on import @@ -69,10 +72,15 @@ def _should_exclude(rel_path: Path) -> bool: """Return True if *rel_path* (relative to hermes root) should be skipped.""" parts = rel_path.parts - # Any path component matches an excluded dir name for part in parts: - if part in _EXCLUDED_DIRS: - return True + if part not in _EXCLUDED_DIRS: + continue + # ``hermes-agent`` only matches at the root level (first component). + # Nested directories with the same name — e.g. + # ``skills/autonomous-ai-agents/hermes-agent/`` — must be preserved. + if part == "hermes-agent" and part != parts[0]: + continue + return True name = rel_path.name @@ -177,10 +185,13 @@ def run_backup(args) -> None: rel_dir = dp.relative_to(hermes_root) # Prune excluded directories in-place so os.walk doesn't descend + # ``hermes-agent`` is only pruned at the root level; nested dirs + # with the same name (e.g. in skills/) must be preserved. + is_root = rel_dir == Path(".") orig_dirnames = dirnames[:] dirnames[:] = [ d for d in dirnames - if d not in _EXCLUDED_DIRS + if d not in _EXCLUDED_DIRS or (d == "hermes-agent" and not is_root) ] for removed in set(orig_dirnames) - set(dirnames): skipped_dirs.add(str(rel_dir / removed)) @@ -211,7 +222,13 @@ def run_backup(args) -> None: try: # Safe copy for SQLite databases (handles WAL mode) if abs_path.suffix == ".db": - with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp: + # Stage the snapshot alongside the output zip so that the + # temp file lives on the same filesystem. The system + # default (/tmp) may be a small tmpfs that cannot hold + # large databases, causing silent backup incompleteness. + with tempfile.NamedTemporaryFile( + suffix=".db", delete=False, dir=str(out_path.parent) + ) as tmp: tmp_db = Path(tmp.name) if _safe_copy_db(abs_path, tmp_db): zf.write(tmp_db, arcname=str(rel_path)) diff --git a/tests/hermes_cli/test_backup.py b/tests/hermes_cli/test_backup.py index 8c0f2a39874..4052267b45e 100644 --- a/tests/hermes_cli/test_backup.py +++ b/tests/hermes_cli/test_backup.py @@ -146,6 +146,12 @@ class TestShouldExclude: from hermes_cli.backup import _should_exclude assert not _should_exclude(Path("logs/agent.log")) + def test_includes_nested_hermes_agent_in_skills(self): + """skills/autonomous-ai-agents/hermes-agent/ must NOT be excluded — + only the root-level hermes-agent/ repo is skipped.""" + from hermes_cli.backup import _should_exclude + assert not _should_exclude(Path("skills/autonomous-ai-agents/hermes-agent/SKILL.md")) + assert not _should_exclude(Path("skills/autonomous-ai-agents/hermes-agent/sub/item.txt")) # --------------------------------------------------------------------------- # Backup tests @@ -206,6 +212,37 @@ class TestBackup: agent_files = [n for n in names if "hermes-agent" in n] assert agent_files == [], f"hermes-agent files leaked into backup: {agent_files}" + def test_includes_nested_hermes_agent_in_skills(self, tmp_path, monkeypatch): + """Backup includes skills/.../hermes-agent/ but NOT root hermes-agent/.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + _make_hermes_tree(hermes_home) + + # Add a nested hermes-agent directory inside skills (like the real layout) + nested = hermes_home / "skills" / "autonomous-ai-agents" / "hermes-agent" + nested.mkdir(parents=True) + (nested / "SKILL.md").write_text("# Hermes Agent Skill\n") + (nested / "sub").mkdir() + (nested / "sub" / "item.txt").write_text("nested content\n") + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + out_zip = tmp_path / "backup.zip" + args = Namespace(output=str(out_zip)) + + from hermes_cli.backup import run_backup + run_backup(args) + + with zipfile.ZipFile(out_zip, "r") as zf: + names = zf.namelist() + # Root hermes-agent must be excluded + root_agent = [n for n in names if n.startswith("hermes-agent/")] + assert root_agent == [], f"root hermes-agent leaked: {root_agent}" + # Nested skill hermes-agent must be included + assert "skills/autonomous-ai-agents/hermes-agent/SKILL.md" in names + assert "skills/autonomous-ai-agents/hermes-agent/sub/item.txt" in names + def test_excludes_pycache(self, tmp_path, monkeypatch): """Backup does NOT include __pycache__ dirs.""" hermes_home = tmp_path / ".hermes" From cedd9b6d475bad9e0917c3ceb861377ae2735959 Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:59:55 -0600 Subject: [PATCH 50/69] fix(update): avoid SSH auth for passive official checks --- apps/desktop/electron/main.cjs | 74 ++++++++++++++++++++++++++- hermes_cli/banner.py | 53 +++++++++++++++++++ tests/hermes_cli/test_update_check.py | 34 +++++++++++- 3 files changed, 159 insertions(+), 2 deletions(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 5e128421a83..dcd30ed5432 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -278,6 +278,8 @@ const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/ // tracks main. User can also override at runtime via // hermesDesktop.updates.setBranch(). const DEFAULT_UPDATE_BRANCH = 'main' +const OFFICIAL_REPO_HTTPS_URL = 'https://github.com/NousResearch/hermes-agent.git' +const OFFICIAL_REPO_CANONICAL = 'github.com/nousresearch/hermes-agent' // desktop.log lives under HERMES_HOME/logs/ so it sits next to agent.log, // errors.log, gateway.log produced by hermes_logging.setup_logging — one log // directory per user, regardless of which UI surface produced the line. @@ -1312,6 +1314,40 @@ function runGit(args, options = {}) { const firstLine = text => (text || '').split('\n').find(Boolean) || '' +function canonicalGitHubRemote(url) { + if (!url) return '' + let value = String(url).trim() + if (value.startsWith('git@github.com:')) { + value = `github.com/${value.slice('git@github.com:'.length)}` + } else if (value.startsWith('ssh://git@github.com/')) { + value = `github.com/${value.slice('ssh://git@github.com/'.length)}` + } else { + try { + const parsed = new URL(value) + if (parsed.hostname && parsed.pathname) value = `${parsed.hostname}${parsed.pathname}` + } catch { + // Leave non-URL forms unchanged. + } + } + value = value.trim().replace(/\/+$/, '') + if (value.endsWith('.git')) value = value.slice(0, -4) + return value.toLowerCase() +} + +function isSshRemote(url) { + const value = String(url || '').trim().toLowerCase() + return value.startsWith('git@') || value.startsWith('ssh://') +} + +function isOfficialSshRemote(url) { + return isSshRemote(url) && canonicalGitHubRemote(url) === OFFICIAL_REPO_CANONICAL +} + +async function getOriginUrl(updateRoot) { + const origin = await runGit(['remote', 'get-url', 'origin'], { cwd: updateRoot }) + return origin.code === 0 ? origin.stdout.trim() : '' +} + function emitUpdateProgress(payload) { const merged = { stage: 'idle', message: '', percent: null, error: null, ...payload, at: Date.now() } rememberLog(`[updates] ${merged.stage}: ${merged.message || merged.error || ''}`) @@ -1331,7 +1367,9 @@ async function resolveHealedBranch(updateRoot, branch) { return branch || 'main' } - const probe = await runGit(['ls-remote', '--exit-code', '--heads', 'origin', branch], { cwd: updateRoot }) + const originUrl = await getOriginUrl(updateRoot) + const remote = isOfficialSshRemote(originUrl) ? OFFICIAL_REPO_HTTPS_URL : 'origin' + const probe = await runGit(['ls-remote', '--exit-code', '--heads', remote, branch], { cwd: updateRoot }) if (probe.code !== 2) { return branch } @@ -1359,6 +1397,40 @@ async function checkUpdates() { } branch = await resolveHealedBranch(updateRoot, branch) + const originUrl = await getOriginUrl(updateRoot) + if (isOfficialSshRemote(originUrl)) { + const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim()) + const [currentSha, target, dirtyStr, currentBranch] = await Promise.all([ + git(['rev-parse', 'HEAD']), + runGit(['ls-remote', OFFICIAL_REPO_HTTPS_URL, `refs/heads/${branch}`], { cwd: updateRoot }), + git(['status', '--porcelain']), + git(['rev-parse', '--abbrev-ref', 'HEAD']) + ]) + const targetSha = firstLine(target.stdout).split(/\s+/)[0] || '' + if (target.code !== 0 || !targetSha) { + return { + supported: true, + branch, + error: 'fetch-failed', + message: firstLine(target.stderr) || 'git ls-remote failed.', + hermesRoot: updateRoot, + fetchedAt: Date.now() + } + } + return { + supported: true, + branch, + currentBranch, + behind: currentSha && currentSha === targetSha ? 0 : 1, + currentSha, + targetSha, + commits: [], + dirty: dirtyStr.length > 0, + hermesRoot: updateRoot, + fetchedAt: Date.now() + } + } + const fetched = await runGit(['fetch', '--quiet', 'origin', branch], { cwd: updateRoot }) if (fetched.code !== 0) { return { diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 1955b009df2..af0bdd5feef 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -11,6 +11,7 @@ import subprocess import threading import time from pathlib import Path +from urllib.parse import urlparse from hermes_constants import get_hermes_home from typing import TYPE_CHECKING, Dict, List, Optional @@ -121,6 +122,53 @@ _UPDATE_CHECK_CACHE_SECONDS = 6 * 3600 UPDATE_AVAILABLE_NO_COUNT = -1 _UPSTREAM_REPO_URL = "https://github.com/NousResearch/hermes-agent.git" +_OFFICIAL_REPO_CANONICAL = "github.com/nousresearch/hermes-agent" + + +def _canonical_github_remote(url: str | None) -> str: + """Return ``host/owner/repo`` for common GitHub remote URL forms.""" + if not url: + return "" + value = url.strip() + if value.startswith("git@github.com:"): + value = "github.com/" + value[len("git@github.com:"):] + elif value.startswith("ssh://git@github.com/"): + value = "github.com/" + value[len("ssh://git@github.com/"):] + else: + parsed = urlparse(value) + if parsed.netloc and parsed.path: + value = f"{parsed.netloc}{parsed.path}" + value = value.strip().rstrip("/") + if value.endswith(".git"): + value = value[:-4] + return value.lower() + + +def _is_ssh_remote(url: str | None) -> bool: + if not url: + return False + value = url.strip().lower() + return value.startswith("git@") or value.startswith("ssh://") + + +def _is_official_ssh_remote(url: str | None) -> bool: + return _is_ssh_remote(url) and _canonical_github_remote(url) == _OFFICIAL_REPO_CANONICAL + + +def _git_stdout(args: list[str], *, cwd: Path, timeout: int = 5) -> Optional[str]: + try: + result = subprocess.run( + ["git", *args], + capture_output=True, + text=True, + timeout=timeout, + cwd=str(cwd), + ) + except Exception: + return None + if result.returncode != 0: + return None + return (result.stdout or "").strip() def _check_via_rev(local_rev: str) -> Optional[int]: @@ -146,6 +194,11 @@ def _check_via_rev(local_rev: str) -> Optional[int]: def _check_via_local_git(repo_dir: Path) -> Optional[int]: """Count commits behind origin/main in a local checkout.""" + origin_url = _git_stdout(["remote", "get-url", "origin"], cwd=repo_dir) + if _is_official_ssh_remote(origin_url): + head_rev = _git_stdout(["rev-parse", "HEAD"], cwd=repo_dir) + return _check_via_rev(head_rev) if head_rev else None + try: subprocess.run( ["git", "fetch", "origin", "--quiet"], diff --git a/tests/hermes_cli/test_update_check.py b/tests/hermes_cli/test_update_check.py index 47c018cbbdb..5c590bff15c 100644 --- a/tests/hermes_cli/test_update_check.py +++ b/tests/hermes_cli/test_update_check.py @@ -93,7 +93,39 @@ def test_check_for_updates_expired_cache(tmp_path, monkeypatch): result = check_for_updates() assert result == 5 - assert mock_run.call_count == 2 # git fetch + git rev-list + assert mock_run.call_count == 3 # origin probe + git fetch + git rev-list + + +def test_check_for_updates_official_ssh_origin_uses_https_probe(tmp_path): + """Passive update checks must not trigger SSH auth for official installs.""" + import hermes_cli.banner as banner + + repo_dir = tmp_path / "hermes-agent" + repo_dir.mkdir() + (repo_dir / ".git").mkdir() + + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + if cmd == ["git", "remote", "get-url", "origin"]: + return MagicMock(returncode=0, stdout="git@github.com:NousResearch/hermes-agent.git\n") + if cmd == ["git", "rev-parse", "HEAD"]: + return MagicMock(returncode=0, stdout="local-sha\n") + if cmd == [ + "git", + "ls-remote", + "https://github.com/NousResearch/hermes-agent.git", + "refs/heads/main", + ]: + return MagicMock(returncode=0, stdout="upstream-sha\trefs/heads/main\n") + raise AssertionError(f"unexpected git command: {cmd!r}") + + with patch("hermes_cli.banner.subprocess.run", side_effect=fake_run): + result = banner._check_via_local_git(repo_dir) + + assert result == banner.UPDATE_AVAILABLE_NO_COUNT + assert ["git", "fetch", "origin", "--quiet"] not in calls def test_check_for_updates_no_git_dir(tmp_path, monkeypatch): From ed2b9e43c8164dc8684b93487e90c32cef3e75ce Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:45:40 +0530 Subject: [PATCH 51/69] fix(backup): stage SQLite snapshots beside output zip in pre-update path too The pre-update / pre-migration backup path (_write_full_zip_backup) had the same /tmp staging bug as run_backup: a small tmpfs at the default tempfile location silently drops large *.db files from the archive. Route its SQLite staging temp files to the output zip's directory as well, and add regression tests (mutation-verified) for both staging paths. Co-authored-by: liuhao1024 --- hermes_cli/backup.py | 8 ++++- tests/hermes_cli/test_backup.py | 60 +++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py index d9bb12d62e1..62997528bd8 100644 --- a/hermes_cli/backup.py +++ b/hermes_cli/backup.py @@ -870,7 +870,13 @@ def _write_full_zip_backup(out_path: Path, hermes_root: Path) -> Optional[Path]: for abs_path, rel_path in files_to_add: try: if abs_path.suffix == ".db": - with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp: + # Stage the snapshot alongside the output zip so that the + # temp file lives on the same filesystem. The system + # default (/tmp) may be a small tmpfs that cannot hold + # large databases, causing silent backup incompleteness. + with tempfile.NamedTemporaryFile( + suffix=".db", delete=False, dir=str(out_path.parent) + ) as tmp: tmp_db = Path(tmp.name) try: if _safe_copy_db(abs_path, tmp_db): diff --git a/tests/hermes_cli/test_backup.py b/tests/hermes_cli/test_backup.py index 4052267b45e..15a2112ac26 100644 --- a/tests/hermes_cli/test_backup.py +++ b/tests/hermes_cli/test_backup.py @@ -192,6 +192,66 @@ class TestBackup: # Skins assert "skins/cyber.yaml" in names + def test_db_snapshots_staged_beside_output_zip(self, tmp_path, monkeypatch): + """SQLite staging temp files must be created on the output zip's + filesystem (dir=out_path.parent), NOT the system /tmp default — a + small tmpfs there silently drops large DBs from the backup (#35376).""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + _make_hermes_tree(hermes_home) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + out_dir = tmp_path / "external-drive" + out_dir.mkdir() + out_zip = out_dir / "backup.zip" + args = Namespace(output=str(out_zip)) + + import hermes_cli.backup as backup_mod + staged_dirs = [] + real_ntf = backup_mod.tempfile.NamedTemporaryFile + + def _spy(*a, **kw): + staged_dirs.append(kw.get("dir")) + return real_ntf(*a, **kw) + + monkeypatch.setattr(backup_mod.tempfile, "NamedTemporaryFile", _spy) + backup_mod.run_backup(args) + + # At least one .db was staged, and every staging call targeted the + # output zip's directory rather than the system temp default. + assert staged_dirs, "no SQLite snapshot was staged" + assert all(d == str(out_dir) for d in staged_dirs), staged_dirs + + def test_pre_update_db_snapshots_staged_beside_output_zip(self, tmp_path, monkeypatch): + """The pre-update/pre-migration zip path (_write_full_zip_backup) must + also stage SQLite snapshots beside its output zip, not in /tmp.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + _make_hermes_tree(hermes_home) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + out_zip = hermes_home / "backups" / "pre-update-test.zip" + out_zip.parent.mkdir(parents=True, exist_ok=True) + + import hermes_cli.backup as backup_mod + staged_dirs = [] + real_ntf = backup_mod.tempfile.NamedTemporaryFile + + def _spy(*a, **kw): + staged_dirs.append(kw.get("dir")) + return real_ntf(*a, **kw) + + monkeypatch.setattr(backup_mod.tempfile, "NamedTemporaryFile", _spy) + result = backup_mod._write_full_zip_backup(out_zip, hermes_home) + + assert result is not None + assert staged_dirs, "no SQLite snapshot was staged" + assert all(d == str(out_zip.parent) for d in staged_dirs), staged_dirs + def test_excludes_hermes_agent(self, tmp_path, monkeypatch): """Backup does NOT include hermes-agent/ directory.""" hermes_home = tmp_path / ".hermes" From 899acfe42ffd2632e0ba0ac6c3893ea0afebdc66 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Wed, 10 Jun 2026 19:14:11 +0700 Subject: [PATCH 52/69] fix(install/windows): repair stale winget registration; refresh PATH after every package manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When ripgrep/ffmpeg is missing, `winget install ` on a package winget already has registered is treated as an upgrade: it finds no newer version and exits 0x8A15002B (-1978335189, APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE) without ensuring the binary is actually present. The installer only logged that code and judged success by `Get-Command rg`, so a stale registration (files removed outside winget, or a missing alias shim) became a permanent dead-end — winget kept reporting "already installed" and the user could never reinstall. Detect that exit code and retry once with `--force` to repair the registration so the shim reappears. Also refresh the process PATH after the choco and scoop fallbacks (not just winget) via a shared helper, so a successful fallback install — or any install on a box without winget — is no longer misreported as "not installed". --- scripts/install.ps1 | 52 +++++++++++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 21e3d495816..23a602ca9e8 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -892,6 +892,22 @@ function Test-Node { return $true } +function Update-ProcessPathForPackages { + # Rebuild the current process PATH from the persisted User+Machine hives plus + # winget's alias-shim directory, so a freshly-installed shim (rg.exe, + # ffmpeg.exe) becomes visible to Get-Command in THIS process without + # spawning a new shell. Called after every package-manager attempt + # (winget/choco/scoop): previously PATH was only refreshed inside the winget + # branch, so a successful choco/scoop fallback -- or any install on a box + # without winget -- could be misreported as "not installed". + $envPath = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") + $wingetLinks = Join-Path $env:LOCALAPPDATA "Microsoft\WinGet\Links" + if (Test-Path $wingetLinks) { + $envPath = "$envPath;$wingetLinks" + } + $env:Path = $envPath +} + function Install-SystemPackages { $script:HasRipgrep = $false $script:HasFfmpeg = $false @@ -961,25 +977,33 @@ function Install-SystemPackages { try { $output = winget install --exact --id $pkg --source winget --silent ` --accept-package-agreements --accept-source-agreements 2>&1 + $code = $LASTEXITCODE $output | Out-File -FilePath $log -Encoding utf8 - "winget exit: $LASTEXITCODE" | Out-File -FilePath $log -Encoding utf8 -Append + "winget exit: $code" | Out-File -FilePath $log -Encoding utf8 -Append + # 0x8A15002B (-1978335189) = APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE. + # winget treats `install` on a package it already has registered as + # an *upgrade*, finds no newer version, and bails with this code -- + # even when the binary is gone from disk/PATH (stale registration, + # files removed outside winget, or a missing alias shim). We KNOW the + # command was missing (that's why we're here), so a plain install + # dead-ends forever. Force a reinstall to repair the registration so + # the shim reappears. + if ($code -eq -1978335189) { + "-> already-installed/no-upgrade; retrying with --force" | Out-File -FilePath $log -Encoding utf8 -Append + $output = winget install --exact --id $pkg --source winget --silent --force ` + --accept-package-agreements --accept-source-agreements 2>&1 + $output | Out-File -FilePath $log -Encoding utf8 -Append + "winget exit (force): $LASTEXITCODE" | Out-File -FilePath $log -Encoding utf8 -Append + } } catch { $_ | Out-File -FilePath $log -Encoding utf8 -Append "winget exit: " | Out-File -FilePath $log -Encoding utf8 -Append } } - # Refresh PATH from both env-var hives AND winget's alias shim directory. - # winget exposes packages via "command line aliases" in %LOCALAPPDATA%\ - # Microsoft\WinGet\Links, which is added to PATH by the AppExecutionAlias - # machinery only in *newly-spawned* shells -- not the current process. - # Without this addition, Get-Command rg below would falsely return null - # immediately after a successful install. - $wingetLinks = Join-Path $env:LOCALAPPDATA "Microsoft\WinGet\Links" - $envPath = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") - if (Test-Path $wingetLinks) { - $envPath = "$envPath;$wingetLinks" - } - $env:Path = $envPath + # Refresh PATH so packages winget exposed via "command line aliases" in + # %LOCALAPPDATA%\Microsoft\WinGet\Links (added to PATH only in + # newly-spawned shells, not this process) are visible to Get-Command below. + Update-ProcessPathForPackages if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) { Write-Success "ripgrep installed" $script:HasRipgrep = $true @@ -1005,6 +1029,7 @@ function Install-SystemPackages { foreach ($pkg in $chocoPkgs) { try { choco install $pkg -y 2>&1 | Out-Null } catch { } } + Update-ProcessPathForPackages if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) { Write-Success "ripgrep installed via chocolatey" $script:HasRipgrep = $true @@ -1023,6 +1048,7 @@ function Install-SystemPackages { foreach ($pkg in $scoopPkgs) { try { scoop install $pkg 2>&1 | Out-Null } catch { } } + Update-ProcessPathForPackages if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) { Write-Success "ripgrep installed via scoop" $script:HasRipgrep = $true From 9662b76d592025c1536a6d7e0cb9aa317c09cc4e Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:49:58 +0530 Subject: [PATCH 53/69] fix(install/windows): merge PATH in Update-ProcessPathForPackages instead of overwriting Follow-up to the winget stale-registration fix. Update-ProcessPathForPackages rebuilt $env:Path wholesale from the persisted User+Machine hives (plus winget's Links dir), discarding any process-only PATH entries added earlier in the installer run. Since the helper now runs after every package manager, that wholesale replace is more likely to clobber a process-local entry than the original winget-branch-only version was. Merge instead: seed from the current process PATH, then append hive and winget-Links entries not already present, with a case-insensitive, order-preserving dedupe. Behaviour on a clean box is unchanged (the hive entries are simply appended); the difference is that pre-existing process-only entries now survive the refresh. --- scripts/install.ps1 | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 23a602ca9e8..b316a99e4f7 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -893,19 +893,39 @@ function Test-Node { } function Update-ProcessPathForPackages { - # Rebuild the current process PATH from the persisted User+Machine hives plus - # winget's alias-shim directory, so a freshly-installed shim (rg.exe, - # ffmpeg.exe) becomes visible to Get-Command in THIS process without - # spawning a new shell. Called after every package-manager attempt - # (winget/choco/scoop): previously PATH was only refreshed inside the winget - # branch, so a successful choco/scoop fallback -- or any install on a box - # without winget -- could be misreported as "not installed". - $envPath = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") + # Make freshly-installed shims (rg.exe, ffmpeg.exe) visible to Get-Command in + # THIS process without spawning a new shell, by folding the persisted + # User+Machine hives plus winget's alias-shim directory into $env:Path. + # Called after every package-manager attempt (winget/choco/scoop): previously + # PATH was only refreshed inside the winget branch, so a successful + # choco/scoop fallback -- or any install on a box without winget -- could be + # misreported as "not installed". + # + # MERGE rather than overwrite: start from the existing process PATH so any + # process-only entries added earlier in this installer run survive, then + # APPEND hive/winget-Links entries not already present (case-insensitive, + # order-preserving dedupe). A wholesale replace would silently drop those + # process-only entries. + $candidates = @() + $candidates += $env:Path + $candidates += [Environment]::GetEnvironmentVariable("Path", "User") + $candidates += [Environment]::GetEnvironmentVariable("Path", "Machine") $wingetLinks = Join-Path $env:LOCALAPPDATA "Microsoft\WinGet\Links" if (Test-Path $wingetLinks) { - $envPath = "$envPath;$wingetLinks" + $candidates += $wingetLinks } - $env:Path = $envPath + $seen = New-Object System.Collections.Generic.HashSet[string] ([StringComparer]::OrdinalIgnoreCase) + $ordered = New-Object System.Collections.Generic.List[string] + foreach ($chunk in $candidates) { + if ([string]::IsNullOrEmpty($chunk)) { continue } + foreach ($entry in $chunk.Split(';')) { + $trimmed = $entry.Trim() + if ($trimmed -and $seen.Add($trimmed)) { + $ordered.Add($trimmed) + } + } + } + $env:Path = [string]::Join(';', $ordered) } function Install-SystemPackages { From 0edeee14c6ce02a58df436638d3ac47dbb4bcd2d Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:53:19 +0530 Subject: [PATCH 54/69] test(desktop): cover official-SSH remote detection for passive updates Extract the remote-detection helpers (canonicalGitHubRemote, isSshRemote, isOfficialSshRemote) from main.cjs into a testable update-remote.cjs sibling module and add a node:test suite, wired into test:desktop:platforms. main.cjs requires('electron') at load, so its inline helpers weren't unit testable. The Python side of #43754 shipped a regression test; this gives the desktop side the same coverage for the security-critical detection that keeps passive update checks off the SSH origin (avoiding FIDO2/passkey touch prompts). Tests assert SSH/HTTPS forms canonicalize equal, official SSH is detected case-insensitively, and forks / other hosts / the HTTPS remote are NOT misclassified. --- apps/desktop/electron/main.cjs | 35 +-------- apps/desktop/electron/update-remote.cjs | 56 ++++++++++++++ apps/desktop/electron/update-remote.test.cjs | 78 ++++++++++++++++++++ apps/desktop/package.json | 2 +- 4 files changed, 139 insertions(+), 32 deletions(-) create mode 100644 apps/desktop/electron/update-remote.cjs create mode 100644 apps/desktop/electron/update-remote.test.cjs diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index dcd30ed5432..5af4b5605ce 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -31,6 +31,10 @@ const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs') const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs') +const { + OFFICIAL_REPO_HTTPS_URL, + isOfficialSshRemote +} = require('./update-remote.cjs') const { buildPosixCleanupScript, buildWindowsCleanupScript, @@ -278,8 +282,6 @@ const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/ // tracks main. User can also override at runtime via // hermesDesktop.updates.setBranch(). const DEFAULT_UPDATE_BRANCH = 'main' -const OFFICIAL_REPO_HTTPS_URL = 'https://github.com/NousResearch/hermes-agent.git' -const OFFICIAL_REPO_CANONICAL = 'github.com/nousresearch/hermes-agent' // desktop.log lives under HERMES_HOME/logs/ so it sits next to agent.log, // errors.log, gateway.log produced by hermes_logging.setup_logging — one log // directory per user, regardless of which UI surface produced the line. @@ -1314,35 +1316,6 @@ function runGit(args, options = {}) { const firstLine = text => (text || '').split('\n').find(Boolean) || '' -function canonicalGitHubRemote(url) { - if (!url) return '' - let value = String(url).trim() - if (value.startsWith('git@github.com:')) { - value = `github.com/${value.slice('git@github.com:'.length)}` - } else if (value.startsWith('ssh://git@github.com/')) { - value = `github.com/${value.slice('ssh://git@github.com/'.length)}` - } else { - try { - const parsed = new URL(value) - if (parsed.hostname && parsed.pathname) value = `${parsed.hostname}${parsed.pathname}` - } catch { - // Leave non-URL forms unchanged. - } - } - value = value.trim().replace(/\/+$/, '') - if (value.endsWith('.git')) value = value.slice(0, -4) - return value.toLowerCase() -} - -function isSshRemote(url) { - const value = String(url || '').trim().toLowerCase() - return value.startsWith('git@') || value.startsWith('ssh://') -} - -function isOfficialSshRemote(url) { - return isSshRemote(url) && canonicalGitHubRemote(url) === OFFICIAL_REPO_CANONICAL -} - async function getOriginUrl(updateRoot) { const origin = await runGit(['remote', 'get-url', 'origin'], { cwd: updateRoot }) return origin.code === 0 ? origin.stdout.trim() : '' diff --git a/apps/desktop/electron/update-remote.cjs b/apps/desktop/electron/update-remote.cjs new file mode 100644 index 00000000000..3cb432d1b1e --- /dev/null +++ b/apps/desktop/electron/update-remote.cjs @@ -0,0 +1,56 @@ +/** + * Pure helpers for choosing a remote URL during passive update checks. + * + * A public install can end up with `origin=git@github.com:NousResearch/hermes-agent.git`. + * If the user's GitHub SSH key is FIDO2/passkey-backed, a background `git fetch + * origin` triggers an unexplained hardware-touch prompt. For passive checks + * against the official repo we substitute the public HTTPS `ls-remote` path, + * which needs no auth and cannot prompt. Active update/apply flows are left + * unchanged. + * + * Extracted from main.cjs so the security-critical remote detection is unit + * testable without booting Electron (main.cjs requires('electron') at load). + */ + +const OFFICIAL_REPO_HTTPS_URL = 'https://github.com/NousResearch/hermes-agent.git' +const OFFICIAL_REPO_CANONICAL = 'github.com/nousresearch/hermes-agent' + +// Normalize common GitHub remote URL forms to `host/owner/repo` (lowercased, +// no trailing slash, no .git suffix) so SSH and HTTPS forms of the same repo +// compare equal. +function canonicalGitHubRemote(url) { + if (!url) return '' + let value = String(url).trim() + if (value.startsWith('git@github.com:')) { + value = `github.com/${value.slice('git@github.com:'.length)}` + } else if (value.startsWith('ssh://git@github.com/')) { + value = `github.com/${value.slice('ssh://git@github.com/'.length)}` + } else { + try { + const parsed = new URL(value) + if (parsed.hostname && parsed.pathname) value = `${parsed.hostname}${parsed.pathname}` + } catch { + // Leave non-URL forms unchanged. + } + } + value = value.trim().replace(/\/+$/, '') + if (value.endsWith('.git')) value = value.slice(0, -4) + return value.toLowerCase() +} + +function isSshRemote(url) { + const value = String(url || '').trim().toLowerCase() + return value.startsWith('git@') || value.startsWith('ssh://') +} + +function isOfficialSshRemote(url) { + return isSshRemote(url) && canonicalGitHubRemote(url) === OFFICIAL_REPO_CANONICAL +} + +module.exports = { + OFFICIAL_REPO_HTTPS_URL, + OFFICIAL_REPO_CANONICAL, + canonicalGitHubRemote, + isSshRemote, + isOfficialSshRemote +} diff --git a/apps/desktop/electron/update-remote.test.cjs b/apps/desktop/electron/update-remote.test.cjs new file mode 100644 index 00000000000..0dfba970138 --- /dev/null +++ b/apps/desktop/electron/update-remote.test.cjs @@ -0,0 +1,78 @@ +/** + * Tests for electron/update-remote.cjs — the remote-detection helpers that + * keep passive update checks off the SSH origin for official installs. + * + * Run with: node --test electron/update-remote.test.cjs + * (Wired into npm test:desktop:platforms in package.json.) + * + * Why this matters: a public install can carry + * origin=git@github.com:NousResearch/hermes-agent.git. A background + * `git fetch origin` then authenticates over SSH and, with a FIDO2/passkey + * key, triggers an unexplained hardware-touch prompt. isOfficialSshRemote + * must reliably recognize the official SSH remote (in every URL form, + * case-insensitively) so the caller can swap in the anonymous HTTPS path — + * while NOT misclassifying forks, other hosts, or the HTTPS remote (which + * never prompts and should keep the normal fetch path). + */ + +const test = require('node:test') +const assert = require('node:assert/strict') + +const { + OFFICIAL_REPO_HTTPS_URL, + OFFICIAL_REPO_CANONICAL, + canonicalGitHubRemote, + isSshRemote, + isOfficialSshRemote +} = require('./update-remote.cjs') + +test('canonicalGitHubRemote normalizes SSH and HTTPS forms to the same value', () => { + assert.equal(canonicalGitHubRemote('git@github.com:NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL) + assert.equal(canonicalGitHubRemote('git@github.com:NousResearch/hermes-agent'), OFFICIAL_REPO_CANONICAL) + assert.equal(canonicalGitHubRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL) + assert.equal(canonicalGitHubRemote('https://github.com/NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL) + // Case-insensitive: an uppercased owner still canonicalizes to the same repo. + assert.equal(canonicalGitHubRemote('git@github.com:nousresearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL) + // Trailing slashes are stripped. + assert.equal(canonicalGitHubRemote('https://github.com/NousResearch/hermes-agent/'), OFFICIAL_REPO_CANONICAL) +}) + +test('canonicalGitHubRemote is empty for falsy input', () => { + assert.equal(canonicalGitHubRemote(''), '') + assert.equal(canonicalGitHubRemote(null), '') + assert.equal(canonicalGitHubRemote(undefined), '') +}) + +test('isSshRemote detects scp-like and ssh:// forms only', () => { + assert.equal(isSshRemote('git@github.com:NousResearch/hermes-agent.git'), true) + assert.equal(isSshRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), true) + assert.equal(isSshRemote('https://github.com/NousResearch/hermes-agent.git'), false) + assert.equal(isSshRemote(''), false) + assert.equal(isSshRemote(null), false) +}) + +test('isOfficialSshRemote is true only for the official repo over SSH', () => { + assert.equal(isOfficialSshRemote('git@github.com:NousResearch/hermes-agent.git'), true) + assert.equal(isOfficialSshRemote('git@github.com:NousResearch/hermes-agent'), true) + assert.equal(isOfficialSshRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), true) + // Case-insensitive owner/repo match. + assert.equal(isOfficialSshRemote('git@github.com:nousresearch/hermes-agent.git'), true) +}) + +test('isOfficialSshRemote does NOT match forks, other hosts, or HTTPS', () => { + // A fork over SSH belongs to the user — fetching it is their own remote, + // not the official upstream, so the SSH-avoidance swap must not apply. + assert.equal(isOfficialSshRemote('git@github.com:someuser/hermes-agent.git'), false) + // Same repo name on a different host is not the official repo. + assert.equal(isOfficialSshRemote('git@gitlab.com:NousResearch/hermes-agent.git'), false) + // HTTPS to the official repo never prompts for SSH/FIDO2, so it keeps the + // normal fetch path — must not be flagged as an official SSH remote. + assert.equal(isOfficialSshRemote('https://github.com/NousResearch/hermes-agent.git'), false) + assert.equal(isOfficialSshRemote(''), false) + assert.equal(isOfficialSshRemote(null), false) +}) + +test('OFFICIAL_REPO_HTTPS_URL canonicalizes to OFFICIAL_REPO_CANONICAL', () => { + // Invariant: the URL we substitute in must be the same repo we detect. + assert.equal(canonicalGitHubRemote(OFFICIAL_REPO_HTTPS_URL), OFFICIAL_REPO_CANONICAL) +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e373fc78825..e45ec8e804b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -35,7 +35,7 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/windows-child-process.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", From 0d3e2cc539525a2a5ebd4cfc92942eb1ec523a98 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Thu, 11 Jun 2026 16:02:27 +0800 Subject: [PATCH 55/69] fix(desktop): deduplicate sidebar rows by compression lineage in mergeSessionPage (#43487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When auto-compression rotates the session tip (old #4 → new #5), the incoming page carries the new tip but the previous list still holds the old one. The old tip's id differs from the new tip's id, so the existing id-only dedup in mergeSessionPage() preserves both as separate sidebar rows. Add lineage-level dedup: build a set of incoming lineage keys (`_lineage_root_id ?? id`) and filter survivors whose lineage key matches any incoming row. This mirrors the existing sessionPinId() logic used for pin stability. Fixes #43483 --- apps/desktop/src/store/session.test.ts | 43 ++++++++++++++++++++++++-- apps/desktop/src/store/session.ts | 8 +++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts index deb4833868f..79fefdccd8e 100644 --- a/apps/desktop/src/store/session.test.ts +++ b/apps/desktop/src/store/session.test.ts @@ -133,13 +133,52 @@ describe('mergeSessionPage', () => { it('keeps a pinned session matched by its lineage root after compression', () => { // The pin is stored on the lineage-root id, but the loaded row surfaces // under its live compression tip. Matching on _lineage_root_id keeps it. - const previous = [session({ id: 'tip', _lineage_root_id: 'root' })] - const incoming = [session({ id: 'other' })] + const previous = [session({ id: 'tip', _lineage_root_id: 'root' })] as SessionInfo[] + const incoming = [session({ id: 'other' })] as SessionInfo[] const merged = mergeSessionPage(previous, incoming, ['root']) expect(merged.map(s => s.id)).toEqual(['tip', 'other']) }) + + it('evicts an old compression tip when the incoming page has the new tip from the same lineage', () => { + // Repro of #43483: after auto-compression rotates the tip (#4 → #5), + // the sidebar showed both the old tip and the new tip as separate rows. + // The old tip must be evicted because its lineage key matches the incoming + // new tip's lineage key. + const previous = [ + session({ id: 'tip-4', _lineage_root_id: 'root' }), + session({ id: 'other' }), + ] as SessionInfo[] + const incoming = [ + session({ id: 'tip-5', _lineage_root_id: 'root' }), + ] as SessionInfo[] + + // 'tip-4' is in the keep set (e.g. it was the active/working session), + // but should still be evicted because the incoming page carries the same + // lineage under a new tip id. + const merged = mergeSessionPage(previous, incoming, ['tip-4']) + + expect(merged.map(s => s.id)).toEqual(['tip-5']) + // The new tip comes from the server payload. + expect(merged.find(s => s.id === 'tip-5')?._lineage_root_id).toBe('root') + }) + + it('preserves an unrelated pinned session even when lineage dedup is active', () => { + // Regression guard: lineage dedup must not accidentally evict sessions + // from a different lineage that happen to be in the keep set. + const previous = [ + session({ id: 'a-old', _lineage_root_id: 'lineage-a' }), + session({ id: 'b', _lineage_root_id: 'lineage-b' }), + ] as SessionInfo[] + const incoming = [ + session({ id: 'a-new', _lineage_root_id: 'lineage-a' }), + ] as SessionInfo[] + + const merged = mergeSessionPage(previous, incoming, ['b']) + + expect(merged.map(s => s.id)).toEqual(['b', 'a-new']) + }) }) describe('workspaceCwdForNewSession', () => { diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 4139915cea2..ed28b92cb88 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -125,10 +125,18 @@ export function mergeSessionPage( } const incomingIds = new Set(incoming.map(session => session.id)) + // Deduplicate by compression lineage: when auto-compression rotates the tip + // id (old #4 → new #5), the incoming page carries the new tip but the + // previous list still holds the old one. Without lineage-level dedup both + // rows survive as separate sidebar entries (fixes #43483). + const incomingLineageKeys = new Set( + incoming.map(session => session._lineage_root_id ?? session.id) + ) const survivors = previous.filter( session => !incomingIds.has(session.id) && + !incomingLineageKeys.has(session._lineage_root_id ?? session.id) && (keep.has(session.id) || (session._lineage_root_id != null && keep.has(session._lineage_root_id))) ) From 875aa8f162aa40f07b19b2ca229720da70193d41 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 11 Jun 2026 03:29:33 -0700 Subject: [PATCH 56/69] =?UTF-8?q?feat(dashboard):=20unify=20multi-profile?= =?UTF-8?q?=20management=20=E2=80=94=20one=20machine=20dashboard,=20global?= =?UTF-8?q?=20profile=20switcher=20(#44007)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dashboard): unify multi-profile management — one machine dashboard, global profile switcher The dashboard becomes a machine-level management surface with one write-target selector, replacing per-profile dashboard fragmentation. Backend: - profile param (query or body) on /api/config (get/put/raw), /api/env (get/put/delete/reveal), /api/mcp/servers (list/add/remove/test/enabled), /api/mcp/catalog (list/install), /api/model/info, /api/model/set — all scoped through the existing _profile_scope() context manager - model/set restructured: expensive-model warning (await) runs before the scope; the config write runs sync inside the scope in a worker thread - MCP catalog installs + git-bootstrap entries spawn 'hermes -p ' - chat PTY: ?profile= on /api/pty points the child's HERMES_HOME at the profile dir (its own gateway subprocess, config/skills/memory/state.db all profile-bound); in-process gateway attach skipped when scoped CLI launch unification: - ' dashboard' routes to the machine dashboard: attach (open browser at ?profile=) when one is listening, else re-exec pinned to the default profile with --open-profile preselecting the launcher - --isolated preserves the old dedicated per-profile server behavior - start_server(initial_profile=...) appends ?profile= to the auto-open URL Frontend: - ProfileProvider + sidebar ProfileSwitcher: ONE global selector, URL- persisted (?profile=), mirrored into fetchJSON which auto-appends the param to the scoped endpoint families (explicit params win) - app-wide amber banner names the managed profile - SkillsPage's page-local selector (from the skills-scoping PR) folded into the global context — single source of truth - ChatPage threads the scope into the PTY WS URL; switching profiles remounts the terminal into a fresh scoped session Omitted profile keeps legacy behavior everywhere. * docs(dashboard): document machine-level multi-profile management - web-dashboard.md: 'Managing multiple profiles' section (switcher, URL deep-links, unified launch, --isolated, scoped Chat, what stays per-profile) + --isolated in the options table - profiles.md: 'From the dashboard' subsection + set-as-active vs switcher clarification - cli-commands.md: --isolated flag + profile-alias launch example * fix(dashboard): address profile-unification review findings Review findings (dev review on PR #44007): 1. HIGH — stale page state on profile switch: pages load data on mount and didn't consume the profile scope, so a page opened under profile A kept showing A's state while writes silently targeted the newly selected B. Fixed structurally: ProfileKeyedRoutes wraps the routed page tree and keys it by the selected profile, remounting every page (fresh state + refetch) on switch. ChatPage keeps its own remount (channel keyed on scopedProfile). 2. HIGH — /api/model/auxiliary read was unscoped while /api/model/set wrote scoped (Models page could show default's aux pins while editing worker's). Endpoint now takes profile + _profile_scope, added to PROFILE_SCOPED_PREFIXES, HTTPException re-raise so ghost profiles 404 instead of 500. Regression test asserts read/write symmetry with differing worker/default aux config. 3. MEDIUM — tools post-setup spawned unscoped from the profile-aware drawer. Now spawns 'hermes -p tools post-setup ' (same mechanism as hub installs); drawer threads its profile prop. Most hooks install machine-level artifacts where the scope is inert, but hooks reading config/env now see the drawer's HERMES_HOME. 4. LOW — ty warnings: env Optional asserts before subscript/membership, fastapi import replaced with web_server.HTTPException re-use. 298 tests green across the four affected suites; tsc -b + vite build green; aux scoping E2E-verified with real imports. * fix(dashboard): address second profile-unification review (gille) 1. BLOCKER — profile scope dropped on sidebar navigation: ProfileProvider derived the selection from the current URL, and nav links are bare paths, so clicking Config from /skills?profile=worker silently reset the write target. State is now the source of truth; an effect re-asserts ?profile= onto the new location after every navigation (URL stays a synchronized projection for deep links/refresh), and an incoming URL param (e.g. 'Manage skills & tools' links) still wins. 2. BLOCKER — /api/model/options unscoped while model/set wrote scoped: the picker context (current model/provider, custom providers, per-profile .env auth state) now loads inside _profile_scope; added to PROFILE_SCOPED_PREFIXES. Test: a worker-only current-model pin appears in the scoped payload and not the unscoped one. 3. BLOCKER — MCP test-server probe escaped the scope after the config read: the probe now re-enters _profile_scope inside the worker thread so env-placeholder expansion resolves against the selected profile's .env. Known limit (documented): the probe's dedicated MCP event-loop thread doesn't inherit the contextvar (OAuth token paths). Test asserts get_hermes_home() inside the probe == the worker profile dir. 4. BLOCKER — broad excepts swallowed unknown-profile 404s: /api/model/info degraded to 200-with-empty-model-info and /api/mcp/catalog to a silently-empty catalog. Both re-raise HTTPException; 404 regression tests added for info/options/catalog. Polish: scope banner clears the fixed mobile header (mt-14 lg:mt-0); --open-profile hidden via argparse.SUPPRESS (internal re-exec flag); attach-path test now asserts the opened ?profile= URL. (Stale-page-state + /api/model/auxiliary findings from this review were already fixed in 92bcd1568 — the review ran against e600f6951.) 35 tests in the two new suites + 274 in the adjacent ones, all green; tsc -b + vite build green; scoping E2E-verified with real imports. * docs(dashboard)+fix: self-review pass — Profiles page section, REST profile-param tip, body-beats-query precedence Docs: - web-dashboard.md: add the missing 'Profiles' subsection to Pages (cards, create/builder, manage-skills jump, set-as-active vs switcher distinction, editors); REST API section gets a profile-scoped-endpoints tip documenting ?profile= / body profile / 404 semantics / /api/pty - (profiles.md + cli-commands.md were already updated in e600f6951) Precedence fix: scoped endpoints taking BOTH a query param and a body field now resolve body.profile first. The SPA's fetchJSON injects the query param from the GLOBAL switcher; an explicit body.profile (e.g. Profile Builder flows writing into a specific new profile) is the more specific intent and must not be overridden by whatever the sidebar happens to be set to. Matches the documented 'explicit beats global' contract in api.ts. Verified: 304 tests green across the four suites; tsc -b + vite build green; docusaurus build green (only pre-existing broken-link warnings, none from this PR's pages). --- hermes_cli/main.py | 75 +++ hermes_cli/subcommands/dashboard.py | 20 + hermes_cli/web_server.py | 586 ++++++++++++------ .../test_dashboard_unified_launch.py | 130 ++++ tests/hermes_cli/test_web_server.py | 16 +- .../test_web_server_profile_unification.py | 385 ++++++++++++ web/src/App.tsx | 48 +- web/src/components/ProfileScopeBanner.tsx | 30 + web/src/components/ProfileSwitcher.tsx | 67 ++ web/src/components/ToolsetConfigDrawer.tsx | 2 +- web/src/contexts/ProfileProvider.tsx | 115 ++++ web/src/contexts/profile-context.ts | 19 + web/src/contexts/useProfileScope.ts | 6 + web/src/i18n/en.ts | 4 + web/src/i18n/types.ts | 4 + web/src/lib/api.ts | 47 +- web/src/pages/ChatPage.tsx | 16 +- web/src/pages/SkillsPage.tsx | 101 +-- website/docs/reference/cli-commands.md | 5 + .../docs/user-guide/features/web-dashboard.md | 59 ++ website/docs/user-guide/profiles.md | 14 + 21 files changed, 1429 insertions(+), 320 deletions(-) create mode 100644 tests/hermes_cli/test_dashboard_unified_launch.py create mode 100644 tests/hermes_cli/test_web_server_profile_unification.py create mode 100644 web/src/components/ProfileScopeBanner.tsx create mode 100644 web/src/components/ProfileSwitcher.tsx create mode 100644 web/src/contexts/ProfileProvider.tsx create mode 100644 web/src/contexts/profile-context.ts create mode 100644 web/src/contexts/useProfileScope.ts diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 53441055958..719a181bc25 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -10220,6 +10220,21 @@ def _report_dashboard_status() -> int: return len(pids) +def _dashboard_listening(host: str, port: int) -> bool: + """True when something is accepting TCP connections at host:port. + + Any listener counts — even a 401 response proves a dashboard is up. + Used by the unified profile-launch routing to decide attach-vs-start. + """ + import socket + + try: + with socket.create_connection((host or "127.0.0.1", port), timeout=1.5): + return True + except OSError: + return False + + def cmd_dashboard(args): """Start the web UI server, or (with --stop/--status) manage running ones.""" # --status: report running dashboards and exit, no deps needed. @@ -10240,6 +10255,65 @@ def cmd_dashboard(args): remaining = _find_stale_dashboard_pids() sys.exit(1 if remaining else 0) + # ── Unified profile launch routing ──────────────────────────────── + # The dashboard is a MACHINE management surface: it can read/write any + # profile via the per-request ?profile= scoping. Running one dashboard + # per profile just fragments that (port collisions, N processes, and a + # "which dashboard am I on?" guessing game). So when a NAMED profile + # launches the dashboard (`worker dashboard` → HERMES_HOME points into + # profiles/), default to the machine dashboard: + # - already running → open the browser at ?profile= and exit + # - not running → re-exec as the machine dashboard (pinned to the + # default profile so _apply_profile_override can't re-route through + # the sticky active_profile file) with the launching profile + # preselected in the UI's switcher. + # `--isolated` opts out and preserves the old per-profile behavior. + try: + from hermes_cli.profiles import get_active_profile_name + _launch_profile = get_active_profile_name() + except Exception: + _launch_profile = "default" + + if ( + _launch_profile not in ("default", "custom") + and not getattr(args, "isolated", False) + and not getattr(args, "open_profile", "") + ): + url = f"http://{args.host or '127.0.0.1'}:{args.port}/?profile={_launch_profile}" + if _dashboard_listening(args.host, args.port): + print(f"Machine dashboard already running on port {args.port}.") + print(f" Managing profile '{_launch_profile}': {url}") + if not args.no_open: + try: + import webbrowser + webbrowser.open(url) + except Exception: + pass + sys.exit(0) + + print( + f"Routing to the machine dashboard (profile '{_launch_profile}' " + f"preselected). Use --isolated for a dedicated per-profile server." + ) + reexec_argv = [ + sys.executable, "-m", "hermes_cli.main", + "-p", "default", + "dashboard", + "--port", str(args.port), + "--host", args.host, + "--open-profile", _launch_profile, + ] + if args.no_open: + reexec_argv.append("--no-open") + if getattr(args, "insecure", False): + reexec_argv.append("--insecure") + if getattr(args, "skip_build", False): + reexec_argv.append("--skip-build") + env = os.environ.copy() + # Drop the profile HERMES_HOME so the child binds the machine root. + env.pop("HERMES_HOME", None) + os.execvpe(sys.executable, reexec_argv, env) + # Attach gui.log early so dashboard startup/build failures are captured in # the same logs directory as every other Hermes surface. try: @@ -10313,6 +10387,7 @@ def cmd_dashboard(args): port=args.port, open_browser=not args.no_open, allow_public=getattr(args, "insecure", False), + initial_profile=getattr(args, "open_profile", "") or "", ) diff --git a/hermes_cli/subcommands/dashboard.py b/hermes_cli/subcommands/dashboard.py index 6bdb858513d..01ee57e2624 100644 --- a/hermes_cli/subcommands/dashboard.py +++ b/hermes_cli/subcommands/dashboard.py @@ -45,6 +45,26 @@ def build_dashboard_parser( "where npm may not be available. Pre-build with: cd web && npm run build" ), ) + dashboard_parser.add_argument( + "--isolated", + action="store_true", + help=( + "When launched from a named profile (e.g. `worker dashboard`), run " + "a dedicated dashboard server scoped to that profile instead of " + "routing to the machine dashboard. Default behavior is unified: " + "profile launches attach to (or start) ONE machine-level dashboard " + "and preselect the profile in the UI's profile switcher." + ), + ) + # Internal flag set by the unified-launch re-exec (cmd_dashboard) to + # preselect the launching profile in the SPA switcher. Hidden from + # --help: users get this behavior automatically via ` dashboard`. + dashboard_parser.add_argument( + "--open-profile", + dest="open_profile", + default="", + help=argparse.SUPPRESS, + ) # Lifecycle flags — mutually exclusive with each other and with the # start-a-server flags above (if both are passed, --stop / --status win # because they exit before the server is started). The dashboard has diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index ef1c15bac93..1b83a95893a 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -625,19 +625,23 @@ CONFIG_SCHEMA = _ordered_schema class ConfigUpdate(BaseModel): config: dict + profile: Optional[str] = None class EnvVarUpdate(BaseModel): key: str value: str + profile: Optional[str] = None class EnvVarDelete(BaseModel): key: str + profile: Optional[str] = None class EnvVarReveal(BaseModel): key: str + profile: Optional[str] = None class MessagingPlatformUpdate(BaseModel): @@ -716,6 +720,7 @@ class ModelAssignment(BaseModel): # the path that actually wires a local endpoint into resolution. base_url: str = "" confirm_expensive_model: bool = False + profile: Optional[str] = None def _apply_main_model_assignment( @@ -2517,8 +2522,9 @@ def _normalize_config_for_web(config: Dict[str, Any]) -> Dict[str, Any]: @app.get("/api/config") -async def get_config(): - config = _normalize_config_for_web(load_config()) +async def get_config(profile: Optional[str] = None): + with _profile_scope(profile): + config = _normalize_config_for_web(load_config()) # Strip internal keys that the frontend shouldn't see or send back return {k: v for k, v in config.items() if not k.startswith("_")} @@ -2544,7 +2550,7 @@ _EMPTY_MODEL_INFO: dict = { @app.get("/api/model/info") -def get_model_info(): +def get_model_info(profile: Optional[str] = None): """Return resolved model metadata for the currently configured model. Calls the same context-length resolution chain the agent uses, so the @@ -2552,7 +2558,8 @@ def get_model_info(): Also returns model capabilities (vision, reasoning, tools) when available. """ try: - cfg = load_config() + with _profile_scope(profile): + cfg = load_config() model_cfg = cfg.get("model", "") # Extract model name and provider from the config @@ -2615,6 +2622,10 @@ def get_model_info(): "effective_context_length": effective_ctx, "capabilities": caps, } + except HTTPException: + # Unknown/invalid profile must surface as 404, not degrade into a + # 200 with empty model info (which would render as "no model set"). + raise except Exception: _log.exception("GET /api/model/info failed") return dict(_EMPTY_MODEL_INFO) @@ -2644,13 +2655,17 @@ _AUX_TASK_SLOTS: Tuple[str, ...] = ( @app.get("/api/model/options") -def get_model_options(): +def get_model_options(profile: Optional[str] = None): """Return authenticated providers + their curated model lists. REST equivalent of the ``model.options`` JSON-RPC on tui_gateway, so the dashboard Models page can render the picker without a live chat session. The response shape matches ``model.options`` 1:1 so ``ModelPickerDialog`` can share the same types. + + ``profile`` scopes the picker context (current model/provider, custom + providers from config, per-profile .env auth state) so the Models page + reads the SAME profile /api/model/set writes. """ try: from hermes_cli.inventory import build_models_payload, load_picker_context @@ -2663,15 +2678,18 @@ def get_model_options(): # come back as skeleton rows carrying `authenticated=False` + # `auth_type`/`key_env`/`warning` so the GUI can render a setup # affordance instead of hiding the provider entirely. - return build_models_payload( - load_picker_context(), - max_models=50, - include_unconfigured=True, - picker_hints=True, - canonical_order=True, - pricing=True, - capabilities=True, - ) + with _profile_scope(profile): + return build_models_payload( + load_picker_context(), + max_models=50, + include_unconfigured=True, + picker_hints=True, + canonical_order=True, + pricing=True, + capabilities=True, + ) + except HTTPException: + raise except Exception: _log.exception("GET /api/model/options failed") raise HTTPException(status_code=500, detail="Failed to list model options") @@ -2750,7 +2768,7 @@ def get_recommended_default_model(provider: str = ""): @app.get("/api/model/auxiliary") -def get_auxiliary_models(): +def get_auxiliary_models(profile: Optional[str] = None): """Return current auxiliary task assignments. Shape: @@ -2761,9 +2779,14 @@ def get_auxiliary_models(): ], "main": {"provider": "openrouter", "model": "anthropic/claude-opus-4.7"}, } + + ``profile`` scopes the read — without it, the Models page would show + the dashboard profile's auxiliary pins while /api/model/set wrote the + selected profile's (read/write asymmetry). """ try: - cfg = load_config() + with _profile_scope(profile): + cfg = load_config() aux_cfg = cfg.get("auxiliary", {}) if not isinstance(aux_cfg, dict): aux_cfg = {} @@ -2788,13 +2811,15 @@ def get_auxiliary_models(): main = {"provider": "", "model": str(model_cfg) if model_cfg else ""} return {"tasks": tasks, "main": main} + except HTTPException: + raise except Exception: _log.exception("GET /api/model/auxiliary failed") raise HTTPException(status_code=500, detail="Failed to read auxiliary config") @app.post("/api/model/set") -async def set_model_assignment(body: ModelAssignment): +async def set_model_assignment(body: ModelAssignment, profile: Optional[str] = None): """Assign a model to the main slot or an auxiliary task slot. Writes to ``~/.hermes/config.yaml`` — applies to **new** sessions only. @@ -2811,8 +2836,10 @@ async def set_model_assignment(body: ModelAssignment): raise HTTPException(status_code=400, detail="scope must be 'main' or 'auxiliary'") try: - cfg = load_config() - + # Expensive-model warning runs BEFORE the profile scope is entered: + # _profile_scope must never be held across an await (the RLock is + # reentrant per-thread, so a second coroutine interleaving on the + # event-loop thread could cross-restore the module globals). if model and not body.confirm_expensive_model: try: from hermes_cli.model_cost_guard import expensive_model_warning @@ -2837,125 +2864,13 @@ async def set_model_assignment(body: ModelAssignment): "confirm_message": warning.message, } - if scope == "main": - if not provider or not model: - raise HTTPException(status_code=400, detail="provider and model required for main") - model_cfg = _apply_main_model_assignment( - cfg.get("model", {}), provider, model, base_url - ) - cfg["model"] = model_cfg + def _apply_assignment(): + with _profile_scope(body.profile or profile): + return _apply_model_assignment_sync( + scope, provider, model, task, base_url + ) - # When switching the main provider to Nous, mirror the CLI's - # post-model-selection behaviour (hermes_cli/main.py - # prompt_enable_tool_gateway / tools_config apply_nous_managed_defaults): - # auto-route any *unconfigured* tools through the Nous Tool Gateway. - # This is purely additive — apply_nous_managed_defaults skips every - # tool where the user already has a direct key (FIRECRAWL_API_KEY, - # FAL_KEY, etc.) or an explicit backend/provider in config, so it - # never overwrites a user's own setup. GUI users thus land on the - # gateway the same way CLI users do, without a separate prompt. - gateway_tools: list[str] = [] - if provider.strip().lower() == "nous": - try: - from hermes_cli.nous_subscription import apply_nous_managed_defaults - from hermes_cli.tools_config import _get_platform_tools - - enabled = _get_platform_tools( - cfg, "cli", include_default_mcp_servers=False - ) - changed = apply_nous_managed_defaults( - cfg, - enabled_toolsets=enabled, - force_fresh=True, - ) - gateway_tools = sorted(changed) - except Exception: - # Portal lookup hiccups / non-subscriber / non-nous gating - # must never block saving the model assignment. - _log.debug("apply_nous_managed_defaults skipped", exc_info=True) - - save_config(cfg) - - # Surface auxiliary slots still pinned to a *different* provider than - # the new main one. Switching the main model does NOT touch aux pins - # (they're independent, sticky per-task overrides — see - # auxiliary_client._resolve_auto). A user who switches main away from - # a now-unpaid provider (e.g. nous with $0 balance) keeps paying 402s - # on every background aux call until they reset those pins. We never - # auto-clear them — pinning aux to a cheaper/different model is a - # legitimate config — but we tell the caller so the UI can offer a - # "reset to main" nudge instead of silently burning credits. - new_provider = provider.strip().lower() - stale_aux: list[dict] = [] - aux_cfg = cfg.get("auxiliary", {}) - if isinstance(aux_cfg, dict): - for slot in _AUX_TASK_SLOTS: - slot_cfg = aux_cfg.get(slot) - if not isinstance(slot_cfg, dict): - continue - slot_provider = str(slot_cfg.get("provider", "") or "").strip() - if ( - slot_provider - and slot_provider.lower() not in {"auto", ""} - and slot_provider.lower() != new_provider - ): - stale_aux.append({ - "task": slot, - "provider": slot_provider, - "model": str(slot_cfg.get("model", "") or ""), - }) - - return { - "ok": True, - "scope": "main", - "provider": provider, - "model": model, - "base_url": model_cfg.get("base_url", ""), - "gateway_tools": gateway_tools, - "stale_aux": stale_aux, - } - - # scope == "auxiliary" - aux = cfg.get("auxiliary") - if not isinstance(aux, dict): - aux = {} - - if task == "__reset__": - # Reset every slot to provider="auto", model="" — keeps other fields intact. - for slot in _AUX_TASK_SLOTS: - slot_cfg = aux.get(slot) - if not isinstance(slot_cfg, dict): - slot_cfg = {} - slot_cfg["provider"] = "auto" - slot_cfg["model"] = "" - aux[slot] = slot_cfg - cfg["auxiliary"] = aux - save_config(cfg) - return {"ok": True, "scope": "auxiliary", "reset": True} - - if not provider: - raise HTTPException(status_code=400, detail="provider required for auxiliary") - - targets = [task] if task else list(_AUX_TASK_SLOTS) - for slot in targets: - if slot not in _AUX_TASK_SLOTS: - raise HTTPException(status_code=400, detail=f"unknown auxiliary task: {slot}") - slot_cfg = aux.get(slot) - if not isinstance(slot_cfg, dict): - slot_cfg = {} - slot_cfg["provider"] = provider - slot_cfg["model"] = model - aux[slot] = slot_cfg - - cfg["auxiliary"] = aux - save_config(cfg) - return { - "ok": True, - "scope": "auxiliary", - "tasks": targets, - "provider": provider, - "model": model, - } + return await asyncio.to_thread(_apply_assignment) except HTTPException: raise except Exception: @@ -2963,6 +2878,138 @@ async def set_model_assignment(body: ModelAssignment): raise HTTPException(status_code=500, detail="Failed to save model assignment") +def _apply_model_assignment_sync( + scope: str, provider: str, model: str, task: str, base_url: str +): + """Synchronous body of POST /api/model/set. + + Runs inside ``_profile_scope`` (in a worker thread) so every + load_config/save_config lands in the requested profile. Raises + HTTPException for validation errors — the async wrapper re-raises them. + """ + cfg = load_config() + + if scope == "main": + if not provider or not model: + raise HTTPException(status_code=400, detail="provider and model required for main") + model_cfg = _apply_main_model_assignment( + cfg.get("model", {}), provider, model, base_url + ) + cfg["model"] = model_cfg + + # When switching the main provider to Nous, mirror the CLI's + # post-model-selection behaviour (hermes_cli/main.py + # prompt_enable_tool_gateway / tools_config apply_nous_managed_defaults): + # auto-route any *unconfigured* tools through the Nous Tool Gateway. + # This is purely additive — apply_nous_managed_defaults skips every + # tool where the user already has a direct key (FIRECRAWL_API_KEY, + # FAL_KEY, etc.) or an explicit backend/provider in config, so it + # never overwrites a user's own setup. GUI users thus land on the + # gateway the same way CLI users do, without a separate prompt. + gateway_tools: list[str] = [] + if provider.strip().lower() == "nous": + try: + from hermes_cli.nous_subscription import apply_nous_managed_defaults + from hermes_cli.tools_config import _get_platform_tools + + enabled = _get_platform_tools( + cfg, "cli", include_default_mcp_servers=False + ) + changed = apply_nous_managed_defaults( + cfg, + enabled_toolsets=enabled, + force_fresh=True, + ) + gateway_tools = sorted(changed) + except Exception: + # Portal lookup hiccups / non-subscriber / non-nous gating + # must never block saving the model assignment. + _log.debug("apply_nous_managed_defaults skipped", exc_info=True) + + save_config(cfg) + + # Surface auxiliary slots still pinned to a *different* provider than + # the new main one. Switching the main model does NOT touch aux pins + # (they're independent, sticky per-task overrides — see + # auxiliary_client._resolve_auto). A user who switches main away from + # a now-unpaid provider (e.g. nous with $0 balance) keeps paying 402s + # on every background aux call until they reset those pins. We never + # auto-clear them — pinning aux to a cheaper/different model is a + # legitimate config — but we tell the caller so the UI can offer a + # "reset to main" nudge instead of silently burning credits. + new_provider = provider.strip().lower() + stale_aux: list[dict] = [] + aux_cfg = cfg.get("auxiliary", {}) + if isinstance(aux_cfg, dict): + for slot in _AUX_TASK_SLOTS: + slot_cfg = aux_cfg.get(slot) + if not isinstance(slot_cfg, dict): + continue + slot_provider = str(slot_cfg.get("provider", "") or "").strip() + if ( + slot_provider + and slot_provider.lower() not in {"auto", ""} + and slot_provider.lower() != new_provider + ): + stale_aux.append({ + "task": slot, + "provider": slot_provider, + "model": str(slot_cfg.get("model", "") or ""), + }) + + return { + "ok": True, + "scope": "main", + "provider": provider, + "model": model, + "base_url": model_cfg.get("base_url", ""), + "gateway_tools": gateway_tools, + "stale_aux": stale_aux, + } + + # scope == "auxiliary" + aux = cfg.get("auxiliary") + if not isinstance(aux, dict): + aux = {} + + if task == "__reset__": + # Reset every slot to provider="auto", model="" — keeps other fields intact. + for slot in _AUX_TASK_SLOTS: + slot_cfg = aux.get(slot) + if not isinstance(slot_cfg, dict): + slot_cfg = {} + slot_cfg["provider"] = "auto" + slot_cfg["model"] = "" + aux[slot] = slot_cfg + cfg["auxiliary"] = aux + save_config(cfg) + return {"ok": True, "scope": "auxiliary", "reset": True} + + if not provider: + raise HTTPException(status_code=400, detail="provider required for auxiliary") + + targets = [task] if task else list(_AUX_TASK_SLOTS) + for slot in targets: + if slot not in _AUX_TASK_SLOTS: + raise HTTPException(status_code=400, detail=f"unknown auxiliary task: {slot}") + slot_cfg = aux.get(slot) + if not isinstance(slot_cfg, dict): + slot_cfg = {} + slot_cfg["provider"] = provider + slot_cfg["model"] = model + aux[slot] = slot_cfg + + cfg["auxiliary"] = aux + save_config(cfg) + return { + "ok": True, + "scope": "auxiliary", + "tasks": targets, + "provider": provider, + "model": model, + } + + def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]: @@ -3018,18 +3065,22 @@ def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]: @app.put("/api/config") -async def update_config(body: ConfigUpdate): +async def update_config(body: ConfigUpdate, profile: Optional[str] = None): try: - save_config(_denormalize_config_from_web(body.config)) + with _profile_scope(body.profile or profile): + save_config(_denormalize_config_from_web(body.config)) return {"ok": True} + except HTTPException: + raise except Exception: _log.exception("PUT /api/config failed") raise HTTPException(status_code=500, detail="Internal server error") @app.get("/api/env") -async def get_env_vars(): - env_on_disk = load_env() +async def get_env_vars(profile: Optional[str] = None): + with _profile_scope(profile): + env_on_disk = load_env() channel_keys = _channel_managed_env_keys() result = {} for var_name, info in OPTIONAL_ENV_VARS.items(): @@ -3052,9 +3103,10 @@ async def get_env_vars(): @app.put("/api/env") -async def set_env_var(body: EnvVarUpdate): +async def set_env_var(body: EnvVarUpdate, profile: Optional[str] = None): try: - save_env_value(body.key, body.value) + with _profile_scope(body.profile or profile): + save_env_value(body.key, body.value) return {"ok": True, "key": body.key} except ValueError as exc: # save_env_value raises ValueError for invalid names and for keys @@ -3165,9 +3217,10 @@ async def validate_provider_credential(body: EnvVarUpdate, request: Request): @app.delete("/api/env") -async def remove_env_var(body: EnvVarDelete): +async def remove_env_var(body: EnvVarDelete, profile: Optional[str] = None): try: - removed = remove_env_value(body.key) + with _profile_scope(body.profile or profile): + removed = remove_env_value(body.key) if not removed: raise HTTPException(status_code=404, detail=f"{body.key} not found in .env") return {"ok": True, "key": body.key} @@ -3184,7 +3237,9 @@ async def remove_env_var(body: EnvVarDelete): @app.post("/api/env/reveal") -async def reveal_env_var(body: EnvVarReveal, request: Request): +async def reveal_env_var( + body: EnvVarReveal, request: Request, profile: Optional[str] = None +): """Return the real (unredacted) value of a single env var. Protected by: @@ -3204,7 +3259,8 @@ async def reveal_env_var(body: EnvVarReveal, request: Request): _reveal_timestamps.append(now) # --- Reveal --- - env_on_disk = load_env() + with _profile_scope(body.profile or profile): + env_on_disk = load_env() value = env_on_disk.get(body.key) if value is None: raise HTTPException(status_code=404, detail=f"{body.key} not found in .env") @@ -6409,6 +6465,7 @@ class MCPServerCreate(BaseModel): env: Dict[str, str] = {} # auth: "oauth" | "header" | None auth: Optional[str] = None + profile: Optional[str] = None def _redact_mcp_env(env: Dict[str, Any]) -> Dict[str, str]: @@ -6439,10 +6496,11 @@ def _mcp_server_summary(name: str, cfg: Dict[str, Any]) -> Dict[str, Any]: @app.get("/api/mcp/servers") -async def list_mcp_servers(): +async def list_mcp_servers(profile: Optional[str] = None): from hermes_cli.mcp_config import _get_mcp_servers - servers = _get_mcp_servers() + with _profile_scope(profile): + servers = _get_mcp_servers() return { "servers": [ _mcp_server_summary(name, cfg) for name, cfg in sorted(servers.items()) @@ -6451,13 +6509,15 @@ async def list_mcp_servers(): @app.post("/api/mcp/servers") -async def add_mcp_server(body: MCPServerCreate): +async def add_mcp_server(body: MCPServerCreate, profile: Optional[str] = None): from hermes_cli.mcp_config import _get_mcp_servers, _save_mcp_server name = (body.name or "").strip() if not name: raise HTTPException(status_code=400, detail="Server name is required") - if name in _get_mcp_servers(): + with _profile_scope(body.profile or profile): + existing = _get_mcp_servers() + if name in existing: raise HTTPException(status_code=409, detail=f"Server '{name}' already exists") if not body.url and not body.command: raise HTTPException( @@ -6478,7 +6538,10 @@ async def add_mcp_server(body: MCPServerCreate): server_config["auth"] = body.auth try: - _save_mcp_server(name, server_config) + with _profile_scope(body.profile or profile): + _save_mcp_server(name, server_config) + except HTTPException: + raise except Exception as exc: _log.exception("POST /api/mcp/servers failed") raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -6487,27 +6550,43 @@ async def add_mcp_server(body: MCPServerCreate): @app.delete("/api/mcp/servers/{name}") -async def remove_mcp_server(name: str): +async def remove_mcp_server(name: str, profile: Optional[str] = None): from hermes_cli.mcp_config import _remove_mcp_server - if not _remove_mcp_server(name): + with _profile_scope(profile): + removed = _remove_mcp_server(name) + if not removed: raise HTTPException(status_code=404, detail=f"Server '{name}' not found") return {"ok": True} @app.post("/api/mcp/servers/{name}/test") -async def test_mcp_server(name: str): +async def test_mcp_server(name: str, profile: Optional[str] = None): """Connect to the server, list its tools, disconnect. Returns tool list.""" from hermes_cli.mcp_config import _get_mcp_servers, _probe_single_server - servers = _get_mcp_servers() + with _profile_scope(profile): + servers = _get_mcp_servers() if name not in servers: raise HTTPException(status_code=404, detail=f"Server '{name}' not found") + def _probe_scoped(): + # Re-enter the scope INSIDE the worker thread so call-time + # resolution during the probe — env-placeholder expansion in + # _resolve_mcp_server_config reading the profile's .env — sees the + # selected profile, matching the config the server was saved into. + # (asyncio.to_thread copies contextvars, but entering explicitly + # keeps the lock-protected SKILLS_DIR swap balanced per-thread.) + # Known limit: the dedicated MCP event-loop thread spawned by the + # probe doesn't inherit the contextvar, so OAuth token-store paths + # resolve against the process HERMES_HOME. + with _profile_scope(profile): + return _probe_single_server(name, servers[name]) + try: # Probe blocks on a dedicated MCP event loop — run in a thread so the # FastAPI event loop is never blocked. - tools = await asyncio.to_thread(_probe_single_server, name, servers[name]) + tools = await asyncio.to_thread(_probe_scoped) except Exception as exc: return { "ok": False, @@ -6522,34 +6601,40 @@ async def test_mcp_server(name: str): class MCPEnabledToggle(BaseModel): enabled: bool + profile: Optional[str] = None @app.put("/api/mcp/servers/{name}/enabled") -async def set_mcp_server_enabled(name: str, body: MCPEnabledToggle): +async def set_mcp_server_enabled( + name: str, body: MCPEnabledToggle, profile: Optional[str] = None +): """Enable or disable an MCP server (takes effect on next session/gateway). Toggles the ``enabled`` key on the server's config.yaml entry — the same flag the agent reads at startup. Disabled servers stay in config so they can be re-enabled without re-entering their settings. """ - cfg = load_config() - servers = cfg.get("mcp_servers") - if not isinstance(servers, dict) or name not in servers: - raise HTTPException(status_code=404, detail=f"Server '{name}' not found") - if not isinstance(servers[name], dict): - raise HTTPException(status_code=400, detail="Malformed server config") - servers[name]["enabled"] = bool(body.enabled) - save_config(cfg) + with _profile_scope(body.profile or profile): + cfg = load_config() + servers = cfg.get("mcp_servers") + if not isinstance(servers, dict) or name not in servers: + raise HTTPException(status_code=404, detail=f"Server '{name}' not found") + if not isinstance(servers[name], dict): + raise HTTPException(status_code=400, detail="Malformed server config") + servers[name]["enabled"] = bool(body.enabled) + save_config(cfg) return {"ok": True, "name": name, "enabled": bool(body.enabled)} @app.get("/api/mcp/catalog") -async def list_mcp_catalog(): +async def list_mcp_catalog(profile: Optional[str] = None): """Browse the Nous-approved MCP catalog (the optional-mcps/ manifests). Each entry reports whether it's already installed and enabled so the UI can show install / enabled state inline. This is the same catalog - `hermes mcp catalog` / `hermes mcp install` read. + `hermes mcp catalog` / `hermes mcp install` read. ``profile`` scopes + the installed/enabled annotations (the catalog itself is repo-shipped + and identical for every profile). """ try: from hermes_cli import mcp_catalog @@ -6559,7 +6644,13 @@ async def list_mcp_catalog(): entries = [] try: - for entry in mcp_catalog.list_catalog(): + with _profile_scope(profile): + catalog_entries = list(mcp_catalog.list_catalog()) + installed_state = { + e.name: (mcp_catalog.is_installed(e.name), mcp_catalog.is_enabled(e.name)) + for e in catalog_entries + } + for entry in catalog_entries: auth = entry.auth entries.append({ "name": entry.name, @@ -6573,9 +6664,12 @@ async def list_mcp_catalog(): for e in getattr(auth, "env", []) or [] ], "needs_install": entry.install is not None, - "installed": mcp_catalog.is_installed(entry.name), - "enabled": mcp_catalog.is_enabled(entry.name), + "installed": installed_state.get(entry.name, (False, False))[0], + "enabled": installed_state.get(entry.name, (False, False))[1], }) + except HTTPException: + # Unknown/invalid profile → 404, not a silently-empty catalog. + raise except Exception: _log.exception("list_mcp_catalog failed") @@ -6596,10 +6690,11 @@ class MCPCatalogInstall(BaseModel): # env: KEY=VALUE map for catalog entries that declare required env vars. env: Dict[str, str] = {} enable: bool = True + profile: Optional[str] = None @app.post("/api/mcp/catalog/install") -async def install_mcp_catalog_entry(body: MCPCatalogInstall): +async def install_mcp_catalog_entry(body: MCPCatalogInstall, profile: Optional[str] = None): """Install a catalog MCP into config.yaml. For HTTP/stdio entries with required env vars, those are written to .env @@ -6616,23 +6711,42 @@ async def install_mcp_catalog_entry(body: MCPCatalogInstall): # Persist any supplied env vars first (catalog entries declare which names # they need; we only write the ones the user provided). + effective_profile = body.profile or profile if body.env: - for k, v in body.env.items(): - if v: - save_env_value(k, v) + with _profile_scope(effective_profile): + for k, v in body.env.items(): + if v: + save_env_value(k, v) # Git-bootstrap entries can take a while to clone — run via the background # action path so the request returns immediately and the UI can tail logs. + # The -p subprocess rebinds HERMES_HOME-derived paths in the child. if entry.install is not None: try: - proc = _spawn_hermes_action(["mcp", "install", name], "mcp-install") + proc = _spawn_hermes_action( + _profile_cli_args(effective_profile) + ["mcp", "install", name], + "mcp-install", + ) + except HTTPException: + raise except Exception as exc: raise HTTPException(status_code=500, detail=f"Install failed: {exc}") return {"ok": True, "name": name, "background": True, "action": "mcp-install"} - # No git step — install synchronously via the catalog API. + # No git step — install synchronously via the catalog API. install_entry + # routes through load_config/save_config + save_env_value, all call-time + # resolvers, so the context override scopes it. Wrap the to_thread body + # in the scope INSIDE the thread (contextvars don't propagate into + # to_thread the other way around — asyncio.to_thread copies context, so + # setting it here works; keep it explicit for clarity). + def _install_scoped(): + with _profile_scope(effective_profile): + mcp_catalog.install_entry(entry, enable=body.enable) + try: - await asyncio.to_thread(mcp_catalog.install_entry, entry, enable=body.enable) + await asyncio.to_thread(_install_scoped) + except HTTPException: + raise except Exception as exc: _log.exception("install_mcp_catalog_entry failed") raise HTTPException(status_code=400, detail=str(exc)) @@ -7437,13 +7551,13 @@ def _profile_cli_args(profile: Optional[str]) -> List[str]: @app.post("/api/skills/hub/install") -async def install_skill_hub(body: SkillInstallRequest): +async def install_skill_hub(body: SkillInstallRequest, profile: Optional[str] = None): identifier = (body.identifier or "").strip() if not identifier: raise HTTPException(status_code=400, detail="identifier is required") try: proc = _spawn_hermes_action( - _profile_cli_args(body.profile) + ["skills", "install", identifier], + _profile_cli_args(body.profile or profile) + ["skills", "install", identifier], "skills-install", ) except HTTPException: @@ -7460,13 +7574,13 @@ class SkillUninstallRequest(BaseModel): @app.post("/api/skills/hub/uninstall") -async def uninstall_skill_hub(body: SkillUninstallRequest): +async def uninstall_skill_hub(body: SkillUninstallRequest, profile: Optional[str] = None): name = (body.name or "").strip() if not name: raise HTTPException(status_code=400, detail="name is required") try: proc = _spawn_hermes_action( - _profile_cli_args(body.profile) + ["skills", "uninstall", name, "--yes"], + _profile_cli_args(body.profile or profile) + ["skills", "uninstall", name, "--yes"], "skills-uninstall", ) except HTTPException: @@ -7482,11 +7596,13 @@ class SkillsUpdateRequest(BaseModel): @app.post("/api/skills/hub/update") -async def update_skills_hub(body: Optional[SkillsUpdateRequest] = None): +async def update_skills_hub( + body: Optional[SkillsUpdateRequest] = None, profile: Optional[str] = None +): try: - profile = body.profile if body else None + effective = (body.profile if body else None) or profile proc = _spawn_hermes_action( - _profile_cli_args(profile) + ["skills", "update"], "skills-update" + _profile_cli_args(effective) + ["skills", "update"], "skills-update" ) except HTTPException: raise @@ -8504,9 +8620,9 @@ async def get_skills(profile: Optional[str] = None): @app.put("/api/skills/toggle") -async def toggle_skill(body: SkillToggle): +async def toggle_skill(body: SkillToggle, profile: Optional[str] = None): from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills - with _profile_scope(body.profile): + with _profile_scope(body.profile or profile): config = load_config() disabled = get_disabled_skills(config) if body.enabled: @@ -8559,7 +8675,7 @@ class ToolsetToggle(BaseModel): @app.put("/api/tools/toolsets/{name}") -async def toggle_toolset(name: str, body: ToolsetToggle): +async def toggle_toolset(name: str, body: ToolsetToggle, profile: Optional[str] = None): """Enable/disable a configurable toolset for the desktop (cli) platform. Persists to ``platform_toolsets.cli`` via the same ``_save_platform_tools`` @@ -8577,7 +8693,7 @@ async def toggle_toolset(name: str, body: ToolsetToggle): if name not in valid: raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}") - with _profile_scope(body.profile): + with _profile_scope(body.profile or profile): config = load_config() enabled = set( _get_platform_tools(config, "cli", include_default_mcp_servers=False) @@ -8659,7 +8775,9 @@ class ToolsetProviderSelect(BaseModel): @app.put("/api/tools/toolsets/{name}/provider") -async def select_toolset_provider(name: str, body: ToolsetProviderSelect): +async def select_toolset_provider( + name: str, body: ToolsetProviderSelect, profile: Optional[str] = None +): """Persist a provider selection for a toolset (no key prompting). Delegates to ``apply_provider_selection`` — the shared, non-interactive @@ -8677,7 +8795,7 @@ async def select_toolset_provider(name: str, body: ToolsetProviderSelect): if name not in valid: raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}") - with _profile_scope(body.profile): + with _profile_scope(body.profile or profile): config = load_config() try: apply_provider_selection(name, body.provider, config) @@ -8693,7 +8811,7 @@ class ToolsetEnvUpdate(BaseModel): @app.put("/api/tools/toolsets/{name}/env") -async def save_toolset_env(name: str, body: ToolsetEnvUpdate): +async def save_toolset_env(name: str, body: ToolsetEnvUpdate, profile: Optional[str] = None): """Persist API keys for a toolset's provider env vars. Writes each ``key: value`` to ``~/.hermes/.env`` via ``save_env_value`` — @@ -8715,7 +8833,7 @@ async def save_toolset_env(name: str, body: ToolsetEnvUpdate): if name not in valid_ts: raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}") - with _profile_scope(body.profile): + with _profile_scope(body.profile or profile): config = load_config() cat = TOOL_CATEGORIES.get(name) allowed: set[str] = set() @@ -8749,10 +8867,13 @@ async def save_toolset_env(name: str, body: ToolsetEnvUpdate): class ToolsetPostSetup(BaseModel): key: str + profile: Optional[str] = None @app.post("/api/tools/toolsets/{name}/post-setup") -async def run_toolset_post_setup(name: str, body: ToolsetPostSetup): +async def run_toolset_post_setup( + name: str, body: ToolsetPostSetup, profile: Optional[str] = None +): """Spawn a provider's post-setup install hook as a background action. Post-setup hooks (npm install for browser/Camofox, pip install for @@ -8762,6 +8883,12 @@ async def run_toolset_post_setup(name: str, body: ToolsetPostSetup): ``GET /api/actions/tools-post-setup/status``. The ``key`` is validated against the declared post-setup allowlist before spawning. Returns 400 for unknown toolset or post-setup key. + + ``profile`` spawns the hook as ``hermes -p tools post-setup``. + Most hooks install machine-level artifacts (repo node_modules, shared + pip packages) where the scope is inert, but hooks that read config or + write per-profile state must see the same HERMES_HOME the rest of the + drawer's writes targeted — so the scope is threaded for consistency. """ from hermes_cli.tools_config import ( _get_effective_configurable_toolsets, @@ -8779,8 +8906,12 @@ async def run_toolset_post_setup(name: str, body: ToolsetPostSetup): try: proc = _spawn_hermes_action( - ["tools", "post-setup", body.key], "tools-post-setup" + _profile_cli_args(body.profile or profile) + + ["tools", "post-setup", body.key], + "tools-post-setup", ) + except HTTPException: + raise except Exception as exc: _log.exception("Failed to spawn tools post-setup") raise HTTPException( @@ -8796,23 +8927,26 @@ async def run_toolset_post_setup(name: str, body: ToolsetPostSetup): class RawConfigUpdate(BaseModel): yaml_text: str + profile: Optional[str] = None @app.get("/api/config/raw") -async def get_config_raw(): - path = get_config_path() +async def get_config_raw(profile: Optional[str] = None): + with _profile_scope(profile): + path = get_config_path() if not path.exists(): return {"yaml": ""} return {"yaml": path.read_text(encoding="utf-8")} @app.put("/api/config/raw") -async def update_config_raw(body: RawConfigUpdate): +async def update_config_raw(body: RawConfigUpdate, profile: Optional[str] = None): try: parsed = yaml.safe_load(body.yaml_text) if not isinstance(parsed, dict): raise HTTPException(status_code=400, detail="YAML must be a mapping") - save_config(parsed) + with _profile_scope(body.profile or profile): + save_config(parsed) return {"ok": True} except yaml.YAMLError as e: raise HTTPException(status_code=400, detail=f"Invalid YAML: {e}") @@ -9260,6 +9394,7 @@ def _ws_auth_ok(ws: "WebSocket") -> bool: def _resolve_chat_argv( resume: Optional[str] = None, sidecar_url: Optional[str] = None, + profile: Optional[str] = None, ) -> tuple[list[str], Optional[str], Optional[dict]]: """Resolve the argv + cwd + env for the chat PTY. @@ -9279,9 +9414,24 @@ def _resolve_chat_argv( `sidecar_url` (when set) is forwarded as ``HERMES_TUI_SIDECAR_URL`` so the spawned ``tui_gateway.entry`` can mirror dispatcher emits to the dashboard's ``/api/pub`` endpoint (see :func:`pub_ws`). + + `profile` (when set) scopes the ENTIRE chat to that profile by pointing + ``HERMES_HOME`` at the profile dir in the child env. Every spawned + process (the TUI and the ``tui_gateway.entry`` it launches) resolves + ``get_hermes_home()`` from that env var at its own import, so the child + binds the profile's config, skills, memory, and state.db from the start + — the same propagation ``hermes -p `` performs. The in-process + ``HERMES_TUI_GATEWAY_URL`` attach is SKIPPED for scoped chats: the + dashboard's in-memory gateway runs under the dashboard's own profile, + so a profile-scoped chat must spawn its own gateway subprocess. """ from hermes_cli.main import PROJECT_ROOT, _make_tui_argv + profile_dir: Optional[Path] = None + requested = (profile or "").strip() + if requested and requested.lower() != "current": + profile_dir = _resolve_profile_dir(requested) + argv, cwd = _make_tui_argv(PROJECT_ROOT / "ui-tui", tui_dev=False) env = os.environ.copy() try: @@ -9299,6 +9449,9 @@ def _resolve_chat_argv( env.setdefault("HERMES_TUI_DISABLE_MOUSE", "1") env.setdefault("HERMES_TUI_INLINE", "1") + if profile_dir is not None: + env["HERMES_HOME"] = str(profile_dir) + if resume: latest_resume, _latest_path = _session_latest_descendant(resume) if latest_resume: @@ -9308,8 +9461,13 @@ def _resolve_chat_argv( if sidecar_url: env["HERMES_TUI_SIDECAR_URL"] = sidecar_url - if gateway_ws_url := _build_gateway_ws_url(): - env["HERMES_TUI_GATEWAY_URL"] = gateway_ws_url + # Profile-scoped chats must NOT attach to the dashboard's in-memory + # gateway — it runs under the dashboard's own profile. Without the + # attach URL, gatewayClient spawns its own `tui_gateway.entry`, which + # inherits the profile HERMES_HOME set above. + if profile_dir is None: + if gateway_ws_url := _build_gateway_ws_url(): + env["HERMES_TUI_GATEWAY_URL"] = gateway_ws_url return list(argv), str(cwd) if cwd else None, env @@ -9472,11 +9630,19 @@ async def pty_ws(ws: WebSocket) -> None: # --- spawn PTY ------------------------------------------------------ resume = ws.query_params.get("resume") or None + profile = ws.query_params.get("profile") or None channel = _channel_or_close_code(ws) sidecar_url = _build_sidecar_url(channel) if channel else None try: - argv, cwd, env = _resolve_chat_argv(resume=resume, sidecar_url=sidecar_url) + argv, cwd, env = _resolve_chat_argv( + resume=resume, sidecar_url=sidecar_url, profile=profile + ) + except HTTPException as exc: + # Unknown/invalid profile from _resolve_profile_dir. + await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc.detail}\x1b[0m\r\n") + await ws.close(code=1011) + return except SystemExit as exc: # _make_tui_argv calls sys.exit(1) when node/npm is missing. await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n") @@ -10754,8 +10920,15 @@ def start_server( port: int = 9119, open_browser: bool = True, allow_public: bool = False, + initial_profile: str = "", ): - """Start the web UI server.""" + """Start the web UI server. + + ``initial_profile`` (when set) is appended to the auto-opened browser + URL as ``?profile=`` so the SPA's profile switcher preselects it + — used when a profile alias (`` dashboard``) routes to the + machine dashboard. + """ import uvicorn # Phase 0: stash the auth-gate flag on app.state so middleware / SPA-token @@ -10846,10 +11019,15 @@ def start_server( ) if _has_display: + _open_url = f"http://{host}:{port}" + if initial_profile: + from urllib.parse import quote + _open_url += f"/?profile={quote(initial_profile)}" + def _open(): try: time.sleep(1.0) - webbrowser.open(f"http://{host}:{port}") + webbrowser.open(_open_url) except Exception: pass diff --git a/tests/hermes_cli/test_dashboard_unified_launch.py b/tests/hermes_cli/test_dashboard_unified_launch.py new file mode 100644 index 00000000000..232d7a4a394 --- /dev/null +++ b/tests/hermes_cli/test_dashboard_unified_launch.py @@ -0,0 +1,130 @@ +"""Tests for the unified profile→machine dashboard launch routing. + +` dashboard` routes to ONE machine-level dashboard instead of +spawning a per-profile server: attach (open browser at ?profile=) when one +is already listening, else re-exec as the machine dashboard with the +launching profile preselected. `--isolated` opts out. +""" +import sys +import types +import pytest + + +@pytest.fixture +def main_mod(): + import hermes_cli.main as main_mod + return main_mod + + +def _args(**kw): + defaults = dict( + status=False, stop=False, host="127.0.0.1", port=9119, + no_open=True, insecure=False, skip_build=False, + isolated=False, open_profile="", + ) + defaults.update(kw) + return types.SimpleNamespace(**defaults) + + +class TestUnifiedDashboardRouting: + def test_profile_launch_attaches_to_running_dashboard(self, main_mod, monkeypatch): + monkeypatch.setattr( + "hermes_cli.profiles.get_active_profile_name", lambda: "worker_x" + ) + monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: True) + execs = [] + monkeypatch.setattr(main_mod.os, "execvpe", lambda *a, **k: execs.append(a)) + + with pytest.raises(SystemExit) as exc: + main_mod.cmd_dashboard(_args()) + assert exc.value.code == 0 + assert execs == [] # attached, never re-exec'd + + def test_profile_launch_attach_opens_scoped_url(self, main_mod, monkeypatch): + """The attach path must open the browser at ?profile= — that + URL is the entire point of attaching (preselects the switcher).""" + monkeypatch.setattr( + "hermes_cli.profiles.get_active_profile_name", lambda: "worker_x" + ) + monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: True) + opened = [] + import webbrowser + monkeypatch.setattr(webbrowser, "open", lambda url: opened.append(url)) + + with pytest.raises(SystemExit) as exc: + main_mod.cmd_dashboard(_args(no_open=False)) + assert exc.value.code == 0 + assert opened == ["http://127.0.0.1:9119/?profile=worker_x"] + + def test_profile_launch_reexecs_machine_dashboard(self, main_mod, monkeypatch): + monkeypatch.setattr( + "hermes_cli.profiles.get_active_profile_name", lambda: "worker_x" + ) + monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: False) + execs = [] + + def fake_exec(exe, argv, env): + execs.append((exe, argv, env)) + raise SystemExit(0) # execvpe never returns + + monkeypatch.setattr(main_mod.os, "execvpe", fake_exec) + + with pytest.raises(SystemExit): + main_mod.cmd_dashboard(_args()) + + assert len(execs) == 1 + exe, argv, env = execs[0] + assert exe == sys.executable + # Pinned to the default profile + launching profile preselected. + assert "-p" in argv and argv[argv.index("-p") + 1] == "default" + assert "--open-profile" in argv + assert argv[argv.index("--open-profile") + 1] == "worker_x" + # Profile HERMES_HOME dropped so the child binds the machine root. + assert "HERMES_HOME" not in env + + def test_isolated_flag_skips_routing(self, main_mod, monkeypatch): + monkeypatch.setattr( + "hermes_cli.profiles.get_active_profile_name", lambda: "worker_x" + ) + listening_calls = [] + monkeypatch.setattr( + main_mod, "_dashboard_listening", + lambda host, port: listening_calls.append(1) or True, + ) + # With --isolated the routing block is skipped entirely; the command + # proceeds to dependency checks. Make the first post-routing step + # bail so the test doesn't actually start a server. + monkeypatch.setitem(sys.modules, "fastapi", None) + + with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)): + main_mod.cmd_dashboard(_args(isolated=True)) + assert listening_calls == [] + + def test_default_profile_launch_skips_routing(self, main_mod, monkeypatch): + monkeypatch.setattr( + "hermes_cli.profiles.get_active_profile_name", lambda: "default" + ) + listening_calls = [] + monkeypatch.setattr( + main_mod, "_dashboard_listening", + lambda host, port: listening_calls.append(1) or True, + ) + monkeypatch.setitem(sys.modules, "fastapi", None) + + with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)): + main_mod.cmd_dashboard(_args()) + assert listening_calls == [] + + def test_reexec_child_does_not_reroute(self, main_mod, monkeypatch): + """The re-exec'd child carries --open-profile; the guard must treat + that as 'already routed' and never re-exec again (no exec loop).""" + monkeypatch.setattr( + "hermes_cli.profiles.get_active_profile_name", lambda: "worker_x" + ) + execs = [] + monkeypatch.setattr(main_mod.os, "execvpe", lambda *a, **k: execs.append(a)) + monkeypatch.setitem(sys.modules, "fastapi", None) + + with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)): + main_mod.cmd_dashboard(_args(open_profile="worker_x")) + assert execs == [] diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 76cbd59efdc..3aeca71c7de 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -4441,7 +4441,7 @@ class TestPtyWebSocket: monkeypatch.setattr( self.ws_module, "_resolve_chat_argv", - lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None), + lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None), ) from starlette.websockets import WebSocketDisconnect @@ -4454,7 +4454,7 @@ class TestPtyWebSocket: monkeypatch.setattr( self.ws_module, "_resolve_chat_argv", - lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None), + lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None), ) from starlette.websockets import WebSocketDisconnect @@ -4467,7 +4467,7 @@ class TestPtyWebSocket: monkeypatch.setattr( self.ws_module, "_resolve_chat_argv", - lambda resume=None, sidecar_url=None: ( + lambda resume=None, sidecar_url=None, profile=None: ( ["/bin/sh", "-c", "printf hermes-ws-ok"], None, None, @@ -4497,7 +4497,7 @@ class TestPtyWebSocket: monkeypatch.setattr( self.ws_module, "_resolve_chat_argv", - lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None), + lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None), ) with self.client.websocket_connect(self._url()) as conn: conn.send_bytes(b"round-trip-payload\n") @@ -4530,7 +4530,7 @@ class TestPtyWebSocket: self.ws_module, "_resolve_chat_argv", # sleep gives the test time to push the resize before the child reads the ioctl. - lambda resume=None, sidecar_url=None: ( + lambda resume=None, sidecar_url=None, profile=None: ( [sys.executable, "-c", winsize_script], None, None, @@ -4566,7 +4566,7 @@ class TestPtyWebSocket: monkeypatch.setattr( self.ws_module, "_resolve_chat_argv", - lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None), + lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None), ) # Patch PtyBridge.spawn at the web_server module's binding. import hermes_cli.web_server as ws_mod @@ -4581,7 +4581,7 @@ class TestPtyWebSocket: def test_resume_parameter_is_forwarded_to_argv(self, monkeypatch): captured: dict = {} - def fake_resolve(resume=None, sidecar_url=None): + def fake_resolve(resume=None, sidecar_url=None, profile=None): captured["resume"] = resume return (["/bin/sh", "-c", "printf resume-arg-ok"], None, None) @@ -4601,7 +4601,7 @@ class TestPtyWebSocket: same channel — which is how tool events reach the dashboard sidebar.""" captured: dict = {} - def fake_resolve(resume=None, sidecar_url=None): + def fake_resolve(resume=None, sidecar_url=None, profile=None): captured["sidecar_url"] = sidecar_url return (["/bin/sh", "-c", "printf sidecar-ok"], None, None) diff --git a/tests/hermes_cli/test_web_server_profile_unification.py b/tests/hermes_cli/test_web_server_profile_unification.py new file mode 100644 index 00000000000..d458348f128 --- /dev/null +++ b/tests/hermes_cli/test_web_server_profile_unification.py @@ -0,0 +1,385 @@ +"""Regression tests for the machine-dashboard multi-profile unification. + +The dashboard is ONE machine-level management surface: config, env, MCP, +model, and chat-PTY endpoints accept an optional ``profile`` so the global +profile switcher can target any profile's HERMES_HOME. These tests pin: +reads/writes land in the REQUESTED profile, the dashboard's own profile +stays untouched, and the chat PTY env is scoped via HERMES_HOME. +""" +import pytest +import yaml + + +@pytest.fixture +def isolated_profiles(tmp_path, monkeypatch, _isolate_hermes_home): + """Isolated default home + one named profile, each with config + .env.""" + from hermes_constants import get_hermes_home + from hermes_cli import profiles + + default_home = get_hermes_home() + profiles_root = default_home / "profiles" + worker_home = profiles_root / "worker_beta" + for home in (default_home, worker_home): + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text("{}\n", encoding="utf-8") + (worker_home / ".env").write_text("", encoding="utf-8") + + monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home) + monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root) + return {"default": default_home, "worker_beta": worker_home} + + +@pytest.fixture +def client(monkeypatch, isolated_profiles): + try: + from starlette.testclient import TestClient + except ImportError: + pytest.skip("fastapi/starlette not installed") + + import hermes_state + from hermes_constants import get_hermes_home + from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN + + monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db") + c = TestClient(app) + c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN + return c + + +def _cfg(home): + return yaml.safe_load((home / "config.yaml").read_text()) or {} + + +class TestProfileScopedConfig: + def test_config_put_lands_in_target_profile_only(self, client, isolated_profiles): + resp = client.put( + "/api/config", + json={"config": {"timezone": "Mars/Olympus"}, "profile": "worker_beta"}, + ) + assert resp.status_code == 200 + assert _cfg(isolated_profiles["worker_beta"]).get("timezone") == "Mars/Olympus" + assert _cfg(isolated_profiles["default"]).get("timezone") != "Mars/Olympus" + + def test_config_get_reads_target_profile(self, client, isolated_profiles): + (isolated_profiles["worker_beta"] / "config.yaml").write_text( + "timezone: Venus/Cloud\n", encoding="utf-8" + ) + resp = client.get("/api/config", params={"profile": "worker_beta"}) + assert resp.status_code == 200 + assert resp.json().get("timezone") == "Venus/Cloud" + # Unscoped read sees the dashboard's own config. + resp = client.get("/api/config") + assert resp.json().get("timezone") != "Venus/Cloud" + + def test_config_query_param_equivalent_to_body(self, client, isolated_profiles): + """The SPA's fetchJSON injects ?profile= — must scope like body.profile.""" + resp = client.put( + "/api/config?profile=worker_beta", + json={"config": {"timezone": "Pluto/Far"}}, + ) + assert resp.status_code == 200 + assert _cfg(isolated_profiles["worker_beta"]).get("timezone") == "Pluto/Far" + assert _cfg(isolated_profiles["default"]).get("timezone") != "Pluto/Far" + + def test_config_raw_round_trip_scoped(self, client, isolated_profiles): + resp = client.put( + "/api/config/raw", + json={"yaml_text": "timezone: Io/Volcano\n", "profile": "worker_beta"}, + ) + assert resp.status_code == 200 + resp = client.get("/api/config/raw", params={"profile": "worker_beta"}) + assert "Io/Volcano" in resp.json()["yaml"] + resp = client.get("/api/config/raw") + assert "Io/Volcano" not in resp.json()["yaml"] + + def test_unknown_profile_404(self, client, isolated_profiles): + resp = client.get("/api/config", params={"profile": "ghost"}) + assert resp.status_code == 404 + + +class TestProfileScopedEnv: + def test_env_set_lands_in_target_profile_only(self, client, isolated_profiles): + resp = client.put( + "/api/env", + json={"key": "FAL_KEY", "value": "test-fal-123", "profile": "worker_beta"}, + ) + assert resp.status_code == 200 + worker_env = (isolated_profiles["worker_beta"] / ".env").read_text() + assert "test-fal-123" in worker_env + default_env_path = isolated_profiles["default"] / ".env" + if default_env_path.exists(): + assert "test-fal-123" not in default_env_path.read_text() + + def test_env_list_reads_target_profile(self, client, isolated_profiles): + (isolated_profiles["worker_beta"] / ".env").write_text( + "FAL_KEY=worker-only-value\n", encoding="utf-8" + ) + resp = client.get("/api/env", params={"profile": "worker_beta"}) + assert resp.status_code == 200 + assert resp.json()["FAL_KEY"]["is_set"] is True + resp = client.get("/api/env") + assert resp.json()["FAL_KEY"]["is_set"] is False + + def test_env_delete_scoped(self, client, isolated_profiles): + (isolated_profiles["worker_beta"] / ".env").write_text( + "FAL_KEY=doomed\n", encoding="utf-8" + ) + resp = client.request( + "DELETE", + "/api/env", + json={"key": "FAL_KEY", "profile": "worker_beta"}, + ) + assert resp.status_code == 200 + assert "doomed" not in (isolated_profiles["worker_beta"] / ".env").read_text() + + +class TestProfileScopedMcp: + def test_mcp_add_and_list_scoped(self, client, isolated_profiles): + resp = client.post( + "/api/mcp/servers", + json={"name": "scoped-srv", "url": "http://localhost:1234/sse", + "profile": "worker_beta"}, + ) + assert resp.status_code == 200 + + worker_cfg = _cfg(isolated_profiles["worker_beta"]) + assert "scoped-srv" in worker_cfg.get("mcp_servers", {}) + assert "scoped-srv" not in _cfg(isolated_profiles["default"]).get("mcp_servers", {}) + + listing = client.get("/api/mcp/servers", params={"profile": "worker_beta"}).json() + assert any(s["name"] == "scoped-srv" for s in listing["servers"]) + listing = client.get("/api/mcp/servers").json() + assert not any(s["name"] == "scoped-srv" for s in listing["servers"]) + + def test_mcp_enabled_toggle_scoped(self, client, isolated_profiles): + (isolated_profiles["worker_beta"] / "config.yaml").write_text( + "mcp_servers:\n srv1:\n url: http://x/sse\n", encoding="utf-8" + ) + resp = client.put( + "/api/mcp/servers/srv1/enabled", + json={"enabled": False, "profile": "worker_beta"}, + ) + assert resp.status_code == 200 + worker_cfg = _cfg(isolated_profiles["worker_beta"]) + assert worker_cfg["mcp_servers"]["srv1"]["enabled"] is False + + def test_mcp_probe_runs_inside_profile_scope( + self, client, isolated_profiles, monkeypatch + ): + """The test-server probe must execute with the selected profile's + scope active so env-placeholder expansion reads the profile's .env, + matching the config the server was saved into.""" + import hermes_cli.mcp_config as mcp_config + from hermes_constants import get_hermes_home + + (isolated_profiles["worker_beta"] / "config.yaml").write_text( + "mcp_servers:\n probe-srv:\n url: http://x/sse\n", + encoding="utf-8", + ) + seen = {} + + def fake_probe(name, config, connect_timeout=30): + seen["home"] = str(get_hermes_home()) + return [("tool-a", "desc")] + + monkeypatch.setattr(mcp_config, "_probe_single_server", fake_probe) + resp = client.post( + "/api/mcp/servers/probe-srv/test", params={"profile": "worker_beta"} + ) + assert resp.status_code == 200 + assert resp.json()["ok"] is True + assert seen["home"] == str(isolated_profiles["worker_beta"]) + + def test_mcp_remove_scoped(self, client, isolated_profiles): + (isolated_profiles["worker_beta"] / "config.yaml").write_text( + "mcp_servers:\n srv2:\n url: http://x/sse\n", encoding="utf-8" + ) + # Removing from the DASHBOARD's profile must 404 (srv2 lives in worker). + resp = client.delete("/api/mcp/servers/srv2") + assert resp.status_code == 404 + resp = client.delete("/api/mcp/servers/srv2", params={"profile": "worker_beta"}) + assert resp.status_code == 200 + assert "srv2" not in _cfg(isolated_profiles["worker_beta"]).get("mcp_servers", {}) + + +class TestProfileScopedModel: + def test_model_set_main_scoped(self, client, isolated_profiles): + resp = client.post( + "/api/model/set", + json={ + "scope": "main", + "provider": "openrouter", + "model": "test/model-1", + "confirm_expensive_model": True, + "profile": "worker_beta", + }, + ) + assert resp.status_code == 200 + worker_cfg = _cfg(isolated_profiles["worker_beta"]) + model_cfg = worker_cfg.get("model", {}) + assert isinstance(model_cfg, dict) + assert model_cfg.get("provider") == "openrouter" + default_model = _cfg(isolated_profiles["default"]).get("model", {}) + if isinstance(default_model, dict): + assert default_model.get("default") != "test/model-1" + + def test_auxiliary_read_scoped_matches_write_target( + self, client, isolated_profiles + ): + """Reads and writes must scope symmetrically: an aux pin written to + the worker profile must show up ONLY in the worker-scoped read. + (Regression: /api/model/auxiliary used to read unscoped while + /api/model/set wrote scoped — the Models page displayed the + dashboard profile's pins while editing the selected profile's.)""" + (isolated_profiles["worker_beta"] / "config.yaml").write_text( + "auxiliary:\n vision:\n provider: openrouter\n" + " model: worker/vision-pin\n", + encoding="utf-8", + ) + resp = client.get("/api/model/auxiliary", params={"profile": "worker_beta"}) + assert resp.status_code == 200 + vision = next(t for t in resp.json()["tasks"] if t["task"] == "vision") + assert vision["model"] == "worker/vision-pin" + + # Unscoped read = the dashboard's own profile, which has no pin. + resp = client.get("/api/model/auxiliary") + assert resp.status_code == 200 + vision = next(t for t in resp.json()["tasks"] if t["task"] == "vision") + assert vision["model"] != "worker/vision-pin" + + def test_auxiliary_unknown_profile_404(self, client, isolated_profiles): + resp = client.get("/api/model/auxiliary", params={"profile": "ghost"}) + assert resp.status_code == 404 + + def test_model_options_scoped_to_profile(self, client, isolated_profiles): + """The Models picker must read the SAME profile model/set writes — + current model/provider in the payload come from the scoped config.""" + (isolated_profiles["worker_beta"] / "config.yaml").write_text( + "model:\n provider: openrouter\n default: worker/current-pin\n", + encoding="utf-8", + ) + resp = client.get("/api/model/options", params={"profile": "worker_beta"}) + assert resp.status_code == 200 + body = resp.json() + # The payload carries the current selection somewhere stable; assert + # the worker pin appears in the scoped response and not the unscoped. + assert "worker/current-pin" in resp.text + resp = client.get("/api/model/options") + assert resp.status_code == 200 + assert "worker/current-pin" not in resp.text + assert isinstance(body, dict) + + def test_model_options_unknown_profile_404(self, client, isolated_profiles): + resp = client.get("/api/model/options", params={"profile": "ghost"}) + assert resp.status_code == 404 + + def test_model_info_unknown_profile_404(self, client, isolated_profiles): + """Regression: the broad except used to convert the 404 into a 200 + with empty model info ("no model set" — silently wrong).""" + resp = client.get("/api/model/info", params={"profile": "ghost"}) + assert resp.status_code == 404 + + def test_mcp_catalog_unknown_profile_404(self, client, isolated_profiles): + resp = client.get("/api/mcp/catalog", params={"profile": "ghost"}) + assert resp.status_code == 404 + + +class TestProfileScopedPostSetup: + def test_post_setup_spawns_with_profile_flag( + self, client, isolated_profiles, monkeypatch + ): + """Post-setup runs in a -p scoped subprocess so hooks that read + config / write per-profile state see the same HERMES_HOME the rest + of the drawer's writes targeted.""" + import hermes_cli.web_server as web_server + + calls = [] + + class _FakeProc: + pid = 777 + + monkeypatch.setattr( + web_server, + "_spawn_hermes_action", + lambda subcommand, name: calls.append(list(subcommand)) or _FakeProc(), + ) + monkeypatch.setattr( + "hermes_cli.tools_config.valid_post_setup_keys", + lambda: {"agent_browser"}, + ) + resp = client.post( + "/api/tools/toolsets/browser/post-setup", + json={"key": "agent_browser", "profile": "worker_beta"}, + ) + assert resp.status_code == 200 + assert calls == [ + ["-p", "worker_beta", "tools", "post-setup", "agent_browser"] + ] + + def test_post_setup_without_profile_keeps_legacy_argv( + self, client, isolated_profiles, monkeypatch + ): + import hermes_cli.web_server as web_server + + calls = [] + + class _FakeProc: + pid = 777 + + monkeypatch.setattr( + web_server, + "_spawn_hermes_action", + lambda subcommand, name: calls.append(list(subcommand)) or _FakeProc(), + ) + monkeypatch.setattr( + "hermes_cli.tools_config.valid_post_setup_keys", + lambda: {"agent_browser"}, + ) + resp = client.post( + "/api/tools/toolsets/browser/post-setup", + json={"key": "agent_browser"}, + ) + assert resp.status_code == 200 + assert calls == [["tools", "post-setup", "agent_browser"]] + + +class TestProfileScopedChatPty: + def test_chat_argv_scopes_hermes_home(self, isolated_profiles, monkeypatch): + import hermes_cli.web_server as web_server + + monkeypatch.setattr( + "hermes_cli.main._make_tui_argv", + lambda root, tui_dev=False: (["cat"], None), + raising=False, + ) + argv, cwd, env = web_server._resolve_chat_argv(profile="worker_beta") + assert env is not None + assert env["HERMES_HOME"] == str(isolated_profiles["worker_beta"]) + # Scoped chat must NOT attach to the dashboard's in-memory gateway. + assert "HERMES_TUI_GATEWAY_URL" not in env + + def test_chat_argv_unscoped_keeps_legacy_env(self, isolated_profiles, monkeypatch): + import hermes_cli.web_server as web_server + + monkeypatch.setattr( + "hermes_cli.main._make_tui_argv", + lambda root, tui_dev=False: (["cat"], None), + raising=False, + ) + argv, cwd, env = web_server._resolve_chat_argv() + assert env is not None + assert env.get("HERMES_HOME") != str(isolated_profiles["worker_beta"]) + + def test_chat_argv_unknown_profile_raises(self, isolated_profiles, monkeypatch): + import hermes_cli.web_server as web_server + + monkeypatch.setattr( + "hermes_cli.main._make_tui_argv", + lambda root, tui_dev=False: (["cat"], None), + raising=False, + ) + # Reuse the HTTPException class web_server itself raises — avoids a + # direct fastapi import (unresolvable in the ty lint environment). + with pytest.raises(web_server.HTTPException) as exc: + web_server._resolve_chat_argv(profile="ghost") + assert exc.value.status_code == 404 diff --git a/web/src/App.tsx b/web/src/App.tsx index 52108a22cec..d3c976358d5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -64,6 +64,10 @@ import { useBelowBreakpoint } from "@nous-research/ui/hooks/use-below-breakpoint import { useSidebarStatus } from "@/hooks/useSidebarStatus"; import { AuthWidget } from "@/components/AuthWidget"; import { PageHeaderProvider } from "@/contexts/PageHeaderProvider"; +import { ProfileProvider } from "@/contexts/ProfileProvider"; +import { useProfileScope } from "@/contexts/useProfileScope"; +import { ProfileSwitcher } from "@/components/ProfileSwitcher"; +import { ProfileScopeBanner } from "@/components/ProfileScopeBanner"; import { useSystemActions } from "@/contexts/useSystemActions"; import type { SystemAction } from "@/contexts/system-actions-context"; import ConfigPage from "@/pages/ConfigPage"; @@ -474,6 +478,7 @@ export default function App() { }, []); return ( +
+
@@ -602,6 +608,8 @@ export default function App() {
+ +
+ ); } +/** + * Remounts the entire routed page tree when the global management profile + * changes. Pages load their data on mount; without this, a page opened + * under profile A would keep showing A's state while writes (via the + * fetchJSON ?profile= injection) silently targeted the newly selected + * profile B — the exact stale-target footgun the switcher exists to kill. + * Keying by profile resets every page's local state so it refetches under + * the new scope. The persistent ChatPage host below handles its own + * remount (channel keyed on scopedProfile). + */ +function ProfileKeyedRoutes({ children }: { children: ReactNode }) { + const { profile } = useProfileScope(); + return
{children}
; +} + function SidebarNavLink({ closeMobile, collapsed, diff --git a/web/src/components/ProfileScopeBanner.tsx b/web/src/components/ProfileScopeBanner.tsx new file mode 100644 index 00000000000..9d5adc2fdfd --- /dev/null +++ b/web/src/components/ProfileScopeBanner.tsx @@ -0,0 +1,30 @@ +import { Users } from "lucide-react"; +import { useProfileScope } from "@/contexts/useProfileScope"; +import { useI18n } from "@/i18n"; + +/** + * App-wide amber banner shown while the global switcher targets a profile + * OTHER than the dashboard's own — every management write (config, keys, + * skills, MCPs, model) and new Chat sessions land in that profile. + */ +export function ProfileScopeBanner() { + const { profile, currentProfile } = useProfileScope(); + const { t } = useI18n(); + + if (!profile || profile === currentProfile) return null; + + return ( + // mt-14 on mobile clears the fixed lg:hidden header (h-14, z-40) so the + // scope banner — the main safety signal for scoped writes — is never + // hidden behind it; lg:mt-0 restores desktop flow. +
+ + + {( + t.app.managingProfileBanner ?? + "Managing profile “{name}” — config, keys, skills, MCPs, model, and new chats apply to that profile." + ).replace("{name}", profile)} + +
+ ); +} diff --git a/web/src/components/ProfileSwitcher.tsx b/web/src/components/ProfileSwitcher.tsx new file mode 100644 index 00000000000..827ea881f6f --- /dev/null +++ b/web/src/components/ProfileSwitcher.tsx @@ -0,0 +1,67 @@ +import { Users } from "lucide-react"; +import { useProfileScope } from "@/contexts/useProfileScope"; +import { useI18n } from "@/i18n"; +import { cn } from "@/lib/utils"; + +/** + * The machine dashboard's single write-target selector. + * + * Rendered in the sidebar above the nav. Every management page (Config, + * Keys, Skills, MCP, Models) reads/writes the selected profile via the + * fetchJSON ?profile= injection. Hidden when only one profile exists. + */ +export function ProfileSwitcher({ collapsed }: { collapsed?: boolean }) { + const { profile, currentProfile, profiles, setProfile } = useProfileScope(); + const { t } = useI18n(); + + if (profiles.length < 2) return null; + + const managed = profile || currentProfile || "default"; + const isOther = !!profile && profile !== currentProfile; + + return ( +
+ + + {collapsed && ( + {managed} + )} +
+ ); +} diff --git a/web/src/components/ToolsetConfigDrawer.tsx b/web/src/components/ToolsetConfigDrawer.tsx index 5bbcba61866..792393c9285 100644 --- a/web/src/components/ToolsetConfigDrawer.tsx +++ b/web/src/components/ToolsetConfigDrawer.tsx @@ -198,7 +198,7 @@ export function ToolsetConfigDrawer({ toolset, profile, onClose, onChanged }: Pr setPostSetupLog([]); setPostSetupKey(provider.post_setup); try { - await api.runToolsetPostSetup(toolset.name, provider.post_setup); + await api.runToolsetPostSetup(toolset.name, provider.post_setup, profile); // Bump the trigger so the poll effect (re)starts tailing the log. setPostSetupTrigger((n) => n + 1); } catch (e) { diff --git a/web/src/contexts/ProfileProvider.tsx b/web/src/contexts/ProfileProvider.tsx new file mode 100644 index 00000000000..0beedb49bc5 --- /dev/null +++ b/web/src/contexts/ProfileProvider.tsx @@ -0,0 +1,115 @@ +import { + useCallback, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { useLocation, useSearchParams } from "react-router-dom"; +import { api, setManagementProfile } from "@/lib/api"; +import { ProfileContext } from "@/contexts/profile-context"; + +/** + * Machine-level management-profile scope. + * + * One switcher (rendered in the sidebar) decides which profile every + * management page reads/writes. React STATE is the source of truth; the + * URL (`?profile=`) is a synchronized projection of it so deep links + * land scoped and refresh survives. The selection is mirrored into the api + * module so `fetchJSON` transparently appends it to the profile-scoped + * endpoint families. "" = the dashboard's own profile. + * + * Why state-first instead of URL-first: sidebar nav links are bare paths + * (`/config`, `/skills`). A URL-derived scope would silently reset to the + * dashboard's own profile on every nav click — the switcher would LOOK + * global while normal navigation dropped the write target. With state as + * truth, the effect below re-asserts `?profile=` onto the new location + * after each navigation, so the scope survives nav and stays deep-linkable. + * + * This exists because "Set as active" on the Profiles page only flips the + * sticky active_profile file (future CLI/gateway runs) — it cannot retarget + * the running dashboard. The switcher is the dashboard's own, visible, + * write-target selector. + */ +export function ProfileProvider({ children }: { children: ReactNode }) { + const [searchParams, setSearchParams] = useSearchParams(); + const { pathname } = useLocation(); + const [profiles, setProfiles] = useState([]); + const [currentProfile, setCurrentProfile] = useState("default"); + + // Initial value comes from the URL (deep link / refresh / unified-launch + // preselect); afterwards state leads and the URL follows. + const [profile, setProfileState] = useState( + () => searchParams.get("profile") ?? "", + ); + + // Mirror into the api module synchronously on every render where it + // changed, so fetches fired by child effects in the same commit see it. + setManagementProfile(profile); + + // A profile param arriving via in-app navigation (e.g. the Profiles + // page's "Manage skills & tools" linking to /skills?profile=X) must win + // over current state — it's an explicit scope request. + const urlProfile = searchParams.get("profile"); + useEffect(() => { + if (urlProfile !== null && urlProfile !== profile) { + setManagementProfile(urlProfile); + setProfileState(urlProfile); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [urlProfile]); + + // Re-assert ?profile= after navigations that dropped it (bare nav links). + // Runs on every pathname/profile change; no-ops when already in sync. + useEffect(() => { + const inUrl = searchParams.get("profile") ?? ""; + if ((profile || "") === inUrl) return; + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (profile) next.set("profile", profile); + else next.delete("profile"); + return next; + }, + { replace: true }, + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname, profile]); + + useEffect(() => { + api + .getProfiles() + .then((res) => setProfiles(res.profiles.map((p) => p.name))) + .catch(() => {}); + api + .getActiveProfile() + .then((info) => setCurrentProfile(info.current || "default")) + .catch(() => {}); + }, []); + + const setProfile = useCallback( + (name: string) => { + setManagementProfile(name); + setProfileState(name); + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (name) next.set("profile", name); + else next.delete("profile"); + return next; + }, + { replace: true }, + ); + }, + [setSearchParams], + ); + + const value = useMemo( + () => ({ profile, currentProfile, profiles, setProfile }), + [profile, currentProfile, profiles, setProfile], + ); + + return ( + {children} + ); +} diff --git a/web/src/contexts/profile-context.ts b/web/src/contexts/profile-context.ts new file mode 100644 index 00000000000..f8b2e5c9514 --- /dev/null +++ b/web/src/contexts/profile-context.ts @@ -0,0 +1,19 @@ +import { createContext } from "react"; + +export interface ProfileContextValue { + /** Profile every management surface reads/writes ("" = the dashboard + * process's own profile). */ + profile: string; + /** The profile the dashboard process itself runs under. */ + currentProfile: string; + /** Known profile names (includes "default"). */ + profiles: string[]; + setProfile: (name: string) => void; +} + +export const ProfileContext = createContext({ + profile: "", + currentProfile: "default", + profiles: [], + setProfile: () => {}, +}); diff --git a/web/src/contexts/useProfileScope.ts b/web/src/contexts/useProfileScope.ts new file mode 100644 index 00000000000..9bd3fefcddc --- /dev/null +++ b/web/src/contexts/useProfileScope.ts @@ -0,0 +1,6 @@ +import { useContext } from "react"; +import { ProfileContext } from "@/contexts/profile-context"; + +export function useProfileScope() { + return useContext(ProfileContext); +} diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 39cf80d6995..853eeb4a9c1 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -93,6 +93,10 @@ export const en: Translations = { statusOverview: "Status overview", system: "System", webUi: "Web UI", + managingProfile: "Managing profile", + currentProfileOption: "this dashboard ({name})", + managingProfileBanner: + "Managing profile \u201c{name}\u201d \u2014 config, keys, skills, MCPs, model, and new chats apply to that profile.", }, status: { diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index cac5688bdc6..aecb863544e 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -110,6 +110,10 @@ export interface Translations { statusOverview: string; system: string; webUi: string; + /** Optional — fall back to English literals until translated. */ + managingProfile?: string; + currentProfileOption?: string; + managingProfileBanner?: string; }; // ── Status page ── diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a7c308353bb..a587a0f0c1a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -41,11 +41,54 @@ function setSessionHeader(headers: Headers, token: string): void { } } +// ── Global management-profile scope ────────────────────────────────── +// The dashboard is a machine-level management surface: one header switcher +// (ProfileProvider in App.tsx) decides which profile the management pages +// read/write, and fetchJSON transparently appends ?profile= to the +// profile-scoped endpoint families below. "" = the dashboard process's own +// profile (legacy behavior). Calls that already carry an explicit profile +// (e.g. ProfileBuilder writes) are left untouched — explicit beats global. +let _managementProfile = ""; + +export function setManagementProfile(name: string): void { + _managementProfile = (name || "").trim(); +} + +export function getManagementProfile(): string { + return _managementProfile; +} + +// Endpoint families that honor ?profile= on the backend (web_server.py +// _profile_scope). Anything else — sessions, analytics, ops, pairing, +// channels, cron (which has its own per-job profile params), profiles +// themselves — is machine-global or self-scoped and must NOT be rewritten. +const PROFILE_SCOPED_PREFIXES = [ + "/api/skills", + "/api/tools/toolsets", + "/api/config", + "/api/env", + "/api/mcp", + "/api/model/info", + "/api/model/set", + "/api/model/auxiliary", + "/api/model/options", +]; + +function withManagementProfile(url: string): string { + if (!_managementProfile) return url; + if (url.includes("profile=")) return url; // explicit param wins + const path = url.split("?")[0]; + if (!PROFILE_SCOPED_PREFIXES.some((p) => path.startsWith(p))) return url; + const sep = url.includes("?") ? "&" : "?"; + return `${url}${sep}profile=${encodeURIComponent(_managementProfile)}`; +} + export async function fetchJSON( url: string, init?: RequestInit, options?: FetchJSONOptions, ): Promise { + url = withManagementProfile(url); // Inject the session token into all /api/ requests. const headers = new Headers(init?.headers); const token = window.__HERMES_SESSION_TOKEN__; @@ -595,13 +638,13 @@ export const api = { body: JSON.stringify({ env, profile: profile || undefined }), }, ), - runToolsetPostSetup: (name: string, key: string) => + runToolsetPostSetup: (name: string, key: string, profile?: string) => fetchJSON( `/api/tools/toolsets/${encodeURIComponent(name)}/post-setup`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ key }), + body: JSON.stringify({ key, profile: profile || undefined }), }, ), diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index e3503848356..34975035530 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -37,11 +37,13 @@ import { useI18n } from "@/i18n"; import { api } from "@/lib/api"; import { PluginSlot } from "@/plugins"; import { useTheme } from "@/themes"; +import { useProfileScope } from "@/contexts/useProfileScope"; function buildWsUrl( authParam: [string, string], resume: string | null, channel: string, + profile: string, ): string { const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; // ``authParam`` is ``["token", ]`` in loopback mode and @@ -49,6 +51,10 @@ function buildWsUrl( // ``_ws_auth_ok`` picks whichever shape matches the current gate state. const qs = new URLSearchParams({ [authParam[0]]: authParam[1], channel }); if (resume) qs.set("resume", resume); + // Profile-scoped chat: the PTY child gets HERMES_HOME pointed at the + // selected profile, so the conversation runs with that profile's model, + // skills, memory, and sessions (see web_server._resolve_chat_argv). + if (profile) qs.set("profile", profile); return `${proto}//${window.location.host}${HERMES_BASE_PATH}/api/pty?${qs.toString()}`; } @@ -173,7 +179,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // treat the current resume target as part of the PTY identity and rebuild the // terminal session when it changes. const resumeParam = searchParams.get("resume"); - const channel = useMemo(() => generateChannelId(), [resumeParam]); + // Profile-scoped chat: spawn the PTY under the globally selected + // management profile. Changing it remounts the terminal (key below / + // effect dep) so the user explicitly starts a fresh scoped session. + const { profile: scopedProfile } = useProfileScope(); + const channel = useMemo(() => generateChannelId(), [resumeParam, scopedProfile]); useEffect(() => { if (!resumeParam) return; @@ -576,7 +586,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { void (async () => { const authParam = await buildWsAuthParam(); if (unmounting) return; - const url = buildWsUrl(authParam, resumeParam, channel); + const url = buildWsUrl(authParam, resumeParam, channel, scopedProfile); const ws = new WebSocket(url); ws.binaryType = "arraybuffer"; wsRef.current = ws; @@ -714,7 +724,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { copyResetRef.current = null; } }; - }, [channel, resumeParam]); + }, [channel, resumeParam, scopedProfile]); // When the user returns to the chat tab (isActive: false → true), the // terminal host just transitioned from display:none to display:flex. diff --git a/web/src/pages/SkillsPage.tsx b/web/src/pages/SkillsPage.tsx index c3a5d324e15..7834de1cf46 100644 --- a/web/src/pages/SkillsPage.tsx +++ b/web/src/pages/SkillsPage.tsx @@ -25,7 +25,6 @@ import { AlertTriangle, Sparkles, Loader2, - Users, } from "lucide-react"; import { api } from "@/lib/api"; import type { @@ -36,9 +35,8 @@ import type { SkillHubInstalledEntry, SkillHubPreview, SkillHubScan, - ProfileInfo, } from "@/lib/api"; -import { useSearchParams } from "react-router-dom"; +import { useProfileScope } from "@/contexts/useProfileScope"; import { ToolsetConfigDrawer } from "@/components/ToolsetConfigDrawer"; import { useToast } from "@nous-research/ui/hooks/use-toast"; import { Toast } from "@nous-research/ui/ui/components/toast"; @@ -137,51 +135,15 @@ export default function SkillsPage() { const { setAfterTitle, setEnd } = usePageHeader(); // ── Profile scoping ── - // The dashboard process runs under ONE profile, but skills/toolsets are - // per-profile state. Without an explicit selector, users who "activated" - // a profile on the Profiles page (which only affects FUTURE CLI/gateway - // runs) toggled skills here and silently wrote into the dashboard's own - // profile. The selector makes the write target explicit and deep-linkable - // via /skills?profile=. - const [searchParams, setSearchParams] = useSearchParams(); - const [profiles, setProfiles] = useState([]); - const [currentProfile, setCurrentProfile] = useState(""); - const urlProfile = searchParams.get("profile") ?? ""; - // "" = the dashboard's own profile (legacy behavior). - const selectedProfile = urlProfile; - - const setSelectedProfile = useCallback( - (name: string) => { - setSearchParams( - (prev) => { - const next = new URLSearchParams(prev); - if (name) next.set("profile", name); - else next.delete("profile"); - return next; - }, - { replace: true }, - ); - }, - [setSearchParams], - ); - - // The profile actually being managed, for display purposes. - const managedProfile = selectedProfile || currentProfile || "default"; - const managingOtherProfile = - !!selectedProfile && selectedProfile !== currentProfile; - - useEffect(() => { - // Profile list + the dashboard's own profile, for the selector. Failure - // leaves the selector hidden — the page still works profile-unscoped. - api - .getProfiles() - .then((res) => setProfiles(res.profiles)) - .catch(() => {}); - api - .getActiveProfile() - .then((info) => setCurrentProfile(info.current || "default")) - .catch(() => setCurrentProfile("default")); - }, []); + // The write target comes from the GLOBAL profile switcher (sidebar) via + // ProfileContext — one selector for the whole dashboard, deep-linkable + // as ?profile=. This page just consumes it: the fetchJSON layer + // appends the param automatically; we still pass it explicitly where the + // call signature supports it (clearer, and robust if a caller bypasses + // the auto-injection). + const { + profile: selectedProfile, + } = useProfileScope(); useEffect(() => { // Promise-chain shape: setState fires only inside async callbacks so the @@ -298,33 +260,6 @@ export default function SkillsPage() { {t.skills.enabledOf .replace("{enabled}", String(enabledCount)) .replace("{total}", String(skills.length))} - {profiles.length > 1 && ( - - - - - )} , ); setEnd( @@ -361,10 +296,6 @@ export default function SkillsPage() { setEnd, skills.length, t, - profiles, - selectedProfile, - currentProfile, - setSelectedProfile, ]); const filteredToolsets = useMemo(() => { @@ -391,18 +322,6 @@ export default function SkillsPage() { - {managingOtherProfile && ( -
- - - {( - t.skills.managingProfile ?? - "Managing profile “{name}” — toggles apply to that profile, not this dashboard’s." - ).replace("{name}", managedProfile)} - -
- )} -